Merge "Linker: Remove some else statements, and unnecessary temporary variables"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Mon, 11 Feb 2019 00:43:31 +0000 (00:43 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Mon, 11 Feb 2019 00:43:31 +0000 (00:43 +0000)
159 files changed:
Gruntfile.js
RELEASE-NOTES-1.33
autoload.php
composer.json
includes/DefaultSettings.php
includes/DummyLinker.php
includes/EventRelayerGroup.php
includes/Linker.php
includes/Setup.php
includes/cache/LinkCache.php
includes/changetags/ChangeTags.php
includes/content/JsonContent.php
includes/exception/MWExceptionHandler.php
includes/installer/i18n/be-tarask.json
includes/installer/i18n/mk.json
includes/installer/i18n/nl.json
includes/installer/i18n/pl.json
includes/installer/i18n/sl.json
includes/mail/EmailNotification.php
includes/page/ImagePage.php
includes/resourceloader/ResourceLoader.php
includes/search/SearchEngine.php
includes/session/SessionManager.php
includes/specialpage/ChangesListSpecialPage.php
includes/specials/SpecialWatchlist.php
includes/user/User.php
languages/Language.php
languages/i18n/ar.json
languages/i18n/be.json
languages/i18n/bg.json
languages/i18n/bqi.json
languages/i18n/diq.json
languages/i18n/eo.json
languages/i18n/es.json
languages/i18n/gcr.json
languages/i18n/hi.json
languages/i18n/hr.json
languages/i18n/ia.json
languages/i18n/ig.json
languages/i18n/io.json
languages/i18n/ja.json
languages/i18n/kiu.json
languages/i18n/min.json
languages/i18n/pl.json
languages/i18n/pt.json
languages/i18n/sc.json
languages/i18n/shi.json
languages/i18n/skr-arab.json
languages/i18n/sl.json
languages/i18n/sr-ec.json
languages/i18n/sr-el.json
languages/i18n/tcy.json
languages/i18n/th.json
languages/i18n/ur.json
languages/i18n/zh-hans.json
languages/i18n/zh-hant.json
maintenance/attachLatest.php
maintenance/includes/MigrateActors.php [new file with mode: 0644]
maintenance/migrateActors.php
maintenance/resources/foreign-resources.yaml
package.json
resources/Resources.php
resources/lib/jquery.client/AUTHORS.txt
resources/lib/jquery.client/jquery.client.js
resources/src/mediawiki.ForeignStructuredUpload.BookletLayout/BookletLayout.js
resources/src/mediawiki.language/mediawiki.language.js
resources/src/mediawiki.language/mediawiki.language.specialCharacters.js
resources/src/mediawiki.libs.jpegmeta/export.js
resources/src/mediawiki.rcfilters/Controller.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/HighlightColors.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/UriProcessor.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/dm/ChangesListViewModel.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/dm/FilterGroup.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/dm/FilterItem.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/dm/FiltersViewModel.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/dm/ItemModel.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/dm/SavedQueriesModel.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/dm/SavedQueryItemModel.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js [deleted file]
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js [deleted file]
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js [deleted file]
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js [deleted file]
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js [deleted file]
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js [deleted file]
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueryItemModel.js [deleted file]
resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js [deleted file]
resources/src/mediawiki.rcfilters/mw.rcfilters.HighlightColors.js [deleted file]
resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js [deleted file]
resources/src/mediawiki.rcfilters/mw.rcfilters.init.js
resources/src/mediawiki.rcfilters/mw.rcfilters.js
resources/src/mediawiki.rcfilters/ui/ChangesLimitAndDateButtonWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/ChangesListWrapperWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/CheckboxInputWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/DatePopupWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/FilterItemHighlightButton.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/FilterMenuHeaderWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/FilterMenuOptionWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/FilterMenuSectionOptionWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/FilterTagItemWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/FormWrapperWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/GroupWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/HighlightColorPickerWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/HighlightPopupWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/ItemMenuOptionWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/LiveUpdateButtonWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/MainWrapperWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/MarkSeenButtonWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/RcTopSectionWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/RclTargetPageWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/RclToOrFromWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/RclTopSectionWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/SaveFiltersPopupButtonWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/SavedLinksListItemWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/SavedLinksListWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/TagItemWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/ValuePickerWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/ViewSwitchWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/WatchlistTopSectionWidget.js [new file with mode: 0644]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitPopupWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CheckboxInputWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DatePopupWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemHighlightButton.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuHeaderWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuOptionWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuSectionOptionWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagItemWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.GroupWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightColorPickerWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightPopupWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ItemMenuOptionWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.LiveUpdateButtonWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MainWrapperWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MarkSeenButtonWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RcTopSectionWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclToOrFromWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTopSectionWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListItemWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.TagItemWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ValuePickerWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ViewSwitchWidget.js [deleted file]
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.WatchlistTopSectionWidget.js [deleted file]
resources/src/mediawiki.special.upload/upload.js
resources/src/mediawiki.user.js
resources/src/startup/mediawiki.js
tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php
tests/selenium/wdio-mediawiki/RunJobs.js

index fdbf0ef..2592815 100644 (file)
@@ -8,7 +8,6 @@ module.exports = function ( grunt ) {
 
        grunt.loadNpmTasks( 'grunt-banana-checker' );
        grunt.loadNpmTasks( 'grunt-contrib-copy' );
-       grunt.loadNpmTasks( 'grunt-contrib-watch' );
        grunt.loadNpmTasks( 'grunt-eslint' );
        grunt.loadNpmTasks( 'grunt-jsonlint' );
        grunt.loadNpmTasks( 'grunt-karma' );
@@ -103,6 +102,18 @@ module.exports = function ( grunt ) {
                },
                karma: {
                        options: {
+                               customLaunchers: {
+                                       ChromeCustom: {
+                                               base: 'ChromeHeadless',
+                                               // Chrome requires --no-sandbox in Docker/CI.
+                                               // Newer CI images expose CHROMIUM_FLAGS which sets this (and
+                                               // anything else it might need) automatically. Older CI images,
+                                               // (including Quibble for MW) don't set it yet.
+                                               flags: ( process.env.CHROMIUM_FLAGS ||
+                                                       ( process.env.ZUUL_PROJECT ? '--no-sandbox' : '' )
+                                               ).split( ' ' )
+                                       }
+                               },
                                proxies: karmaProxy,
                                files: [ {
                                        pattern: wgServer + wgScriptPath + '/index.php?title=Special:JavaScriptTest/qunit/export',
@@ -122,13 +133,10 @@ module.exports = function ( grunt ) {
                                crossOriginAttribute: false
                        },
                        main: {
-                               browsers: [ 'Chrome' ]
-                       },
-                       chromium: {
-                               browsers: [ 'Chromium' ]
+                               browsers: [ 'ChromeCustom' ]
                        },
                        firefox: {
-                               browsers: [ 'Firefox' ]
+                               browsers: [ 'FirefoxHeadless' ]
                        }
                },
                copy: {
@@ -159,7 +167,4 @@ module.exports = function ( grunt ) {
        grunt.registerTask( 'minify', 'svgmin' );
        grunt.registerTask( 'lint', [ 'eslint', 'banana', 'stylelint' ] );
        grunt.registerTask( 'qunit', [ 'assert-mw-env', 'karma:main' ] );
-
-       grunt.registerTask( 'test', [ 'lint' ] );
-       grunt.registerTask( 'default', [ 'minify', 'test' ] );
 };
index e725198..95e611d 100644 (file)
@@ -66,6 +66,9 @@ production.
   * The deprecated IPSet\IPSet alias was removed, Wikimedia\IPSet must be
     used instead.
 * Updated qunitjs from 2.6.2 to 2.9.1.
+* Updated jquery-client from 2.0.1 to 2.0.2.
+* Updated psy/psysh from 0.9.6 to 0.9.9 (dev-only).
+* Updated nikic/php-parser from 3.1.3 to 3.1.5 (dev-only).
 
 ==== Removed external libraries ====
 
@@ -209,6 +212,34 @@ because of Phabricator reports.
 * SiteSQLStore, deprecated in 1.27 and whose only method, ::newInstance(),
   would return the global SiteStore instance, has been removed. You can get to
   this via MediaWiki\MediaWikiServices::getInstance()->getSiteStore() directly.
+* Linker::formatSize, deprecated in 1.28, has been removed (with DummyLinker's).
+  Instead, use Language->formatSize() with the relevant Language object.
+* Linker::formatTemplates, deprecated in 1.28, has been removed (along with the
+  version in DummyLinker). You can use TemplatesOnThisPageFormatter directly.
+* EventRelayerGroup::singleton(), deprecated in 1.27, has been removed. You can
+  use MediaWikiServices::getInstance()->getEventRelayerGroup() directly.
+* LinkCache->addLink(), deprecated in 1.27, has been removed. It is thought to
+  be unused, and is distinct from OutputPage->addLink(), which remains.
+* JsonContent->getJsonData(), deprecated in 1.25, has been removed. Instead, use
+  JsonContent->getData().
+* MWExceptionHandler::getLogId(), deprecated in 1.27, has been removed, as the
+  exception ID is the same as the request ID, from WebRequest::getRequestId().
+* SearchEngine::getNearMatchResultSet(), deprecated in 1.27, has been removed.
+  You can use SearchEngine::getNearMatcher() instead.
+* EmailNotification::updateWatchlistTimestamp, deprecated in 1.27, has been
+  removed. Instead, use WatchedItemStore::updateNotificationTimestamp directly.
+* User::getGroupName() and ::getGroupMember(), both deprecated in 1.29, have
+  been removed. Instead, please use UserGroupMembership::getGroupName() and
+  UserGroupMembership::getGroupMemberName().
+* Backwards compatibility for setting wgSessionsInObjectCache to false or using
+  wgSessionHandler, both of which were deprecated in 1.27 with the introduction
+  of SessionManager, has been removed.
+* SessionManager::autoCreateUser, deprecated in 1.27, has been removed. Use
+  MediaWiki\Auth\AuthManager::autoCreateUser instead.
+* The mw.libs.jpegmeta property, deprecated in 1.31, was removed.
+  Use require( 'mediawiki.libs.jpegmeta' ) instead.
+* The mw.user.stickyRandomId() method, deprecated in 1.32, was removed.
+  Use mw.user.getPageviewToken() instead.
 
 === Deprecations in 1.33 ===
 * The configuration option $wgUseESI has been deprecated, and is expected
@@ -253,10 +284,11 @@ because of Phabricator reports.
   use the new extension registration key 'QUnitTestModule'.
 * (T213426) The jquery.throttle-debounce module has been deprecated. JavaScript
   code that needs this behaviour should use OO.ui.debounce/throttle.
+* The mw.language.specialCharacters property from the
+  'mediawiki.language.specialCharacters' module has been deprecated.
+  Use require( 'mediawiki.language.specialCharacters' ) instead.
 
 === Other changes in 1.33 ===
-* (T208871) The hard-coded Google search form on the database error page was
-  removed.
 * (T201747) Html::openElement() warns if given an element name with a space
   in it.
 
index 471fdd7..d577272 100644 (file)
@@ -978,7 +978,7 @@ $wgAutoloadLocalClasses = [
        'MessageContent' => __DIR__ . '/includes/content/MessageContent.php',
        'MessageLocalizer' => __DIR__ . '/languages/MessageLocalizer.php',
        'MessageSpecifier' => __DIR__ . '/includes/libs/MessageSpecifier.php',
-       'MigrateActors' => __DIR__ . '/maintenance/migrateActors.php',
+       'MigrateActors' => __DIR__ . '/maintenance/includes/MigrateActors.php',
        'MigrateArchiveText' => __DIR__ . '/maintenance/migrateArchiveText.php',
        'MigrateComments' => __DIR__ . '/maintenance/migrateComments.php',
        'MigrateFileRepoLayout' => __DIR__ . '/maintenance/migrateFileRepoLayout.php',
index fdc1730..db9f303 100644 (file)
                "justinrainbow/json-schema": "~5.2",
                "mediawiki/mediawiki-codesniffer": "24.0.0",
                "monolog/monolog": "~1.22.1",
-               "nikic/php-parser": "3.1.3",
+               "nikic/php-parser": "3.1.5",
                "seld/jsonlint": "1.7.1",
                "nmred/kafka-php": "0.1.5",
                "phpunit/phpunit": "4.8.36 || ^6.5",
-               "psy/psysh": "0.9.6",
+               "psy/psysh": "0.9.9",
                "wikimedia/avro": "1.8.0",
                "wikimedia/testing-access-wrapper": "~1.0",
                "wmde/hamcrest-html-matchers": "^0.1.0"
index e6b44ed..dc8f1e8 100644 (file)
@@ -2512,21 +2512,11 @@ $wgMainStash = 'db-replicated';
  */
 $wgParserCacheExpireTime = 86400;
 
-/**
- * @deprecated since 1.27, session data is always stored in object cache.
- */
-$wgSessionsInObjectCache = true;
-
 /**
  * The expiry time to use for session storage, in seconds.
  */
 $wgObjectCacheSessionExpiry = 3600;
 
-/**
- * @deprecated since 1.27, MediaWiki\Session\SessionManager doesn't use PHP session storage.
- */
-$wgSessionHandler = null;
-
 /**
  * Whether to use PHP session handling ($_SESSION and session_*() functions)
  *
index ba1233e..e46c45e 100644 (file)
@@ -403,38 +403,10 @@ class DummyLinker {
                );
        }
 
-       /**
-        * @deprecated since 1.28, use TemplatesOnThisPageFormatter directly
-        */
-       public function formatTemplates(
-               $templates,
-               $preview = false,
-               $section = false,
-               $more = null
-       ) {
-               wfDeprecated( __METHOD__, '1.28' );
-
-               return Linker::formatTemplates(
-                       $templates,
-                       $preview,
-                       $section,
-                       $more
-               );
-       }
-
        public function formatHiddenCategories( $hiddencats ) {
                return Linker::formatHiddenCategories( $hiddencats );
        }
 
-       /**
-        * @deprecated since 1.28, use Language::formatSize() directly
-        */
-       public function formatSize( $size ) {
-               wfDeprecated( __METHOD__, '1.28' );
-
-               return Linker::formatSize( $size );
-       }
-
        public function titleAttrib( $name, $options = null, array $msgParams = [] ) {
                return Linker::titleAttrib(
                        $name,
index 95d11d9..091a5ca 100644 (file)
@@ -18,8 +18,6 @@
  * @file
  */
 
-use MediaWiki\MediaWikiServices;
-
 /**
  * Factory class for spawning EventRelayer objects using configuration
  *
@@ -39,15 +37,6 @@ class EventRelayerGroup {
                $this->configByChannel = $config;
        }
 
-       /**
-        * @deprecated since 1.27 Use MediaWikiServices::getInstance()->getEventRelayerGroup()
-        * @return EventRelayerGroup
-        */
-       public static function singleton() {
-               wfDeprecated( __METHOD__, '1.27' );
-               return MediaWikiServices::getInstance()->getEventRelayerGroup();
-       }
-
        /**
         * @param string $channel
         * @return EventRelayer Relayer instance that handles the given channel
index 7d6b149..049fb07 100644 (file)
@@ -1903,47 +1903,6 @@ class Linker {
                return self::link( $title, $html, $attrs, $query, $options );
        }
 
-       /**
-        * @deprecated since 1.28, use TemplatesOnThisPageFormatter directly
-        *
-        * Returns HTML for the "templates used on this page" list.
-        *
-        * Make an HTML list of templates, and then add a "More..." link at
-        * the bottom. If $more is null, do not add a "More..." link. If $more
-        * is a Title, make a link to that title and use it. If $more is a string,
-        * directly paste it in as the link (escaping needs to be done manually).
-        * Finally, if $more is a Message, call toString().
-        *
-        * @since 1.16.3. $more added in 1.21
-        * @param Title[] $templates Array of templates
-        * @param bool $preview Whether this is for a preview
-        * @param bool $section Whether this is for a section edit
-        * @param Title|Message|string|null $more An escaped link for "More..." of the templates
-        * @return string HTML output
-        */
-       public static function formatTemplates( $templates, $preview = false,
-               $section = false, $more = null
-       ) {
-               wfDeprecated( __METHOD__, '1.28' );
-
-               $type = false;
-               if ( $preview ) {
-                       $type = 'preview';
-               } elseif ( $section ) {
-                       $type = 'section';
-               }
-
-               if ( $more instanceof Message ) {
-                       $more = $more->toString();
-               }
-
-               $formatter = new TemplatesOnThisPageFormatter(
-                       RequestContext::getMain(),
-                       MediaWikiServices::getInstance()->getLinkRenderer()
-               );
-               return $formatter->format( $templates, $type, $more );
-       }
-
        /**
         * Returns HTML for the "hidden categories on this page" list.
         *
@@ -1971,23 +1930,6 @@ class Linker {
                return $outText;
        }
 
-       /**
-        * @deprecated since 1.28, use Language::formatSize() directly
-        *
-        * Format a size in bytes for output, using an appropriate
-        * unit (B, KB, MB or GB) according to the magnitude in question
-        *
-        * @since 1.16.3
-        * @param int $size Size to format
-        * @return string
-        */
-       public static function formatSize( $size ) {
-               wfDeprecated( __METHOD__, '1.28' );
-
-               global $wgLang;
-               return htmlspecialchars( $wgLang->formatSize( $size ) );
-       }
-
        /**
         * Given the id of an interface element, constructs the appropriate title
         * attribute from the system messages.  (Note, this is usually the id but
index 516937e..b4b6ce6 100644 (file)
@@ -580,21 +580,6 @@ if ( $wgMaximalPasswordLength !== false ) {
        $wgPasswordPolicy['policies']['default']['MaximalPasswordLength'] = $wgMaximalPasswordLength;
 }
 
-// Backwards compatibility warning
-if ( !$wgSessionsInObjectCache ) {
-       wfDeprecated( '$wgSessionsInObjectCache = false', '1.27' );
-       if ( $wgSessionHandler ) {
-               wfDeprecated( '$wgSessionsHandler', '1.27' );
-       }
-       $cacheType = get_class( ObjectCache::getInstance( $wgSessionCacheType ) );
-       wfDebugLog(
-               'caches',
-               "Session data will be stored in \"$cacheType\" cache with " .
-                       "expiry $wgObjectCacheSessionExpiry seconds"
-       );
-}
-$wgSessionsInObjectCache = true;
-
 if ( $wgPHPSessionHandling !== 'enable' &&
        $wgPHPSessionHandling !== 'warn' &&
        $wgPHPSessionHandling !== 'disable'
index b9944a8..b3dc004 100644 (file)
@@ -189,22 +189,6 @@ class LinkCache {
                $this->goodLinks->clear( $dbkey );
        }
 
-       /**
-        * Add a title to the link cache, return the page_id or zero if non-existent
-        *
-        * @deprecated since 1.27, unused
-        * @param string $title Prefixed DB key
-        * @return int Page ID or zero
-        */
-       public function addLink( $title ) {
-               wfDeprecated( __METHOD__, '1.27' );
-               $nt = Title::newFromDBkey( $title );
-               if ( !$nt ) {
-                       return 0;
-               }
-               return $this->addLinkObj( $nt );
-       }
-
        /**
         * Fields that LinkCache needs to select
         *
index a1cf468..6ebe800 100644 (file)
@@ -141,11 +141,11 @@ class ChangeTags {
         * we consider the tag hidden, and return false.
         *
         * @param string $tag
-        * @param IContextSource $context
+        * @param MessageLocalizer $context
         * @return string|bool Tag description or false if tag is to be hidden.
         * @since 1.25 Returns false if tag is to be hidden.
         */
-       public static function tagDescription( $tag, IContextSource $context ) {
+       public static function tagDescription( $tag, MessageLocalizer $context ) {
                $msg = $context->msg( "tag-$tag" );
                if ( !$msg->exists() ) {
                        // No such message, so return the HTML-escaped tag name.
@@ -168,11 +168,11 @@ class ChangeTags {
         * for the long description.
         *
         * @param string $tag
-        * @param IContextSource $context
+        * @param MessageLocalizer $context
         * @return Message|bool Message object of the tag long description or false if
         *  there is no description.
         */
-       public static function tagLongDescriptionMessage( $tag, IContextSource $context ) {
+       public static function tagLongDescriptionMessage( $tag, MessageLocalizer $context ) {
                $msg = $context->msg( "tag-$tag-description" );
                if ( !$msg->exists() ) {
                        return false;
@@ -196,6 +196,8 @@ class ChangeTags {
         * @return string Truncated long tag description.
         */
        public static function truncateTagDescription( $tag, $length, IContextSource $context ) {
+               // FIXME: Make this accept MessageLocalizer and Language instead of IContextSource
+
                $originalDesc = self::tagLongDescriptionMessage( $tag, $context );
                // If there is no tag description, return empty string
                if ( !$originalDesc ) {
index 0f8a9a9..2cd1fb3 100644 (file)
@@ -28,17 +28,6 @@ class JsonContent extends TextContent {
                parent::__construct( $text, $modelId );
        }
 
-       /**
-        * Decodes the JSON into a PHP associative array.
-        *
-        * @deprecated since 1.25 Use getData instead.
-        * @return array|null
-        */
-       public function getJsonData() {
-               wfDeprecated( __METHOD__, '1.25' );
-               return FormatJson::decode( $this->getText(), true );
-       }
-
        /**
         * Decodes the JSON string.
         *
index 951498e..6e3fa79 100644 (file)
@@ -462,22 +462,6 @@ TXT;
                }, $trace );
        }
 
-       /**
-        * Get the ID for this exception.
-        *
-        * The ID is saved so that one can match the one output to the user (when
-        * $wgShowExceptionDetails is set to false), to the entry in the debug log.
-        *
-        * @since 1.22
-        * @deprecated since 1.27: Exception IDs are synonymous with request IDs.
-        * @param Exception|Throwable $e
-        * @return string
-        */
-       public static function getLogId( $e ) {
-               wfDeprecated( __METHOD__, '1.27' );
-               return WebRequest::getRequestId();
-       }
-
        /**
         * If the exception occurred in the course of responding to a request,
         * returns the requested URL. Otherwise, returns false.
index eaa5914..0e83199 100644 (file)
        "config-invalid-db-server-oracle": "Няслушнае TNS базы зьвестак «$1».\nВыкарыстоўвайце або «TNS Name», або радок «Easy Connect» ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Мэтады найменьня Oracle])",
        "config-invalid-db-name": "Няслушная назва базы зьвестак «$1».\nНазва можа ўтрымліваць толькі ASCII-літары (a-z, A-Z), лічбы (0-9), сымбалі падкрэсьліваньня(_) і працяжнікі (-).",
        "config-invalid-db-prefix": "Няслушны прэфікс базы зьвестак «$1».\nЁн можа зьмяшчаць толькі ASCII-літары (a-z, A-Z), лічбы (0-9), сымбалі падкрэсьліваньня (_) і працяжнікі (-).",
-       "config-connection-error": "$1.\n\nПраверце хост, імя карыстальніка і пароль ніжэй і паспрабуйце зноў.",
+       "config-connection-error": "$1.\n\nПраверце хост, імя карыстальніка і пароль і паспрабуйце зноў. Калі вы ўжываеце «localhost» у якасьці хосту базы зьвестак, паспрабуйце «127.0.0.1» замест (ці наадварот).",
        "config-invalid-schema": "Няслушная схема для MediaWiki «$1».\nВыкарыстоўвайце толькі ASCII-літары (a-z, A-Z), лічбы (0-9) і сымбалі падкрэсьліваньня (_).",
        "config-db-sys-create-oracle": "Праграма ўсталяваньня падтрымлівае толькі выкарыстаньне рахунку SYSDBA для стварэньня новага рахунку.",
        "config-db-sys-user-exists-oracle": "Рахунак карыстальніка «$1» ужо існуе. SYSDBA можа выкарыстоўвацца толькі для стварэньня новых рахункаў!",
index 4e1ae0e..22f278d 100644 (file)
@@ -83,7 +83,7 @@
        "config-using-32bit": "<strong>Предупредување:</strong> вашиот систем работи на 32-битни цели броеви. Ова [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-не се препорачува].",
        "config-db-type": "Тип на база:",
        "config-db-host": "Домаќин на базата:",
-       "config-db-host-help": "Ð\90ко Ð²Ð°Ñ\88аÑ\82а Ð±Ð°Ð·Ð° Ðµ Ð½Ð° Ð´Ñ\80Ñ\83г Ð¾Ð¿Ñ\81лÑ\83жÑ\83ваÑ\87, Ñ\82огаÑ\88 Ñ\82Ñ\83ка Ð²Ð½ÐµÑ\81еÑ\82е Ð³Ð¾ Ð¸Ð¼ÐµÑ\82о Ð½Ð° Ð´Ð¾Ð¼Ð°Ñ\9cиноÑ\82 Ð¸Ð»Ð¸ IP-адÑ\80еÑ\81аÑ\82а.\n\nÐ\90ко ÐºÐ¾Ñ\80иÑ\81Ñ\82иÑ\82е Ð·Ð°ÐµÐ´Ð½Ð¸Ñ\87ко (Ñ\81поделено) Ð²Ð´Ð¾Ð¼Ñ\83ваÑ\9aе, Ñ\82огаÑ\88 Ð²Ð°Ñ\88иоÑ\82 Ð²Ð´Ð¾Ð¼Ð¸Ñ\82ел Ñ\82Ñ\80еба Ð´Ð° Ð³Ð¾ Ð½Ð°Ð²ÐµÐ´Ðµ Ñ\82оÑ\87ноÑ\82о Ð¸Ð¼Ðµ Ð½Ð° Ð´Ð¾Ð¼Ð°Ñ\9cиноÑ\82 Ð²Ð¾ Ð½ÐµÐ³Ð¾Ð²Ð°Ñ\82а Ð´Ð¾ÐºÑ\83менÑ\82аÑ\86иÑ\98а.\n\nÐ\90ко Ð²Ð¾Ñ\81поÑ\81Ñ\82авÑ\83ваÑ\82е Ð½Ð° Ð¾Ð¿Ñ\81лÑ\83жÑ\83ваÑ\87 Ð½Ð° Windows Ð¸ ÐºÐ¾Ñ\80иÑ\81Ñ\82иÑ\82е MySQL, Ð¼Ð¾Ð¶Ð½Ð¾Ñ\81Ñ\82а â\80\9elocalhostâ\80\9c Ð¼Ð¾Ð¶Ðµ Ð´Ð° Ð½Ðµ Ñ\84Ñ\83нкÑ\86иониÑ\80а Ð·Ð° Ð¾Ð¿Ñ\81лÑ\83жÑ\83ваÑ\87коÑ\82о Ð¸Ð¼Ðµ. Ð\92о Ñ\82оÑ\98 Ñ\81лÑ\83Ñ\87аÑ\98, Ð¾Ð±Ð¸Ð´ÐµÑ\82е Ñ\81е Ñ\81о Ð²Ð½ÐµÑ\81Ñ\83ваÑ\9aе Ð½Ð° â\80\9e127.0.0.1â\80\9c ÐºÐ°ÐºÐ¾ Ð¼ÐµÑ\81на IP-адÑ\80еÑ\81а.\n\nÐ\90ко ÐºÐ¾Ñ\80иÑ\81Ñ\82иÑ\82е PostgreSQL, Ð¾Ñ\81Ñ\82авеÑ\82е Ð³Ð¾ Ð¿Ð¾Ð»ÐµÐ²Ð¾ Ð¿Ñ\80азно Ð·Ð° Ð´Ð° Ñ\81е Ð¿Ð¾Ð²Ñ\80зеÑ\82е Ð¿Ñ\80екÑ\83 Unix-пÑ\80иклÑ\83Ñ\87ок.",
+       "config-db-host-help": "Ако вашата база е на друг опслужувач, тогаш тука внесете го името на домаќинот или IP-адресата.\n\nАко користите заедничко (споделено) вдомување, тогаш вашиот вдомител треба да го наведе точното име на домаќинот во неговата документација.\n\nАко користите MySQL, можноста „localhost“ може да не функционира за опслужувачкото име. Во тој случај, обидете се со внесување на „127.0.0.1“ како месна IP-адреса.\n\nАко користите PostgreSQL, оставете го полево празно за да се поврзете преку Unix-приклучок.",
        "config-db-host-oracle": "TNS на базата:",
        "config-db-host-oracle-help": "Внесете важечко [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm месно име за поврзување]. На оваа воспоставка мора да ѝ биде видлива податотеката tnsnames.ora.<br />Ако користите клиентски библиотеки 10g или понови, тогаш можете да го користите и методот на иметнување на [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect].",
        "config-db-wiki-settings": "Идентификувај го викиво",
index 8c19cb1..54f743d 100644 (file)
@@ -84,9 +84,9 @@
        "config-wincache": "[https://www.iis.net/downloads/microsoft/wincache-extension WinCache] is op dit moment geïnstalleerd",
        "config-no-cache-apcu": "<strong>Waarschuwing:</strong> [https://secure.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] of [https://www.iis.net/downloads/microsoft/wincache-extension WinCache] is niet aangetroffen.\nHet cachen van objecten is niet ingeschakeld.",
        "config-mod-security": "<strong>Waarschuwing:</strong> Uw webserver heeft de module [https://modsecurity.org/ mod_security]/mod_security2 ingeschakeld. Veel standaard instellingen hiervan zorgen voor problemen in combinatie met MediaWiki en andere software die gebruikers in staat stelt willekeurige inhoud te posten.\nIndien mogelijk, zou deze moeten worden uitgeschakeld. Lees anders de [https://modsecurity.org/documentation/ documentatie over mod_security] of neem contact op met de helpdesk van uw provider als u tegen problemen aanloopt.",
-       "config-diff3-bad": "GNU diff3 niet aangetroffen.",
+       "config-diff3-bad": "GNU diff3 niet aangetroffen. U kunt dit voorlopig negeren, maar bewerkingsconflicten kunnen vaker voorkomen.",
        "config-git": "Versiecontrolesoftware git is aangetroffen: <code>$1</code>",
-       "config-git-bad": "Geen git versiecontrolesoftware aangetroffen.",
+       "config-git-bad": "Geen git versiecontrolesoftware aangetroffen. U kunt dit voorlopig negeren. Merk op dat Speciaal:SoftwareVersie geen commit hashes toont.",
        "config-imagemagick": "ImageMagick aangetroffen: <code>$1</code>.\nHet aanmaken van miniaturen van afbeeldingen wordt ingeschakeld als u uploaden inschakelt.",
        "config-gd": "Ingebouwde GD grafische bibliotheek aangetroffen.\nHet aanmaken van miniaturen van afbeeldingen wordt ingeschakeld als u uploaden inschakelt.",
        "config-no-scaling": "Noch de GD-bibliotheek noch ImageMagick zijn  aangetroffen.\nHet maken van miniaturen van afbeeldingen wordt uitgeschakeld.",
index 890da8f..0efcc11 100644 (file)
        "config-using-32bit": "<strong>Uwaga:</strong> twój system wydaje się działać na 32 bitowej architekturze. Jest to [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit niezalecane].",
        "config-db-type": "Typ bazy danych:",
        "config-db-host": "Adres serwera bazy danych:",
-       "config-db-host-help": "Jeśli serwer bazy danych jest na innej maszynie, wprowadź jej nazwę domenową lub adres IP.\n\nJeśli korzystasz ze współdzielonego hostingu, operator serwera powinien podać Ci prawidłową nazwę serwera w swojej dokumentacji.\n\nJeśli instalujesz oprogramowanie na serwerze Windows i korzystasz z MySQL, użycie „localhost” może nie zadziałać jako nazwa hosta. Jeśli wystąpi ten problem, użyj „127.0.0.1” jako lokalnego adresu IP.\n\nJeżeli korzystasz z PostgreSQL, pozostaw to pole puste, aby połączyć się poprzez gniazdo Unixa.",
+       "config-db-host-help": "Jeśli serwer bazy danych jest na innej maszynie, wprowadź jej nazwę domenową lub adres IP.\n\nJeśli korzystasz ze współdzielonego hostingu, operator serwera powinien podać Ci prawidłową nazwę serwera w swojej dokumentacji.\n\nJeśli korzystasz z MySQL, użycie „localhost” może nie zadziałać jako nazwa hosta. Jeśli wystąpi ten problem, użyj „127.0.0.1” jako lokalnego adresu IP.\n\nJeżeli korzystasz z PostgreSQL, pozostaw to pole puste, aby połączyć się poprzez gniazdo Unixa.",
        "config-db-host-oracle": "Nazwa instancji bazy danych (TNS):",
        "config-db-host-oracle-help": "Wprowadź prawidłową [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm nazwę połączenia lokalnego]. Plik „tnsnames.ora” musi być widoczny dla instalatora.<br />Jeśli używasz biblioteki klienckiej 10g lub nowszej możesz również skorzystać z metody nazw [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm łatwego łączenia].",
        "config-db-wiki-settings": "Zidentyfikuj tę wiki",
        "config-invalid-db-server-oracle": "Nieprawidłowa nazwa instancji bazy danych (TNS) „$1”.\nUżyj \"TNS Name\" lub \"Easy Connect\" ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracle Naming Methods])",
        "config-invalid-db-name": "Nieprawidłowa nazwa bazy danych „$1”.\nUżywaj wyłącznie liter ASCII (a-z, A-Z), cyfr (0-9), podkreślenia (_) lub znaku odejmowania (-).",
        "config-invalid-db-prefix": "Nieprawidłowy prefiks bazy danych „$1”.\nUżywaj wyłącznie liter ASCII (a-z, A-Z), cyfr (0-9), podkreślenia (_) lub znaku odejmowania (-).",
-       "config-connection-error": "$1.\n\nSprawdź adres serwera, nazwę użytkownika i hasło, a następnie spróbuj ponownie.",
+       "config-connection-error": "$1.\n\nSprawdź adres serwera, nazwę użytkownika i hasło, a następnie spróbuj ponownie. Jeżeli korzystasz z „localhosta” jako serwera bazy danych, spróbuj zamiast tego użyć „127.0.0.1” (lub na odwrót).",
        "config-invalid-schema": "Nieprawidłowa nazwa schematu dla MediaWiki „$1”.\nNazwa może zawierać wyłącznie liter ASCII (a-z, A-Z), cyfr (0-9) i podkreślenia (_).",
        "config-db-sys-create-oracle": "Instalator może wykorzystać wyłącznie konto SYSDBA do tworzenia nowych kont użytkowników.",
        "config-db-sys-user-exists-oracle": "Konto użytkownika „$1” już istnieje. SYSDBA można użyć tylko do utworzenia nowego konta!",
index a01d5b7..97288bb 100644 (file)
@@ -91,7 +91,7 @@
        "config-invalid-db-server-oracle": "Neveljaven TNS zbirke podatkov »$1«.\nUporabite ali \"ime TNS\" ali niz \"Easy Connect\" ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Načini poimenovanja Oracle])",
        "config-invalid-db-name": "Neveljavno ime zbirke podatkov »$1«.\nUporabljajte samo črke ASCII (a-z, A-Z), številke (0-9), podčrtaje (_) in vezaje (-).",
        "config-invalid-db-prefix": "Neveljavna predpona zbirke podatkov »$1«.\nUporabljajte samo črke ASCII (a-z, A-Z), številke (0-9), podčrtaje (_) in vezaje (-).",
-       "config-connection-error": "$1.\n\nPreverite gostitelja, uporabniško ime in geslo spodaj ter poskusite znova.",
+       "config-connection-error": "$1.\n\nPreverite gostitelja, uporabniško ime in geslo ter poskusite znova. Če kot gostitelja zbirke podatkov uporabljate »localhost«, poskusite namesto tega uporabiti »127.0.0.1« (ali obratno).",
        "config-postgres-old": "Potreben je PostgreSQL $1 ali novejši; vi imate $2.",
        "config-sqlite-connection-error": "$1.\n\nPreverite mapo podatkov in ime zbirke podatkov spodaj ter poskusite znova.",
        "config-sqlite-readonly": "Datoteka <code>$1</code> ni zapisljiva.",
index 76a7760..4d1b855 100644 (file)
@@ -24,7 +24,6 @@
  * @author Luke Welling lwelling@wikimedia.org
  */
 
-use MediaWiki\Linker\LinkTarget;
 use MediaWiki\MediaWikiServices;
 
 /**
@@ -89,33 +88,6 @@ class EmailNotification {
                return $this->pageStatus;
        }
 
-       /**
-        * @deprecated since 1.27 use WatchedItemStore::updateNotificationTimestamp directly
-        *
-        * @param User $editor The editor that triggered the update.  Their notification
-        *  timestamp will not be updated(they have already seen it)
-        * @param LinkTarget $linkTarget The link target of the title to update timestamps for
-        * @param string $timestamp Set the update timestamp to this value
-        *
-        * @return int[] Array of user IDs
-        */
-       public static function updateWatchlistTimestamp(
-               User $editor,
-               LinkTarget $linkTarget,
-               $timestamp
-       ) {
-               wfDeprecated( __METHOD__, '1.27' );
-               $config = RequestContext::getMain()->getConfig();
-               if ( !$config->get( 'EnotifWatchlist' ) && !$config->get( 'ShowUpdatedMarker' ) ) {
-                       return [];
-               }
-               return MediaWikiServices::getInstance()->getWatchedItemStore()->updateNotificationTimestamp(
-                       $editor,
-                       $linkTarget,
-                       $timestamp
-               );
-       }
-
        /**
         * Send emails corresponding to the user $editor editing the page $title.
         *
index 76b2de0..66804bc 100644 (file)
@@ -357,7 +357,7 @@ class ImagePage extends Article {
                                # image
                                # "Download high res version" link below the image
                                # $msgsize = $this->getContext()->msg( 'file-info-size', $width_orig, $height_orig,
-                               #   Linker::formatSize( $this->displayImg->getSize() ), $mime )->escaped();
+                               #   Language::formatSize( $this->displayImg->getSize() ), $mime )->escaped();
                                # We'll show a thumbnail of this image
                                if ( $width > $maxWidth || $height > $maxHeight || $this->displayImg->isVectorized() ) {
                                        list( $width, $height ) = $this->getDisplayWidthHeight(
index b7c85d4..b648260 100644 (file)
@@ -1218,7 +1218,9 @@ MESSAGE;
                $name, $scripts, $styles, $messages, $templates
        ) {
                if ( $scripts instanceof XmlJsCode ) {
-                       if ( self::inDebugMode() ) {
+                       if ( $scripts->value === '' ) {
+                               $scripts = null;
+                       } elseif ( self::inDebugMode() ) {
                                $scripts = new XmlJsCode( "function ( $, jQuery, require, module ) {\n{$scripts->value}\n}" );
                        } else {
                                $scripts = new XmlJsCode( 'function($,jQuery,require,module){' . $scripts->value . '}' );
index a0100ca..0b29dd7 100644 (file)
@@ -279,18 +279,6 @@ abstract class SearchEngine {
                return static::defaultNearMatcher()->getNearMatch( $searchterm );
        }
 
-       /**
-        * Do a near match (see SearchEngine::getNearMatch) and wrap it into a
-        * SearchResultSet.
-        * @deprecated since 1.27; Use SearchEngine::getNearMatcher()
-        * @param string $searchterm
-        * @return SearchResultSet
-        */
-       public static function getNearMatchResultSet( $searchterm ) {
-               wfDeprecated( __METHOD__, '1.27' );
-               return static::defaultNearMatcher()->getNearMatchResultSet( $searchterm );
-       }
-
        /**
         * Get chars legal for search
         * NOTE: usage as static is deprecated and preserved only as BC measure
index ceb9ceb..385cc35 100644 (file)
@@ -377,23 +377,6 @@ final class SessionManager implements SessionManagerInterface {
         * @{
         */
 
-       /**
-        * Auto-create the given user, if necessary
-        * @private Don't call this yourself. Let Setup.php do it for you at the right time.
-        * @deprecated since 1.27, use MediaWiki\Auth\AuthManager::autoCreateUser instead
-        * @param User $user User to auto-create
-        * @return bool Success
-        * @codeCoverageIgnore
-        */
-       public static function autoCreateUser( User $user ) {
-               wfDeprecated( __METHOD__, '1.27' );
-               return \MediaWiki\Auth\AuthManager::singleton()->autoCreateUser(
-                       $user,
-                       \MediaWiki\Auth\AuthManager::AUTOCREATE_SOURCE_SESSION,
-                       false
-               )->isGood();
-       }
-
        /**
         * Prevent future sessions for the user
         *
index 4201f80..4e23777 100644 (file)
@@ -792,10 +792,6 @@ abstract class ChangesListSpecialPage extends SpecialPage {
                        $out->addJsConfigVars( 'wgStructuredChangeFiltersMessages', $messages );
                        $out->addJsConfigVars( 'wgStructuredChangeFiltersCollapsedState', $collapsed );
 
-                       $out->addJsConfigVars(
-                               'wgRCFiltersChangeTags',
-                               $this->getChangeTagList()
-                       );
                        $out->addJsConfigVars(
                                'StructuredChangeFiltersDisplayConfig',
                                [
@@ -823,26 +819,35 @@ abstract class ChangesListSpecialPage extends SpecialPage {
                                'wgStructuredChangeFiltersCollapsedPreferenceName',
                                static::$collapsedPreferenceName
                        );
-
-                       $out->addJsConfigVars(
-                               'StructuredChangeFiltersLiveUpdatePollingRate',
-                               $this->getConfig()->get( 'StructuredChangeFiltersLiveUpdatePollingRate' )
-                       );
                } else {
                        $out->addBodyClasses( 'mw-rcfilters-disabled' );
                }
        }
 
+       /**
+        * Get config vars to export with the mediawiki.rcfilters.filters.ui module.
+        *
+        * @param ResourceLoaderContext $context
+        * @return array
+        */
+       public static function getRcFiltersConfigVars( ResourceLoaderContext $context ) {
+               return [
+                       'RCFiltersChangeTags' => self::getChangeTagList( $context ),
+                       'StructuredChangeFiltersEditWatchlistUrl' =>
+                               SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
+               ];
+       }
+
        /**
         * Fetch the change tags list for the front end
         *
+        * @param ResourceLoaderContext $context
         * @return array Tag data
         */
-       protected function getChangeTagList() {
+       protected static function getChangeTagList( ResourceLoaderContext $context ) {
                $cache = ObjectCache::getMainWANInstance();
-               $context = $this->getContext();
                return $cache->getWithSetCallback(
-                       $cache->makeKey( 'changeslistspecialpage-changetags', $context->getLanguage()->getCode() ),
+                       $cache->makeKey( 'changeslistspecialpage-changetags', $context->getLanguage() ),
                        $cache::TTL_MINUTE * 10,
                        function () use ( $context ) {
                                $explicitlyDefinedTags = array_fill_keys( ChangeTags::listExplicitlyDefinedTags(), 0 );
@@ -858,6 +863,10 @@ abstract class ChangesListSpecialPage extends SpecialPage {
                                */
                                $tagHitCounts = array_merge( $explicitlyDefinedTags, $softwareActivatedTags );
 
+                               // HACK work around ChangeTags::truncateTagDescription() requiring a RequestContext
+                               $fakeContext = new RequestContext;
+                               $fakeContext->setLanguage( Language::factory( $context->getLanguage() ) );
+
                                // Build the list and data
                                $result = [];
                                foreach ( $tagHitCounts as $tagName => $hits ) {
@@ -873,7 +882,9 @@ abstract class ChangesListSpecialPage extends SpecialPage {
                                                        ),
                                                        'description' =>
                                                                ChangeTags::truncateTagDescription(
-                                                                       $tagName, self::TAG_DESC_CHARACTER_LIMIT, $context
+                                                                       $tagName,
+                                                                       self::TAG_DESC_CHARACTER_LIMIT,
+                                                                       $fakeContext
                                                                ),
                                                        'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ),
                                                        'hits' => $hits,
index 0fc6e13..971aa43 100644 (file)
@@ -102,11 +102,6 @@ class SpecialWatchlist extends ChangesListSpecialPage {
 
                if ( $this->isStructuredFilterUiEnabled() ) {
                        $output->addModuleStyles( [ 'mediawiki.rcfilters.highlightCircles.seenunseen.styles' ] );
-
-                       $output->addJsConfigVars(
-                               'wgStructuredChangeFiltersEditWatchlistUrl',
-                               SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL()
-                       );
                }
        }
 
index f5eee34..f4e2e48 100644 (file)
@@ -5124,31 +5124,6 @@ class User implements IDBAccessObject, UserIdentity {
                return true;
        }
 
-       /**
-        * Get the localized descriptive name for a group, if it exists
-        * @deprecated since 1.29 Use UserGroupMembership::getGroupName instead
-        *
-        * @param string $group Internal group name
-        * @return string Localized descriptive group name
-        */
-       public static function getGroupName( $group ) {
-               wfDeprecated( __METHOD__, '1.29' );
-               return UserGroupMembership::getGroupName( $group );
-       }
-
-       /**
-        * Get the localized descriptive name for a member of a group, if it exists
-        * @deprecated since 1.29 Use UserGroupMembership::getGroupMemberName instead
-        *
-        * @param string $group Internal group name
-        * @param string $username Username for gender (since 1.19)
-        * @return string Localized name for group member
-        */
-       public static function getGroupMember( $group, $username = '#' ) {
-               wfDeprecated( __METHOD__, '1.29' );
-               return UserGroupMembership::getGroupMemberName( $group, $username );
-       }
-
        /**
         * Return the set of defined explicit groups.
         * The implicit groups (by default *, 'user' and 'autoconfirmed')
index 445e6cb..3dbde01 100644 (file)
@@ -245,7 +245,7 @@ class Language {
                        // It's not possible to customise this code with class files, so
                        // just return a Language object. This is to support uselang= hacks.
                        $lang = new Language;
-                       $lang->setCode( $code );
+                       $lang->mCode = $code;
                        return $lang;
                }
 
@@ -267,7 +267,7 @@ class Language {
                        $class = self::classFromCode( $fallbackCode );
                        if ( class_exists( $class ) ) {
                                $lang = new $class;
-                               $lang->setCode( $code );
+                               $lang->mCode = $code;
                                return $lang;
                        }
                }
@@ -4449,6 +4449,7 @@ class Language {
         * @deprecated since 1.32, use Language::factory to create a new object instead.
         */
        public function setCode( $code ) {
+               wfDeprecated( __METHOD__, '1.32' );
                $this->mCode = $code;
                // Ensure we don't leave incorrect cached data lying around
                $this->mHtmlCode = null;
index e79de32..5752fe1 100644 (file)
        "ipb_expiry_old": "توقيت انتهاء المنع واقع في الماضي.",
        "ipb_expiry_temp": "عمليات منع أسماء المستخدمين المخفية يجب أن تكون دائمة.",
        "ipb_hide_invalid": "غير قادر على منع الحساب؛ لديه أكثر من {{PLURAL:$1|تعديل واحد|$1 تعديل}}.",
+       "ipb_hide_partial": "عمليات المنع التي تشمل إخفاء اسم المستخدم يجب أن تكون عمليات منع كاملة.",
        "ipb_already_blocked": "\"$1\" ممنوع حالياً",
        "ipb-needreblock": "$1 ممنوع حالياً. هل تريد تغيير الإعدادات؟",
        "ipb-otherblocks-header": "{{PLURAL:$1||المنع الآخر|المنعان الآخران|المنوعات الأخرى}}",
index 56b6f0f..be1b221 100644 (file)
        "ipb-disableusertalk": "Забараніць гэтаму ўдзельніку рэдагаваць сваю старонку размоў падчас блакіроўкі",
        "ipb-change-block": "Змяніць настройкі блакіравання ўдзельніка",
        "ipb-confirm": "Пацвердзіць блакіроўку",
+       "ipb-sitewide": "Ва ўсім праекце",
+       "ipb-partial": "Частковая",
+       "ipb-pages-label": "Старонкі",
+       "ipb-namespaces-label": "Прасторы назваў",
        "badipaddress": "Недапушчальны адрас IP",
        "blockipsuccesssub": "Паспяховае блакаванне",
        "blockipsuccesstext": "[[Special:Contributions/$1|$1]] быў заблакаваны.<br />\nБлокі пералічаны ў [[Special:BlockList|спісе блокаў]].",
        "ipb-blocklist": "Паказаць наяўныя блокі",
        "ipb-blocklist-contribs": "Уклад {{GENDER:$1|$1}}",
        "ipb-blocklist-duration-left": "засталося $1",
+       "block-actions": "Дзеянні для блакіроўкі:",
        "block-expiry": "Згасае:",
+       "block-options": "Дадатковыя опцыі:",
+       "block-target": "Удзельнік або адрас IP:",
        "unblockip": "Зняць блок з удзельніка",
        "unblockiptext": "З дапамогай формы ніжэй можна вярнуць дазвол на праўкі для раней заблакіраванага IP-адраса або ўдзельніка.",
        "ipusubmit": "Зняць гэты блок",
        "ipb_expiry_old": "Час сканчэння — у мінулым.",
        "ipb_expiry_temp": "Скрытыя блокі на імёны ўдзельнікаў мусяць быць сталымі.",
        "ipb_hide_invalid": "Немагчыма заглушыць гэты рахунак; для яго маецца больш за {{PLURAL:$1|адну праўку|$1 праўкі|$1 правак}}.",
+       "ipb_hide_partial": "Блакіроўкі схаваных імёнаў удзельнікаў мусяць пашырацца на ўвесь праект.",
        "ipb_already_blocked": "\"$1\" ужо знаходзіцца пад блокам",
        "ipb-needreblock": "$1 ужо заблакіраваны. Жадаеце змяніць настройкі блакіроўкі?",
        "ipb-otherblocks-header": "{{PLURAL:$1|Іншая блакіроўка|Іншыя блакіроўкі}}",
index 1c86ba5..4a5cbe9 100644 (file)
        "ipb_expiry_old": "Срокът на изтичане е минал.",
        "ipb_expiry_temp": "Скритите потребителски имена трябва да се блокират безсрочно.",
        "ipb_hide_invalid": "Тази потребителска сметка не може да бъде прикрита; с нея са направени повече от {{PLURAL:$1|една редакция|$1 редакции}}.",
+       "ipb_hide_partial": "Скритите забрани за потребителски имена трябва да се прилагат за цяло уики.",
        "ipb_already_blocked": "„$1“ е вече блокиран.",
        "ipb-needreblock": "$1 е вече блокиран. Желаете ли да промените настройките?",
        "ipb-otherblocks-header": "{{PLURAL:$1|Друго блокиране|Други блокирания}}",
index 5f32141..cbf9212 100644 (file)
        "mainpage": "سرتال",
        "mainpage-description": "سرتال",
        "policy-url": "Project:رٱڤشت کار",
-       "portal": "سرآسۊناْ کاریاروݩ",
-       "portal-url": "Project:سرآسۊناْ کاریاروݩ",
+       "portal": "سرآسوناْ کاریاروݩ",
+       "portal-url": "Project:سرآسوناْ کاریاروݩ",
        "privacy": "رٱدیارکونی رازڤادٙاری",
        "privacypage": "Project:رٱدیارکونی رازڤاڌاری",
        "badaccess": "خٱتا :ساْلا بیار",
        "red-link-title": "$1 (چونو بلگاْیی نیڌس)",
        "sort-descending": "ڤدین یٱک کٱم آڤیڌن",
        "sort-ascending": "پوشت سریٱک کم ڤابیڌن",
-       "nstab-main": "بٱلگٱ",
+       "nstab-main": "بٱلگاْ",
        "nstab-user": "بٱلگاْ کاریار",
        "nstab-media": "بٱلگاْ ڤارسگٱری",
        "nstab-special": "بٱلگاْ ڤیجٱ",
        "delete-scheduled": "بٱلٛگاْ$1 سی پاکسا کۊڌالکاری آڤیڌاْ.لوتفٱن سٱڤۊر ڤۊهین",
        "delete-hook-aborted": "پژار ڤا قولاڤ لٱق آڤیڌ\nاشکافنیڌنی سی هؽ داڌ نڤیڌ",
        "no-null-revision": "سی بٱلگاْ $1 ڤانیٱری خومسا ناْ راس کونین",
-       "badtitle": "داسۊن گٱن",
-       "badtitletext": "داسۊن خاسدنی نادیار، هالی، یا داسۊنی کاْ میٛنجقا زڤونی یا میٛنجقا ڤیکی ڤا هومپاٛیڤٱند دوروسد ناراْ و یا گاشا چٱنتا کاراکتر داراْ کاْ ڤا میٛن داسۊن نۉ باْیوفتاْ ڤا کار.",
+       "badtitle": "داسوݩ گٱن",
+       "badtitletext": "داسوݩ خاسدنی نادیار، هالی، یا داسۊنی کاْ میٛنجقا زڤونی یا میٛنجقا ڤیکی ڤا هومپاٛیڤٱند دوروسد ناراْ و یا گاشا چٱنتا کاراکتر داراْ کاْ ڤا میٛن داسۊن نۉ باْیوفتاْ ڤا کار.",
        "title-invalid-empty": "اوڌڤان بٱلٛگاْ دٱرخاس آڤیڌاْ پٱتی هؽڌآ یا تاٛنا اوڌڤان مؽن نوم گوڌ آڤیڌاْ هؽڌآ",
        "title-invalid-utf8": "اوڌڤان بٱلٛگاْ دٱرخاس آڤیڌاْ هؽل ڤیڌاْ نادوروس یونیکوڌ هؽڌآ",
        "title-invalid-interwiki": "بٱلٛگاْ دٱرخاس آڤیڌاْ دارای پاٛڤٱن مؽن ڤیکی هؽڌآ کاْ نؽڤۊهاْ مؽن اوڌڤانٱل نهاڌاْ ڤۊهاْ",
        "cannotlogoutnow-title": "ایسا ایساْ نٱترین بیائین ب دٱر",
        "cannotlogoutnow-text": "تا گاتی کاْ $1 ناْ ڤٱنین ڤا کار نٱترین بیائین ڤامیٛن.",
        "welcomeuser": "خوش ٱڤوڌین،$1!",
-       "welcomecreation-msg": "هساوتوݩ راسد ڤابی.\nب ڤیرتوݩ بۊ کاْ {{نوم دیارگٱ}} [[Special:Preferences|preferences]]  خوتۊناْ آلشد کونین.",
+       "welcomecreation-msg": "هساوتوݩ راسد ڤابی.\nب ڤیرتوݩ بۊ کاْ {{نوم دیارگٱ}} [[Special:Preferences|preferences]]  خوتوناْ آلشد کونین.",
        "yourname": "نوم کاریاری:",
        "userlogin-yourname": "نوم کاریاری",
        "userlogin-yourname-ph": "نوم کاریاریتۊناْ بزنین",
        "password-change-forbidden": "ایسا نٱترین رازیناْ گوڌٱشتن خوتۊناْ میٛن ای ڤیکی آلشد کونین.",
        "externaldberror": "اٛشتڤایی میٛن پاٛیڤٱند ڤا رسیناْگا اْتفاق ڤٱستاْ یا ایسا ساْلا یوناْ کاْ یٱ هساو کاریاری خارجی ز خوتۊناْ ب هاْنگوم سازی کونین نارین.",
        "login": "ڤامیٛن ٱڤوڌن",
-       "login-security": "نشۊن دیارکون خوتۊناْ آلشد کونین",
+       "login-security": "نشوݩ دیارکون خوتۊناْ آلشد کونین",
        "nav-login-createaccount": "ٱڤوڌن ڤامیٛن/راس کردن هساو کاریاری",
        "logout": "ز سامۊناْ درٱڤوڌن",
        "userlogout": "ز سامۊناْ درٱڤوڌن",
        "userlogin-helplink2": "هومیاری کردن سی ڤامیٛن ٱڤوڌن",
        "userlogin-loggedin": "ایسا ایساْ چی {{GENDER:$1|$1}} ٱڤۊڌین ڤامیٛن. فورم داْڤۊنی ناْ ڤٱنین ڤا کار و چی یٱ کاریار دیٱ بیائین ڤا میٛن",
        "userlogin-createanother": "یٱ هساو کاریاری دیٱ راسد کونین",
-       "createacct-emailrequired": "تیرنشۊن ٱنجومانامٱ",
-       "createacct-emailoptional": "تیرنشۊن ٱنجومانامٱ",
-       "createacct-email-ph": "تیرنشۊن ٱنجوماناماْ تۊناْ بزنین.",
-       "createacct-another-email-ph": "تیرنشۊن ٱنجوماناماْ تۊناْ بزنین.",
+       "createacct-emailrequired": "تیرنشوݩ ٱنجومانامٱ",
+       "createacct-emailoptional": "تیرنشوݩ ٱنجومانامٱ",
+       "createacct-email-ph": "تیرنشوݩ ٱنجوماناماْ تۊناْ بزنین.",
+       "createacct-another-email-ph": "تیرنشوݩ ٱنجوماناماْ تۊناْ بزنین.",
        "createaccountmail": "یٱ رازیناْ گوڌٱشتن موڤٱقٱتی ناْ ڤاْنین ڤا کار و سی یٱ تیرنشوݩ ٱنجوماناماْ تیار ڤابیڌاْ باْسیس کونین.",
        "createaccountmail-help": "ایسا ترین یٱ هساو کاریاری سی یکی دیٱ راسد کونین بی یو کاْ رازیناْ گوڌٱشتنساْ ڤٱنین ڤا ڤیر.",
        "createacct-realname": "نوم راستٱکی(اٛژباری نی)",
        "createacct-reason": "دلیل",
        "createacct-reason-ph": "سی چ ایسا دارین یٱ هساو کاریاری دیٱر راسد اْکونین",
        "createacct-reason-help": "پاٛیغوم دیار کرداْ میٛن پاٛرستنوماْ راسد کردن هساو کاریاری",
-       "createacct-submit": "هساو خوتۊناْ راسد کونین",
+       "createacct-submit": "هساو خوتوناْ راسد کونین",
        "createacct-another-submit": "راسد کردن هساو کارياری",
-       "createacct-continue-submit": "هساو راسد کردن خوتۊناْ اٛڌاماْ بڌین",
-       "createacct-another-continue-submit": "هساڤ راسد کردن خوتۊناْ اٛڌاماْ بڌین",
+       "createacct-continue-submit": "هساو راسد کردن خوتوناْ اٛڌاماْ بڌین",
+       "createacct-another-continue-submit": "هساڤ راسد کردن خوتوناْ اٛڌاماْ بڌین",
        "createacct-benefit-heading": "{{SITENAME}}  ڤ دٱسد خٱلکی چی ایسا رٱڤٱندیاری ڤابیڌاْ.",
        "createacct-benefit-body1": "{{PLURAL:$1|آلشدکاری|آلشدکاریٱل}}",
        "createacct-benefit-body2": "{{PLURAL:$1|بٱلگاْ|بٱلگاْیٱل}}",
        "passwordremindertitle": "رازیناْ گوڌٱشتن موڤٱقٱتی سی {{SITENAME}}",
        "passwordremindertext": "یٱ نفر (گاشا خوتوݩ، ز تیرنشوݩ آی پی $1) یٱ رازیناْ گوڌٱشتن تازاْ خاسداْ سی  {{SITENAME}} ($4). یٱ رازیناْ گوڌاْشتن موڤٱقٱتی سی کاریار\n\"$2\" راسد ڤابیڌاْ و میٛن\"$3\" لاهاڌاْ ڤابیڌاْ. ٱ ب دلتوݩ بۊ, ڤا رۉین میٛن ساموناْ و یٱ رازیناْ گوڌاْشتن تازاْ گولاْڤورچین کونین.\n\nٱر هو کٱسی کاْ چونو چی خاسداْ بۊ کاْس دیٱری بۊ, یا ٱر ایسا رازیناْ گوڌٱشتنتوݩ ب ڤیرتوݩ بۊ و سی یٱ گات تیلدار خاین هوناْ آلشد کونین، ایسا ڤا ای پاٛیغوم ناْ باْنین کنار و هٱمچونو هٱمو رازیناْ گوڌٱشتن دیندایی خوتوناْ ڤٱنین ڤا کار.",
        "noemail": "هیژ تیرنشوݩ ٱنجوماناماْیی سی کاریار \"$1\" زٱفت نٱڤابیڌاْ.",
-       "passwordsent": "یه رمز تازه ارسال وابید به نشانی امیل ثبت وابده سی \"$1\".\nلطفا بعد از دریافت آن داخل سیستم بوین.",
+       "noemailcreate": "ایسا ڤا یٱ تیرنشوݩ جادیار داشداْ بۊین",
+       "passwordsent": "یٱ رازیناْ گوڌٱشتن باْسی ڤابی ب تیرنشوݩ ٱنجوماناماْیی کاْ سٱڤت کردیناْ \"$1\".\nخاهشت اْکونیم نیا گرهڌنس بیائین ڤامیٛن.",
        "eauthentsent": "یٱ ٱنجوماناماْ پوشت راست کردنی سی یٱ تیرنشوݩ ڤیجاْ بیٛسی ڤابیڌاْ.\nنیا یو کاْ یٱ ٱنجوماناماْ دیٱر سی هساوتوݩ بیٛسی ڤابۊ، ایسا ڤا نیا رٱدیارکونی ناْ ز ٱنجوماناماْ بگرین، سی یو کاْ هساو ایسا ز راستی پوشت راست ڤابۊ.",
+       "mailerror": "خٱتا میٛن باْسی کردن ٱنجوماناماْ:$1",
+       "emailconfirmlink": "تیرنشوݩ ٱنجوماناماْ خوتوناْ پوشت راسدکاری کونین.",
+       "cannotchangeemail": "نٱترین تیرنشوݩ ٱنجوماناماْ هساو میٛن ای ڤیکی ناْ آلشدکاری کونین",
        "emaildisabled": "ای دیارگٱ نٱتٱراْ سیتوݩ ٱنجوماناماْ بفرشناْ",
        "accountcreated": "هساو راسد ڤابی",
        "createaccount-title": "هساڤ سي {{SITENAME}} راسد ڤابي",
+       "login-abort-generic": "ٱڤوڌن ڤامیٛنتو خراو ڤابی یا نتیجاْ ناشت.",
        "loginlanguagelabel": "زڤون:$1",
        "pt-login": "ڤامین ٱڤوڌن",
        "pt-login-button": "ڤامیٛن ٱڤوڌن",
        "oldpassword": "رازينإ گوڤأرتن ديندایي:",
        "newpassword": "رازينإ گوڤأرتن تازأ:",
        "retypenew": "تایپ دوباره رمز:",
+       "resetpass_submit": "رازیناْ گوڌٱشتن توݩ بزنین بیائین ڤامیٛن",
+       "changepassword-success": "رازیناْ گوڌٱشتنتوݩ آلشد ڤابی!",
        "botpasswords": "رازينإیل گوڤأرتن بوتا",
+       "botpasswords-disabled": "نٱترین سی بوتٱل رازیناْ گوڌٱشتن باْنین",
+       "botpasswords-existing": "رازیناْ گوڌٱشتن سی بوتٱل",
+       "botpasswords-createnew": "یٱ زاریناْ گوڌٱشتن تازاْ سی بوت راسد کونین.",
+       "botpasswords-label-needsreset": "(ڤا ز نۉ رازیناْ گوڌٱشتن باْنین)",
        "botpasswords-label-appid": "نوم بوت:",
        "botpasswords-label-create": "راس كردن",
        "botpasswords-label-update": "ب هنگوم سازی",
-       "botpasswords-label-cancel": "أنجومشيڤ کردن",
+       "botpasswords-label-cancel": "ٱنجومشیڤ کردن",
        "botpasswords-label-delete": "پاکسا کردن",
-       "botpasswords-label-resetpassword": "ز نۉ داڌن رازينإ گوأرتن",
-       "botpasswords-label-grants-column": "داڌإ ڤابي",
-       "resetpass-submit-loggedin": "آلشد کردن رازينإ گوڤأرتن",
-       "resetpass-submit-cancel": "أنجومشيڤ کردن",
-       "passwordreset": "ز نۉ داڌن رازیناْ گوڤٱرتن",
-       "passwordreset-username": "نوم کارياري",
-       "passwordreset-domain": "پوشگر",
-       "passwordreset-email": "تيرنشۈن أنجومانامأ",
+       "botpasswords-label-resetpassword": "ز نۉ داڌن رازیناْ گوڌٱشتن",
+       "botpasswords-label-grants-column": "داڌاْ ڤابی",
+       "botpasswords-bad-appid": "نوم\"$1\" سی بوت خۊ نی.",
+       "botpasswords-insert-failed": "اْزاف کردن نوم \"$1\" سی بوت ناخوش سرٱنجوم بی. آیا هاْنی اْزاف نٱڤابیڌاْ?",
+       "botpasswords-update-failed": "ب هنگوم سازی نوم \"$1\" سی بوت ناخوش سرٱنجوم بی. آیا هاْنی پاکسا نٱڤٱبیڌاْ?",
+       "botpasswords-created-title": "رازیناْ گوڌٱشتن سی بوت راسد ڤابی",
+       "botpasswords-created-body": "رازیناْ گوڌٱشتن سی \"$1\" {{GENDER:$2|کاریار}} \"$2\" راسد ڤابی.",
+       "botpasswords-updated-title": "رازیناْ گوڌٱشتن بوت ب هنگوم سازی ڤابی",
+       "botpasswords-updated-body": "رازیناْ گوڌٱشتن سی \"$1\" {{GENDER:$2|کاریار}} \"$2\" ب هنگوم ساز ڤابی.",
+       "botpasswords-deleted-title": "رازیناْ گوڌٱشتن سی بوت پاکسا ڤابی",
+       "botpasswords-deleted-body": "رازیناْ گوڌٱشتن سی \"$1\" {{GENDER:$2|کاریار}} \"$2\" پاکسا ڤابی.",
+       "resetpass_forbidden": "نیبۊ رازیناْیٱل گوڌٱشتن ناْ آلشد کونین",
+       "resetpass_forbidden-reason": "نٱترین رازیناْیٱل گوڌٱشتن سی $1 آلشد کونین.",
+       "resetpass-no-info": "ایسا سی یو کاْ ب ای بٱلگاْ دٱسرسی داشداْ بۊین ڤا بیائین ڤامیٛن.",
+       "resetpass-submit-loggedin": "آلشد کردن رازیناْ گوڌٱشتن",
+       "resetpass-submit-cancel": "ٱنجومشیڤ کردن",
+       "passwordreset": "ز نۉ داڌن رازیناْ گوڌٱشتن",
+       "passwordreset-username": "نوم کاریاری",
+       "passwordreset-domain": "پۊشگر",
+       "passwordreset-email": "تیرنشوݩ ٱنجومانامٱ",
        "passwordreset-emailtitle": "جوزيات هساو میٛن {{SITENAME}}",
-       "passwordreset-invalidemail": "تيرنشۈن أنجومانامأ نادوروسد",
-       "changeemail-oldemail": "تيرنشۈن أنجومانامإ ايسني",
-       "changeemail-newemail": "تيرنشۈن أنجومانامإ تازأ:",
-       "changeemail-none": "(هيش كوم)",
-       "changeemail-password": "رازينإ گوڤأرتن {{SITENAME}} ایسا:",
-       "changeemail-submit": "آلشد کردن أنجومانامأ",
-       "resettokens": "ز نۉ کردن نشۈنإیل",
+       "passwordreset-invalidemail": "تیرنشوݩ ٱنجوماناماْ نادوروسد",
+       "changeemail-oldemail": "تیرنشوݩ ٱنجوماناماْ ایسنی:",
+       "changeemail-newemail": "تیرنشوݩ ٱنجوماناماْ تازاْ:",
+       "changeemail-none": "(هیش كوم)",
+       "changeemail-password": "رازیناْ گوڌٱشتن {{SITENAME}} ایسا:",
+       "changeemail-submit": "آلشد کردن ٱنجوماناماْ",
+       "resettokens": "ز نۉ کردن نشوناْیٱل",
        "resettokens-text": "اؽسا تٱرین شناساننداٛیٱلؽ کاْ اجازاٛ دٱسرٱسی ڤاْ قٱرڌؽ داداٛیٱل سیخؤاٛی مؽنڌار ڤا هساوتۊن ناْ اْڌاْ دوکرتشناسی کونین.\nؤخڌؽ ڤا ای کارناْ ٱنجوم ڤڌین کاْ تٱساڌوفٱن هونوناْ ڤا کسؽ ڤاْ هومبٱشنی نهاڌین یا کسؽ ڤاْمؽ ڤیڌ ڤاْ هساو اؽسا",
        "resettokens-no-tokens": "هیچ شناسانٱنڌاٛئی سی دوکرتشناسی نؽڌا",
-       "resettokens-tokens": "نشۈنإیل:",
-       "resettokens-token-label": "$1 (أرزایشت تازأ: $2)",
+       "resettokens-tokens": "نشوناْیٱل:",
+       "resettokens-token-label": "$1 (ٱرزایشت تازاْ: $2)",
        "resettokens-watchlist-token": "شناسانٱنڌاٛ خوراک ڤباٛی [[Special:Watchlist|آلشڌ بٱلٛگیٱلؽ کاْ دیناگری اْکونین]] (ٱتم/آراْس‌اْس)",
        "resettokens-done": "دوکرتشناسی شناسانٱنڌاٛیٱل",
        "resettokens-resetbutton": "دوکرتشناسی شناسانٱنڌاٛیٱل دزاْ آڤیڌاْ",
        "bold_tip": "متن گٱپ نما",
        "italic_sample": "متن ایتالیک",
        "italic_tip": "متن ایتالیک",
-       "link_sample": "داسۊن هومپاٛیڤٱند",
+       "link_sample": "داسوݩ هومپاٛیڤٱند",
        "link_tip": "هومپاٛیڤٱند داخلی",
-       "extlink_sample": "http://www.example.com داسۊن هومپاٛیڤٱند",
+       "extlink_sample": "http://www.example.com داسوݩ هومپاٛیڤٱند",
        "extlink_tip": "(ڤٱن ڤا ڤیرت http:// prefix)\nهومپاٛیڤٱند  خارجی",
        "headline_sample": "سرخٱت متن",
        "headline_tip": "ریتراز 2 سرخٱت",
        "sig_tip": "اْمزا ایسا ڤا گاتدیساْ",
        "hr_tip": "خٱت ٱوفوتی (کم ڤٱنین ڤا کار)",
        "summary": "چکستٱ:",
-       "subject": "داسۈن",
+       "subject": "داسوݩ",
        "minoredit": "یو یٱ ڤیرایشد کۊچیراْ",
        "watchthis": "پاٛگری ای بٱلگاْ",
        "savearticle": "بٱلگاْ اْمایاْ ڤابۊ",
        "publishpage-start": "تیژنیڌن بٱلٛگاْ....",
        "publishchanges-start": "تیژنیڌن آلشڌکاریٱل",
        "preview": "پيش ساٛیل",
-       "showpreview": "نشۊن دائن پیش ساٛیل",
-       "showdiff": "نشۊن دائن آلشدا",
+       "showpreview": "نشوݩ دائن پیش ساٛیل",
+       "showdiff": "نشوݩ دائن آلشدا",
        "anoneditwarning": "<strong>ب ڤیرتوݩ بۊ:</strong> ایسا هاْنی نٱڤۊڌین ڤامین. تیرنشوݩ آی پی ایسا سی هر گاتی کاْ آلشدکاری کونین سی کول خٱلک دیاراْ. ٱر <strong>[$1 رۉین ڤامین]</strong> یا <strong>[$2 یٱ هساو کاریاری راسد کونین]</strong>، آلشدکاریٱل ایسا ڤا نوم کاریاری خوتوݩ دیاری اْبۊ و یو سی ایسا بیتراْ.",
        "summary-preview": "پیش ساٛیل آلشدکاری خولاساْ:",
        "blockedtext": " \"'''دٱسرسی نوم کاریاری یا تیرنشوݩ آی پی ایسا نیاگری ڤابیڌاْ.'''\n $1 چونو کرداْ.\nدلیلس یو بیڌاْ: $2''\n* شورۊ نیاگری: $8\n* مجال تٱموم ڤابیڌن نیاگری: $6\n* کاریاری کاْ ڤا نیاگری ڤابیڌاْ بۊ: $7\nایسا تاْرین ڤا $1 یا یکی ز [[{{MediaWiki:Grouppage-sysop}}|سٱردیڤۊنکاروݩ]] تماس بگرین و ڤاسوݩ گٱپ بزنین.\nب ڤیرتوݩ بۊ کاْ ایسا ناْترن «ب ای کاریار ٱنجوماناماْ» بفرشنین مٱر تیرنشوݩ جادیاری ناْ میٛن  [[Special:Preferences|چیا ٱسلی کاریاری]] خوتوݩ سٱبت کرداْ بۊین.\nتیرنشوݩ IP ایسا $3 و شوماراْ نیاگری ڤابیڌاْ ایسا $5 اْ. لوتفٱن چونو شوماراْ یٱلی ناْ میٛن پاٛی جۊریٱل توݩ ب ڤیرتوݩ بۊ.",
        "blockednoreason": "هیژ دلیلی سیس نی",
        "nosuchsectiontitle": "بٱئرجا دیاری نیکوناْ",
-       "loginreqtitle": "ڤامإن إڤوڌن لازومإ",
-       "loginreqlink": "ڤامین ٱڤوڌن",
-       "accmailtitle": "رازينإ گوڤأرتن فرشناڌإ ڤابيڌإ",
-       "newarticle": "(تازه)",
+       "loginreqtitle": "ڤامیٛن ٱڤوڌن لازوماْ.",
+       "loginreqlink": "ڤاÙ\85Û\8cÙ\9bÙ\86 Ù±Ú¤Ù\88Ú\8cÙ\86",
+       "accmailtitle": "رازیناْ گوڌٱشتن باْسی ڤابیڌاْ",
+       "newarticle": "(تازاْ)",
        "newarticletext": "ایسا ز دین یٱ هومپاٛیڤٱندی هڌین کاْ نیڌس. سی رٱڤٱندیاری بٱلگاْ شورۊ کونین میٛن ای جٱڤاْ داٛڤۊنی بنڤیسین(سی دۊنسدن بیشدر سئیل [$1]کونین).\nیر ایسا سی اْشتڤاکارش ايچونین، دوگماْ رٱهڌن ڤاپوشد نٱ بپۊرنین.",
        "noarticletext": " ایساْ ای بٱلگاْ نڤشداْیی ناراْ، ایسا تاْرین [[Special:Search/{{PAGENAME}}داسۊن ای بٱلگاْ نٱ میٛن بٱلگاْیٱل دیٱری پاٛی جۊری کونین]] یا [{{fullurl:{{FULLPAGENAME}}|action=edit}} ای بٱلگاْ نٱ آلشدکاری کونين].",
        "noarticletext-nopermission": " ایساْ ای بٱلگاْ نڤشداْیی ناراْ، ایسا تاْرین [[Special:Search/{{PAGENAME}}داسۊن ای بٱلگاْ نٱ میٛن بٱلگاْیٱل دیٱری پاٛی جۊری کونین]] یا [{{fullurl:{{FULLPAGENAME}}|action=edit}} ای بٱلگاْ نٱ آلشد کونين].",
        "yourtext": "متن ايسا",
        "storedversion": "ڤانیٱری کۊ ڤابیڌاْ",
        "yourdiff": "فرخ",
-       "copyrightwarning": "لطفاً دقت کنین که درنظر گریده ابوه که همه شراکتهای ایسا  {{SITENAME}} تحت «$2» منتشر ابون ).\n\n\n(سی دیدن  جزئیات بیشتر به $1 برین\n\nایر نه خوین نوشته‌هاتو بی‌رحمانه اصلاح بوه و به دلخواه ارسال بوه، ایچو نفرستن.<br />\nدرضمن ایسادارین به ایما قول ادین که خودتو یونه نوشتین یا هونه زه یک منبع آزاد با مالکیت عمومی یا مثل هو ورداشتین. '''کارهای دارای کارهای دارای حق کپی رایت را بی‌اجازه نفرستین!'''',",
+       "copyrightwarning": "ب ڤیرتوݩ بۊ کاْ تٱموم هومیاریٱل ایسا   {{SITENAME}} زیرناْخیز «$2» دٱرتیچ اْبوݩ).\n\n(سی دیڌن  جوزئیات بیشتر ز $1 رۉین\n\nٱر نیخاین نڤشداْیٱلوݩ گٱن آلشدکاری نٱڤبۊن و دل ب خایی باْسی ڤابۊن، ایچو باْسی سوݩ نٱکونین.<br />\nهٱنی ٱم ایسا دارین بیما قۉل اْڌین کاْ خوتوݩ یوناْ نڤشدیناْ یا هوناْ ز یٱ سرچشماْ آزاڌ ڤا مالکیت خٱلکمٱند یا چی هو ڤورداشتین ساْ. '''چیا ناْ بی موجٱڤز و بی سلا کوپی رایت باْسی نٱکونین!''''",
+       "copyrightwarning2": "ب ڤیرتوݩ بۊ کاْ تٱموم هومیاریٱل ایسا   {{SITENAME}} زیرناْخیز «$2» دٱرتیچ اْبوݩ).\n\n(سی دیڌن  جوزئیات بیشتر ز $1 رۉین\n\nٱر نیخاین نڤشداْیٱلوݩ گٱن آلشدکاری نٱڤبۊن و دل ب خایی باْسی ڤابۊن، ایچو باْسی سوݩ نٱکونین.<br />\nهٱنی ٱم ایسا دارین بیما قۉل اْڌین کاْ خوتوݩ یوناْ نڤشدیناْ یا هوناْ ز یٱ سرچشماْ آزاڌ ڤا مالکیت خٱلکمٱند یا چی هو ڤورداشتین ساْ. '''چیا ناْ بی موجٱڤز و بی سلا کوپی رایت باْسی نٱکونین!''''",
        "templatesused": "{{PLURAL:$1|چۊاْ|چۊاْیٱل}} ڤا کار ڤٱسداْ میٛن ای بٱلگاْ:",
        "templatesusedpreview": "قالڤٱل یا اولگۊیٱل ڤاْ کار رٱئڌاْ مؽن ای نهانماو",
        "template-protected": "(پٱر و پیم ڤابیڌٱ)",
        "moveddeleted-notice": "ای بٱلٛیاْ پاکسا آڤیڌاْ،ؤرداوناْ سیاهؽ پاکسا،هناڌاری ۉ کلٛ کرڌن ای بٱلٛیاْ ؤرتی نهاڌ آڤیڌاْ",
        "edit-conflict": "ری ب ری کاری میٛن ڤیرایشت.",
        "slot-name-main": "سرتال",
-       "content-model-wikitext": "ڤيکي تکست",
-       "content-model-javascript": "جاڤا Ø¥Ø³Ú©Ø±Ù\8aپت",
-       "content-json-empty-object": "داسۊن هالی",
+       "content-model-wikitext": "ڤیکی تکست",
+       "content-model-javascript": "جاڤا Ø§Ù\92سکرÛ\8cپت",
+       "content-json-empty-object": "داسوݩ هالی",
        "content-json-empty-array": "آرایاْ هالی",
        "undo-failure": "سی نڤیڌن سلۊکی ڤا آلشڌکاریٱل مؽنجخائی ای آلشڌکاریناْ نؽڤۊ بؽ هرنڳ کرڌ",
        "viewpagelogs": "دیاری کردن پهرستنۊماْیٱل ای بٱلگاْ",
index da46154..bd52853 100644 (file)
        "newmessageslinkplural": "{{PLURAL:$1|yew mesaco newe|999=mesacê newey}}",
        "newmessagesdifflinkplural": "{{PLURAL:$1|vurnayışo peyên|999=vurnayışê peyêni}}",
        "youhavenewmessagesmulti": "$1 mesaco newe esto",
-       "editsection": "bıvırne",
+       "editsection": "bıvurne",
        "editold": "bıvurne",
        "viewsourceold": "çımey cı bıvinê",
-       "editlink": "bıvırne",
+       "editlink": "bıvurne",
        "viewsourcelink": "çımey bıvêne",
        "editsectionhint": "Leteyo ke bıvuriyo: $1",
        "toc": "Zerreki",
        "rightslog": "Qeydê heqanê karberi",
        "rightslogtext": "Ena listeyê loganê ke heqqa karbaranî mucneno.",
        "action-read": "ena pela wanayış",
-       "action-edit": "ena perre bıvurnê",
-       "action-createpage": "na perer bıvıraz",
+       "action-edit": "ena pele bıvurne",
+       "action-createpage": "na pele vıraze",
        "action-createtalk": "pelanê werênayışi bıvıraze",
        "action-createaccount": "hesabê nê karberi bıvıraze",
        "action-autocreateaccount": "nê hesabê karberiyê teberi otomatik vıraze",
        "rcfilters-tag-remove": "'$1' wedare",
        "rcfilters-legend-heading": "<strong>Lista kılmkerdışan:</strong>",
        "rcfilters-other-review-tools": "Hacetê çımeştışê bini",
+       "rcfilters-group-results-by-page": "Goreyê pele neticeyê gruban",
        "rcfilters-activefilters": "Parzûnê aktifi",
        "rcfilters-activefilters-hide": "Bınımne",
        "rcfilters-activefilters-show": "Bımocne",
        "rcfilters-advancedfilters": "Parzûnê raverşiyayeyi",
        "rcfilters-limit-title": "Neticeyê ke bımocniyê",
        "rcfilters-limit-and-date-label": "$1 {{PLURAL:$1|vurnayış|vurnayışi}}, $2",
+       "rcfilters-date-popup-title": "Leteyê demiyo ke cıgeyriyo",
        "rcfilters-days-title": "Rocê peyêni",
        "rcfilters-hours-title": "Seatê peyêni",
        "rcfilters-days-show-days": "($1 {{PLURAL:$1|roce|roci}})",
        "double-redirect-fixer": "Fixerî redirek bike",
        "brokenredirects": "Serşıkıtışê xırabeyi",
        "brokenredirectstext": "Redireksiyonê ey ki pelanê hama çiniyeno ra link dano:",
-       "brokenredirects-edit": "bıvırne",
+       "brokenredirects-edit": "bıvurne",
        "brokenredirects-delete": "bestere",
        "withoutinterwiki": "Pelê ke zıwananê binan rê gıreyê cı çıniyo",
        "withoutinterwiki-summary": "Enê pelî ke versiyonê ziwanî binî ra link nidano.",
        "pagesize": "(bitî)",
        "restriction-edit": "Bıvurne",
        "restriction-move": "Bıkırış",
-       "restriction-create": "Bıvıraz",
+       "restriction-create": "Vıraze",
        "restriction-upload": "Bar ke",
        "restriction-level-sysop": "tam pawiyayo",
        "restriction-level-autoconfirmed": "nêm pawiyayo",
        "version-software-version": "Versiyon",
        "version-entrypoints": "Heruna cıkewtışê URLi",
        "version-entrypoints-header-entrypoint": "Heruna dekewtışi",
-       "version-entrypoints-header-url": "GRE",
+       "version-entrypoints-header-url": "URL",
        "version-entrypoints-articlepath": "[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgArticlePath Article path]",
        "version-entrypoints-scriptpath": "[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgScriptPath Script path]",
        "version-libraries-library": "Kıtıbxane",
index 27cc07b..f8507b8 100644 (file)
        "undo-main-slot-only": "La redakto ne povis esti malfarita, ĉar ĝi koncernas enhavon ekster la ĉefa traktujo.",
        "undo-norev": "La redakto ne povis esti malfarita ĉar ĝi aŭ ne ekzistas aŭ estis forigita.",
        "undo-nochange": "Ŝajne la redakto jam estis malfarita.",
-       "undo-summary": "Nuligis version $1 de [[Special:Contributions/$2|$2]] ([[User talk:$2|Diskuto]] | [[Special:Contributions/$2|{{MediaWiki:Contribslink}}]])",
+       "undo-summary": "Versiono $1 de [[Special:Contributions/$2|$2]] ([[User talk:$2|Diskuto]] | [[Special:Contributions/$2|{{MediaWiki:Contribslink}}]]) nuligita",
        "undo-summary-username-hidden": "Malfari ŝanĝon $1 de kaŝita uzanto",
        "cantcreateaccount-text": "Konto-kreado de ĉi tiu IP-adreso ('''$1''') estis forbarita de [[User:$3|$3]].\n\nLa kialo donata de $3 estas ''$2''.",
        "cantcreateaccount-range-text": "La kreado de kontoj de IP-adresoj en la intervalo <strong>$1</strong>, kiu inkludas vian IP-adreson (<strong>$4</strong>), estis blokita de [[User:$3|$3]].\n\nLa donita kialo de $3 estas <em>$2</em>",
index de6344d..b97a7cc 100644 (file)
        "edit-gone-missing": "No se ha podido actualizar la página.\nParece haber sido borrada.",
        "edit-conflict": "Conflicto de edición.",
        "edit-no-change": "Se ignoró tu edición porque no se hizo ningún cambio en el texto.",
+       "edit-slots-missing": "{{PLURAL:$1|Falta el siguiente espacio|Faltan los siguientes espacios}}: $2",
        "postedit-confirmation-created": "Se ha creado la página.",
        "postedit-confirmation-restored": "Se ha restaurado la página.",
        "postedit-confirmation-saved": "Se ha guardado tu edición.",
        "ipb_expiry_old": "El tiempo de expiración está en el pasado.",
        "ipb_expiry_temp": "Los bloqueos a nombres de usuario ocultos deben ser permanentes.",
        "ipb_hide_invalid": "No se puede suprimir esta cuenta; tiene más de {{PLURAL:$1|una edición|$1 ediciones}}.",
+       "ipb_hide_partial": "Los bloqueos que ocultan nombres de usuario no pueden ser parciales.",
        "ipb_already_blocked": "La cuenta «$1» ya está bloqueada.",
        "ipb-needreblock": "$1 ya está bloqueado. ¿Quieres cambiar el bloqueo?",
        "ipb-otherblocks-header": "{{PLURAL:$1|Otro bloqueo|Otros bloqueos}}",
        "specialpages-group-developer": "Herramientas para desarrolladores",
        "blankpage": "Página vacía",
        "intentionallyblankpage": "Esta página está en blanco de manera intencionada.",
+       "disabledspecialpage-disabled": "Esta página ha sido desactivada por el administrador del sistema.",
        "external_image_whitelist": " #Deja esta línea exactamente como está<pre>\n#Colocar fragmentos de expresiones regulares (sólo la parte que va entre los //) debajo\n#Estos coincidirán con los URLs de las imágenes externas (hotlinked)\n#Aquellos que coincidan serán mostrados como imágenes, de lo contrario solamente un vínculo a la imagen será mostrada\n#Las líneas que empiezan por «#» se consideran comentarios\n#Esta es insensible a las mayúsculas\n\n#Colocar todos los fragmentos regex arriba de esta línea. Deja esta línea exactamente como está</pre>",
        "tags": "Etiquetas de cambios",
        "tag-filter": "Filtro de [[Special:Tags|etiquetas]]:",
        "logentry-block-block": "$1 {{GENDER:$2|bloqueó}} a {{GENDER:$4|$3}} durante un plazo de $5 $6",
        "logentry-block-unblock": "$1 {{GENDER:$2|desbloqueó}} a {{GENDER:$4|$3}}",
        "logentry-block-reblock": "$1 {{GENDER:$2|cambió}} la configuración del bloqueo de {{GENDER:$4|$3}} durante un plazo de $5 $6",
-       "logentry-partialblock-block": "$1 {{GENDER:$2|bloqueó}} a {{GENDER:$4|$3}} la edición en {{PLURAL:$8|la página|las páginas}} $7 por un tiempo de caducidad de $5 $6",
-       "logentry-partialblock-reblock": "$1 {{GENDER:$2|cambió}} la configuración del bloqueo a {{GENDER:$4|$3}} impidiendo la edición en {{PLURAL:$8|la página|las páginas}} $7 por un tiempo de caducidad de $5 $6",
+       "logentry-partialblock-block-page": "{{PLURAL:$1|la página|las páginas}} $2",
+       "logentry-partialblock-block-ns": "{{PLURAL:$1|el espacio de nombres|los espacios de nombres}} $2",
+       "logentry-partialblock-block": "$1 {{GENDER:$2|bloqueó}} a {{GENDER:$4|$3}} la edición en $7 durante un plazo de $5 $6",
+       "logentry-partialblock-reblock": "$1 {{GENDER:$2|cambió}} la configuración del bloqueo a {{GENDER:$4|$3}} impidiendo la edición en $7 durante un plazo de $5 $6",
+       "logentry-non-editing-block-block": "$1 {{GENDER:$2|bloqueó}} a {{GENDER:$4|$3}} para acciones específicas no relativas con la edición durante un plazo de $5 $6",
+       "logentry-non-editing-block-reblock": "$1 {{GENDER:$2|modificó}} la configuración del bloqueo de {{GENDER:$4|$3}} para acciones específicas no relativas con la edición durante un plazo de $5 $6",
        "logentry-suppress-block": "$1 {{GENDER:$2|bloqueó}} a {{GENDER:$4|$3}} durante un plazo de $5 $6",
        "logentry-suppress-reblock": "$1 {{GENDER:$2|cambió}} la configuración del bloqueo de {{GENDER:$4|$3}} durante un plazo de $5 $6",
        "logentry-import-upload": "$1 {{GENDER:$2|importó}} $3 subiendo un archivo",
index 2b33f5d..5bdfdab 100644 (file)
        "tog-watchuploads": "Ajouté nouvèl fiché ki mo ka enpòrté à mo lis di swivi",
        "tog-watchrollback": "Ajouté à mo lis di swivi paj-ya asou lakèl mo éfègtchwé roun révokasyon",
        "tog-minordefault": "Marké tout mo modifikasyon-yan kou fika minò pa défo",
-       "tog-previewontop": "Afiché prévizwalizasyon-an laro di zonn di modifikasyon",
-       "tog-previewonfirst": "Afiché prévizwalizasyon-an lò di pronmyé modifikasyon",
-       "tog-enotifwatchlistpages": "Avèrti mo pa kouryé lòské roun paj oben roun fiché di mo lis di swivi sa modifyé",
-       "tog-enotifusertalkpages": "Avèrti mo pa kouryé lò mo paj di diskisyon sa modifyé",
-       "tog-enotifminoredits": "Avèrti mo pa kour égalman lò dé modifikasyon minò dé paj oben dé fiché",
-       "tog-enotifrevealaddr": "Afiché mo adrès élègtronnik annan kour di notifikasyon",
+       "tog-previewontop": "Afiché prévizwèlizasyon-an laro di zonn-an di modifikasyon",
+       "tog-previewonfirst": "Afiché prévizwèlizasyon-an lò di pronmyé modifikasyon-an",
+       "tog-enotifwatchlistpages": "Avèrti mo pa kourilèt lò roun paj oben roun fiché di mo lis di swivi fika modifyé",
+       "tog-enotifusertalkpages": "Avèrti mo pa kourilèt lò mo paj di diskisyon fika modifyé",
+       "tog-enotifminoredits": "Avèrti mo pa kourilèt égalman lò dé modifikasyon minò dé paj oben dé fiché",
+       "tog-enotifrevealaddr": "Afiché mo adrès élègtronnik annan kourilèt di notifikasyon",
        "tog-shownumberswatching": "Afiché nonm-an di itilizatò an kour",
        "tog-oldsig": "Zòt signatir atchwèl :",
        "tog-fancysig": "Trété signatir-a kou di wikitègs (san lyannaj otonmantik)",
        "tog-uselivepreview": "Afiché apèrsou san roucharjé paj-a",
-       "tog-forceeditsummary": "Avèrti mo lòské mo pa èspésifyé di rézimen di modifikasyon",
+       "tog-forceeditsummary": "Avèrti mo lò mo pa èspésifyé di rézimen di modifikasyon",
        "tog-watchlisthideown": "Maské mo pròp modifikasyon annan lis di swivi",
        "tog-watchlisthidebots": "Maské modifikasyon-yan ki fè pa dé robo annan lis di swivi",
        "tog-watchlisthideminor": "Maské modifikasyon-yan minò annan lis di swivi",
        "tog-watchlisthideanons": "Maské modifikasyon-yan di itilizatò annonnim annan lis di swivi-a",
        "tog-watchlisthidepatrolled": "Maské modifikasyon-yan ki rouli annan lis di swivi",
        "tog-watchlisthidecategorization": "Maské katégorizasyon dé paj",
-       "tog-ccmeonemails": "• Voyé mo roun kopi dé kour ki mo ka voyé pou ròt itilizatò",
+       "tog-ccmeonemails": "• Voyé mo roun kopi dé kourilèt ki mo ka voyé pou ròt itilizatò",
        "tog-diffonly": "Pa afiché kontni di paj-ya anba diff",
        "tog-showhiddencats": "Afiché katégori-ya ki kaché",
-       "tog-norollbackdiff": "Pa afiché diff aprè révoké",
+       "tog-norollbackdiff": "Pa afiché diff-a apré révoké",
        "tog-useeditwarning": "Avèrti mo lò mo ka kité roun paj an kour di modifikasyon san sovgardé",
        "tog-prefershttps": "Toujou itilizé roun konnègsyon sékirizé lò mo konnègté",
        "underline-always": "Toujou",
        "category-header-numerals": "$1–$2",
        "about": "Apropo",
        "article": "Paj di kontni",
-       "newwindow": "(Ka ouvri so kò annan roun nouvèl lafinèt)",
+       "newwindow": "(ka louvri so kò annan roun nouvèl lafinèt)",
        "cancel": "Annilé",
        "moredotdotdot": "Plis...",
        "morenotlisted": "Sa lis pouvé fika enkonplèt",
        "toolbox": "Zouti",
        "tool-link-userrights": "Modifyé group-ya di itiliz{{GENDER:$1|ò|ris}}-a",
        "tool-link-userrights-readonly": "Wè group-ya di itilizat{{GENDER:$1|ò|ris}}",
-       "tool-link-emailuser": "Voyé roun kouryé pou {{GENDER:$1|sa itilizatò|sa itilizatris}}",
+       "tool-link-emailuser": "Voyé roun kourilèt pou {{GENDER:$1|sa itilizatò}}",
        "imagepage": "Wè paj-a di fiché",
        "mediawikipage": "Wè paj di mésaj",
        "templatepage": "Wè paj di modèl",
        "laggedslavemode": "Panga, sa paj pa pouvé kontni tout dannyé modifikasyon-yan ki éfègtchwé",
        "readonly": "Baz di data vérouyé",
        "enterlockreason": "Endiké rézon-an di vérouyaj ensi ki roun èstimasyon di so douré",
-       "readonlytext": "Ajou ké mizajou di baz di data sa atchwèlman bloké, probabman pou pèrmèt mentnans di baz-a, aprè sa, tout bagaj ké rantré annan lòrd.\n\nAdministratò sistenm-an ki vérouyé baz di data fourni èsplikasyon-an ki ka swiv :<br /> $1",
+       "readonlytext": "Ajou-ya ké mizajou-ya di baz di data fika atchwèlman bloké, probabman pou pèrmèt mentnans-a di baz-a, apré sa, tout bagaj ké rantré annòrd.\n\nAdministratò sistenm-an ki vérouyé baz di data fourni lèsplikasyon-an ki ka swiv :<br /> $1",
        "missing-article": "Baz-a di data pa trouvé tègs-a di roun paj ki li té divèt trouvé, ki entitilé « $1 » $2.\n\nJénéralman, sala ka sirvini an swivan roun lyannaj bò'd roun dif ki périmen oben bò'd listorik-a di roun paj ki siprimen.\n\nSi a pa sa ki la, zòt pitèt trouvé roun annonmanli annan progranm-an.\nSouplé, signalé li à roun [[Special:ListUsers/sysop|administratò]] é pa bliyé di endiké li URL-a di paj-a.",
        "missingarticle-rev": "(niméro di vèrsyon : $1)",
        "missingarticle-diff": "(diff : $1, $2)",
        "userlogin-loggedin": "Zòt déja konnègté an tan ki $1.\nItilizé fòrmilèr-a ki anba pou konnègté zòt kò ké rounòt kont itilizatò.",
        "userlogin-reauth": "Zòt divèt roukonèkté zòt kò pou vérifyé ki zòt sa {{GENDER:$1|$1}}.",
        "userlogin-createanother": "Kréyé rounòt kont",
-       "createacct-emailrequired": "Adrès di kour",
-       "createacct-emailoptional": "Adrès di kour (fakiltativ)",
-       "createacct-email-ph": "Zòt adrès di kouryé",
-       "createacct-another-email-ph": "Rantré adrès-a di kouryé",
-       "createaccountmail": "Itilizé roun mo di pas aléyatwar tanporèr é voyé li pou adrès-a di kouryé spésifyé",
+       "createacct-emailrequired": "Adrès di kourilèt",
+       "createacct-emailoptional": "Adrès di kourilèt (fakiltativ)",
+       "createacct-email-ph": "Antré zòt adrès di kourilèt",
+       "createacct-another-email-ph": "Antré adrès-a di kourilèt",
+       "createaccountmail": "Itilizé roun modipas aléyatwè ki tanporèr é voyé li pou adrès-a di kourilèt ki èspésifyé",
        "createaccountmail-help": "Pouvé fika itilizé pou kréyé roun kont pou rounòt moun san konèt mo di pas-a.",
        "createacct-realname": "Non réyèl (fakiltatif)",
        "createacct-reason": "Motif",
        "mailmypassword": "Réynisyalizé modipas-a",
        "passwordremindertitle": "Nouvèl mo di pas tanporèr pou {{SITENAME}}",
        "passwordremindertext": "Tchèk moun (dipi adrès IP-a $1) doumandé roun modipas nòv pou {{SITENAME}} ($4). Oun modipas tanporèr pou itilizatò-a\n« $2 » té kréyé é sa « $3 ». Si sala té zòt entansyon,\nzòt divèt konnègté zòt kò é chwézi roun modipas nòv.\nZòt modipas tanporèr ké èspiré annan $5 jou{{PLURAL:}}.\n\nSi zòt pa lotò di sa doumann, oben si zòt ka souvni zòt kò atchwèlman di zòt modipas é zòt pli ka swété an chanjé, zòt pouvé ignoré sa mésaj é kontinwé di itilizé zòt ansyen modipas.",
-       "noemail": "Pyès adrès di kouryé té anréjistré pou itilizat{{GENDER:$1|ò|ris}}-a « $1 ».",
-       "noemailcreate": "Zòt divèt fourni roun adrès di kour valid",
-       "passwordsent": "Roun nouvèl mo di pas té voyé kot adrès-a di kouryé di itilizat{{GENDER:$1|ò|ris}} « $1 ».\nSouplé, roukonèkté zòt kò aprè ki zòt rousouvri li.",
+       "noemail": "Pyès adrès di kourilèt fika anréjistré pou itilizatò-a « $1 ».",
+       "noemailcreate": "Zòt divèt fourni roun adrès di kourilèt valid",
+       "passwordsent": "Roun nouvèl modipas fika voyé bò'd adrès-a di kourilèt di itilizatò « $1 ».\nSouplé, roukonnègté zòt kò apré ki zòt rousouvri li.",
        "blocked-mailpassword": "Zòt adrès IP bloké an modifikasyon. Pou évité abi-ya, i pa otorizé di itilizé rékipérasyon-an di mo à partir di sa adrès IP.",
-       "eauthentsent": "Roun kouryé di konfirmasyon té voyé à adrès-a ki endiké.\nAnvan ki rounòt kouryé fika voyé à sa kont, zòt ké divèt swiv lenstrigsyon di kouryé é konfirmen ki kont-a sa byen zòtpa.",
-       "throttled-mailpassword": "Roun kouryé di réynisyalizasyon di zòt modipas té ja fika voyé douran {{PLURAL:$1|dannyé lò}}. \nAfen di évité abi-ya, roun sèl kouryé di réynisyalizasyon di zòt modipas ké fika voyé pa {{PLURAL:$1|lò|entèrval di $1 lò}}.",
-       "mailerror": "Lérò lò di voyé-a di kour : $1",
+       "eauthentsent": "Roun kourilèt di konfirmasyon fika voyé bò'd adrès-a ki endiké.\nAnvan ki rounòt kourilèt fika voyé bò'd sa kont, zòt ké divèt swiv lenstrigsyon di kourilèt é konfirmen ki kont-a byen di zòt.",
+       "throttled-mailpassword": "Roun kourilèt di réynisyalizasyon di zòt modipas té ja fika voyé pannan {{PLURAL:$1|dannyé lèr|$1 dannyé lèr-ya}}. \nPou évité abi-ya, roun sèl kourilèt di réynisyalizasyon di zòt modipas ké fika voyé pa {{PLURAL:$1|lèr|entèrval di $1 lèr}}.",
+       "mailerror": "Lérò lò di voyé-a di kourilèt : $1",
        "acct_creation_throttle_hit": "Vizitò-ya di sa wiki ki ka itilizé zòt adrès IP kréyé {{PLURAL:$1|roun kont|$1 kont}} douran dannyé $2, sa ki fika limit magsimal ki otorizé annan sa entèrval di tan.\nPa konsékan, kréyasyon-an di kont pou vizitò-ya ki ka itilizé sa adrès IP sa tanporèrman sispann.",
-       "emailauthenticated": "Zòt adrès di kouryé té konfirmen $2 à $3.",
-       "emailnotauthenticated": "Zòt adrès di kouryé pòkò konfirmen.\nPyès kouryé ké fika voyé pou chaken dé fongsyon ki ka swiv.",
-       "noemailprefs": "Endiké roun adrès di kour annan zòt préférans pou itilizé sa fongsyon-yan.",
-       "emailconfirmlink": "Konfirmen zòt adrès di kour",
-       "invalidemailaddress": "Sa adrès kouryé pa pouvé fika asèpté pas so fòrma ka parèt enkorèk.\nRantré roun adrès korèkman fòrmaté oben lésé sa chan vid.",
-       "cannotchangeemail": "Adrès di kouryé dé kont pa pouvé fika modifyé asou sa wiki.",
-       "emaildisabled": "Sa sit pa pouvé voyé di kour.",
+       "emailauthenticated": "Zòt adrès di kourilèt fika konfirmen $2 à $3.",
+       "emailnotauthenticated": "Zòt adrès di kourilèt pòkò konfirmen.\nPyès kourilèt ké fika voyé pou chaken dé fongsyon ki ka swiv.",
+       "noemailprefs": "Endiké roun adrès di kourilèt annan zòt préférans pou itilizé sa fongsyon-yan.",
+       "emailconfirmlink": "Konfirmen zòt adrès di kourilèt",
+       "invalidemailaddress": "Sa adrès kourilèt pa pouvé fika asèpté pas so fòrma ka parèt enkorèk.\nAntré roun adrès ki korèkman fòrmaté oben lésé sa chan-an vid.",
+       "cannotchangeemail": "Adrès-ya di kourilèt dé kont pa pouvé fika modifyé asou sa wiki.",
+       "emaildisabled": "Sa sit pa pouvé voyé di kourilèt.",
        "accountcreated": "Kont kréyé",
        "accountcreatedtext": "Kont itilizatò pou [[{{ns:User}}:$1|$1]] ([[{{ns:User talk}}:$1|diskisyon]]) té kréyé.",
        "createaccount-title": "Kréyasyon di roun kont pou {{SITENAME}}",
-       "createaccount-text": "Tchèk moun kréyé roun kont pou zòt adrès di kouryé asou {{SITENAME}} ($4) ki entitilé « $2 », ké modipas « $3 ».\nZòt divèt ouvri roun sésyon é modifyé atchwèlman zòt modipas.\n\nIgnoré sa mésaj si sa kont té kréyé pa lérò.",
+       "createaccount-text": "Tchèk moun kréyé roun kont pou zòt adrès di kourilèt asou {{SITENAME}} ($4) ki entitilé « $2 », ké modipas « $3 ».\nZòt divèt louvri roun sésyon é modifyé atchwèlman zòt modipas.\n\nIgnoré sa mésaj si sa kont fika kréyé pa lérò.",
        "login-throttled": "Zòt tanté dannyéman roun nonm tròp élvé di konnègsyon.\nSouplé, antann $1 anvan di éséyé òkò.",
        "login-abort-generic": "Zòt échwé zòt tantativ di konnègsyon",
        "login-migrated-generic": "Zòt kont té migré, é zòt non d'itilizatò pa ka ègzisté òkò asou sa wiki.",
        "pt-createaccount": "Kréyé roun kont",
        "pt-userlogout": "Dékonnègté so kò",
        "php-mail-error-unknown": "Lérò enkonnèt annan fongsyon-an <kod>mail()</kod> di PHP.",
-       "user-mail-no-addy": "Enposib di voyé roun kouryé san adrès di kouryé.",
-       "user-mail-no-body": "Ésè di voyé di roun kouryé ké roun kò vid oben anòrmalman kour.",
+       "user-mail-no-addy": "Enposib di voyé roun kourilèt san adrès di kourilèt.",
+       "user-mail-no-body": "Lésè di voyé di roun kourilèt ké roun kò vid oben anòrmalman kourt.",
        "changepassword": "Chanjé di modipas",
        "resetpass_announce": "Pou tèrminé zòt enskripsyon, zòt divèt fourni roun mo di pas nòv.",
        "resetpass_text": "<!-- Ajouté tègs-a isi -->",
        "resetpass-submit-cancel": "Annilé",
        "resetpass-wrong-oldpass": "Mo di pas atchwèl oben tanporèr envalid.\nZòt pitèt ja chanjé zòt mo di pas oben doumandé roun mo di pas nòv tanporèr.",
        "resetpass-recycled": "Souplé, modifyé zòt modipas ké ròt kichoz ki atchwèl-a.",
-       "resetpass-temp-emailed": "Zòt konnègté ké roun kod tanporèr ki fourni pa kouryé.\nPou tèrminé konnègsyon-an, zòt divèt fourni roun nouvèl modipas isi :",
+       "resetpass-temp-emailed": "Zòt konnègté ké roun kod tanporèr ki fourni pa kourilèt.\nPou tèrminen konnègsyon-an, zòt divèt fourni roun nouvèl modipas isi :",
        "resetpass-temp-password": "Modipas tanporèr :",
        "resetpass-expired-soft": "Zòt modipas èspiré, é divèt fika modifyé. Souplé, chwézi roun nouvèl atchwèlman oben kliké asou « {{int:authprovider-resetpass-skip-label}} » pou fè li plita.",
        "resetpass-validity-soft": "Zòt modipas pa valid : $1\n\nSouplé, chwézi roun nouvèl modipas atchwèlman, oben kliké asou « {{int:authprovider-resetpass-skip-label}} » pou modifyé li plita.",
        "passwordreset": "Réynisyalizasyon di modipas",
        "passwordreset-text-one": "Ranplisé sa fòrmilèr pou zòt mo di pas.",
-       "passwordreset-emaildisabled": "Fongsyonnalité-ya di kouryé té dézagtivé asou sa wiki.",
+       "passwordreset-emaildisabled": "Fongsyonnalité-ya di kourilèt fika dézagtivé asou sa wiki.",
        "passwordreset-username": "Non di itilizatò :",
        "passwordreset-domain": "Domenn :",
-       "passwordreset-email": "Adrès di kour :",
+       "passwordreset-email": "Adrès di kourilèt :",
        "passwordreset-emailtitle": "Détay di kont asou {{SITENAME}}",
        "passwordreset-emailelement": "Non di itilizatò : \n$1\n\nMo di pas tanporèr : \n$2",
        "passwordreset-nocaller": "Oun apélan divèt fika fourni",
        "passwordreset-nosuchcaller": "Apélan-an pa ka ègzisté : $1",
        "passwordreset-invalidemail": "Adrès di mésajri envalid",
        "passwordreset-nodata": "Pyès non d'itilizatò oben adrès di mésajri té fourni",
-       "changeemail": "Chanjé oben siprimen adrès-a di kour",
+       "changeemail": "Chanjé oben siprimen adrès-a di kourilèt",
        "changeemail-no-info": "Zòt divèt fika konnègté pou agsédé dirèkman à sa paj.",
-       "changeemail-oldemail": "Adrès di kour atchwèl :",
-       "changeemail-newemail": "Nouvèl adrès di kour :",
+       "changeemail-oldemail": "Adrès di kourilèt atchwèl :",
+       "changeemail-newemail": "Nouvèl adrès di kourilèt :",
        "changeemail-none": "(pyès)",
        "changeemail-password": "Zòt mo di pas asou {{SITENAME}} :",
-       "changeemail-submit": "Chanjé adrès di kouryé",
+       "changeemail-submit": "Chanjé adrès-a di kourilèt",
        "changeemail-throttled": "Zòt fè tròp tantativ di konnègsyon. \nSouplé, antann $1 anvan di réyéséyé.",
-       "changeemail-nochange": "Souplé, sézi roun nouvèl adrès di kouryé diférant di présédant-a.",
+       "changeemail-nochange": "Souplé, sézi roun nouvèl adrès di kourilèt ki diféran di présédant-a.",
        "resettokens": "Réynisyalizé jéton-yan.",
        "resettokens-no-tokens": "I pa gen pyès jéton à réynisyalizé.",
        "resettokens-tokens": "Jéton :",
        "savechanges-start": "Anréjistré modifikasyon-yan…",
        "publishpage-start": "Pibliyé paj-a…",
        "publishchanges-start": "Pibliyé modifikasyon-yan…",
-       "preview": "Prévizwalizasyon",
+       "preview": "Prévizwèlizasyon",
        "showpreview": "Prévizwèlizé",
        "showdiff": "Wè modifikasyon-yan",
        "anoneditwarning": "<strong>Panga :</strong> zòt pa konnègté. Zòt adrès IP ké vizib pa tout moun si zòt ka fè dé modifikasyon. Si zòt <strong>[$1 ka konnègté zòt kò]</strong> oben <strong>[$2 kréyé roun kont]</strong>, zòt modifikasyon ké fika atribiyé à zòt pròp non di itilizatò é zòt ké gen ròt avantaj.",
-       "blockedtext": "<strong>Zòt kont itilizatò oben zòt adrès IP bloké.</strong>\n\nBlokaj té éfègtchwé pa $1.\nRézon-an ki évoké ka swiv : <em>$2</em>.\n\n* Koumansman di blokaj : $8\n* Lèspirasyon di blokaj : $6\n* Kont bloké : $7.\n\nZòt pouvé kontagté $1 oben rounòt [[{{MediaWiki:Grouppage-sysop}}|administratò]] pou an diskité.\nZòt pouvé itilizé fongsyon-an « {{int:emailuser}} » rounso si roun adrès di kouryé valid sa èspésifyé annan zòt [[Special:Preferences|préférans]] é rounso si sa fongsyonnalité pa bloké pou zòt.\nZòt adrès IP atchwèl sa $3 é zòt idantifyan di blokaj sa $5.\nSouplé, enkli tout détay-ya lasou'l annan chaken dé rékèt ki zòt ké fè.",
+       "blockedtext": "<strong>Zòt kont itilizatò oben zòt adrès IP fika bloké.</strong>\n\nBlokaj té éfègtchwé pa $1.\nRézon-an ki évoké ka swiv : <em>$2</em>.\n\n* Koumansman di blokaj : $8\n* Lèspirasyon di blokaj : $6\n* Kont bloké : $7.\n\nZòt pouvé kontagté $1 oben rounòt [[{{MediaWiki:Grouppage-sysop}}|administratò]] pou diskité apropo di sa.\nZòt pouvé itilizé fongsyon-an « {{int:emailuser}} » rounso si roun adrès di kourilèt valid sa èspésifyé annan zòt [[Special:Preferences|préférans]] é rounso si sa fongsyonnalité pa fika bloké ba zòt.\nZòt adrès IP atchwèl sa $3 é zòt idantifyan di blokaj sa $5.\nSouplé, enkli tout détay-ya lasou'l annan chak rékèt ki zòt ké fè.",
        "loginreqlink": "konnègté so kò",
        "newarticletext": "Zòt swiv roun lyannaj bò'd roun paj ki pa ka ègzisté òkò. \nAfen di kréyé sa paj, rantré zòt tègs annan bwèt-a ki apré (zòt pouvé konsilté [$1 paj di lèd-a] pou plis di lenfòrmasyon).\nSi zòt vini{{GENDER:|}} isi pa lérò, kliké asou bouton-an <strong>Viré</strong> di zòt navigatò.",
-       "anontalkpagetext": "----\n<em>Zòt asou paj di diskisyon di roun itilizatò annonnim ki pa òkò kréyé di kont oben ki pa ka an itilizé</em>.\nPou sa rézon, nou divèt itilizé so adrès IP pou idantifyé li.\nOun adrès IP pouvé fika patajé pa plizyò itilizatò.\nSi zòt roun itiliza{{GENDER:|ò}} annonnim é si zòt ka kontasté ki dé koumantèr ki pa ka konsèrné zòt sa adrèsé pou zòt, zòt pouvé [[Special:CreateAccount|kréyé roun kont]] oben [[Special:UserLogin|konnègté zòt kò]] atò di évité tout konfizyon fitir ké ròt kontribitò annonnim.",
+       "anontalkpagetext": "----\n<em>Zòt asou paj-q di diskisyon di roun itilizatò annonnim ki pa òkò kréyé di kont oben ki pa ka itilizé roun</em>.\nPou sa rézon, nou divèt itilizé so adrès IP pou idantifyé li.\nOun adrès konran IP pouvé fika patajé pa plizyò itilizatò.\nSi zòt sa roun itiliza{{GENDER:|ò}} annonnim é si zòt ka kontasté ki dé koumantèr ki pa ka konsèrnen zòt, fika adrésé ba zòt, zòt pouvé [[Special:CreateAccount|kréyé roun kont]] oben [[Special:UserLogin|konnègté zòt kò]] pou évité tout konfizyon fitir ké ròt kontribitò annonnim.",
        "noarticletext": "I pa gen atchwèlman pyès tègs asou sa paj.\nZòt pouvé [[Special:Search/{{PAGENAME}}|lansé oun sasé asou sa tit]] annan ròt paj-ya,\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} sasé annan lopérasyon-yan ki lyannen]\noben [{{fullurl:{{FULLPAGENAME}}|action=edit}} kréyé sa paj]</span>.",
        "noarticletext-nopermission": "I pa gen atchwèlman pyès tègs asou sa paj.\nZòt pouvé [[Special:Search/{{PAGENAME}}|fè roun sasé asou sa tit]] annan ròt paj-ya,\noben <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|paj={{FULLPAGENAMEE}}}} sasé annan journal-ya ki asosyé]</span>, mé zòt pa gen pèrmisyon-an di kréyé sa paj.",
        "userpage-userdoesnotexist-view": "Kont itilizatò-a « $1 » pa anréjistré.",
        "clearyourcache": "<strong>Nòt :</strong> apré zòt anréjistré zòt modifikasyon, zòt divèt fòrsé roucharjman konplè di kach di zòt navigatò pou wè chanjman-yan.\n* <strong>Firefox / Safari :</strong> mentni touch-a <em>Maj</em> (<em>Shift</em>) an klikan asou bouton-an <em>Atchwalizé</em> oben présé <em>Ctrl-F5</em> oben <em>Ctrl-R</em> (<em>⌘-R</em> asou roun Mac) \n* <strong>Google Chrome :</strong> apiyé asou <em>Ctrl-Maj-R</em> (<em>⌘-Shift-R</em> asou roun Mac) \n* <strong>Internet Explorer :</strong> mentni touch-a <em>Ctrl</em> an klikan asou bouton-an <em>Atchwalizé</em> oben présé <em>Ctrl-F5</em> \n* <strong>Opera :</strong> alé annan <em>Menu → Settings</em> (<em>Opera → Préférences</em> asou roun Mac) é answit à <em>Konfidansyalité & sékrité → Éfasé data di lésplorasyon-yan → Zimaj ké fiché an kach</em>.",
-       "previewnote": "<strong>Raplé-zòt ki a jis roun prévizwalizasyon.</strong>\nZòt modifikasyon pa òkò anréjistré !",
+       "previewnote": "<strong>Pa bliyé ki a jis roun prévizwèlizasyon.</strong>\nZòt modifikasyon pa òkò fika anréjistré !",
        "continue-editing": "Alé kot zonn di modifikasyon",
        "editing": "Modifikasyon di $1",
        "creating": "Kréyasyon di $1",
        "yourtext": "Zòt tègs",
        "yourdiff": "Diférans",
        "templatesused": "{{PLURAL:$1|Modèl itilizé}} pa sa paj :",
-       "templatesusedpreview": "{{PLURAL:$1|Modèl itilizé}} annan sa prévizwalizasyon :",
+       "templatesusedpreview": "{{PLURAL:$1|Modèl itilizé}} annan sa prévizwèlizasyon :",
        "template-protected": "(protéjé)",
        "template-semiprotected": "(sémi-protéjé)",
        "hiddencategories": "{{PLURAL:$1|Katégori kaché}} don sa paj ka fè parti :",
        "allpages-hide-redirects": "Maské roudirègsyon-yan",
        "categories": "Lis dé katégori",
        "listgrouprights-members": "(lis dé manm)",
-       "emailuser": "Voyé li roun kour",
+       "emailuser": "Voyé li roun kourilèt",
        "usermessage-editor": "Mésajé di sistèm",
        "watchlist": "Lis di swivi",
        "mywatchlist": "Lis di swivi",
        "tooltip-t-recentchangeslinked": "Lis dé modifikasyon résan ki lyannen ké sa paj",
        "tooltip-feed-atom": "Flux Atom pou sa paj",
        "tooltip-t-contributions": "Wè lis dé kontribisyon di {{GENDER:$1|sa itilizatò|sa itilizatris}}",
-       "tooltip-t-emailuser": "Voyé roun kouryé à {{GENDER:$1|sa itilizatò|sa itilizatris}}",
+       "tooltip-t-emailuser": "Voyé roun kourilèt pou {{GENDER:$1|sa itilizatò}}",
        "tooltip-t-upload": "Télévèrsé dé fiché",
        "tooltip-t-specialpages": "Lis di tout paj èspésyal",
        "tooltip-t-print": "Vèrsyon enprimab di sa paj",
        "tooltip-compareselectedversions": "Afiché diférans-ya ant dé vèrsyon-yan ki sélègsyonnen di sa paj",
        "tooltip-watch": "Ajouté sa paj annan zòt lis di swivi",
        "tooltip-rollback": "« Révoké » ka annilé an roun klik modifikasyon(-an oben -yan) di sa paj ki réyalizé pa so dannyé kontribitò",
-       "tooltip-undo": "« Annilé » ka rétabli modifikasyon-an ki ka présédé é ka ouvri lafinèt di modifikasyon an mòd prévizwalizasyon. I posib di ajouté roun rézon annan rézimen-an.",
+       "tooltip-undo": "« Annilé » ka rétabli modifikasyon-an ki ka présédé é ka louvri lafinèt di modifikasyon an mòd prévizwèlizasyon. I posib di ajouté roun rézon annan rézimen-an.",
        "tooltip-summary": "Rantré roun brèf rézimen",
        "simpleantispam-label": "Vérifikasyon anti-pouryèl.\nPa <strong>enskri</strong> anyen isi !",
        "pageinfo-title": "Lenfòrmasyon pou « $1 »",
index f228ab7..2e120fc 100644 (file)
        "tog-watchlisthideminor": "मेरी ध्यानसूची से छोटे परिवर्तन छिपाएँ",
        "tog-watchlisthideliu": "मेरी ध्यानसूची में सत्रारंम्भित सदस्यों के सम्पादन न दिखाएँ",
        "tog-watchlistreloadautomatically": "जब भी छननी बदलने पर ध्यानसूची को अपने आप ही लोड करें (जावास्क्रिप्ट अनिवार्य)",
-       "tog-watchlistunwatchlinks": "दà¥\87à¤\96नà¥\87वालà¥\80 à¤¸à¥\82à¤\9aà¥\80 à¤ªà¥\8dरविषà¥\8dà¤\9fियà¥\8bà¤\82 à¤\95à¥\87 à¤²à¤¿à¤\8f à¤¸à¥\80धा à¤\85नदà¥\87à¤\96ा/दà¥\87à¤\96ा à¤\95ड़à¥\80 à¤\9cà¥\8bड़à¥\87à¤\82 (à¤\9fà¥\89à¤\97ल à¤\95ारà¥\8dयà¤\95à¥\8dषमता à¤\95à¥\87 à¤²à¤¿à¤\8f à¤\9cावासà¥\8dà¤\95à¥\8dरिपà¥\8dà¤\9f à¤\86वशà¥\8dयà¤\95)",
+       "tog-watchlistunwatchlinks": "दà¥\87à¤\96नà¥\87वालà¥\80 à¤¸à¥\82à¤\9aà¥\80 à¤ªà¥\8dरविषà¥\8dà¤\9fियà¥\8bà¤\82 à¤\95à¥\87 à¤²à¤¿à¤\8f à¤¸à¥\80धा à¤\85नदà¥\87à¤\96ा/दà¥\87à¤\96ा à¤\9aिहà¥\8dन ({{int:Watchlist-unwatch}}/{{int:Watchlist-unwatch-undo}}) à¤ªà¥\83षà¥\8dठà¥\8bà¤\82 à¤\95à¥\8b à¤ªà¤°à¤¿à¤µà¤°à¥\8dतन à¤\95à¥\87 à¤¸à¤¾à¤¥ à¤\9cà¥\8bड़à¥\87à¤\82 (à¤\9fà¥\89à¤\97ल à¤\95à¥\87 à¤\95ारà¥\8dय à¤\95रनà¥\87 à¤¹à¥\87तà¥\81 à¤\9cावासà¥\8dà¤\95à¥\8dरिपà¥\8dà¤\9f à¤\86वशà¥\8dयà¤\95 à¤¹à¥\88)",
        "tog-watchlisthideanons": "अनाम सदस्यों द्वारा किए सम्पादनों को मेरी ध्यानसूची में न दिखायें",
        "tog-watchlisthidepatrolled": "परीक्षित सम्पादन मेरी ध्यानसूची में छुपाएँ",
        "tog-watchlisthidecategorization": "पृष्ठों का श्रेणीकरण छुपाएँ",
        "lockmanager-fail-closelock": "\"$1\" की लॉक फ़ाइल बंद नहीं की जा सकी।",
        "lockmanager-fail-deletelock": "\"$1\" की लॉक फ़ाइल हटाई नहीं जा सकी।",
        "lockmanager-fail-acquirelock": "\"$1\" के लिए लॉक प्राप्त नहीं किया जा सका।",
-       "lockmanager-fail-openlock": "\"$1\" à¤\95à¥\87 à¤²à¤¿à¤¯à¥\87 à¤²à¥\89à¤\95 à¥\9eाà¤\87ल à¤\96à¥\8bलà¥\80 à¤¨à¤¹à¥\80à¤\82 à¤\9cा à¤¸à¤\95à¥\80।",
+       "lockmanager-fail-openlock": "\"$1\" à¤\95à¥\87 à¤²à¤¿à¤¯à¥\87 à¤²à¥\89à¤\95 à¤«à¤¼à¤¾à¤\87ल à¤\96à¥\8bलà¥\80 à¤¨à¤¹à¥\80à¤\82 à¤\9cा à¤¸à¤\95à¥\80। à¤¸à¥\81निशà¥\8dà¤\9aित à¤\95रà¥\87à¤\82 à¤\95ि à¤\86पà¤\95à¥\80 à¤\85पलà¥\8bड à¤¡à¤¾à¤¯à¤°à¥\87à¤\95à¥\8dà¤\9fà¥\8dरà¥\80 à¤¸à¤¹à¥\80 à¤¸à¥\87 à¤\95à¥\89नà¥\8dफ़िà¤\97र à¤¹à¥\88 à¤¤à¤¥à¤¾ à¤\86पà¤\95ा à¤µà¥\87ब à¤¸à¤°à¥\8dवर à¤\95à¥\8b à¤µà¤\83 à¤¡à¤¾à¤¯à¤°à¥\87à¤\95à¥\8dà¤\9fà¥\8dरà¥\80 à¤¦à¥\87à¤\96नà¥\87 à¤\95à¥\80 à¤\85नà¥\81मति à¤¹à¥\88।\nà¤\85धिà¤\95 à¤\9cानà¤\95ारà¥\80 à¤\95à¥\87 à¤²à¤¿à¤¯à¥\87 https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgUploadDirectory à¤¦à¥\87à¤\96à¥\87à¤\82।",
        "lockmanager-fail-releaselock": "\"$1\" के लिए लॉक हटाया नहीं जा सका।",
        "lockmanager-fail-db-bucket": "बकेट $1 में आवश्यक संख्या में लॉक डाटाबेसों से सम्पर्क नहीं हो पाया।",
        "lockmanager-fail-db-release": "डाटाबेस $1 से ताला हटाया नहीं जा सका।",
        "uploadstash-zero-length": "फ़ाइल शून्य लंबाई की है|",
        "invalid-chunk-offset": "अग्राह्य चंक ऑफ़सेट",
        "img-auth-accessdenied": "अनुमति नहीं है",
-       "img-auth-nopathinfo": "PATH_INFO मौजूद नहीं है।\nआपके सर्वर में इस जानकारी को भेजने के लिए जमाव नहीं है।\nयह सी॰जी॰आई-आधारित हो सकता है और img_auth को स्वीकार नहीं करता है।\nhttps://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Image_Authorization देखें।",
+       "img-auth-nopathinfo": "पाथ जानकारी उपलब्ध नहीं है।\nआपके सर्वर को REQUEST_URI तथा/अथवा PATH_INFO चरों को पास करने के लिये सेट अप होना चाहिये। अगर ऐसा है तो $wgUsePathInfo को सक्रिय करने का प्रयास करें।  \nhttps://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Image_Authorization देखें।",
        "img-auth-notindir": "अनुरोधित पथ जमाई हुई अपलोड डायरेक्टरी में नहीं है।",
        "img-auth-badtitle": "\"$1\" से एक वैध शीर्षक बनाने में असमर्थ।",
        "img-auth-nologinnWL": "आपने सत्रारंभ नहीं किया हुआ है और \"$1\" श्वेतसूची में नहीं है।",
        "move": "स्थानान्तरण",
        "movethispage": "पृष्ठ का नाम बदलें",
        "unusedimagestext": "निम्न फ़ाइलें मौजूद हैं, पर किसी भी पृष्ठ में प्रयुक्त नहीं हैं।\nकृपया ध्यान दें कि अन्य वेब साइट एक सीधी कड़ी से फ़ाइल से जुड़ी हो सकती हैं, और सक्रिय उपयोग में होने के बावजूद यहाँ दिखाई जा सकती है।",
+       "unusedimagestext-categorizedimgisused": "निम्नोक्त फ़ाइल अस्तित्व में तो है मगर किसी पृष्ठ से जुड़ा नहीं है। श्रेणीबद्ध चित्र को प्रयुक्त ही माना जाता है भले ही वह किसी पृष्ठ से न जुड़े हों।\nकृपया ध्यान दें कि अन्य वेबसाइटें सीधे इस फ़ाइल से जुड़ी हो सकती हैं, और कई अन्य भी यहाँ सूचीबद्ध हो सकती हैं भले ही वह वर्तमान प्रयोग में न हों।",
        "unusedcategoriestext": "निम्नलिखित श्रेणी पृष्ठ मौजूद हैं जबकि कोई भी पृष्ठ या अन्य श्रेणियाँ इनका प्रयोग नहीं करते हैं।",
        "notargettitle": "लक्ष्य नहीं",
        "notargettext": "इस क्रिया को करने के लिये आपने लक्ष्य पृष्ठ या सदस्य बताया नहीं है।",
        "apisandbox-dynamic-parameters-add-label": "प्राचल जोड़ें:",
        "apisandbox-dynamic-parameters-add-placeholder": "प्राचल नाम",
        "apisandbox-dynamic-error-exists": "प्राचल नाम \"$1\" पहले से मौजूद है।",
+       "apisandbox-templated-parameter-reason": "This [[Special:ApiHelp/main#main/templatedparams|templated parameter]] is offered based on the {{PLURAL:$1|value|values}} of $2.",
        "apisandbox-deprecated-parameters": "प्राचल पुराना हो चुका है",
        "apisandbox-fetch-token": "टोकन स्वतः भरें",
        "apisandbox-add-multi": "जोड़ें",
        "cachedspecial-refresh-now": "नवीनतम देखें।",
        "categories": "श्रेणियाँ",
        "categories-submit": "दिखाएँ",
-       "categoriespagetext": "निम्नोक्त {{PLURAL:$1|श्रेणी|श्रेणियों}} में पृष्ठ या मीडिया है।\nजिन श्रेणियों का [[Special:UnusedCategories|अप्रयुक्त श्रेणियाँ]] यहाँ नहीं दिखाई गई हैं।\n[[Special:WantedCategories|वांछित श्रेणियाँ]] भी देखें।",
+       "categoriespagetext": "निम्नोक्त {{PLURAL:$1|श्रेणी|श्रेणियों}} में पृष्ठ या मीडिया है और यह अप्रयुक्त नहीं भी हो सकती है।\n[[Special:WantedCategories|वांछित श्रेणियाँ]] भी देखें।",
        "categoriesfrom": "इस अक्षर से शुरू होने वाली श्रेणीयाँ दर्शायें:",
        "deletedcontributions": "हटाए गए सदस्य योगदान",
        "deletedcontributions-title": "हटाए गए सदस्य योगदान",
        "ipb_expiry_old": "समाप्ती समय बीत चुका है।",
        "ipb_expiry_temp": "छुपायें हुए सदस्यनाम ब्लॉक्स हमेशा के लिये होने चाहिये।",
        "ipb_hide_invalid": "इस खाते को छिपा नहीं पाए; इस से {{PLURAL:$1|एक सम्पादन किया गया है|$1 सम्पादन किये गये हैं}}।",
+       "ipb_hide_partial": "छुपे सदस्यनाम अवरोध साइटवाइड अवरोध होने चाहिये।",
        "ipb_already_blocked": "\"$1\" को पहलेसे ब्लॉक हैं",
        "ipb-needreblock": "$1 पहले ही अवरोधित है।\nक्या आप अवरोध के जमाव बदलना चाहेंगे?",
        "ipb-otherblocks-header": "अन्य  {{PLURAL:$1| block|blocks}}",
        "mcrundofailed": "वापस लेना असफल रहा",
        "mcrundo-missingparam": "अनुरोध पर अपेक्षित प्राचल गायब हैं।",
        "mcrundo-changed": "आपके परिवर्तन देखने के बाद पृष्ठ बदल चुका है। कृपया नये परिवर्तनों का पुनरीक्षण करें।",
+       "mcrundo-parse-failed": "नये अवतरण की व्याख्या असफल रही: $1",
        "semicolon-separator": ";",
        "quotation-marks": "\"$1\"",
        "imgmultipageprev": "← पिछला पृष्ठ",
        "logentry-partialblock-block-ns": "{{PLURAL:$1|नामस्थान}} $2",
        "logentry-partialblock-block": "$1 ने {{GENDER:$4|$3}} को $7 सम्पादित करने से $5 $6 समय तक {{GENDER:$2|अवरोधित कर दिया है}}",
        "logentry-partialblock-reblock": "$1 ने {{GENDER:$4|$3}} की $7 पर अवरोध सेटिंग में {{GENDER:$2|बदलाव कर दिया है}}। अब यह प्रतिबन्ध $5 $6 समय तक रहेगा।",
+       "logentry-non-editing-block-block": "$1 ने {{GENDER:$4|$3}} को विशेष गैर-सम्पादन कार्यों से $5 $6 समय तक {{GENDER:$2|अवरोधित कर दिया है}}",
+       "logentry-non-editing-block-reblock": "$1 ने {{GENDER:$4|$3}} की विशेष गैर-सम्पादन कार्यों की अवरोध सेटिंग में {{GENDER:$2|बदलाव कर दिया है}}। अब यह प्रतिबन्ध $5 $6 समय तक रहेगा।",
        "logentry-suppress-block": "$1 ने {{GENDER:$4|$3}} को $5 के लिए {{GENDER:$2|अवरोधित}} कर दिया। $6",
        "logentry-suppress-reblock": "$1 ने {{GENDER:$4|$3}} के अवरोध में {{GENDER:$2|बदलाव}} कर दिया और यह अवरोध $5 रहेगा। $6",
        "logentry-import-upload": "$1 {{GENDER:$2|आयात किया गया}} $3 फ़ाइल अपलोड के माध्यम से",
index 7e4bcd9..1aa611d 100644 (file)
@@ -77,7 +77,7 @@
        "tog-watchlisthideminor": "Sakrij manje promjene s popisa praćenja",
        "tog-watchlisthideliu": "Sakrij uređivanja prijavljenih s popisa praćenja",
        "tog-watchlistreloadautomatically": "Ponovo učitaj popis praćenja kad god dođe do promjene filtra (potreban JavaScript)",
-       "tog-watchlistunwatchlinks": "Dodaj poveznice za izravno dodavanje/uklanjanje stranica s popisa praćenja (za funkcionalnost mogućnosti potreban je JavaScript)",
+       "tog-watchlistunwatchlinks": "Dodaj oznake za praćenje/prekid praćenja ({{int:Watchlist-unwatch}}/{{int:Watchlist-unwatch-undo}}) praćenim stranicama koje su promijenjene (za funkcionalnost mogućnosti potreban je JavaScript)",
        "tog-watchlisthideanons": "Sakrij uređivanja neprijavljenih s popisa praćenja",
        "tog-watchlisthidepatrolled": "Sakrij pregledane izmjene u popisu praćenja",
        "tog-watchlisthidecategorization": "Sakrij kategorizaciju stranica",
        "returnto": "Vrati se na $1.",
        "tagline": "Izvor: {{SITENAME}}",
        "help": "Pomoć",
+       "help-mediawiki": "Pomoć o MediaWikiju",
        "search": "Traži",
        "searchbutton": "Traži",
        "go": "Kreni",
        "wrongpasswordempty": "Niste unijeli zaporku. Pokušajte ponovno.",
        "passwordtooshort": "Zaporka mora sadržavati najmanje {{PLURAL:$1|1 znak|$1 znaka|$1 znakova}}.",
        "passwordtoolong": "Zaporke ne mogu biti duže od {{PLURAL:$1|jednoga znaka|$1 znaka|$1 znakova}}.",
-       "passwordtoopopular": "Uobičajeno upotrebljavane zaporke ne može se koristiti. Molimo Vas da izaberete što jedinstveniju zaporku.",
+       "passwordtoopopular": "Uobičajeno upotrebljavane zaporke ne mogu se koristiti. Molimo Vas izaberite što jedinstveniju zaporku.",
+       "passwordinlargeblacklist": "Unešena zaporka je na popisu uobičajeno upotrebljavanih. Molimo Vas izaberite što jedinstveniju zaporku.",
        "password-name-match": "Vaša zaporka mora biti različita od Vašeg suradničkog imena.",
        "password-login-forbidden": "Uporaba ovog suradničkog imena i lozinke nije dozvoljena.",
        "mailmypassword": "Pošalji mi novu zaporku",
        "passwordremindertitle": "{{SITENAME}}: nova zaporka.",
-       "passwordremindertext": "Netko je (vjerojatno Vi, s IP adrese $1) zatražio novu zaporku za projekt {{SITENAME}} ($4).\nPrivremena zaporka za suradnika \"$2\" je postavljena na \"$3\".\nUkoliko ste to Vi učinili, molimo Vas da se prijavite i promijenite zaporku.\nPrivremena zaporka vrijedi još {{PLURAL:$5|$5 dan|$5 dana}}.\n\nUkoliko niste zatražili novu zaporku, ili ste se sjetili stare zaporke i\nviše ju ne želite promijeniti, slobodno zanemarite ovu poruku i nastavite\nkoristiti staru zaporku.",
+       "passwordremindertext": "Netko je (s IP adrese $1) zatražio novu zaporku za projekt {{SITENAME}} ($4).\nPrivremena zaporka za suradnika \"$2\" je postavljena na \"$3\".\nUkoliko ste to Vi učinili, molimo Vas da se prijavite i promijenite zaporku.\nPrivremena zaporka vrijedi još {{PLURAL:$5|$5 dan|$5 dana}}.\n\nUkoliko niste zatražili novu zaporku, ili ste se sjetili stare zaporke i\nviše ju ne želite promijeniti, slobodno zanemarite ovu poruku i nastavite\nkoristiti staru zaporku.",
        "noemail": "Suradnik \"$1\" nema zapisanu e-mail adresu.",
        "noemailcreate": "Morate navesti važeću e-mail adresu",
        "passwordsent": "Nova je zaporka poslana na adresu elektroničke pošte suradnika \"$1\"",
        "user-mail-no-body": "Pokušali ste poslati e-mail bez sadržaja ili s prekratkim sadržajem.",
        "changepassword": "Promjena zaporke",
        "resetpass_announce": "Da biste završili proces mijenjanja zaporke, upišite \nnovu zaporku.",
+       "resetpass_text": "<!-- Ovdje dodajte tekst -->",
        "resetpass_header": "Promijeni zaporku računa",
        "oldpassword": "Stara zaporka",
        "newpassword": "Nova zaporka",
        "botpasswords-update-failed": "Nije moguće ažurirati bot s imenom \"$1\". Možda je izbrisan?",
        "botpasswords-created-title": "Stvorena bot zaporka",
        "botpasswords-updated-title": "Zaporka za Vašeg bota obnovljena je",
-       "botpasswords-updated-body": "Zaporka za bota imena »$1« suradnika »$2« obnovljena je.",
+       "botpasswords-updated-body": "Zaporka za bota imena »$1« {{GENDER:$2|suradnika|suradnice}} »$2« obnovljena je.",
        "botpasswords-deleted-title": "Zaporka je za Vašeg bota uklonjena",
+       "botpasswords-deleted-body": "Zaporka za bota imena »$1« {{GENDER:$2|suradnika|suradnice}} »$2« izbrisana je.",
        "resetpass_forbidden": "Zaporka ne može biti promijenjena",
        "resetpass_forbidden-reason": "Zaporka ne može biti promijenjena: $1",
        "resetpass-no-info": "Morate biti prijavljeni da biste izravno pristupili ovoj stranici.",
        "resetpass-abort-generic": "Poništena je promjena zaporke.",
        "resetpass-expired": "Istekla Vam je valjanost zaporke. Molimo Vas, potvrdite novu zaporku za prijavu.",
        "resetpass-expired-soft": "Istekla vam je valjanost zaporke i trebate ju promijeniti. Molimo odaberite novu zaporku ili pritisnite na \"{{int:authprovider-resetpass-skip-label}}\", za kasniju promjenu.",
+       "resetpass-validity": "Vaša je zaporka nevaljala: $1\n\nMolimo postavite novu zaporku za prijavu.",
        "resetpass-validity-soft": "Zaporka Vam ne vrijedi: $1\n\nMolimo odaberite novu zaporku ili pritisnite na \"{{int:authprovider-resetpass-skip-label}}\", za kasniju promjenu.",
        "passwordreset": "Ponovo postavi zaporku",
        "passwordreset-text-one": "Ispunite ovaj obrazac ako želite ponovno postaviti Vašu zaporku.",
        "subject-preview": "Pregled teme:",
        "previewerrortext": "Pri pokušaju prikazivanja pretpregleda vaših promjena došlo je do pogrješke.",
        "blockedtitle": "Suradnik je blokiran",
-       "blockedtext": "<strong>Vaše je suradničko ime blokirano ili je Vaša IP adresa blokirana.</strong>\n\nBlokirao Vas je $1.\nRazlog blokiranja je sljedeći: <em>$2</em>.\n\n* Početak blokade: $8\n* Blokada istječe: $6\n* Blokirani suradnik: $7\n\nMožete kontaktirati $1 ili jednog od [[{{MediaWiki:Grouppage-sysop}}|administratora]] kako bi Vam pojasnili razlog blokiranja.\n\nPrimijetite da ne možete koristiti opciju \"Pošalji e-poruku suradnici – suradniku\" ako niste upisali valjanu adresu e-pošte u Vašim [[Special:Preferences|suradničkim postavkama]] i ako niste u tome onemogućeni prilikom blokiranja.\n\nVaša trenutačna IP adresa je $3, a oznaka bloka #$5. Molimo uvrstite sve gore navedene detalje u svaki upit koji napišete.",
-       "autoblockedtext": "Vaša IP adresa automatski je blokirana zbog toga što ju je koristio drugi suradnik, kojeg je blokirao $1.\nRazlog blokiranja je sljedeći:\n\n:<em>$2</em>\n\n* Početak blokade: $8\n* Blokada istječe: $6\n* Blokirani suradnik: $7\n\nMožete kontaktirati $1 ili jednog od [[{{MediaWiki:Grouppage-sysop}}|administratora]] kako bi Vam pojasnili razlog blokiranja.\n\nPrimijetite da ne možete rabiti opciju \"Pošalji e-poruku suradnici – suradniku\" ako niste upisali valjanu adresu e-pošte u Vašim [[Special:Preferences|suradničkim postavkama]] i ako niste u tome onemogućeni prilikom blokiranja.\n\nVaša trenutačna IP adresa je $3, a oznaka bloka #$5. Molimo uvrstite sve gore navedene detalje u svaki upit koji napišete.",
+       "blockedtext": "<strong>Vaše je suradničko ime blokirano ili je Vaša IP adresa blokirana.</strong>\n\nBlokirao Vas je $1.\nRazlog blokiranja je sljedeći: <em>$2</em>.\n\n* Početak blokade: $8\n* Blokada istječe: $6\n* Blokirani suradnik: $7\n\nMožete kontaktirati $1 ili jednog od [[{{MediaWiki:Grouppage-sysop}}|administratora]] kako bi Vam pojasnili razlog blokiranja.\n\nPrimijetite da ne možete koristiti mogućnost \"{{int:emailuser}}\" ako niste upisali valjanu adresu e-pošte u Vašim [[Special:Preferences|suradničkim postavkama]] i ako niste u tome onemogućeni prilikom blokiranja.\n\nVaša trenutačna IP adresa je $3, a oznaka bloka #$5. Molimo uvrstite sve gore navedene detalje u svaki upit koji napišete.",
+       "autoblockedtext": "Vaša IP adresa automatski je blokirana zbog toga što ju je koristio drugi suradnik, kojeg je blokirao $1.\nRazlog blokiranja je sljedeći:\n\n:<em>$2</em>\n\n* Početak blokade: $8\n* Blokada istječe: $6\n* Blokirani suradnik: $7\n\nMožete kontaktirati $1 ili jednog od [[{{MediaWiki:Grouppage-sysop}}|administratora]] kako bi Vam pojasnili razlog blokiranja.\n\nPrimijetite da ne možete rabiti mogućnost \"{{int:emailuser}}\" ako niste upisali valjanu adresu e-pošte u Vašim [[Special:Preferences|suradničkim postavkama]] i ako niste u tome onemogućeni prilikom blokiranja.\n\nVaša trenutačna IP adresa je $3, a oznaka bloka #$5. Molimo uvrstite sve gore navedene detalje u svaki upit koji napišete.",
        "systemblockedtext": "MediaWiki je automatski blokirao Vaše suradničko ime ili IP-adresu.\nDano je sljedeće obrazloženje:\n\n:<em>$2</em>\n\n* Početak blokade: $8\n* Istek blokade: $6\n* Blokada je namijenjena za: $7\n\nVaša trenutačna IP-adresa je $3.\nAko imate pitanja u svezi s blokadom, priložite sve pojedinosti koje su prethodno navedene.",
+       "actionblockedtext": "Izvršenje ove radnje Vam je blokirano.",
        "blockednoreason": "bez obrazloženja",
        "whitelistedittext": "Za uređivanje stranice molimo $1.",
        "confirmedittext": "Morate potvrditi Vašu adresu e-pošte prije nego što Vam bude omogućeno uređivanje. Molim unesite i ovjerite Vašu adresu e-pošte u [[Special:Preferences|suradničkim postavkama]].",
        "accmailtext": "Nova zaporka za [[User talk:$1|$1]] je poslana na $2.\n\nNakon prijave, zaporka za ovaj novi račun može biti promijenjena na stranici ''[[Special:ChangePassword|promijeni zaporku]]'' nakon prijave.",
        "newarticle": "(Novo)",
        "newarticletext": "Došli ste na stranicu koja još ne postoji.\nAko želite stvoriti tu stranicu, počnite tipkati u prozor ispod ovog teksta (pogledajte [$1 stranicu za pomoć]).\nAko ste ovamo dospjeli slučajno, kliknite gumb '''natrag''' (back) u svom pregledniku.",
-       "anontalkpagetext": "----\n<em>Ovo je stranica za razgovor s neprijavljenim suradnikom koji još nije otvorio suradnički račun ili se njime ne koristi.</em>\nZbog toga se moramo služiti brojčanom IP adresom kako bismo ga identificirali. \nTakvu adresu često može dijeliti više ljudi. \nAko ste neprijavljeni suradnik i smatrate da su Vam upućeni irelevantni komentari, molimo Vas da [[Special:CreateAccount|otvorite suradnički račun]] ili [[Special:UserLogin|se prijavite]] te tako u budućnosti izbjegnete zamjenu s drugim neprijavljenim suradnicima.",
+       "anontalkpagetext": "----\n<em>Ovo je stranica za razgovor s neprijavljenim suradnikom koji još nije otvorio suradnički račun ili se njime ne koristi.</em>\nZbog toga se moramo služiti brojčanom IP adresom kako bismo ih identificirali. \nTakvu adresu često može dijeliti više ljudi. \nAko ste neprijavljeni suradnik i smatrate da su Vam upućeni irelevantni komentari, molimo Vas da [[Special:CreateAccount|otvorite suradnički račun]] ili [[Special:UserLogin|se prijavite]] te tako u budućnosti izbjegnete zamjenu s drugim neprijavljenim suradnicima.",
        "noarticletext": "Na ovoj stranici trenutačno nema sadržaja.\nMožete [[Special:Search/{{PAGENAME}}|potražiti ovaj naslov]] na drugim stranicama,\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} pretražiti povezane evidencije]\nili [{{fullurl:{{FULLPAGENAME}}|action=edit}} stvoriti ovu stranicu]</span>.",
        "noarticletext-nopermission": "Ova stranica nema sadržaja.\nMožete [[Special:Search/{{PAGENAME}}|tražiti naslov ove stranice]] na drugim stranicama ili <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} pretražiti povezane evidencije]</span>, ali ne možete stvoriti ovu stranicu.",
        "missing-revision": "Uređivanje broj $1 na stranici \"{{FULLPAGENAME}}\" ne postoji.\n\nOvo je obično uzrokovano kada kliknete na zastarjelu poveznicu na stranice koja je obrisana.\nViše informacija možete pronaći u [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} evidenciji brisanja].",
        "userpage-userdoesnotexist": "Suradničko ime \"<nowiki>$1</nowiki>\" nije prijavljeno. Jeste li sigurni da želite stvoriti/uređivati ovu stranicu?",
        "userpage-userdoesnotexist-view": "Suradnički račun \"$1\" nije registriran.",
        "blocked-notice-logextract": "Ovaj suradnik je trenutačno blokiran.\nPosljednja stavka evidencije blokiranja navedena je niže kao napomena:",
-       "clearyourcache": "'''Napomena:''' Nakon snimanja ćete možda trebati očistiti međuspremnik svog preglednika kako biste vidjeli promjene.\n* '''Firefox / Safari:''' držite ''Shift'' i pritisnite ''Reload'', ili pritisnite bilo ''Ctrl-F5'' ili ''Ctrl-R'' (''Command-R'' na Macu)\n* '''Google Chrome:''' pritisnite ''Ctrl-Shift-R'' (''Command-Shift-R'' na Macu)\n* '''Internet Explorer:''' držite ''Ctrl'' i kliknite ''Refresh'', ili pritisnite ''Ctrl-F5''\n* '''Opera:''' očistite međuspremnik u ''Tools → Preferences''",
+       "clearyourcache": "<strong>Napomena:</strong> Nakon snimanja ćete možda trebati očistiti međuspremnik svog preglednika kako biste vidjeli promjene.\n* <strong>Firefox / Safari:</strong> držite <em>Shift</em>em i pritisnite <em>Reload</em>, ili pritisnite bilo <em>Ctrl-F5</em> ili <em>Ctrl-R</em> (<em>⌘-R</em> na Macu)\n* <strong>Google Chrome:</strong> pritisnite <em>Ctrl-Shift-R</em> (<em>⌘-Shift-R</em> na Macu)\n* <strong>Internet Explorer:</strong> držite <em>Ctrl</em> i kliknite <em>Refresh</em>, ili pritisnite <em>Ctrl-F5</em>\n* <strong>Opera:</strong> idite na <em>Menu → Settings</em> (<em>Opera → Preferences</em> na Macu) te na <em>Privacy & security → Clear browsing data → Cached images and files</em>.",
        "usercssyoucanpreview": "'''Savjet:''' Rabite puce \"{{int:showpreview}}\" za testiranje svog CSS-a prije snimanja.",
        "userjsyoucanpreview": "'''Savjet:''' Rabite puce \"{{int:showpreview}}\" za testiranje svog novog JavaScripta prije snimanja.",
        "usercsspreview": "'''Ne zaboravite: samo isprobavate/pregledavate svoj suradnički CSS. Još nije snimljen!'''",
        "previewnote": "<strong>Ne zaboravite da je ovo samo pregled kako će stranica izgledati.</strong>\nVaše uređivanje još nije snimljeno!",
        "continue-editing": "Nastavi uređivati",
        "previewconflict": "Ovaj pregled odražava stanje u gornjem polju za unos koje će biti sačuvano\nako pritisnete \"Sačuvaj stranicu\".",
-       "session_fail_preview": "'''Ispričavamo se! Nismo mogli obraditi Vašu izmjenu zbog gubitka podataka o prijavi.\nMolimo pokušajte ponovno. Ako i dalje ne bude uspijevalo, pokušajte se [[Special:UserLogout|odjaviti]] i ponovno prijaviti.'''",
-       "session_fail_preview_html": "'''Oprostite! Pretpregled nije moguć jer je ''session'' istekao.'''\n\n''Budući da je na ovom wikiju ({{SITENAME}}) omogućen unos HTML oznaka (tagova), pretpregled je skriven kao mjera predostrožnosti protiv napada pomoću JavaScripta.''\n\n'''Ako ste pokušali vidjeti kako stranica izgleda, molimo probajte opet. Ako ne uspije, [[Special:UserLogout|odjavite se]] i prijavite se ponovo.'''",
+       "session_fail_preview": "Ispričavamo se! Nismo mogli obraditi Vašu izmjenu zbog gubitka podataka o prijavi.\n\nMoguće je da ste odjavljeni. <strong>Molimo provjerite jeste li još uvijek prijavljeni i pokušajte ponovno</strong>.\nAko ovo ne radi, probajte se [[Special:UserLogout|odjaviti]] i opet prijaviti te provjeriti dopušta li preglednik kolačiće za ovo mrežno mjesto.",
+       "session_fail_preview_html": "Ispričavamo se! Nismo mogli obraditi Vašu izmjenu zbog gubitka podataka o prijavi.\n\n<em>Budući da je na ovom wikiju ({{SITENAME}}) omogućen unos HTML oznaka (tagova), pretpregled je skriven kao mjera predostrožnosti protiv napada pomoću JavaScripta.</em>\n\n<strong>Ako je ovo opravdan pokušaj uređivanja, molimo pokušajte opet</strong>\nAko ovo ne radi, probajte se [[Special:UserLogout|odjaviti]] i opet prijaviti te provjeriti dopušta li preglednik kolačiće za ovo mrežno mjesto.",
        "token_suffix_mismatch": "'''Vaše uređivanje je odbačeno jer je Vaš web preglednik ubacio znak/znakove interpunkcije u token uređivanja.'''\nStoga je uređivanje odbačeno da se spriječi uništavanje teksta stranice.\nTo se ponekad događa kad rabite neispravan web-baziran anonimni posrednik (proxy).",
        "edit_form_incomplete": "'''Neki dijelovi obrasca za uređivanja nisu dostigli do poslužitelja; provjerite jesu li izmjene netaknute i pokušajte ponovno.'''",
        "editing": "Uređujete $1",
        "editingsection": "Uređujete $1 (odlomak)",
        "editingcomment": "Uređujete $1 (novi odlomak)",
        "editconflict": "Istovremeno uređivanje: $1",
-       "explainconflict": "Netko je u međuvremenu promijenio stranicu.\nGornje polje sadrži sadašnji tekst stranice.\nU donjem polju prikazane su Vaše promjene.\nMorat ćete unijeti Vaše promjene u sadašnji tekst.\n'''Samo''' će tekst u gornjem polju biti sačuvan kad pritisnete \"$1\".",
+       "explainconflict": "Someone else has changed this page since you started editing it.\nThe upper text area contains the page text as it currently exists.\nYour changes are shown in the lower text area.\nYou will have to merge your changes into the existing text.\n<strong>Only</strong> the text in the upper text area will be saved when you press \"$1\".\n\nNetko je promijenio ovu stranicu od kada ste je Vi počeli uređivati. Gornje polje sadrži sadašnji tekst stranice.\nU donjem polju prikazane su Vaše promjene.\nMorat ćete unijeti Vaše promjene u sadašnji tekst.\n<strong>'''Samo</strong>''' će tekst u gornjem polju biti sačuvan kad pritisnete \"$1\".",
        "yourtext": "Vaš tekst",
        "storedversion": "Pohranjena inačica",
        "editingold": "'''UPOZORENJE: Uređujete stariju inačicu\nove stranice. Ako je sačuvate, sve će promjene učinjene nakon ove inačice biti izgubljene.'''",
        "permissionserrorstext-withaction": "Nemate dopuštenje za $2, iz {{PLURAL:$1|navedenog|navedenih}} razloga:",
        "recreate-moveddeleted-warn": "<strong>Upozorenje: ponovo stvarate stranicu koja je prethodno bila izbrisana.</strong>\n\nRazmotrite je li prikladno nastaviti s uređivanjem ove stranice.\nZa Vašu informaciju slijedi evidencija brisanja i premještanja ove stranice:",
        "moveddeleted-notice": "Ova je stranica izbrisana.\nEvidencije brisanja, zaštićivanja i premještanja za ovu stranicu prikazane su niže za uputu.",
-       "moveddeleted-notice-recent": "Žao nam je, ova stranica je izbrisana u prošla 24 sata. \nNiže je navedena evidencija brisanja i premještanja.",
+       "moveddeleted-notice-recent": "Žao nam je, ova stranica je izbrisana u prošla 24 sata. \nNiže je navedena evidencija brisanja, zaštićivanja i premještanja.",
        "log-fulllog": "Prikaži cijelu evidenciju",
        "edit-hook-aborted": "Uređivanje prekinuto kukom.\nRazlog nije ponuđen.",
        "edit-gone-missing": "Stranica nije spremljena.\nČini se kako je obrisana.",
        "postedit-confirmation-created": "Stranica je stvorena.",
        "postedit-confirmation-restored": "Stranica je vraćena.",
        "postedit-confirmation-saved": "Vaše je uređivanje sačuvano.",
+       "postedit-confirmation-published": "Vaše je uređivanje objavljeno.",
        "edit-already-exists": "Neuspješno stvaranje nove stranice.\nStranica već postoji.",
        "defaultmessagetext": "Prvotni tekst poruke",
        "content-failed-to-parse": "Obrada (''parsiranje'') formata $2 za model $1 nije uspjela: $3",
        "editpage-invalidcontentmodel-text": "Model sadržaja »$1« nije podržan.",
        "editpage-notsupportedcontentformat-title": "Format sadržaja nije podržan",
        "editpage-notsupportedcontentformat-text": "Format sadržaja $1 nije podržan modelom sadržaja $2.",
+       "slot-name-main": "Glavni",
        "content-model-wikitext": "wikitekst",
        "content-model-text": "obični tekst",
        "content-model-javascript": "JavaScript",
        "content-model-css": "CSS",
+       "content-model-json": "JSON",
        "content-json-empty-object": "Prazan objekt",
        "content-json-empty-array": "Prazno polje",
        "deprecated-self-close-category": "Stranice s krivo zatvorenim HTML oznakama‎",
        "rev-showdeleted": "prikaži",
        "revisiondelete": "Izbriši/vrati izmjene",
        "revdelete-nooldid-title": "Nema tražene izmjene",
-       "revdelete-nooldid-text": "Niste naveli željenu izmjenu (izmjene), željena izmjena ne postoji, ili  pokušavate sakriti trenutačnu izmjenu.",
+       "revdelete-nooldid-text": "Niste naveli željenu izmjenu (izmjene), željena izmjena ne postoji, ili pokušavate sakriti trenutačnu izmjenu.",
        "revdelete-no-file": "Navedena datoteka ne postoji.",
        "revdelete-show-file-confirm": "Jeste li sigurni da želite pregledati izbrisanu inačicu datoteke \"<nowiki>$1</nowiki>\" od $2 u $3?",
        "revdelete-show-file-submit": "Da",
        "prefs-editwatchlist-clear": "očisti popis praćenja",
        "prefs-watchlist-days": "Broj dana koji će se prikazati na popisu praćenja:",
        "prefs-watchlist-days-max": "Najviše $1 {{PLURAL:$1|dan|dana}}",
-       "prefs-watchlist-edits": "Broj uređivanja koji će se prikazati na proširenom popisu praćenja:",
+       "prefs-watchlist-edits": "Najveći broj uređivanja koji će se prikazati na popisu praćenja:",
        "prefs-watchlist-edits-max": "Maksimalni broj: 1000",
        "prefs-watchlist-token": "Tajni ključ popisa praćenja:",
        "prefs-watchlist-managetokens": "Upravljaj tajnim ključevima",
        "stub-threshold-disabled": "Onemogućeno",
        "recentchangesdays": "Broj dana prikazanih u nedavnim promjenama:",
        "recentchangesdays-max": "(maksimalno $1 {{PLURAL:$1|dan|dana}})",
-       "recentchangescount": "Zadani broj izmjena koje se prikazuju:",
-       "prefs-help-recentchangescount": "Ovo uključuje nedavne promjene, stare izmjene, i evidencije.",
-       "prefs-help-watchlist-token2": "Ovo je tajni ključ prema sažetku Vašeg popisa praćenja. Svaki suradnik kojem je poznat, moći će čitati Vaš popis praćenih stranica. Ne dijelite ga ni s kim. [[Special:ResetTokens|Kliknite ovdje ako ga želite ponovo postaviti]].",
+       "recentchangescount": "Zadani broj izmjena koje se prikazuju u nedavnim promjenama, povijesti stranica i u evidencijama:",
+       "prefs-help-recentchangescount": "Najveći broj: 1000",
+       "prefs-help-watchlist-token2": "Ovo je tajni ključ prema sažetku Vašeg popisa praćenja. \nSvaki suradnik kojem je poznat, moći će čitati Vaš popis praćenih stranica. Ne dijelite ga ni s kim.\nAko je potrebno možete ga [[Special:ResetTokens|ponovo postaviti]].",
        "savedprefs": "Vaše postavke su sačuvane.",
        "savedrights": "Suradnička su prava {{GENDER:$1|suradnika $1|suradnice $1}} spremljena.",
        "timezonelegend": "Vremenska zona:",
        "localtime": "Lokalno vrijeme:",
        "timezoneuseserverdefault": "Koristi postavke wikija ($1)",
-       "timezoneuseoffset": "Drugo (odredite razliku)",
+       "timezoneuseoffset": "Drugo (navedite razliku)",
        "servertime": "Vrijeme na poslužitelju:",
        "guesstimezone": "Vrijeme dobiveno od preglednika",
        "timezoneregion-africa": "Afrika",
        "timezoneregion-europe": "Europa",
        "timezoneregion-indian": "Indijski ocean",
        "timezoneregion-pacific": "Tihi ocean",
-       "allowemail": "Omogući primanje e-maila od drugih suradnika",
+       "allowemail": "Dopusti drugim suradnicima da mi šalju e-poštu",
        "email-allow-new-users-label": "Dopusti e-poruke od posve novopridošlih suradnika",
        "email-blacklist-label": "Zabrani sljedećim suradnicima da mi šalju e-poruke:",
        "prefs-searchoptions": "Način traženja",
        "prefs-files": "Datoteke",
        "prefs-custom-css": "Prilagođen CSS",
        "prefs-custom-js": "Prilagođen JS",
-       "prefs-common-config": "Dijeljeni CSS/JS za sve izglede:",
+       "prefs-common-config": "Dijeljeni CSS/JSON/JavaScript za sve izglede:",
        "prefs-reset-intro": "Možete koristiti ovu stranicu za povrat Vaših postavki na prvotne postavke. Ovo se ne može poništiti.",
        "prefs-emailconfirm-label": "Potvrda e-mail adrese:",
        "youremail": "Vaša adresa e-pošte:",
        "grant-createaccount": "Otvori račune",
        "grant-createeditmovepage": "Stvaranje, uređivanje i premještanje stranica",
        "grant-delete": "Brisanje stranica, izmjena i unosa u evidencijama",
-       "grant-editmyoptions": "Uređivanje vlastitih suradničkih postavki",
+       "grant-editmyoptions": "Uređivanje vlastitih suradničkih postavki i JSON konfiguracije",
        "grant-editmywatchlist": "Uređivanje Vašega popisa praćenih stranica",
        "grant-editpage": "Uređivanje postojećih stranica",
        "grant-editprotected": "Uređivanje zaštićenih stranica",
        "rcfilters-activefilters-show-tooltip": "Prikaži područja aktivnih filtara",
        "rcfilters-advancedfilters": "Napredni filtri",
        "rcfilters-limit-title": "Rezultata za prikaz",
-       "rcfilters-limit-and-date-label": "{{PLURAL:$1|$1 izmjena|$1 izmjene|$1 izmjena}}, $2",
+       "rcfilters-limit-and-date-label": "$1 {{PLURAL:$1|izmjena|izmjene|izmjena}}, $2",
        "rcfilters-date-popup-title": "Razdoblje za pretraživanje",
        "rcfilters-days-title": "Nedavnih dana",
        "rcfilters-hours-title": "Nedavnih sati",
        "rcfilters-savedqueries-rename": "Preimenuj",
        "rcfilters-savedqueries-setdefault": "Postavi kao predodređeno",
        "rcfilters-savedqueries-unsetdefault": "Ukloni kao zadano",
-       "rcfilters-savedqueries-remove": "Ukloni",
+       "rcfilters-savedqueries-remove": "Izbriši",
        "rcfilters-savedqueries-new-name-label": "Naziv",
        "rcfilters-savedqueries-new-name-placeholder": "Opišite svrhu filtra",
        "rcfilters-savedqueries-apply-label": "Stvori filtar",
        "rcfilters-restore-default-filters": "Vrati zadane filtre",
        "rcfilters-clear-all-filters": "Očisti sve filtre",
        "rcfilters-show-new-changes": "Vidi najnovije izmjene",
-       "rcfilters-search-placeholder": "Filtriraj nedavne promjene (pretražite ili počnite unositi)",
+       "rcfilters-search-placeholder": "Filtriraj promjene (rabite izbornik ili potražite ime filtra)",
        "rcfilters-invalid-filter": "Filter nije valjan",
        "rcfilters-empty-filter": "Nema aktivnih filtara. Prikazani su svi doprinosi.",
        "rcfilters-filterlist-title": "Filtri",
        "rcfilters-filterlist-whatsthis": "Kako ovo radi?",
-       "rcfilters-filterlist-feedbacklink": "Recite nam Vaše mišljenje o ovim (novim) oruđima za filtriranje",
+       "rcfilters-filterlist-feedbacklink": "Recite nam Vaše mišljenje o ovim oruđima za filtriranje",
        "rcfilters-highlightbutton-title": "Označi rezultate",
        "rcfilters-highlightmenu-title": "Odaberite boju",
        "rcfilters-highlightmenu-help": "Odaberite boju za označavanje ovog svojstva",
        "rcfilters-filterlist-noresults": "Nema rezultata za traženo filtriranje",
        "rcfilters-noresults-conflict": "Rezultati pretrage nisu pronađeni zbog sukoba kriterija pretrage",
-       "rcfilters-state-message-fullcoverage": "Označavanje svih filtera u grupi je isto kao da nije označen niti jedan, tako da filter nema učinka. Grupa uključuje: $1",
+       "rcfilters-state-message-fullcoverage": "Označavanje svih filtera u ovoj grupi je isto kao da nije označen niti jedan, tako da filter nema učinka. Grupa uključuje: $1",
        "rcfilters-filtergroup-authorship": "Doprinosi prema autorima",
        "rcfilters-filter-editsbyself-label": "Uređivanja koja ste Vi napravili",
        "rcfilters-filter-editsbyself-description": "Vaša uređivanja.",
        "rcfilters-filter-editsbyother-label": "Promjene drugih suradnika",
        "rcfilters-filter-editsbyother-description": "Sve promjene osim Vaših.",
-       "rcfilters-filtergroup-userExpLevel": "Napredna razina (samo za registrirane suradnike)",
+       "rcfilters-filtergroup-userExpLevel": "Suradnička registracija i iskustvo",
        "rcfilters-filter-user-experience-level-registered-label": "Registrirani",
        "rcfilters-filter-user-experience-level-registered-description": "Prijavljeni suradnici.",
        "rcfilters-filter-user-experience-level-unregistered-label": "Neregistrirani",
        "rcfilters-filter-user-experience-level-unregistered-description": "Suradnici koji nisu prijavljeni.",
        "rcfilters-filter-user-experience-level-newcomer-label": "Novopridošli",
-       "rcfilters-filter-user-experience-level-newcomer-description": "Registrirani suradnici s manje od 10 uređivanja i 4 dana aktivnosti.",
+       "rcfilters-filter-user-experience-level-newcomer-description": "Registrirani suradnici s manje od 10 uređivanja ili 4 dana aktivnosti.",
        "rcfilters-filter-user-experience-level-learner-label": "Početnici",
        "rcfilters-filter-user-experience-level-learner-description": "Registrirani suradnici čije je iskustvo između novih i iskusnih suradnika.",
        "rcfilters-filter-user-experience-level-experienced-label": "Iskusni suradnici",
        "rcfilters-watchlist-markseen-button": "Označi sve izmjene kao pregledane",
        "rcfilters-watchlist-edit-watchlist-button": "Izmijeni popis praćenih stranica",
        "rcfilters-watchlist-showupdated": "Izmjene na stranicama koje niste posjetili otkako su se izmjene dogodile istaknute su <strong>podebljanim slovima</strong>, s ispunjenim kružićima.",
-       "rcfilters-preference-label": "Skrij poboljšanu inačicu nedavnih promjena",
-       "rcfilters-preference-help": "Vraća natrag stanje prije redizajna sučelja 2017., te svih oruđa dodanih tada i poslije toga.",
-       "rcfilters-watchlist-preference-label": "Sakrij poboljšanu inačicu popisa praćenja",
-       "rcfilters-watchlist-preference-help": "Vraća natrag stanje prije redizajna sučelja 2017., te svih oruđa dodanih tada i poslije toga.",
+       "rcfilters-preference-label": "Rabi sučelje bez JavaScripta",
+       "rcfilters-preference-help": "Učitavanje nedavnih promjena bez pretrage s filtrima ili mogućnosti isticanja.",
+       "rcfilters-watchlist-preference-label": "Rabi sučelje bez JavaScripta",
+       "rcfilters-watchlist-preference-help": "Učitavanje popisa praćenja bez pretrage s filtrima ili mogućnosti isticanja.",
        "rcfilters-filter-showlinkedfrom-label": "Prikaži promjene na povezanim stranicama",
        "rcfilters-filter-showlinkedfrom-option-label": "<strong>Stranice na koje se povezuje</strong> izabrana stranica",
        "rcfilters-filter-showlinkedto-option-label": "<strong>Stranice koje povezuju</strong> na izabranu stranicu",
        "recentchangeslinked-feed": "Povezane stranice",
        "recentchangeslinked-toolbox": "Povezane promjene",
        "recentchangeslinked-title": "Povezane promjene sa stranicom »$1«",
-       "recentchangeslinked-summary": "Ova posebna stranica pokazuje nedavne promjene na povezanim stranicama (ili stranicama određene kategorije). Stranice koje su na [[Special:Watchlist|Vašem popisu praćenja]] su '''podebljane'''.",
+       "recentchangeslinked-summary": "Unesite ime stranice da biste vidjeli promjene na stranicama povezanim s ovom ili s nje. (Da biste vidjeli članove kategorije, unesite {{ns:category}}:ime kategorije). Izmjene na stranicama na [[Special:Watchlist|Vašem popisu praćenja]] su <strong>podebljane</strong>.",
        "recentchangeslinked-page": "Naslov stranice:",
        "recentchangeslinked-to": "Pokaži promjene na stranicama s poveznicom na ovu stranicu",
        "recentchanges-page-added-to-category": "[[:$1]] dodano u kategoriju",
        "largefileserver": "Veličina ove datoteke veća je od one dopuštene postavkama poslužitelja.",
        "emptyfile": "Datoteka koju ste postavili je prazna. Možda se radi o krivo utipkanom imenu datoteke. Provjerite želite li zaista postaviti ovu datoteku.",
        "windows-nonascii-filename": "Ovaj wiki ne podržava imena datoteka s posebnim znakovima.",
-       "fileexists": "Datoteka s ovim imenom već postoji, pogledajte <strong>[[:$1]]</strong> ako niste sigurni želite li je uistinu promijeniti.\n[[$1|thumb]]",
+       "fileexists": "Datoteka s ovim imenom već postoji, molimo provjerite <strong>[[:$1]]</strong> ako niste sigurni želite li je uistinu promijeniti.\n[[$1|thumb]]",
        "filepageexists": "Opis stranice za ovu datoteku je već napravljen ovdje <strong>[[:$1]]</strong>, ali datoteka sa ovim nazivom trenutno ne postoji.\nSažetak koji ste naveli neće se pojaviti na stranici opisa.\nDa bi se Vaš opis ovdje našao, potrebno je da ga ručno uredite.\n[[$1|thumb]]",
        "fileexists-extension": "Već postoji datoteka sa sličnim imenom: [[$2|thumb]]\n* Ime datoteke koju postavljate: <strong>[[:$1]]</strong>\n* Ime postojeće datoteke: <strong>[[:$2]]</strong>\nŽelite li možda izabrati više različito ime?",
        "fileexists-thumbnail-yes": "Datoteka je najvjerojatnije slika u smanjenoj veličini ''(thumbnail)''. [[$1|thumb]]\nMolimo provjerite datoteku <strong>[[:$1]]</strong>.\nUkoliko je ta datoteka ista kao i ova koju ste upravo pokušali snimiti, samo u višoj rezoluciji, nije nužno snimanje smanjenje slike ''(thumbnaila)'', prikazivanje smanjene slike iz izvornika radi se softverski.",
        "backend-fail-read": "Datoteka \"$1\" je nečitljiva.",
        "backend-fail-create": "Ne mogu stvoriti ili pisati u datoteku $1.",
        "backend-fail-maxsize": "Ne mogu zapisati datoteku \"$1\" jer je veća od {{PLURAL:$2|$2 bajta|$2 bajta|$2 bajtova}}.",
-       "backend-fail-readonly": "Baza ili datotečni sustav \"$1\" trenutačno nije dostupan za pisanje. Razlog je: \"''$2''\"",
+       "backend-fail-readonly": "Baza ili datotečni sustav \"$1\" trenutačno nije dostupan za pisanje. Razlog je: <em>$2</em>",
        "backend-fail-synced": "Datoteka \"$1\" nije identična inačici u internom skladištu",
        "backend-fail-connect": "Ne mogu se spojiti na spremište poslužitelja \"$1“.",
        "backend-fail-internal": "Došlo je do nepoznate pogrješke u spremištu poslužitelja \"$1\".",
        "lockmanager-fail-closelock": "Ne mogu zatvoriti ''lock'' datoteku za \"$1\".",
        "lockmanager-fail-deletelock": "Ne mogu obrisati ''lock'' datoteku  za \"$1\".",
        "lockmanager-fail-acquirelock": "Ne mogu stvoriti ''lock'' datoteku za \"$1\".",
-       "lockmanager-fail-openlock": "Ne mogu otvoriti ''lock'' datoteku  za \"$1\".",
+       "lockmanager-fail-openlock": "Ne mogu otvoriti zaključanu datoteku za \"$1\". Provjerite je li popis datoteka (direktorij) ispravno konfiguriran te ima li mrežni poslužitelj dopuštenje za pisanje u popis. Za više informacija vidjeti https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgUploadDirectory.",
        "lockmanager-fail-releaselock": "Ne mogu obrisati ''lock'' datoteku  za \"$1\".",
        "lockmanager-fail-db-bucket": "Ne mogu uspostaviti vezu s poslužiteljem zaključavanja za $1",
        "lockmanager-fail-db-release": "Ne mogu otključati bazu podataka $1.",
        "uploadstash-summary": "Ova stranica pruža pristup datotekama koje su snimljene na wiki (ili u procesu snimanja), ali još nisu objavljeni na wiki. Ove datoteke nisu vidljive nikome, osim suradniku koji ih je snimio.",
        "uploadstash-clear": "Očisti niz datoteka",
        "uploadstash-nofiles": "Nemate neobjavljenih datoteka",
-       "uploadstash-badtoken": "Obavljanje akcije je bilo neuspješano, možda jer je vaša prijava istekla. Pokušajte ponovno.",
-       "uploadstash-errclear": "Brisanje neobjavljenih datoteka nije uspjelo.",
+       "uploadstash-badtoken": "Obavljanje radnje nije bilo uspješno, moguće da su Vam istekla prava uređivanja. Pokušajte ponovno.",
+       "uploadstash-errclear": "Brisanje datoteka nije uspjelo.",
        "uploadstash-refresh": "Osvježi popis datoteka",
        "uploadstash-thumbnail": "pogledaj kao minijaturu",
        "uploadstash-exception": "Postavljanje u zalihu nije bilo moguće ($1): »$2«.",
        "uploadstash-zero-length": "Veličina datoteke je nula bajtova.",
        "invalid-chunk-offset": "Nevaljana točka nastavka snimanja",
        "img-auth-accessdenied": "Pristup onemogućen",
-       "img-auth-nopathinfo": "Nedostaje PATH_INFO.\nVaš poslužitelj nije postavljen da prosljeđuje ovu informaciju.\nMožda se temelji na CGI skripti i ne može podržavati img_auth.\n[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Image_Authorization?uselang=hr Pogledajte stranicu o autorizaciji slika]",
+       "img-auth-nopathinfo": "Nedostaje informacija o putanji.\nVaš poslužitelj mora biti postavljen tako da prosljeđuje varijable REQUEST_URI i/ili PATH_INFO.\nAko je tako, probajte omogućiti $wgUsePathInfo.\n[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Image_Authorization?uselang=hr Pogledajte stranicu o autorizaciji slika]",
        "img-auth-notindir": "Zahtjevana putanja nije u direktoriju podešenom za postavljanje.",
        "img-auth-badtitle": "Ne mogu stvoriti valjani naslov iz \"$1\".",
        "img-auth-nologinnWL": "Niste prijavljeni i \"$1\" nije na popisu dozvoljenih.",
        "filehist-filesize": "Veličina datoteke",
        "filehist-comment": "Komentar",
        "imagelinks": "Uporaba datoteke",
-       "linkstoimage": "{{PLURAL:$1|Sljedeća stranica povezuje|$1 sljedeće stranice povezuju|$1 sljedećih stranica povezuje}} na ovu datoteku:",
-       "linkstoimage-more": "Više od $1 {{PLURAL:$1|stranice povezuje|stranica povezuje}} na ovu datoteku.\nSljedeći popis prikazuje {{PLURAL:$1|stranice koje|prvih $1 stranica koje}} vode na ovu datoteku.\n[[Special:WhatLinksHere/$2|Ovdje se nalazi]] potpuni popis.",
-       "nolinkstoimage": "Nijedna stranica ne povezuje na ovu sliku.",
+       "linkstoimage": "{{PLURAL:$1|Sljedeća stranica rabi|$1 sljedeće stranice rabe|$1 sljedećih stranica rabi}} ovu datoteku:",
+       "linkstoimage-more": "Više od $1 {{PLURAL:$1|stranice rabi|stranica rabi}} na ovu datoteku.\nSljedeći popis prikazuje {{PLURAL:$1|stranice koje|prvih $1 stranica koje}} rabe ovu datoteku.\n[[Special:WhatLinksHere/$2|Ovdje se nalazi]] potpuni popis.",
+       "nolinkstoimage": "Nijedna stranica ne rabi ovu datoteku.",
        "morelinkstoimage": "Pogledaj [[Special:WhatLinksHere/$1|više poveznica]] za ovu datoteku.",
        "linkstoimage-redirect": "$1 (preusmjeravanje datoteke) $2",
        "duplicatesoffile": "{{PLURAL:$1|Sljedeća datoteka je kopija|$1 sljedeće datoteke su kopije|$1 sljedećih datoteka su kopije}} ove datoteke ([[Special:FileDuplicateSearch/$2|više detalja]]):",
        "filedelete-maintenance": "Brisanje i vraćanje datoteka privremeno je onemogućeno zbog održavanja.",
        "filedelete-maintenance-title": "Ne mogu obrisati datoteku",
        "mimesearch": "MIME tražilica",
-       "mimesearch-summary": "Ova stranica omogućuje pretraživanje datoteka prema njihovim MIME zaglavljima. Ulazni parametar: tip_datoteke/podtip, npr. <code>image/jpeg</code>.",
+       "mimesearch-summary": "Ova stranica omogućuje pretraživanje datoteka prema njihovim MIME zaglavljima. Ulazni parametar: tip_datoteke/podtip/*, npr. <code>image/jpeg</code>.",
        "mimetype": "MIME tip datoteke:",
        "download": "skidanje",
        "unwatchedpages": "Nepraćene stranice",
        "pageswithprop-submit": "Idi",
        "doubleredirects": "Dvostruka preusmjeravanja",
        "doubleredirectstext": "Ova stranica sadrži popis stranica koje preusmjeravju na druge stranice za preusmjeravanje.\nSvaki redak sadrži poveznice na prvo i drugo preusmjeravanje, kao i odredište drugog preusmjeravanja\nkoja obično ukazuje na \"pravu\" odredišnu stranicu, na koju bi trebalo pokazivati prvo preusmjeravanje.\n<del>Precrtane</del> stavke su riješene.",
-       "double-redirect-fixed-move": "[[$1]] je premješten, sada je preusmjeravanje na [[$2]]",
-       "double-redirect-fixed-maintenance": "Ispravljanje dvostrukih preusmjeravanja s [[$1]] na [[$2]].",
+       "double-redirect-fixed-move": "[[$1]] je premješten. Automatski je ažurirano i preusmjerava na [[$2]]",
+       "double-redirect-fixed-maintenance": "Automatsko ispravljanje dvostrukih preusmjeravanja s [[$1]] na [[$2]] kao dio održavanja.",
        "double-redirect-fixer": "Popravljač preusmjeravanja",
        "brokenredirects": "Kriva preusmjeravanja",
        "brokenredirectstext": "Sljedeća preusmjeravanja povezuju na nepostojeće stranice:",
        "prefixindex": "Sve stranice prema početku naslova",
        "prefixindex-namespace": "Sve stranice s predmetkom (imenski prostor $1)",
        "prefixindex-submit": "Prikaži",
-       "prefixindex-strip": "Ne prikazuj predmetak u popisu",
+       "prefixindex-strip": "Ne prikazuj predmetak u rezultatima",
        "shortpages": "Kratke stranice",
        "longpages": "Duge stranice",
        "deadendpages": "Stranice na koje ne vodi ijedna druga stranica",
        "cachedspecial-refresh-now": "Pogledaj najnoviju.",
        "categories": "Kategorije",
        "categories-submit": "Prikaži",
-       "categoriespagetext": "Sljedeće {{PLURAL:$1|kategorija sadrži|kategorije sadrže}} stranice ili datoteke.\n[[Special:UnusedCategories|Nekorištene kategorije]] ovdje nisu prikazane.\nTakođer pogledajte [[Special:WantedCategories|tražene kategorije]].",
+       "categoriespagetext": "{{PLURAL:$1|Sljedeća kategorija postoji|Sljedeće kategorije postoje}} na wikiju i možda {{PLURAL:$1|je/nije u uporabi|jesu/nisu u uporabi}}.\n\nTakođer pogledajte [[Special:WantedCategories|tražene kategorije]].",
        "categoriesfrom": "Prikaži kategorije počevši od:",
        "deletedcontributions": "Obrisani suradnički doprinosi",
        "deletedcontributions-title": "Obrisani suradnički doprinosi",
        "enotif_body_intro_restored": "Stranica $1 projekta {{SITENAME}} {{GENDER:$2|vratio|vratila}} je dana $PAGEEDITDATE {{GENDER:$2|suradnik|suradnica}} $2, vidi $3 za trenutačnu inačicu stranice.",
        "enotif_body_intro_changed": "Stranica $1 projekta {{SITENAME}} {{GENDER:$2|promijenio|promijenila}} je dana $PAGEEDITDATE {{GENDER:$2|suradnik|suradnica}} $2, vidi $3 za trenutačnu inačicu stranice.",
        "enotif_lastvisited": "Za sve izmjene od Vašega posljednjeg posjeta, pogledajte $1",
-       "enotif_lastdiff": "Pogledajte $1 kako biste mogli vidjeti tu izmjenu.",
+       "enotif_lastdiff": "Da biste vidjeli ovu izmjenu, pogledajte $1.",
        "enotif_anon_editor": "neprijavljeni suradnik $1",
        "enotif_body": "Poštovani $WATCHINGUSERNAME,\n\n$PAGEINTRO $NEWPAGE\n\nSažetak uređivača: $PAGESUMMARY $PAGEMINOREDIT\n\nMožete kontaktirati suradnika koji je posljednji uređivao stranicu:\nmail: $PAGEEDITOR_EMAIL\nwiki: $PAGEEDITOR_WIKI\n\nDo Vašega ponovnog posjeta stranici ne ćete dobivati nove obavijesti. Postavke za izvješćivanje možete vratiti na prvobitno zadane za sve praćene stranice Vašega popisa praćenja.\n\nVaš sustav izvješćivanja {{SITENAME}}.\n\n--\nZa promjene postavki izvješćivanja putem e-pošte, posjetite\n{{canonicalurl:{{#special:Preferences}}}}\n\nZa promjene svog popisa praćenja, posjetite\n{{canonicalurl:{{#special:EditWatchlist}}}}\n\nZa brisanje stranica iz svog popisa praćenja, posjetite\n$UNWATCHURL\n\nZa povratne informacije i pomoć posjetite:\n$HELPPAGE",
        "enotif_minoredit": "Ovo je sitnije uređivanje",
        "delete-toobig": "Ova stranica ima veliku povijest uređivanja, preko $1 {{PLURAL:$1|promjene|promjena}}. Brisanje takvih stranica je ograničeno da se onemoguće slučajni problemi u radu {{SITENAME}}.",
        "delete-warning-toobig": "Ova stranica ima veliku povijest uređivanja, preko $1 {{PLURAL:$1|promjene|promjena}}. Brisanje može poremetiti bazu podataka {{SITENAME}}; postupajte s oprezom.",
        "deleteprotected": "Ne možete obrisati ovu stranicu jer je zaštićena.",
-       "deleting-backlinks-warning": "'''Upozorenje:''' brišete stranicu koja je uključena u druge ili druge stranice povezuju na nju.",
+       "deleting-backlinks-warning": "<strong>Upozorenje:</strong> \nbrišete stranicu koja je uključena u [[Special:WhatLinksHere/{{FULLPAGENAME}}|druge]] ili druge stranice povezuju na nju.",
        "rollback": "Ukloni posljednju promjenu",
        "rollbacklink": "ukloni",
        "rollbacklinkcount": "ukloni $1 {{PLURAL:$1|uređivanje|uređivanja}}",
        "rollback-success": "Uklonjeno uređivanje {{GENDER:$3|suradnika|suradnice}} $1; vraćeno na posljednju inačicu {{GENDER:$4|suradnika|suradnice}} $2.",
        "rollback-success-notify": "Uklonili ste izmjene suradnika $1;\nvraćeno na posljednju izmjenu suradnika $2. [$3 Prikaži izmjene]",
        "sessionfailure-title": "Prekid sesije",
-       "sessionfailure": "Izgleda da postoji problem s uspostavom sjednice kod Vašega prijavljivanja; ta radnja otkazana je kao način sprječavanja krađe sjednice. Molimo Vas da se u pregledniku vratite natrag na prethodnu stranicu, ponovo ju učitate i zatim pokušate opet.",
+       "sessionfailure": "Izgleda da postoji problem s Vašom prijavom; ta radnja otkazana je kao način sprječavanja zlouporabe. Molimo ponovno pošaljite obrazac.",
        "changecontentmodel": "Promjena modela sadržaja stranice",
        "changecontentmodel-legend": "Promijeni model sadržaja",
        "changecontentmodel-title-label": "Naziv stranice",
        "protect-locked-blocked": "Ne možete mijenjati nivo zaštite dok ste blokirani.\nSlijede postavke stranice '''$1''':",
        "protect-locked-dblock": "Razina zaštite ne može biti promijenjena jer je baza zaključana.\nSlijede postavke stranice '''$1''':",
        "protect-locked-access": "Nemate ovlasti za mijenjanje razine zaštite.\nSlijede trenutačne postavke stranice '''$1''':",
-       "protect-cascadeon": "Ova stranica je zaštićena jer je uključena u {{PLURAL:$1|stranicu, koja ima|stranice, koje imaju|stranice, koje imaju}} uključenu prenosivu zaštitu. Možete promijeniti stupanj zaštite ove stranice, no to neće utjecati na prenosivu zaštitu.",
+       "protect-cascadeon": "Ova je stranica zaštićena jer je uključena u {{PLURAL:$1|stranicu koja ima|stranice koje imaju}} uključenu prenosivu zaštitu. \nMožete promijeniti stupanj zaštite ove stranice, no to neće utjecati na prenosivu zaštitu.",
        "protect-default": "Omogućeno svim suradnicima",
        "protect-fallback": "Potrebno je imati \"$1\" ovlasti",
        "protect-level-autoconfirmed": "Dopušteno samo autopotvrđenim suradnicima",
        "undeletepagetext": "{{PLURAL:$1|Sljedeća stranica je obrisana, ali se još uvijek nalazi|Sljedećih $1 stranica su obrisane, ali se još uvijek nalaze}} u bazi i mogu se obnoviti.\nBaza se povremeno čisti od ovakvih stranica.",
        "undelete-fieldset-title": "Vrati izmjene",
        "undeleteextrahelp": "Da biste vratili cijelu povijest stranice, ostavite sve ''kućice'' neoznačene i kliknite '''''{{int:undeletebtn}}'''''.\nAko želite vratiti određene izmjene, označite ih i kliknite '''''{{int:undeletebtn}}'''''.",
-       "undeleterevisions": "$1 {{PLURAL:$1|inačica je arhivirana|inačice su arhivirane|inačica je arhivirano}}",
+       "undeleterevisions": "$1 {{PLURAL:$1|inačica je izbrisana|inačice su izbrisane|inačica je izbrisano}}",
        "undeletehistory": "Ako vratite izbrisanu stranicu, bit će vraćene i sve prijašnje promjene. Ako je u međuvremenu stvorena nova stranica s istim imenom, vraćena stranica bit će upisana kao prijašnja promjena sadašnje.",
        "undeleterevdel": "Vraćanje stranice neće biti izvršeno ako je rezultat toga djelomično brisanje posljednjeg uređivanja.\nU takvim slučajevima morate isključiti ili otkriti najnovije obrisane promjene.",
        "undeletehistorynoadmin": "Ovaj je članak izbrisan. Razlog za brisanje prikazan je u donjem sažetku, zajedno s\ndetaljima o suradnicima koji su uređivali ovu stranicu prije brisanja.\nTekst izbrisanih inačica dostupan je samo administratorima.",
        "undeleteviewlink": "pregled",
        "undeleteinvert": "Obrni odabir",
        "undeletecomment": "Razlog:",
-       "cannotundelete": "Vraćanje obrisane inačice nije uspjelo:\n$1",
+       "cannotundelete": "Sva ili neka vraćanja nisu uspjela:\n$1",
        "undeletedpage": "'''$1 je vraćena'''\n\nPogledajte [[Special:Log/delete|evidenciju brisanja]] za zapise nedavnih brisanja i vraćanja.",
        "undelete-header": "Pogledaj [[Special:Log/delete|evidenciju brisanja]] za nedavno obrisane stranice.",
        "undelete-search-title": "Pretraži obrisane stranice",
        "block": "Blokiraj suradnika",
        "unblock": "Deblokiraj suradnika",
        "blockip": "Blokiraj {{GENDER:$1|suradnika|suradnicu}}",
-       "blockiptext": "Koristite donji obrazac za blokiranje pisanja pojedinih suradnika ili IP adresa .\nTo biste trebali raditi samo zbog sprječavanja vandalizma i u skladu\nsa [[{{MediaWiki:Policy-url}}|smjernicama]].\nUpišite i razlog za ovo blokiranje (npr. stranice koje su\nvandalizirane).",
+       "blockiptext": "Koristite donji obrazac za blokiranje pisanja pojedinih suradnika ili IP adresa.\nTo biste trebali raditi samo zbog sprječavanja vandalizma i u skladu\nsa [[{{MediaWiki:Policy-url}}|smjernicama]].\nUpišite i razlog za ovo blokiranje (npr. stranice koje su\nvandalizirane).\nMožete blokirati i opseg IP adresa rabeći [https://hr.wikipedia.org/wiki/CIDR CIDR] sintaksu; najveći dopušteni opseg je /$1 za IPv4 i /$2 za IPv6.",
        "ipaddressorusername": "IP adresa ili suradničko ime",
        "ipbreason": "Razlog:",
        "ipbreason-dropdown": "*Najčešći razlozi za blokiranje\n** Netočne informacije\n** Uklanjanje sadržaja stranica\n** Postavljanje ''spam'' vanjskih poveznica\n** Grafiti\n** Osobni napadi (ili napadačko ponašanje)\n** Čarapare (zloporaba više suradničkih računa)\n** Neprihvatljivo suradničko ime",
        "ipb-hardblock": "Onemogući prijavljene suradnike uređivati s ove IP adrese",
-       "ipbcreateaccount": "Spriječi otvaranje suradničkih računa",
-       "ipbemailban": "Onemogući blokiranom suradniku slanje e-pošte",
+       "ipbcreateaccount": "Stvaranje računa",
+       "ipbemailban": "Slanje e-pošte",
        "ipbenableautoblock": "Automatski blokiraj IP adrese koje koristi ovaj suradnik",
        "ipbsubmit": "Blokiraj ovog suradnika",
        "ipbother": "Drugi rok:",
        "ipboptions": "2 sata:2 hours,1 dan:1 day,3 dana:3 days,1 tjedan:1 week,2 tjedna:2 weeks,1 mjesec:1 month,3 mjeseca:3 months,6 mjeseci:6 months,1 godine:1 year,neograničeno:infinite",
        "ipbhidename": "Sakrij suradničko ime iz uređivanja i popisa",
        "ipbwatchuser": "Prati suradničku stranicu i stranicu za razgovor ovog suradnika",
-       "ipb-disableusertalk": "Onemogući ovog suradnika da uređuje svoju stranicu za razgovor dok je blokiran",
+       "ipb-disableusertalk": "Uređivanje vlastite stranice za razgovor",
        "ipb-change-block": "Ponovno blokiraj suradnika s ovim postavkama",
        "ipb-confirm": "Potvrdi blokiranje",
        "badipaddress": "Nevaljana IP adresa.",
        "lockedbyandtime": "(od $1 dana $2 u $3)",
        "move-page": "Premjesti $1",
        "move-page-legend": "Premjesti stranicu",
-       "movepagetext": "Uporabom ovog obrasca ćete preimenovati stranicu i premjestiti sve stare izmjene na novo ime.\nStari će se naslov pretvoriti u stranicu koja automatski preusmjerava na novi naslov.\nMožete odabrati automatsko ažuriranje preusmjeravanja na izvorni naslov.\nAko se ne odlučite na to, provjerite [[Special:DoubleRedirects|dvostruka]] ili [[Special:BrokenRedirects|neispravna preusmjeravanja]].\nDužni ste provjeriti da sve poveznice i dalje nastave voditi na prave stranice.\n\nStranica se '''ne će''' premjestiti ako već postoji stranica s novim naslovom, osim u slučaju prazne stranice ili stranice za preusmjeravanje koja nema nikakvih starih izmjena.\nTo znači: 1. ako pogriješite, možete opet preimenovati stranicu na stari naslov, 2. ne može se dogoditi da izbrišete neku postojeću stranicu.\n\n'''Upozorenje!'''\nOvo može biti drastična i neočekivana promjena kad su u pitanju popularne stranice. Molimo dobro razmislite prije nego što preimenujete stranicu.",
+       "movepagetext": "Uporabom ovog obrasca ćete preimenovati stranicu i premjestiti povijest uređivanja na novo ime.\nStari će naslov preusmjeravati na stranicu s novim imenom.\nMožete odabrati automatsko ažuriranje preusmjeravanja na izvorni naslov.\nAko se ne odlučite na to, provjerite [[Special:DoubleRedirects|dvostruka]] ili [[Special:BrokenRedirects|neispravna preusmjeravanja]].\nDužni ste provjeriti da sve poveznice i dalje nastave voditi na prave stranice.\n\nStranica se <strong>ne će</strong> premjestiti ako već postoji stranica s novim naslovom, osim u slučaju prazne stranice ili stranice za preusmjeravanje koja nema nikakvih starih izmjena.\nTo znači da možete preimenovati stranicu natrag odakle je preimenovana ako napravite grešku, ali ne možete prepisati preko postojeće stranice.\n\n<strong>Napomena:</strong>\novo može biti drastična i neočekivana promjena kad su u pitanju popularne stranice. Molimo dobro razmislite prije nego što preimenujete stranicu.",
        "movepagetext-noredirectfixer": "Pomoću donjeg obrasca ćete preimenovati stranicu i premjestiti sve stare izmjene na novo ime. \nStari će se naslov pretvoriti u stranicu koja automatski preusmjerava na novi naslov. \nBudite sigurni da ste provjerili [[Special:DoubleRedirects|dvostruka]] ili [[Special:BrokenRedirects|nevaljana preusmjeravanja]]. \nVi ste odgovorni za to da poveznice i dalje povezuju tamo gdje treba.\n\nImajte na umu da stranica <strong>ne će</strong> biti premještena ako već postoji stranica s novim naslovom, osim u slučaju stranice za preusmjeravanje koja nema nikakvih starih izmjena. \nTo znači da stranicu možete preimenovati u prethodno ime ako ste pogriješili te ne možete pisati preko postojeće stranice. \n\n<strong>Napomena:</strong>\nOvo može biti drastična i neočekivana promjena kad su u pitanju popularne stranice; \nbudite sigurni da razumijete posljedice ove akcije prije nastavka.",
-       "movepagetalktext": "Stranica za razgovor, ako postoji, automatski će se premjestiti zajedno sa stranicom koju premještate. '''Stranica se za razgovor ne će premjestiti ako:'''\n*premještate stranicu iz jednog prostora u drugi,\n*pod novim imenom već postoji stranica za razgovor s nekim sadržajem, ili\n*maknete kvačicu u kućici na dnu ove stranice.\n\nU tim ćete slučajevima morati sami premjestiti ili iskopirati stranicu za razgovor,\nako to želite.",
+       "movepagetalktext": "Ako označite ovu kvačicu, pripadajuća stranica za razgovor biti će automatski premještena na novo ime osim ako već postoji stranica za razgovor koja nije prazna. \n\nU tom slučaju morat ćete ručno premjestiti ili spojiti stranice.",
        "moveuserpage-warning": "'''Upozorenje:''' Premještate suradničku stranicu. Imajte na umu da će stranica biti premještena, ali suradnik ''ne će'' biti preimenovan.",
        "movenologintext": "Ako želite premjestiti stranicu morate biti [[Special:UserLogin|prijavljeni]].",
        "movenotallowed": "Nemate pravo premještanja stranica.",
        "movenosubpage": "Ova stranica nema podstranica.",
        "movereason": "Razlog:",
        "revertmove": "vrati",
-       "delete_and_move_text": "==Nužno brisanje==\n\nOdredišni članak \"[[:$1]]\" već postoji. Želite li ga obrisati da biste napravili mjesto za premještaj?",
+       "delete_and_move_text": "Odredišna stranica \"[[:$1]]\" već postoji. Želite li je obrisati da biste napravili mjesto za premještaj?",
        "delete_and_move_confirm": "Da, izbriši stranicu",
        "delete_and_move_reason": "obrisano kako bi se napravilo mjesto za premještaj, stari naziv \"[[$1]]\"",
        "selfmove": "Izvorni i odredišni naslov su isti; ne mogu premjestiti stranicu na nju samu.",
        "move-leave-redirect": "Ostavi preusmjeravanje",
        "protectedpagemovewarning": "<strong>Upozorenje:</strong> ova je stranica zaštićena i mogu je premjestiti samo suradnici s administratorskim pravima.</strong>\nPosljednja stavka u evidenciji navedena je niže kao napomena:",
        "semiprotectedpagemovewarning": "<strong>Napomena:</strong> ova je stranica zaštićena i mogu je premjestiti samo automatski potvrđeni suradnici.\nPosljednja stavka u evidenciji navedena je niže kao napomena:",
-       "move-over-sharedrepo": "== Datoteka postoji ==\n[[:$1]] postoji na zajednički korištenom repozitoriju. Premještanje datoteke na ovaj naslov će prepisati zajednički korištenu datoteku.",
+       "move-over-sharedrepo": "[[:$1]] postoji na zajedničkom repozitoriju. Premještanje datoteke na ovaj naslov će prepisati zajednički korištenu datoteku.",
        "file-exists-sharedrepo": "Naziv datoteke koje ste odabrali već se rabi na zajednički korištenom repozitoriju.\nMolimo odaberite drugo ime.",
        "export": "Izvezi stranice",
        "exporttext": "Možete izvesti tekst i prijašnje promjene jedne ili više stranica uklopljene u kȏd XML. U budućim inačicama MediaWiki softvera bit će moguće uvesti ovakvu stranicu u neki drugi wiki pomoću [[Special:Import|import page]]. Trenutačna inačica to još ne podržava.\n\nZa izvoz stranica unesite njihove naslove u polje ispod, jedan naslov po retku, i označite želite li trenutačnu inačicu zajedno sa svim prijašnjima, ili samo trenutačnu inačicu s informacijom o zadnjoj promjeni.\n\nU potonjem slučaju možete rabiti i poveznicu, npr. [[{{#Special:Export}}/{{MediaWiki:Mainpage}}]] za članak [[{{MediaWiki:Mainpage}}]].",
        "import-nonewrevisions": "Nema uvezenih inačica (ili su sve već prisutne ili su preskočene zbog pogrješaka).",
        "xml-error-string": "$1 u retku $2, stupac $3 (bajt $4): $5",
        "import-upload": "Postavljanje XML datoteka",
-       "import-token-mismatch": "Izgubljeni su podaci o sesiji. Molimo pokušajte ponovno.",
+       "import-token-mismatch": "Gubitak podataka o prijavi.\n\nMoguće je da ste odjavljeni. '''Molimo provjerite jeste li još uvijek prijavljeni i pokušajte ponovno'''.\nAko ovo ne radi, probajte se [[Special:UserLogout|odjaviti]] i opet prijaviti te provjeriti dopušta li preglednik kolačiće za ovo mrežno mjesto.",
        "import-invalid-interwiki": "Ne mogu uvesti iz navedene wiki.",
        "import-error-edit": "Stranica \"$1\" nije uvezena jer vam nije dopušteno da je uređujete.",
        "import-error-create": "Stranica \"$1\" nije uvezena jer vam nije dopušteno da ju stvorite.",
        "import-error-interwiki": "Stranica \"$1\" nije uvezena jer je njen naziv rezerviran za vanjsko povezivanje (međuwiki poveznice).",
        "import-error-special": "Stranica \"$1\" nije uvezena jer pripada posebnom imenskom prostoru u koji se stranice ne uvoze.",
-       "import-error-invalid": "Stranica \"$1\" nije uvezena jer je njen naziv nevaljan.",
+       "import-error-invalid": "Stranica \"$1\" nije uvezena jer je njen naziv na koji bi trebala biti uvezena nevaljan na ovom wikiju.",
        "import-error-unserialize": "Inačica $2 stranice \"$1\" ne može biti pročitana/uvezena. Zapisano je da inačica rabi $3 tip sadržaja u $4 formatu.",
        "import-options-wrong": "{{PLURAL:$2|Pogrješna opcija|Pogrješne opcije}}: <nowiki>$1</nowiki>",
        "import-rootpage-invalid": "Zadana početna stranica ima nevaljan naslov.",
        "import-rootpage-nosubpage": "Imenski prostor \"$1\" početne stranice ne dopušta podstranice.",
        "importlogpage": "Evidencija uvoza članaka",
        "importlogpagetext": "Administrativni uvoz stranica s poviješću uređivanja s drugih wikija.",
-       "import-logentry-upload-detail": "$1 {{PLURAL:$1|izmjena|izmjene|izmjena}}",
-       "import-logentry-interwiki-detail": "$1 {{PLURAL:$1|promjena|promjene|promjena}} od $2",
+       "import-logentry-upload-detail": "$1 {{PLURAL:$1|izmjena uvezena|izmjene uvezene|izmjena uvezeno}}",
+       "import-logentry-interwiki-detail": "$1 {{PLURAL:$1|promjena uvezena|promjene uvezene|promjena uvezeno}} od $2",
        "javascripttest": "Testiranje JavaScripta",
        "javascripttest-qunit-intro": "Pogledajte [$1 testnu dokumentaciju] na mediawiki.org.",
        "tooltip-pt-userpage": "Moja suradnička stranica",
        "previousdiff": "← Starija izmjena",
        "nextdiff": "Novija izmjena →",
        "mediawarning": "'''Upozorenje''': Ova datoteka možda sadrži štetan kod.\nNjegovim izvršavanjem mogli biste oštetiti svoj sustav.",
-       "imagemaxsize": "Ograniči veličinu slike:<br />''(za stranicu s opisom datoteke)''",
+       "imagemaxsize": "Ograniči veličinu slike na stranicama za opis datoteka:",
        "thumbsize": "Veličina sličice (umanjene inačice slike):",
        "widthheightpage": "$1 × $2, $3 {{PLURAL:$3|stranica|stranice}}",
        "file-info": "veličina datoteke: $1, MIME tip: $2",
        "scarytranscludefailed-httpstatus": "[Preuzimanje predloška nije uspjelo za $1: HTTP $2]",
        "scarytranscludetoolong": "[URL je predug]",
        "deletedwhileediting": "'''Upozorenje''': ova stranica je obrisana nakon što ste počeli uređivati!",
-       "confirmrecreate": "Suradnik [[User:$1|$1]] ([[User talk:$1|talk]]) izbrisao je ovaj članak nakon što ste ga počeli uređivati. Razlog brisanja\n: ''$2''\nPotvrdite namjeru vraćanja ovog članka.",
-       "confirmrecreate-noreason": "Suradnik [[User:$1|$1]] ([[User talk:$1|razgovor]]) je obrisao ovaj članak nakon što ste ga počeli uređivati. Molimo potvrdite da stvarno želite ponovo započeti ovaj članak.",
+       "confirmrecreate": "Suradnik [[User:$1|$1]] ([[User talk:$1|razgovor]]) {{GENDER:$1|izbrisao|izbrisala}} je ovaj članak nakon što ste ga počeli uređivati. Razlog brisanja\n: <em>$2</em>\nMolimo potvrdite namjeru vraćanja ove stranice.",
+       "confirmrecreate-noreason": "Suradnik [[User:$1|$1]] ([[User talk:$1|razgovor]]) {{GENDER:$1|izbrisao|izbrisala}} je ovu stranicu nakon što ste započeli uređivanje. Molimo potvrdite da stvarno želite ponovo započeti ovu stranicu.",
        "recreate": "Vrati",
        "confirm-purge-title": "Osvježi ovu stranicu",
        "confirm_purge_button": "U redu",
        "version-poweredby-others": "ostali",
        "version-poweredby-translators": "prevoditelji s projekta translatewiki.net",
        "version-credits-summary": "Željeli bismo zahvaliti sljedećim suradnicima na njihovom doprinosu [[Special:Version|MediaWikiju]].",
-       "version-license-info": "MediaWiki je slobodni softver; možete ga distribuirati i/ili mijenjati pod uvjetima GNU opće javne licencije u obliku u kojem ju je objavila Free Software Foundation; bilo verzije 2 licencije, ili (Vama na izbor) bilo koje kasnije verzije.\n\nMediaWiki je distribuiran u nadi da će biti koristan, no BEZ IKAKVOG JAMSTVA; čak i bez impliciranog jamstva MOGUĆNOSTI PRODAJE ili PRIKLADNOSTI ZA ODREĐENU NAMJENU. Pogledajte GNU opću javnu licenciju za više detalja.\n\nTrebali ste primiti [{{SERVER}}{{SCRIPTPATH}}/COPYING kopiju GNU opće javne licencije] uz ovaj program; ako ne, pišite na Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, ili je [//www.gnu.org/licenses/old-licenses/gpl-2.0.html pročitajte online].",
+       "version-license-info": "MediaWiki je slobodni softver; možete ga distribuirati i/ili mijenjati pod uvjetima GNU opće javne licencije u obliku u kojem ju je objavila Free Software Foundation; bilo verzije 2 licencije, ili (Vama na izbor) bilo koje kasnije verzije.\n\nMediaWiki je distribuiran u nadi da će biti koristan, no <em>BEZ IKAKVOG JAMSTVA</em>; čak i bez impliciranog jamstva <strong>MOGUĆNOSTI PRODAJE</strong> ili <strong>PRIKLADNOSTI ZA ODREĐENU NAMJENU</strong>. Pogledajte GNU opću javnu licenciju za više detalja.\n\nTrebali ste primiti [{{SERVER}}{{SCRIPTPATH}}/COPYING kopiju GNU opće javne licencije] uz ovaj program; ako ne, pišite na Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, ili je [//www.gnu.org/licenses/old-licenses/gpl-2.0.html pročitajte online].",
        "version-software": "Instalirani softver",
        "version-software-product": "Proizvod",
        "version-software-version": "Verzija",
        "special-characters-title-endash": "crtica",
        "special-characters-title-emdash": "dulja crtica",
        "special-characters-title-minus": "znak za minus",
-       "mw-widgets-abandonedit": "Jeste li sigurni da želite napustiti uređivanje stranice bez spremanja izmjena?",
+       "mw-widgets-abandonedit": "Jeste li sigurni da želite napustiti uređivanje bez spremanja izmjena?",
        "mw-widgets-abandonedit-discard": "Odbaci izmjene",
        "mw-widgets-abandonedit-keep": "Nastavi s uređivanjem",
        "mw-widgets-abandonedit-title": "Jeste li sigurni?",
index 6f53635..78914c8 100644 (file)
        "ipb_expiry_old": "Le hora de expiration es in le passato.",
        "ipb_expiry_temp": "Le blocadas de nomines de usator celate debe esser permanente.",
        "ipb_hide_invalid": "Impossibile supprimer iste conto; illo ha plus de {{PLURAL:$1|un modification|$1 modificationes}}.",
+       "ipb_hide_partial": "Blocadas in que le nomine de usator es celate debe esser pro tote le sito.",
        "ipb_already_blocked": "\"$1\" es ja blocate",
        "ipb-needreblock": "$1 es ja blocate. Esque tu vole cambiar le configurationes?",
        "ipb-otherblocks-header": "Altere {{PLURAL:$1|blocada|blocadas}}",
index 5008609..ea128ce 100644 (file)
@@ -49,6 +49,7 @@
        "tog-showhiddencats": "Zi ébéonọr zonari a zonari",
        "tog-norollbackdiff": "Kwà diff mgbe byárá na mgbe láázú mèchàrà",
        "tog-useeditwarning": "gwam mgbe m hapụrụ ihu akwụkwọ nhaziri na echekwaghị ihe ndị m gbamworo",
+       "tog-prefershttps": "gbaa mbọ na eji njikọta doro anya mgbe ọbụla ị chọrọ ibanye n'ịntanetị",
        "underline-always": "M̀gbèọbụlà",
        "underline-never": "Emelaème",
        "underline-default": "Ndatụ ihü njikota",
        "october-date": "Ọnwaìri $1",
        "november-date": "Ọnwaìrinàotù $1",
        "december-date": "Ọnwa Iri na abụọ $1",
+       "period-pm": "oge mgbede",
        "pagecategories": "{{PLURAL:$1|Ụdàkọ}}",
        "category_header": "Ihu nà ime ụdàkọ \"$1\"",
        "subcategories": "Ụdàkọòkpurù",
        "view": "Lèzí",
        "view-foreign": "Zi nà $1",
        "edit": "Mèzi",
+       "edit-local": "hazie nkọwa njirimara",
        "create": "Ké",
        "create-local": "Tinye nkọwa ebe osi",
        "delete": "Kàcha",
        "talk": "Nkpata okà",
        "views": "Há hụrụ ya olé",
        "toolbox": "Ngwa ọrụ",
+       "tool-link-userrights": "gbanwee {{GENDER:$1|user}} otu ndia",
+       "tool-link-userrights-readonly": "lee {{GENDER:$1|user}} otu ndia",
+       "tool-link-emailuser": "ziga mailụ a{{GENDER:$1|user}}",
        "imagepage": "Zìri ihu àfabà",
        "mediawikipage": "Zìri ihunde ozi",
        "templatepage": "Zìri ihunde àtụ̀",
        "otherlanguages": "Nà asụ̀sụ̀ ndị ọ̀zọ",
        "redirectedfrom": "(Dupụ̀rụ̀ sì $1)",
        "redirectpagesub": "Kufù ebe ihü nka na ga",
+       "redirectto": "zigagharia na",
        "lastmodifiedat": "Oge ikpeazu Edeziri ihuakwụkwọ a bụ $1, mgbe $2",
        "viewcount": "Ha banyere ihü nka na {{PLURAL:$1|otu|$1 mgbe ole}}.",
        "protectedpage": "Ihü a cẹdolu a cẹdo",
        "jumptonavigation": "nturuụzọ̀",
        "jumptosearch": "tùwe",
        "view-pool-error": "Ndó, ihe na enye juchàrà ejucha oge nka.\nMadu kachạrạ ndi choro ihu ihü nka.\nBiko chetukwa oge kà oruo mgbe I choro I banyé ihü nka ozor.\n\n$1",
+       "generic-pool-error": "Ewewla iwe, sava ejula eju ugbu a.\nọtụtụ ndị mmadụ na-achọ inyocha akọrọngwa a.\nbiko chetụ obere oge tupu ịchọo ịga nwaa ọzọ.",
        "pool-timeout": "Ógè e zuole Í ché ncedọ",
        "pool-queuefull": "Pool kyu zùrù",
        "pool-errorunknown": "Nsogbu nke námaghi",
+       "pool-servererror": "pulu kauta sava adịghị ugbu a",
+       "poolcounter-usage-error": "e nwere nsogbu",
        "aboutsite": "Màkà {{SITENAME}}",
        "aboutpage": "Project:Màkà",
        "copyright": "Ihe di ime nọr okpúrụ $1",
index 295f1b4..04556d4 100644 (file)
        "accmailtitle": "Pasovorto sendita.",
        "accmailtext": "Hazarde kreita pasovorto por [[User talk:$1|$1]] sendesis ad $2. Ol povas chanjesar che la pagino <em>[[Special:ChangePassword|chanjar pasovorto]]</em> pos vua 'login'.",
        "newarticle": "(nova)",
-       "newarticletext": "Vu sequis ligilo a pagino qua ne existas ankore.\nPor krear ica pagino, voluntez startar skribar en la infra buxo.\n(regardez la [$1 helpo] por plusa informo).\nSe vu esas hike erore, kliktez sur la butono por retrovenar en vua navigilo.",
+       "newarticletext": "Vu sequis ligilo a pagino qua ankore ne existas.\nPor krear ica pagino, komencez skribar en l'infra buxo (regardez la [$1 helpo] por plusa informo).\nSe vu esas hike erore, kliktez sur la butono por retrovenar en vua navigilo.",
        "anontalkpagetext": "----\n<em>Yen la diskuto-pagino por anonima uzero, qua ankore ne kreabas konto, o se kreis ne uzas ol.</em>\nDo, ni mustas uzar la IP-adreso por identifikar ilu/elu.\nTala IP-adreso povas uzesar da multa uzeri.\nSe vu esas anonima uzero e kreas ke nerelevanta komenti sendesis a vu, voluntez [[Special:CreateAccount|krear konto]], o [[Special:UserLogin|facar 'log in']] por preventar futura konfundo kun altra anonima uzeri.",
        "noarticletext": "Til nun ne existas texto en ica pagino.\nVu povas [[Special:Search/{{PAGENAME}}|serchar ica titulo]] en altra pagini, <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} serchar en la relata registri], o [{{fullurl:{{FULLPAGENAME}}|action=edit}} redaktar ica pagino]</span>.",
        "noarticletext-nopermission": "Til nun ne existas texto en ica pagino.\nVu povas [[Special:Search/{{PAGENAME}}|serchar ica titulo]] en altra pagini, <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} serchar en la relata registri], o [{{fullurl:{{FULLPAGENAME}}|action=edit}} redaktar ica pagino]</span>, tamen vu ne havas permiso por krear ica pagino.",
        "linkstoimage-redirect": "$1 (arkivo ridirektita) $2",
        "sharedupload": "Ca arkivo esas de $1 e posible esas uzata da altra projekti.",
        "sharedupload-desc-here": "Ca arkivo jacas en $1, e povas uzesar en altra projeti.\nLa deskriptado en lua [$2 pagino di deskriptado] montresas adinfre.",
+       "sharedupload-desc-edit": "Ca arkivo venas de $1 e povas uzesar en altra projeti.\nPosible vu deziros redaktar ibe lua deskripto en [$2 lua deskripto-pagino].",
+       "sharedupload-desc-create": "Ca arkivo venas de $1 e povas uzesar en altra projeti.\nPosible vu deziros redaktar ibe lua deskripto en [$2 lua deskripto-pagino].",
        "filepage-nofile": "Nula arkivo kun ica nomo existas.",
        "filepage-nofile-link": "Ne existas arkivo kun ta nomo, tamen vu povas [$1 sendar ol].",
        "uploadnewversion-linktext": "Adkargez nova versiono dil arkivo",
        "autosumm-changed-redirect-target": "Chanjis la ridirektilo de [[$1]] a [[$2]]",
        "autosumm-new": "Pagino kreesis kun '$1'",
        "autosumm-newblank": "Kreita vakua pagino",
+       "size-bytes": "$1 {{PLURAL:$1|bicoko*|bicoki*}}",
        "watchlistedit-normal-title": "Modifikez surveyo-listo",
        "watchlistedit-normal-legend": "Removar tituli de surveyo-listo",
        "watchlistedit-normal-explain": "La tituli de vua surveyo-listo montresas adinfre.\nPor removar ula titulo, markizez la buxo proxim ol, e kliktez \"{{int:Watchlistedit-normal-submit}}\".\nVu anke povas [[Special:EditWatchlist/raw|redaktar direkte la 'kruda' listo]].",
index 1d91d0e..b39ee0d 100644 (file)
        "rcfilters-quickfilters-placeholder-description": "フィルターの設定を保存し、後で再び使用するためには、下のアクティブフィルター内のブックマークアイコンをクリックしてください。",
        "rcfilters-savedqueries-defaultlabel": "保存したフィルター",
        "rcfilters-savedqueries-rename": "名称を変更",
-       "rcfilters-savedqueries-setdefault": "デフォルトに設定",
+       "rcfilters-savedqueries-setdefault": "既定に設定",
        "rcfilters-savedqueries-unsetdefault": "既定から削除",
        "rcfilters-savedqueries-remove": "削除",
        "rcfilters-savedqueries-new-name-label": "名称",
index 38a6b90..f077799 100644 (file)
        "botpasswords-label-cancel": "Bıtexelne",
        "resetpass_forbidden": "Paroley nêşikinê bıvurniyê",
        "resetpass-submit-loggedin": "Parola bıvurne",
-       "resetpass-submit-cancel": "Peyd kı",
+       "resetpass-submit-cancel": "Bıtexelne",
        "resetpass-temp-password": "Parola vêrdiye:",
        "bold_sample": "Nusto qolınd",
        "bold_tip": "Nusto qolınd",
        "recentchanges-label-bot": "No vurnais terefê zu boti ra bi",
        "recentchanges-label-unpatrolled": "No vurnais hona çım ra ranêvêrdo",
        "recentchanges-legend-newpage": "$1 - pela newiye",
+       "rcfilters-savedqueries-cancel-label": "Bıtexelne",
        "rcnotefrom": "Cêr de vurnayîşê esto ke '''$2''' ra raver  (heta '''$1''' mucnayiyo).",
        "rclistfrom": "$3 $2 ra hata nıka vurnaisunê newu bıasne",
        "rcshowhideminor": "$1 vurnaisê qızkeki",
index 778b55e..e53fa1e 100644 (file)
                        "Baloch Khan",
                        "Muhraz",
                        "Ardzun",
-                       "Amjad Khan"
+                       "Amjad Khan",
+                       "Zakiy"
                ]
        },
        "tog-underline": "Garih bawahi tautan:",
        "tog-hideminor": "Suruakan suntiangan ketek di parubahan baru",
        "tog-hidepatrolled": "Suruakan suntiangan nan lah dipatroli di parubahan tabaru",
        "tog-newpageshidepatrolled": "Suruakan laman nan lah dipatroli dari daftar laman baru",
+       "tog-hidecategorization": "Suruakan pankgategorian halaman",
        "tog-extendwatchlist": "Kambangan daftar pantau untuak mancaliak kasado parubahan, indak nan baru se",
-       "tog-usenewrc": "Gunoan tampilan parubahan tingkek lanjuik (paralu JavaScript)",
+       "tog-usenewrc": "Kalompokkan suntiangan di tampilan parubahan paliang baru jo daftar pantauan badasarkan halaman",
        "tog-numberheadings": "Agiah nomor judul sacaro otomatis",
        "tog-editondblclick": "Suntiang laman jo klik duo kali (paralu JavaScript)",
-       "tog-editsectiononrightclick": "Aktipan bagian panyuntiangan jo mangklik kanan pado judul bagian (paralu JavaScript)",
+       "tog-editsectiononrightclick": "Aktipan bagian panyuntiangan jo mangklik kanan pado judul bagian",
        "tog-watchcreations": "Tambahan laman nan den buek jo gambar nan den unggah ka daftar pantau",
        "tog-watchdefault": "Tambahan laman jo gambar nan den suntiang ka daftar pantau",
        "tog-watchmoves": "Tambahan laman jo gambar nan den pindah ka daftar pantau",
        "tog-watchdeletion": "Tambahan laman jo gambar nan den hapuih ka daftar pantau",
+       "tog-watchuploads": "Tambahan bakeh baru nan ambo unggah ka daftar pantauan",
+       "tog-watchrollback": "Tambahan halaman nan pernah ambo baliakan ke dalam daftar pantauan ambo",
        "tog-minordefault": "Tandoi kasado suntiangan sabagai suntiangan ketek sacaro baku",
        "tog-previewontop": "Tunjuakan pratonton sabalun kotak suntiang",
        "tog-previewonfirst": "Tunjuakan pratonton pado suntiangan patamo",
        "tog-shownumberswatching": "Tunjuakan jumlah pamantau",
        "tog-oldsig": "Tando tangan kini:",
        "tog-fancysig": "Jadikan tando tangan manjadi teks wiki (indak jo tautan otomatis)",
-       "tog-uselivepreview": "Gunoan pratonton langsuang (paralu JavaScript) (uji-cubo)",
+       "tog-uselivepreview": "Gunoan pratonton langsuang",
        "tog-forceeditsummary": "Ingekan ambo bilo kotak ikhtisar suntiangan kosong",
        "tog-watchlisthideown": "Suruakan suntiangan surang pado daftar pantau",
        "tog-watchlisthidebots": "Suruakan suntiangan bot pado daftar pantau",
        "tog-watchlisthideminor": "Suruakan suntiangan ketek pado daftar pantau",
        "tog-watchlisthideliu": "Suruakan suntiangan pangguno masuak log pado daftar pantau",
+       "tog-watchlistreloadautomatically": "Muek ulang daftar pantauan sacaro otomatih katiko sabuah sariangan baubah (paralu JavaScript)",
        "tog-watchlisthideanons": "Suruakan suntiangan pangguno anonim pado daftar pantau",
        "tog-watchlisthidepatrolled": "Suruakan suntiangan tapatroli pado daftar pantau",
        "tog-ccmeonemails": "Kiriman Ambo salinan surel nan dikiriman ka urang lain",
index 5b08e77..e88b6b3 100644 (file)
        "ipb_expiry_old": "Czas wygaśnięcia blokady już minął.",
        "ipb_expiry_temp": "Ukryte blokowanie nazwy użytkownika należy wykonać trwale.",
        "ipb_hide_invalid": "Ukrycie konta tego użytkownika nie jest możliwe, wykonał on więcej niż {{PLURAL:$1|jedną edycję|$1 edycje|$1 edycji}}.",
+       "ipb_hide_partial": "Ukrywanie nazwy użytkownika jest możliwe tylko przy blokadach całkowitych.",
        "ipb_already_blocked": "„$1” jest już zablokowany",
        "ipb-needreblock": "$1 jest już zablokowany. Czy chcesz zmienić ustawienia blokady?",
        "ipb-otherblocks-header": "{{PLURAL:$1|Inna blokada|Inne blokady}}",
index c7b28a9..36c6a1b 100644 (file)
        "ipb_expiry_old": "O tempo de expiração está no passado.",
        "ipb_expiry_temp": "Bloqueios com nome de utilizador oculto devem ser permanentes.",
        "ipb_hide_invalid": "Não foi possível suprimir esta conta; ela tem mais de {{PLURAL:$1|uma edição|$1 edições}}.",
+       "ipb_hide_partial": "Os bloqueios em que o nome de utilizador é ocultado não podem ser parciais, têm de ser para todo o ''site''.",
        "ipb_already_blocked": "\"$1\" já se encontra bloqueado",
        "ipb-needreblock": "$1 já se encontra bloqueado. Deseja alterar as configurações?",
        "ipb-otherblocks-header": "{{PLURAL:$1|Outro bloqueio|Outros bloqueios}}",
index 360f0ec..13ebce5 100644 (file)
@@ -17,6 +17,7 @@
        "tog-hideminor": "Cua is acontzos minores in sa pàgina de is ùrtimas mudàntzias",
        "tog-hidepatrolled": "Cua is mudas verificadas in is ùrtimos càmbios",
        "tog-newpageshidepatrolled": "Cua sas pàginas verificadas dae sa lista de sas pàginas noas",
+       "tog-hidecategorization": "Cua sa clasificatzione de sas pàginas",
        "tog-extendwatchlist": "Ammània sa watchlist pro ammustrare totu sos càmbios, non sos prus reghentes ebbia",
        "tog-usenewrc": "Pone in pare sos càmbios de cada pàgina in sos ùrtimos càmbios e in sa watchlist",
        "tog-numberheadings": "Auto-numeratzione de sos tìtulos",
@@ -26,6 +27,7 @@
        "tog-watchdefault": "Annanghe pàginas e documentos chi apo cambiadu in sa lista de pàginas annotadas mea",
        "tog-watchmoves": "Annanghe pàginas e documentos chi apo mòvidu in sa lista de pàginas annotadas mea",
        "tog-watchdeletion": "Annanghe pàginas e documentos chi apo burradu in sa lista de pàginas annotadas mea",
+       "tog-watchuploads": "Annanghe sos documentos noos chi càrrigo a sas pàginas annotadas",
        "tog-watchrollback": "Pone is pàginas innue apo fatu su rollback in is pàginas annotadas",
        "tog-minordefault": "Marca comente minores pro difetu totu sos càmbios",
        "tog-previewontop": "Ammustra s'anteprima in subra de sa casella de càmbiu e no in suta",
        "tog-enotifminoredits": "Imbia·mi una post.el. fintzas pro sos càmbios minores de sas pàginas e documentos",
        "tog-enotifrevealaddr": "Faghe ischire s'indiritzu de sa post.el. mea in sas notìficas de sa post.els",
        "tog-shownumberswatching": "Ammustra su nùmeru de is impitadores chi ant annotadu sa pàgina",
-       "tog-oldsig": "Firma atuale:",
+       "tog-oldsig": "Sa firma atuale tua:",
        "tog-fancysig": "Trata sa firma comente unu testu wiki (sena ligàmenes automàticos)",
-       "tog-uselivepreview": "Imprea sa funtzione \"anteprima bia\" (isperimentale)",
+       "tog-uselivepreview": "Ammustra sas anteprimas chene torrare a carrigare sa pàgina",
        "tog-forceeditsummary": "Averte·mi si su campu ogetu est bòidu",
        "tog-watchlisthideown": "Cua sas modìficas meas dae sa watclist",
        "tog-watchlisthidebots": "Cua sas mudas de sos bots dae sa watchlist",
        "tog-watchlisthideminor": "Cua sos càmbios minores dae sa watchlist",
        "tog-watchlisthideliu": "Cua is càmbios de is utentes identificados dae sa lista de pàginas annotadas",
+       "tog-watchlistreloadautomatically": "Torra a carrigare sa lista de sas pàginas annotadas cada borta chi unu filtru benit cambiadu (tenet bisòngiu de JavaScript)",
        "tog-watchlisthideanons": "Cua is càmbios de is impitadores anònimos dae sa lista de pàginas annotadas",
        "tog-watchlisthidepatrolled": "Cua mudas verificadas dae sa watchlist",
+       "tog-watchlisthidecategorization": "Cua sa clasificatzione de sas pàginas",
        "tog-ccmeonemails": "Imbia·mi sas còpias de is post.els chi imbio a is àteros utentes",
        "tog-diffonly": "No ammustras su cuntenutu de sa pàgina a pustis de sa bisura de is diferèntzias",
        "tog-showhiddencats": "Ammustra sas categorias cuadas",
        "october-date": "Santugaine $1",
        "november-date": "Santandria $1",
        "december-date": "Nadale $1",
+       "period-am": "AM",
+       "period-pm": "PM",
        "pagecategories": "{{PLURAL:$1|Categoria|Categorias}}",
        "category_header": "Pàginas in sa categoria \"$1\"",
        "subcategories": "Sutacategorias",
        "returnto": "Torra a $1.",
        "tagline": "Dae {{SITENAME}}",
        "help": "Agiudu",
+       "help-mediawiki": "Agiudu a pitzu de MediaWiki",
        "search": "Chirca",
        "searchbutton": "Chirca",
        "go": "Bae",
        "mypreferencesprotected": "Non tenes su permissu de cambiare is preferèntzias tuas.",
        "ns-specialprotected": "Is pàginas ispetziales non podent èssere acontzadas.",
        "titleprotected": "Sa creatzione de una pàgina cun custu tìtulu est istada arreada dae [[User:$1|$1]].\nSa motivatzione est <em>$2</em>.",
+       "invalidtitle": "Tìtulu non bàlidu",
        "invalidtitle-knownnamespace": "Su tìtulu cun nùmene-logu \"$2\" e testu \"$3\" no est bàlidu",
        "invalidtitle-unknownnamespace": "Su tìtulu cun nùmene-logu disconnotu de nùmeru $1 e testu \"$2\" no est bàlidu",
        "exception-nologin": "Non ses intrau",
        "virus-scanfailed": "iscansione faddida (còdighe $1)",
        "virus-unknownscanner": "antivirus disconnotu:",
        "logouttext": "<strong>As acabadu sa sessione.</strong>\n\nTene contu ca is pàginas ki sunt giai abertas in àteras bentanas podent sighire a pàrrer comente cando fias identificadu, fintzas a cando non ddas renfriscas dae su browser tuo.",
+       "cannotlogoutnow-title": "Impossìbile essire como",
+       "cannotlogoutnow-text": "No est possìbile a essire cando ses impreende $1.",
        "welcomeuser": "Bene bènnidu, $1!",
        "welcomecreation-msg": "Su contu tuo est istadu creadu.\nSi boles podes cambiare is [[Special:Preferences|prefèntzias tuas]] pro {{SITENAME}}",
        "yourname": "Nùmene utente:",
        "yourdomainname": "Ispetzìfica su domìniu",
        "password-change-forbidden": "Non podes cambiare sa password in custa wiki.",
        "login": "Intra",
+       "login-security": "Verìfica s'identidade tua",
        "nav-login-createaccount": "Intra / crea contu",
        "logout": "Serra sessione",
        "userlogout": "Essida",
        "createacct-reason": "Motivu",
        "createacct-reason-ph": "Pro ite ses creende un àteru contu",
        "createacct-submit": "Crea su contu tuo",
-       "createacct-another-submit": "Crea un àteru contu",
+       "createacct-another-submit": "Crea unu contu",
+       "createacct-continue-submit": "Sighi cun sa creatzione de su contu",
+       "createacct-another-continue-submit": "Sighi cun sa creatzione de su contu",
        "createacct-benefit-heading": "{{SITENAME}} est òpera de gente che tue.",
        "createacct-benefit-body1": "{{PLURAL:$1|acontzu|acontzos}}",
        "createacct-benefit-body2": "{{PLURAL:$1|pàgina|pàginas}}",
        "createacct-benefit-body3": "{{PLURAL:$1|contribudore retzente|contribudores retzentes}}",
        "badretype": "Is passwords chi as insertadu non currispondent.",
+       "usernameinprogress": "Sa creatzione de unu contu pro custu impitadore est giai in caminu. Pro praghere iseta.",
        "userexists": "Su nùmene impitadore insertadu est giai impreadu.\nSèbera unu nùmene diferente.",
        "loginerror": "Faddina de identificatzione",
        "createacct-error": "Faddina in sa creatzione de su contu",
        "retypenew": "Torra a iscrìere sa password noa:",
        "resetpass_submit": "Càmbia sa password e identifica·ti",
        "changepassword-success": "Sa password tua est istada cambiada in manera currègida!",
+       "botpasswords-label-create": "Crea",
+       "botpasswords-label-update": "Agiorna",
+       "botpasswords-label-cancel": "Annulla",
+       "botpasswords-label-delete": "Iscantzella",
+       "botpasswords-label-resetpassword": "Torra a impostare sa crae de intrada",
        "resetpass_forbidden": "Non faghet a cambiare sa password",
+       "resetpass_forbidden-reason": "Non faghet a cambiare sas craes: $1",
        "resetpass-no-info": "Depes èsser identificadu pro abèrrer custa pàgina deretu.",
        "resetpass-submit-loggedin": "Càmbia password",
        "resetpass-submit-cancel": "Burra",
        "passwordreset-email": "Indiritzu email:",
        "passwordreset-emailtitle": "Particulares de s'impitadore in {{SITENAME}}",
        "passwordreset-emailelement": "Nùmene utente: \n$1\n\nPassword temporànea: \n$2",
+       "passwordreset-invalidemail": "Indiritzu de posta eletrònica non vàlidu",
        "changeemail": "Càmbia indiritzu email",
        "changeemail-header": "Càmbia s'indirìtzu email de su contu",
        "changeemail-oldemail": "Indiritzu email atuale:",
        "sig_tip": "Firma·ti cun data e ora",
        "hr_tip": "Lìnia orizontale (de impreare cun critèriu)",
        "summary": "Ogetu:",
-       "subject": "Tema/tìtulu:",
+       "subject": "Sugetu:",
        "minoredit": "Custu est unu càmbiu minore",
        "watchthis": "Annota custa pàgina",
        "savearticle": "Sarva sa pàgina",
+       "publishpage": "Pùblica sa pàgina",
+       "publishchanges": "Pùblica sas modìficas",
+       "savearticle-start": "Sarva sa pàgina...",
+       "savechanges-start": "Sarva sas modìficas...",
+       "publishpage-start": "Pùblica sa pàgina...",
+       "publishchanges-start": "Pùblica sas modìficas...",
        "preview": "Antiprima",
        "showpreview": "Ammustra s'antiprima",
        "showdiff": "Ammustra is càmbios",
+       "blankarticle": "<strong>Atentzione:</strong> Sa pàgina chi ses creende est bòida.\nSi as a incarcare torra \"$1\", sa pàgina at a èssere creada chene cuntenutu perunu.",
        "anoneditwarning": "<strong>Atentzione:</strong> Non ses identificadu.\nS'indiritzu IP tuo at a èssere annotadudu si faghes unos cantos càmbios. Si <strong>identìficas</strong> tibe o <strong>[$2 creas unu contu]</strong>, is càmbios tuos ant a èssere marcados cun su nùmene utente tuo, paris a àteros giuamentos.",
        "anonpreviewwarning": "''Non ses identificadu. Sarvende s'indiritzu IP tuo at a èssere registradu in s'istòria de sa pàgina.''",
-       "missingcommenttext": "Inserta unu cummentu inoghe suta.",
+       "missingsummary": "<strong>Ammenta·ti</strong> No as frunidu unu resumu de sas modìficas. Si as a incarcare torra \"$1\", sa modìfica tua at a èssere sarvada chene resumu.",
+       "missingcommenttext": "Inserta unu cummentu.",
        "summary-preview": "Antiprima ogetu:",
        "subject-preview": "Antiprima tema/tìtulu:",
        "blockedtitle": "S'impitadore est istadu bloccadu",
        "content-not-allowed-here": "Cuntenutu a manera \"$1\" no adduidu in sa pàgina [[:$2]]",
        "editwarning-warning": "S'essida dae custa pàgina diat pòdere cajonare sa pèrdida de totus sos càmbios chi as fatu.\nSi ses autentificadu, podes disabilitare custu avisu in sa setzione \"{{int:prefs-editing}}\" de sas preferèntzias tuas.",
        "editpage-notsupportedcontentformat-title": "Formadu de càbidu non suportadu",
+       "slot-name-main": "Printzipale",
        "content-model-wikitext": "wikitestu",
        "content-model-text": "testu normale",
        "content-model-javascript": "JavaScript",
        "content-model-css": "CSS",
+       "content-json-empty-object": "Ogetu bòidu",
        "post-expand-template-inclusion-category": "Pàginas in is cale sa dimensione templates inclùdidos propassat su lìmite cunsentidu",
        "post-expand-template-argument-category": "Pàginas cuntenentes templates cun argumentos fartados",
        "viewpagelogs": "Càstia is registros de custa pàgina",
        "pageinfo-lasttime": "Data de s'ùrtimu càmbiu",
        "pageinfo-edits": "Nùmeru totale de càmbios",
        "pageinfo-authors": "Nùmeru totale de autores dislindados",
+       "pageinfo-hidden-categories": "{{PLURAL:$1|Categoria cuada|Categorias cuadas}} ($1)",
        "pageinfo-toolboxlink": "Informatziones pro sa pàgina",
        "pageinfo-redirectsto-info": "info",
        "pageinfo-contentpage-yes": "Eja",
        "watchlistedit-normal-title": "Càmbia sa lista de annotadas",
        "watchlistedit-raw-titles": "Tìtulos:",
        "watchlistedit-clear-titles": "Tìtulos:",
+       "watchlisttools-clear": "Isbòida sa lista de sas pàginas annotadas",
        "watchlisttools-view": "Càstia mudàntzias de importu",
        "watchlisttools-edit": "Càstia e càmbia sa lista de pàginas annotadas",
        "watchlisttools-raw": "Acontza sa watchlist dae su testu",
        "specialpages-group-pages": "Listas de is pàginas",
        "tag-filter": "Filtra pro [[Special:Tags|etichetta]]:",
        "tag-filter-submit": "Filtru",
+       "tag-list-wrapper": "[[Special:Tags|{{PLURAL:$1|Eticheta|Etichetas}}]]: $2",
        "tags-active-yes": "Eja",
        "tags-active-no": "No",
        "tags-edit": "càmbia",
index 74d4ab6..7e8d9e2 100644 (file)
        "categorypage": "ⵥⵔ ⵜⴰⵙⵏⴰ ⵏ ⵜⴰⴳⴳⴰⵢⵜ",
        "viewtalkpage": "ⵥⵔ ⴰⵎⵙⴰⵡⴰⵍ",
        "otherlanguages": "ⵙ ⵜⵓⵜⵍⴰⵢⵉⵏ ⵢⴰⴹⵏ",
-       "redirectedfrom": "(âµ\89ⴽⴽⴰ â´· $1)",
+       "redirectedfrom": "(âµ\9câµ\8eâµ\8eâ´°âµ\9câµ\9câµ\89 â´· âµ£âµ\96 $1)",
        "redirectpagesub": "ⵜⴰⵙⵏⴰ ⵏ ⵓⵙⵎⴰⵜⵜⵉ",
        "redirectto": "ⴰⵙⵎⴰⵜⵜⵉ ⵙ :",
        "lastmodifiedat": "ⴰⵙⵏⴼⵍ ⵉⴳⴳⵯⵔⴰⵏ ⵖ ⵜⴰⵙⵏⴰ ⴰⴷ ⵉⵜⵜⵢⴰⵡⵙⴽⴰⵔ ⴰⵙⵙ ⵏ $1 ⵖ $2.",
        "last": "ⵣⵡⵔ",
        "page_first": "ⵜⴰⵎⵣⵡⴰⵔⵓⵜ",
        "page_last": "ⵜⴰⵎⴳⴳⴰⵔⵓⵜ",
-       "histlegend": "Diff selection: âµ\95âµ\9bâµ\8e the radio boxes âµ\8f âµ\9câµ\93âµ\8fâµ\96âµ\89âµ\8dâµ\89âµ\8f âµ\8fâµ\8fâ´° âµ\9câµ\94âµ\89âµ\9c â´°â´· âµ\9câµ\99âµ\8eⵣⴰⵣⴰâµ\8dâµ\9c, âµ\9câ´°â´·â´·âµ\9c âµ\96â´¼ enter âµ\8fâµ\96 âµ\9câ´°â´±âµ\93âµ\9fâµ\93âµ\8fâµ\9c âµ\89âµ\8dâµ\8dâ´°âµ\8f â´·â´·â´°âµ¡ â´°âµ\99.<br />\nâµ\9câµ\89ⵣⴳⵣâµ\89âµ\8dâµ\89âµ\8f: <strong>({{int:cur}})</strong> = â´°âµ\8eⵣⴰâµ\94â´°âµ¢ âµ\89âµ\8dâµ\8dâ´°âµ\8f â´· âµ\9câµ\93âµ\8fâµ\96âµ\89âµ\8dâµ\9c âµ\89ⴳⴳⵯâµ\94â´°âµ\8f, <strong>({{int:last}})</strong> = â´°âµ\8eⵣⴰâµ\94â´°âµ¢ âµ\89âµ\8dâµ\8dâ´°âµ\8f â´· âµ\9câµ\93âµ\8fâµ\96âµ\89âµ\8dâµ\9c âµ\89ⵣⵡⴰâµ\94âµ\8f âµ\9câ´°â´·, <strong>{{int:minoreditletter}}</strong> = â´°âµ\99âµ\8fâ´¼âµ\8d âµ\93âµ\8eâµ¥âµ\89âµ¢.",
+       "histlegend": "Diff selection: âµ\84âµ\8dâµ\8dâµ\8e âµ\9câµ\93âµ\8fâµ\96âµ\89âµ\8dâµ\89âµ\8f âµ\8fâµ\8fâ´° âµ\9câµ\94âµ\89âµ\9c â´°â´· âµ\9câµ\99âµ\8eⵣⴰⵣⴰâµ\8dâµ\9c, âµ\9câ´°â´·â´·âµ\9c â´¼ enter âµ\8fâµ\96 âµ\9câ´°â´±âµ\93âµ\9fâµ\93âµ\8fâµ\9c âµ\89âµ¥âµ\8dâµ\89âµ\8f âµ\99 âµ\8eⴰⵢⴰâµ\8fâµ\8f.<br />\nâ´°âµ\8fâ´°âµ\8eâ´½ âµ\8f âµ\9cⵣⴳⵣâµ\89âµ\8dâµ\89âµ\8f : <strong>({{int:cur}})</strong> = â´°âµ\8eⵣⴰâµ\94â´°âµ¢ âµ\89âµ\8dâµ\8dâ´°âµ\8f â´³âµ\94â´°âµ\99 â´· âµ\9câµ\93âµ\8fâµ\96âµ\89âµ\8dâµ\9c â´°â´½â´½âµ¯ âµ\89ⴳⴳⵯâµ\94â´°âµ\8f, <strong>({{int:last}})</strong> = â´°âµ\8eⵣⴰâµ\94â´°âµ¢ âµ\89âµ\8dâµ\8dâ´°âµ\8f â´³âµ\94â´°âµ\99 â´· âµ\9câµ\93âµ\8fâµ\96âµ\89âµ\8dâµ\9c âµ\9câµ\9c â´· âµ\89ⵣⵡⴰâµ\94âµ\8f, <strong>{{int:minoreditletter}}</strong> = â´°âµ\99âµ\8fâ´¼âµ\8d âµ\8eⵥⵥâµ\89âµ\8f.",
        "history-fieldset-title": "ⵙⵉⴳⴳⵍ ⵉⵣⵣⵔⴰⵢⵏ",
        "history-show-deleted": "ⵖⴰⵔ ⵜⵓⵏⵖⵉⵍⵜ ⵏⵏⴰ ⵉⵜⵜⵡⴰⴽⴽⵙⵏ",
        "histfirst": "ⴰⴽⴽⵯ ⵉⵇⴷⵎⵏ",
        "searchprofile-advanced-tooltip": "ⵙⵉⴳⴳⵍ ⵖ custom namespaces",
        "search-result-size": "$1 ({{PLURAL:$2|1 ⵜⴳⵓⵔⵉ|$2 ⵜⴳⵓⵔⵉⵡⵉⵏ}})",
        "search-result-category-size": "$1 {{PLURAL:$1|ⵓⴳⵎⴰⵎ|ⵉⴳⵎⴰⵎⵏ}} ($2 {{PLURAL:$2|ⵡⴰⴷⵓⵎⵙⵉⵍ|ⵉⴷⵓⵎⵙⵉⵍⵏ}}, $3 {{PLURAL:$3|ⵓⴼⴰⵢⵍⵓ|ⵉⴼⴰⵢⵍⵓⵜⵏ}})",
-       "search-redirect": "(âµ\89ⴽⴽⴰ â´· $1)",
+       "search-redirect": "(âµ\9câµ\8eâµ\8eâ´°âµ\9câµ\9câµ\89 â´· âµ£âµ\96 $1)",
        "search-section": "(ⵜⵉⴳⵣⵎⵉ $1)",
        "search-category": "(ⵜⴰⴳⴳⴰⵢⵜ $1)",
        "search-suggest": "ⵉⵙ ⵜⵔⵉⵜ ⴰⴷ ⵜⵉⵏⵉⵜ: $1",
        "recentchanges-label-unpatrolled": "Ambddl ad ura jju ittmẓra",
        "recentchanges-label-plusminus": "ⵜⵏⴼⵍ ⵜⵉⴷⴷⵉ ⵏ ⵜⴰⵙⵏⴰ ⵙ ⵡⵓⵟⵟⵓⵏ ⴰⴷ ⵏ ⵉⴷ ⴱⴰⵢⵜ",
        "recentchanges-legend-newpage": "{{int:recentchanges-label-newpage}} (ⵥⵔ ⵓⵍⴰ [[Special:NewPages|ⵜⴰⵍⵉⵙⵜⵜ ⵏ ⵜⴰⵙⵏⵉⵡⵉⵏ ⵜⵉⵎⴰⵢⵏⵓⵜⵉⵏ]])",
-       "rcfilters-legend-heading": "<strong>ⵜⵉⵣⴳⵣⵉⵍⵉⵏ:</strong>",
+       "rcfilters-legend-heading": "<strong>ⵜⵉⵣⴳⵣⵉⵍⵉⵏ :</strong>",
        "rcfilters-days-title": "ⵓⵙⵙⴰⵏ ⴳⴳⵯⵔⴰⵏⵉⵏ",
        "rcfilters-hours-title": "ⵜⵉⵙⵔⴰⴳⵉⵏ ⴳⴳⵯⵔⴰⵏⵉⵏ",
        "rcfilters-days-show-days": "$1 {{PLURAL:$1|ⵡⴰⵙⵙ|ⵡⵓⵙⵙⴰⵏ}}",
        "movethispage": "ⵙⵎⵓⵜⵜⵉ ⵜⴰⵙⵏⴰ ⴰⴷ",
        "unusedcategoriestext": "Taggayin ad llant waxxa gis nt ur tlli kra n tasna wala kra n taggayin yaḍnin",
        "notargettitle": "ⵡⴰⵍⵓ ⴰⵡⵜⵜⴰⵙ",
-       "nopagetext": "Tasna li trit ur tlli",
+       "nopagetext": "ⵜⴰⵙⵏⴰ ⵉⴳⴰⵏ ⴰⵡⵜⵜⴰⵙ ⵏⵏⴰ ⵏⵏ ⵜⴳⵉⵜ ⵓⵔ ⵜⵍⵍⵉ.",
        "pager-newer-n": "{{PLURAL:$1|1 ⴰⴽⴽⵯ ⵉⵊⴷⵉⴷⵏ|$1 ⴰⴽⴽⵯ ⵉⵊⴷⵉⴷⵏ}}",
        "pager-older-n": "{{PLURAL:$1|aqbur 1|aqbur $1}}",
        "suppress": "ⴽⴽⵙ",
        "undeleteviewlink": "Ẓṛ",
        "undelete-search-submit": "ⵙⵉⴳⴳⵍ",
        "undelete-show-file-submit": "ⵢⴰⵀ",
-       "namespace": "Taɣult",
-       "invert": "amglb n ustay",
+       "namespace": "ⵉⴳⵔ :",
+       "invert": "ⵙⴳⴰⵍⴱ ⵜⴰⵙⵉⵍⵉⴽⵙⵢⵓⵏⵜ",
        "blanknamespace": "(ⴰⴷⵙⵍⴰⵏ)",
        "contributions": "ⵜⵉⴷⵔⴰⵡⵉⵏ ⵏ {{GENDER:$1|ⵓⵙⵎⵔⴰⵙ|ⵜⵙⵎⵔⴰⵙⵜ}}",
        "contributions-title": "ⵜⵉⴷⵔⴰⵡⵉⵏ ⵏ {{GENDER:$1|ⵓⵙⵎⵔⴰⵙ|ⵜⵙⵎⵔⴰⵙⵜ}} $1",
        "whatlinkshere": "ⵎⴰⴷ ⵉⵜⵜⴰⵡⵉⵏ ⵙ ⵖⵉⴷ",
        "whatlinkshere-title": "ⵜⴰⵙⵏⵉⵡⵉⵏ ⵉⵜⵜⴰⵡⵢⵏ ⵙ \"$1\"",
        "whatlinkshere-page": "ⵜⴰⵙⵏⴰ :",
-       "linkshere": "ⵜⴰⵙⵏⵉⵡⵉⵏ ⴰⴷ ⴹⴼⴰⵔⵏⵉⵏ ⴰⵔ ⵜⵜⴰⵡⵉⵏⵜ ⵙ <strong>$2</strong>:",
+       "linkshere": "ⵜⴰⵙⵏⵉⵡⵉⵏ ⴰⴷ ⴹⴼⴰⵔⵏⵉⵏ ⴰⵔ ⵜⵜⴰⵡⵉⵏⵜ ⵙ <strong>$2</strong> :",
        "nolinkshere": "ⵓⵍⴰ ⴽⵔⴰ ⵏ ⵜⴰⵙⵏⴰ ⵓⵔ ⴰⵔ ⵜⴻⵜⵜⴰⵡⵉ ⵙ <strong>$2</strong>.",
        "nolinkshere-ns": "Ur tlla kra n tasna izdin d  '''$2''' ɣ tɣult l-ittuystayn.",
-       "isredirect": "Tasna immutin",
+       "isredirect": "ⵜⴰⵙⵏⴰ ⵏ ⵓⵙⵎⴰⵜⵜⵉ",
        "istemplate": "Illa gis",
        "isimage": "ⴰⵍⵉⵏⴽ ⵏ ⵓⴼⴰⵢⵍⵓ",
        "whatlinkshere-prev": "{{PLURAL:$1|$1 ⵉⵣⵡⴰⵔⵏ|$1 ⵣⵡⴰⵔⵏⵉⵏ}}",
        "tooltip-ca-addsection": "ⵙⵙⵏⵜⵉ ⴽⵔⴰ ⵏ ⵜⴳⵣⵎⵉ ⵜⴰⵎⴰⵢⵏⵓⵜ",
        "tooltip-ca-viewsource": "Tasnatad tuyḥba. mac dẓdart at tẓrt aɣbalu nes.",
        "tooltip-ca-history": "ⵜⵓⵏⵖⵉⵍⵉⵏ ⵣⵔⵉⵏⵉⵏ ⵏ ⵜⴰⵙⵏⴰ ⴰⴷ",
-       "tooltip-ca-protect": "Ḥbu tasna yad",
+       "tooltip-ca-protect": "ⴰⵔⵉ ⵜⴰⵙⵏⴰ ⴰⴷ",
        "tooltip-ca-unprotect": "ⵙⵏⴼⵍ ⴰⴼⵔⴰⴳ ⵏ ⵜⴰⵙⵏⴰ ⴰⴷ",
        "tooltip-ca-delete": "ⴽⴽⵙ ⵜⴰⵙⵏⴰ ⴰⴷ",
        "tooltip-ca-undelete": "Rard imbddeln imzwura li ittyskarnin ɣ tasna yad",
        "tooltip-t-permalink": "ⴰⵍⵉⵏⴽ ⵉⴳⴰⵏ ⵡⵉⵏ ⴱⴷⴷⴰ ⵉ ⵜⵓⵏⵖⵉⵍⵜ ⴰⴷ ⵏ ⵜⴰⵙⵏⴰ",
        "tooltip-ca-nstab-main": "ⵥⵔ ⵜⴰⵙⵏⴰ ⵏ ⵜⵓⵎⴰⵢⵜ",
        "tooltip-ca-nstab-user": "Ẓr tasna n useqdac",
-       "tooltip-ca-nstab-media": "Iẓri n tasna n midya",
+       "tooltip-ca-nstab-media": "ⵥⵔ ⵜⴰⵙⵏⴰ ⵏ ⵎⵉⴷⵢⴰ",
        "tooltip-ca-nstab-special": "ⵜⴰⴷ ⵜⴳⴰ ⵢⴰⵜ ⵜⴰⵙⵏⴰ ⵉⵥⵍⵉⵏ, ⴷ ⵓⵔ ⵉⵎⴽⵉⵏ ⴰⴷ ⵜⵜ ⵜⵙⵏⴼⵍⵜ",
        "tooltip-ca-nstab-project": "Żr tasna n twwuri",
        "tooltip-ca-nstab-image": "ⵥⵔ ⵜⴰⵙⵏⴰ ⵏ ⵓⴼⴰⵢⵍⵓ",
        "tooltip-ca-nstab-mediawiki": "Żr tabrat nu-nagraw.",
        "tooltip-ca-nstab-template": "Żr tamudemt",
-       "tooltip-ca-nstab-help": "Źr tasna nu-saws",
+       "tooltip-ca-nstab-help": "ⵥⵔ ⵜⴰⵙⵏⴰ ⵏ ⵜⵡⵉⵙⵉ",
        "tooltip-ca-nstab-category": "ⵥⵔ ⵜⴰⵙⵏⴰ ⵏ ⵜⴰⴳⴳⴰⵢⵜ",
        "tooltip-minoredit": "ⵔⵛⵎ ⴰⵢⴰ ⵎⴰⵙ ⵉⴳⴰ ⴰⵙⵏⴼⵍ ⵓⵎⵥⵉⵢ",
        "tooltip-save": "Ḥbu imbddel nek",
        "tooltip-diff": "ⵎⵍ ⵎⴰⵏ ⵉⵙⵏⴼⵉⵍⵏ ⴰⴷ ⵜⵙⴽⵔⵜ ⵉ ⵓⴹⵔⵉⵙ",
        "tooltip-compareselectedversions": "Ẓr inaḥyatn gr sin lqimat li ttuystaynin ɣ tasna yad.",
        "tooltip-watch": "ⵔⵏⵓ ⵜⴰⵙⵏⴰ ⴰⴷ ⵉ ⵜⵍⴳⴰⵎⵜ {{GENDER:|ⵏⵏⴽ|ⵏⵏⵎ}} ⵏ ⵓⴹⴼⴼⵓⵔ",
-       "tooltip-recreate": "Als askr n tasna yad waxxa ttuwḥiyyad",
+       "tooltip-recreate": "ⵙⵏⵓⵍⴼⵓ ⴷⴰⵖ ⵜⴰⵙⵏⴰ ⴰⴷ ⵎⵇⵇⴰⵔ ⵢⴰⴷ ⵜⴻⵜⵜⵡⴰⴽⴽⵙ",
        "tooltip-upload": "Izwir siɣ tullt.",
        "tooltip-rollback": "\"Rard\" s yan klik ażrig (iżrign) s ɣiklli sttin kkan tiklit li igguran",
        "tooltip-undo": "\"Sglb\" ḥiyd ambdl ad t mmurẓmt tasatmt n umbdl ɣ umuḍ tiẓri tamzwarut.",
index 339cfd6..807ddd5 100644 (file)
        "disclaimerpage": "Project:عام لاتعلقی اظہار",
        "edithelp": "لکھݨ وچ مدد",
        "helppage-top-gethelp": "مدد",
-       "mainpage": "Ù\88Ý\99ا Ù\88رÙ\82Û\81",
-       "mainpage-description": "Ù\88Ý\99ا Ù\88رÙ\82Û\81",
+       "mainpage": "Ù¾Û\81Ù\84ا Ù¾Ø±Øª",
+       "mainpage-description": "Ù¾Û\81Ù\84ا Ù¾Ø±Øª",
        "policy-url": "Project:پالیسی",
        "portal": "بیٹھک",
        "portal-url": "Project:دیوان عام",
        "nstab-template": "سانچہ",
        "nstab-help": "مدد ورقہ",
        "nstab-category": "ونکی",
-       "mainpage-nstab": "Ù\88Ý\99ا Ù\88رÙ\82Û\81",
+       "mainpage-nstab": "Ù¾Û\81Ù\84ا Ù¾Ø±Øª",
        "nosuchaction": "کوئی اینجھا کم کائنی",
        "nosuchspecialpage": "اینجھا کوئی خاص ورقہ کائنی",
        "error": "نقص",
        "tooltip-search": "ڳولو {{SITENAME}}",
        "tooltip-search-go": "جے ایں عنوان دا ورقہ ہے تاں اتھ ونڄو",
        "tooltip-search-fulltext": "ایں عبارت کوں ورقیاں وچ ڳولو",
-       "tooltip-p-logo": "Ù\88Ý\99ا Ù\88رÙ\82Û\81 ݙیکھو",
-       "tooltip-n-mainpage": "Ù\88Ý\99ا Ù\88رÙ\82Û\81 ݙیکھو",
-       "tooltip-n-mainpage-description": "پہلے ورقے تے ونڄو",
+       "tooltip-p-logo": "Ù¾Û\81Ù\84ا Ù¾Ø±Øª ݙیکھو",
+       "tooltip-n-mainpage": "Ù¾Û\81Ù\84ا Ù¾Ø±Øª ݙیکھو",
+       "tooltip-n-mainpage-description": "پہلے ورقے تے ون٘ڄو",
        "tooltip-n-portal": "ایں مںصوبے بارے، تساں کیا کر سڳدو، ، چیزاں کتھوں ڳولوں",
        "tooltip-n-currentevents": "موجودہ حالات وچ پچھلیاں معلومات ݙیکھو",
        "tooltip-n-recentchanges": "وکی تے نویاں تبدیلیاں۔",
index 27c6d5c..cd2628c 100644 (file)
        "ipb_expiry_old": "Čas izteka je v preteklosti.",
        "ipb_expiry_temp": "Blokade skritih uporabniških imen morajo biti trajne.",
        "ipb_hide_invalid": "Ne morem skriti tega računa; ima več kot $1 {{PLURAL:$1|urejanje|urejanji|urejanja|urejanj}}.",
+       "ipb_hide_partial": "Blokade s skritim uporabniškim imenom morajo biti blokade po celotni strani.",
        "ipb_already_blocked": "\"$1\" je že blokiran",
        "ipb-needreblock": "$1 je že blokiran.\nAli želite spremeniti nastavitve blokade?",
        "ipb-otherblocks-header": "{{PLURAL:$1|Druga blokada|Drugi blokadi|Druge blokade}}",
index 83851c8..7220850 100644 (file)
        "watchlistedit-clear-done": "Ваш списак надгледања је очишћен.",
        "watchlistedit-clear-jobqueue": "Ваш списак надгледања ће бити очишћен. Ово може потрајати неко време!",
        "watchlistedit-clear-removed": "{{PLURAL:$1|1 наслов је уклоњен|$1 наслова су уклоњена|$1 наслова је уклоњено}}:",
-       "watchlistedit-too-many": "Има превише страница за приказ овде.",
+       "watchlistedit-too-many": "Има превише страница за приказ.",
        "watchlisttools-clear": "очисти списак надгледања",
        "watchlisttools-view": "прикажи сродне промене",
        "watchlisttools-edit": "прикажи и уреди списак надгледања",
index 6fe42d0..4a5f826 100644 (file)
        "watchlistedit-clear-done": "Vaš spisak nadgledanja je očišćen.",
        "watchlistedit-clear-jobqueue": "Vaš spisak nadgledanja će biti očišćen. Ovo može potrajati neko vreme!",
        "watchlistedit-clear-removed": "{{PLURAL:$1|1 naslov je uklonjen|$1 naslova su uklonjena|$1 naslova je uklonjeno}}:",
-       "watchlistedit-too-many": "Ima previše stranica za prikaz ovde.",
+       "watchlistedit-too-many": "Ima previše stranica za prikaz.",
        "watchlisttools-clear": "očisti spisak nadgledanja",
        "watchlisttools-view": "prikaži srodne promene",
        "watchlisttools-edit": "prikaži i uredi spisak nadgledanja",
index 0b8133b..56e20d3 100644 (file)
        "reblock-logentry": "[[$1]] ನ ತಡೆ ವ್ಯವಸ್ಥೆಲೆಡ್ ಕೈದಾಪಿನ ಪೊರ್ತುನು $2 ಗ್ ಬದಲ್ ಮಲ್ತೆರ್ $3",
        "unblocklogentry": "$1 ಖಾತೆನ್ ಅನ್-ಬ್ಲಾಕ್ ಮಲ್ತ್’ನ್ಡ್",
        "block-log-flags-nocreate": "ಖಾತೆ ಉಂಡುಮಲ್ಪುನೇನ್ ತಡೆಪತ್ತ್'ದ್ಂಡ್",
+       "ipb_hide_partial": "ದೆಂಗಾಯಿನ ಬಳಕೆನಾಮ ತಡೆಲು ತಾನ-ಅಗೆಲದ ತಡೆಲಾದುಪ್ಪೊಡು.",
        "proxyblocker": "ಪ್ರಾಕ್ಸಿ ತಡೆಪತ್ತುನಾರ್",
        "cannotmove": "ಈ ದುಂಬುದ ಕಾರಣೊಗಾದ್ ಪುಟೊನು ಕೊಣರೆ ಆಪುಜಿ\n{{PLURAL:$1|reason|reasons}}:",
        "movelogpage": "ಸ್ತಲಾಂತರೊದ ದಾಕಲೆ",
index 32b11e2..b44024b 100644 (file)
        "searchdisabled": "การค้นหา {{SITENAME}} ถูกปิดใช้งาน \nคุณสามารถค้นหาโดยทางกูเกิลในระหว่างนั้น\nโปรดทราบว่าดัชนีเนื้อหา {{SITENAME} อาจล้าสมัย",
        "search-error": "มีข้อผิดพลาดขณะค้นหา: $1",
        "search-warning": "มีคำเตือนขณะค้นหา: $1",
-       "preferences": "à¸\84à¹\88าà¸\81ำหà¸\99à¸\94",
-       "mypreferences": "à¸\84à¹\88าà¸\81ำหà¸\99à¸\94",
+       "preferences": "à¸\81ารà¸\95ัà¹\89à¸\87à¸\84à¹\88า",
+       "mypreferences": "à¸\81ารà¸\95ัà¹\89à¸\87à¸\84à¹\88า",
        "prefs-edits": "จำนวนการแก้ไข:",
        "prefsnologintext2": "โปรดเข้าสู่ระบบเพื่อเปลี่ยนแปลงการตั้งค่าของคุณ",
        "prefs-skin": "หน้าตา",
        "userrights-conflict": "พบการเปลี่ยนแปลงสิทธิผู้ใช้ขัดกัน! โปรดทบทวนและยืนยันการเปลี่ยนแปลงของคุณ",
        "group": "กลุ่ม:",
        "group-user": "ผู้ใช้",
-       "group-autoconfirmed": "à¸\9cูà¹\89à¹\83à¸\8aà¹\89à¸\97ัà¹\88วà¹\84à¸\9b",
+       "group-autoconfirmed": "à¸\9cูà¹\89à¹\83à¸\8aà¹\89ยืà¸\99ยัà¸\99อัà¸\95à¹\82à¸\99มัà¸\95ิ",
        "group-bot": "บอต",
        "group-sysop": "ผู้ดูแลระบบ",
        "group-interface-admin": "ผู้ดูแลระบบอินเตอร์เฟซ",
        "group-suppress": "ผู้ดูแลประวัติ",
        "group-all": "(ทั้งหมด)",
        "group-user-member": "{{GENDER:$1|ผู้ใช้}}",
-       "group-autoconfirmed-member": "{{GENDER:$1|à¸\9cูà¹\89à¹\83à¸\8aà¹\89à¸\97ัà¹\88วà¹\84à¸\9b}}",
+       "group-autoconfirmed-member": "{{GENDER:$1|à¸\9cูà¹\89à¹\83à¸\8aà¹\89ยืà¸\99ยัà¸\99อัà¸\95à¹\82à¸\99มัà¸\95ิ}}",
        "group-bot-member": "{{GENDER:$1|บอต}}",
        "group-sysop-member": "{{GENDER:$1|ผู้ดูแลระบบ}}",
        "group-interface-admin-member": "{{GENDER:$1|ผู้ดูแลระบบอินเตอร์เฟซ}}",
        "group-bureaucrat-member": "{{GENDER:$1|ผู้ดูแลระบบสิทธิ์แต่งตั้ง}}",
        "group-suppress-member": "{{GENDER:$1|ผู้ดูแลประวัติ}}",
        "grouppage-user": "{{ns:project}}:ผู้ใช้",
-       "grouppage-autoconfirmed": "{{ns:project}}:à¸\9cูà¹\89à¹\83à¸\8aà¹\89à¸\97ัà¹\88วà¹\84à¸\9b",
+       "grouppage-autoconfirmed": "{{ns:project}}:à¸\9cูà¹\89à¹\83à¸\8aà¹\89ยืà¸\99ยัà¸\99อัà¸\95à¹\82à¸\99มัà¸\95ิ",
        "grouppage-bot": "{{ns:project}}:บอต",
        "grouppage-sysop": "{{ns:project}}:ผู้ดูแลระบบ",
        "grouppage-interface-admin": "{{ns:project}}:ผู้ดูแลระบบอินเตอร์เฟซ",
        "tooltip-pt-anonuserpage": "หน้าผู้ใช้ของเลขที่อยู่ไอพีที่คุณกำลังใช้แก้ไข",
        "tooltip-pt-mytalk": "หน้าคุย{{GENDER:|ของคุณ}}",
        "tooltip-pt-anontalk": "อภิปรายเกี่ยวกับการแก้ไขจากเลขที่อยู่ไอพีนี้",
-       "tooltip-pt-preferences": "à¸\84à¹\88าà¸\81ำหà¸\99à¸\94{{GENDER:|ของคุณ}}",
+       "tooltip-pt-preferences": "à¸\81ารà¸\95ัà¹\89à¸\87à¸\84à¹\88า{{GENDER:|ของคุณ}}",
        "tooltip-pt-watchlist": "รายการหน้าที่คุณกำลังเฝ้าดูการเปลี่ยนแปลง",
        "tooltip-pt-mycontris": "รายการการเข้ามีส่วนร่วมของ{{GENDER:|คุณ}}",
        "tooltip-pt-anoncontribs": "รายการการแก้ไขจากเลขที่อยู่ไอพีนี้",
        "common.css": "/* สไตล์ชีตในหน้านี้จะส่งผลแก่ผู้ใช้ทุกสกิน */",
        "print.css": "/* สไตล์ชีตในหน้านี้จะส่งผลแก่ข้อมูลส่งออกเป็นสิ่งพิมพ์ */",
        "noscript.css": "/* สไตล์ชีตในหน้านี้จะส่งผลแก่ผู้ใช้ที่ปิดการใช้งานจาวาสคริปต์ */",
-       "group-autoconfirmed.css": "/* à¸ªà¹\84à¸\95ลà¹\8cà¸\8aีà¸\95à¹\83à¸\99หà¸\99à¹\89าà¸\99ีà¹\89à¸\88ะสà¹\88à¸\87à¸\9cลà¹\81à¸\81à¹\88à¸\9cูà¹\89à¹\83à¸\8aà¹\89à¸\97ัà¹\88วà¹\84à¸\9bเท่านั้น */",
+       "group-autoconfirmed.css": "/* à¸ªà¹\84à¸\95ลà¹\8cà¸\8aีà¸\95à¹\83à¸\99หà¸\99à¹\89าà¸\99ีà¹\89à¸\88ะสà¹\88à¸\87à¸\9cลà¹\81à¸\81à¹\88à¸\9cูà¹\89à¹\83à¸\8aà¹\89ยืà¸\99ยัà¸\99อัà¸\95à¹\82à¸\99มัà¸\95ิเท่านั้น */",
        "group-bot.css": "/* สไตล์ชีตในหน้านี้จะส่งผลแก่บอตเท่านั้น */",
        "group-sysop.css": "/* สไตล์ชีตในหน้านี้จะส่งผลแก่ผู้ดูแลเท่านั้น */",
        "group-bureaucrat.css": "/* สไตล์ชีตในหน้านี้จะส่งผลแก่ผู้ดูแลระบบสิทธิ์แต่งตั้งเท่านั้น */",
        "common.js": "/* จาวาสคริปต์ใด ๆ ในหน้านี้จะถูกโหลดให้แก่ผู้ใช้ทุกคนในทุกหน้า */",
-       "group-autoconfirmed.js": "/* à¸\88าวาสà¸\84ริà¸\9bà¸\95à¹\8cà¹\83à¸\94 à¹\86 à¹\83à¸\99หà¸\99à¹\89าà¸\99ีà¹\89à¸\88ะà¸\96ูà¸\81à¹\82หลà¸\94à¹\83หà¹\89à¹\81à¸\81à¹\88à¸\9cูà¹\89à¹\83à¸\8aà¹\89à¸\97ัà¹\88วà¹\84à¸\9bเท่านั้น */",
+       "group-autoconfirmed.js": "/* à¸\88าวาสà¸\84ริà¸\9bà¸\95à¹\8cà¹\83à¸\94 à¹\86 à¹\83à¸\99หà¸\99à¹\89าà¸\99ีà¹\89à¸\88ะà¸\96ูà¸\81à¹\82หลà¸\94à¹\83หà¹\89à¹\81à¸\81à¹\88à¸\9cูà¹\89à¹\83à¸\8aà¹\89ยืà¸\99ยัà¸\99อัà¸\95à¹\82à¸\99มัà¸\95ิเท่านั้น */",
        "group-bot.js": "/* จาวาสคริปต์ใด ๆ ในหน้านี้จะถูกโหลดให้แก่บอตเท่านั้น */",
        "group-sysop.js": "/* จาวาสคริปต์ใด ๆ ในหน้านี้จะถูกโหลดให้แก่ผู้ดูแลเท่านั้น */",
        "group-bureaucrat.js": "/* จาวาสคริปต์ใด ๆ ในหน้านี้จะถูกโหลดให้แก่ผู้ดูแลระบบสิทธิ์แต่งตั้งเท่านั้น */",
index 373ddd5..591f101 100644 (file)
        "move-watch": "اصل اور ہدف صفحہ کو زیر نظر کریں",
        "movepagebtn": "مـنـتـقـل",
        "pagemovedsub": "منتقلی کامیاب",
+       "cannotmove": "حسبِ ذیل {{PLURAL:$1|وجہ|وجوہات}} کی بِنا پر صفحہ منتقل نہیں کیا جا سکتا۔",
        "movepage-moved": "<strong>\"$1\" کو \"$2\" کی جانب منتقل کر دیا گیا</strong>",
        "movepage-moved-redirect": "رجوع مکرر تخلیق کر دیا گیا۔",
        "movepage-moved-noredirect": "رجوع مکرر کو بننے سے روک دیا گیا ہے۔",
index c51f2b0..3dab2da 100644 (file)
        "ipb_expiry_old": "终止时间已过去。",
        "ipb_expiry_temp": "隐藏用户名的封禁必须是永久性的。",
        "ipb_hide_invalid": "无法隐藏此用户名;它拥有多于$1次编辑。",
+       "ipb_hide_partial": "隐藏用户名封禁必须为站内封禁",
        "ipb_already_blocked": "“$1”已被封禁。",
        "ipb-needreblock": "$1已被封禁。您是否想更改封禁设置?",
        "ipb-otherblocks-header": "其他{{PLURAL:$1|封禁}}",
index b2611c1..33573f1 100644 (file)
        "protectedarticle-comment": "{{GENDER:$2|受保護}} \"[[$1]]\"",
        "modifiedarticleprotection-comment": "{{GENDER:$2|已變更}} \"[[$1]]\" 的保護層級",
        "unprotectedarticle-comment": "{{GENDER:$2|已移除}} \"[[$1]]\" 的保護",
-       "protect-title": "變更 \"$1\" 的保護層級",
+       "protect-title": "變更「$1」的保護層級",
        "protect-title-notallowed": "檢視 \"$1\" 的保護層級",
        "prot_1movedto2": "已移動 [[$1]] 至 [[$2]]",
        "protect-badnamespace-title": "不可保護的命名空間",
index 897972c..4da0901 100644 (file)
@@ -24,6 +24,8 @@
  * @ingroup Maintenance
  */
 
+use MediaWiki\MediaWikiServices;
+
 require_once __DIR__ . '/Maintenance.php';
 
 /**
@@ -53,6 +55,7 @@ class AttachLatest extends Maintenance {
                        $conds,
                        __METHOD__ );
 
+               $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
                $n = 0;
                foreach ( $result as $row ) {
                        $pageId = intval( $row->page_id );
@@ -78,6 +81,7 @@ class AttachLatest extends Maintenance {
                        if ( $this->hasOption( 'fix' ) ) {
                                $page = WikiPage::factory( $title );
                                $page->updateRevisionOn( $dbw, $revision );
+                               $lbFactory->waitForReplication();
                        }
                        $n++;
                }
diff --git a/maintenance/includes/MigrateActors.php b/maintenance/includes/MigrateActors.php
new file mode 100644 (file)
index 0000000..ceba9b5
--- /dev/null
@@ -0,0 +1,584 @@
+<?php
+/**
+ * Helper for migrating actors from pre-1.31 columns to the 'actor' table
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Maintenance
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+require_once __DIR__ . '/../Maintenance.php';
+
+/**
+ * Maintenance script that migrates actors from pre-1.31 columns to the
+ * 'actor' table
+ *
+ * @ingroup Maintenance
+ */
+class MigrateActors extends LoggedUpdateMaintenance {
+       public function __construct() {
+               parent::__construct();
+               $this->addDescription( 'Migrates actors from pre-1.31 columns to the \'actor\' table' );
+               $this->setBatchSize( 100 );
+       }
+
+       protected function getUpdateKey() {
+               return __CLASS__;
+       }
+
+       protected function doDBUpdates() {
+               global $wgActorTableSchemaMigrationStage;
+
+               if ( !( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) ) {
+                       $this->output(
+                               "...cannot update while \$wgActorTableSchemaMigrationStage lacks SCHEMA_COMPAT_WRITE_NEW\n"
+                       );
+                       return false;
+               }
+
+               $this->output( "Creating actor entries for all registered users\n" );
+               $end = 0;
+               $dbw = $this->getDB( DB_MASTER );
+               $max = $dbw->selectField( 'user', 'MAX(user_id)', '', __METHOD__ );
+               $count = 0;
+               while ( $end < $max ) {
+                       $start = $end + 1;
+                       $end = min( $start + $this->mBatchSize, $max );
+                       $this->output( "... $start - $end\n" );
+                       $dbw->insertSelect(
+                               'actor',
+                               'user',
+                               [ 'actor_user' => 'user_id', 'actor_name' => 'user_name' ],
+                               [ "user_id >= $start", "user_id <= $end" ],
+                               __METHOD__,
+                               [ 'IGNORE' ],
+                               [ 'ORDER BY' => [ 'user_id' ] ]
+                       );
+                       $count += $dbw->affectedRows();
+                       wfWaitForSlaves();
+               }
+               $this->output( "Completed actor creation, added $count new actor(s)\n" );
+
+               $errors = 0;
+               $errors += $this->migrateToTemp(
+                       'revision', 'rev_id', [ 'revactor_timestamp' => 'rev_timestamp', 'revactor_page' => 'rev_page' ],
+                       'rev_user', 'rev_user_text', 'revactor_rev', 'revactor_actor'
+               );
+               $errors += $this->migrate( 'archive', 'ar_id', 'ar_user', 'ar_user_text', 'ar_actor' );
+               $errors += $this->migrate( 'ipblocks', 'ipb_id', 'ipb_by', 'ipb_by_text', 'ipb_by_actor' );
+               $errors += $this->migrate( 'image', 'img_name', 'img_user', 'img_user_text', 'img_actor' );
+               $errors += $this->migrate(
+                       'oldimage', [ 'oi_name', 'oi_timestamp' ], 'oi_user', 'oi_user_text', 'oi_actor'
+               );
+               $errors += $this->migrate( 'filearchive', 'fa_id', 'fa_user', 'fa_user_text', 'fa_actor' );
+               $errors += $this->migrate( 'recentchanges', 'rc_id', 'rc_user', 'rc_user_text', 'rc_actor' );
+               $errors += $this->migrate( 'logging', 'log_id', 'log_user', 'log_user_text', 'log_actor' );
+
+               $errors += $this->migrateLogSearch();
+
+               return $errors === 0;
+       }
+
+       /**
+        * Calculate a "next" condition and a display string
+        * @param IDatabase $dbw
+        * @param string[] $primaryKey Primary key of the table.
+        * @param object $row Database row
+        * @return array [ string $next, string $display ]
+        */
+       private function makeNextCond( $dbw, $primaryKey, $row ) {
+               $next = '';
+               $display = [];
+               for ( $i = count( $primaryKey ) - 1; $i >= 0; $i-- ) {
+                       $field = $primaryKey[$i];
+                       $display[] = $field . '=' . $row->$field;
+                       $value = $dbw->addQuotes( $row->$field );
+                       if ( $next === '' ) {
+                               $next = "$field > $value";
+                       } else {
+                               $next = "$field > $value OR $field = $value AND ($next)";
+                       }
+               }
+               $display = implode( ' ', array_reverse( $display ) );
+               return [ $next, $display ];
+       }
+
+       /**
+        * Make the subqueries for `actor_id`
+        * @param IDatabase $dbw
+        * @param string $userField User ID field name
+        * @param string $nameField User name field name
+        * @return string SQL fragment
+        */
+       private function makeActorIdSubquery( $dbw, $userField, $nameField ) {
+               $idSubquery = $dbw->buildSelectSubquery(
+                       'actor',
+                       'actor_id',
+                       [ "$userField = actor_user" ],
+                       __METHOD__
+               );
+               $nameSubquery = $dbw->buildSelectSubquery(
+                       'actor',
+                       'actor_id',
+                       [ "$nameField = actor_name" ],
+                       __METHOD__
+               );
+               return "CASE WHEN $userField = 0 OR $userField IS NULL THEN $nameSubquery ELSE $idSubquery END";
+       }
+
+       /**
+        * Add actors for anons in a set of rows
+        * @param IDatabase $dbw
+        * @param string $nameField
+        * @param object[] &$rows
+        * @param array &$complainedAboutUsers
+        * @param int &$countErrors
+        * @return int Count of actors inserted
+        */
+       private function addActorsForRows(
+               IDatabase $dbw, $nameField, array &$rows, array &$complainedAboutUsers, &$countErrors
+       ) {
+               $needActors = [];
+               $countActors = 0;
+
+               $keep = [];
+               foreach ( $rows as $index => $row ) {
+                       $keep[$index] = true;
+                       if ( $row->actor_id === null ) {
+                               // All registered users should have an actor_id already. So
+                               // if we have a usable name here, it means they didn't run
+                               // maintenance/cleanupUsersWithNoId.php
+                               $name = $row->$nameField;
+                               if ( User::isUsableName( $name ) ) {
+                                       if ( !isset( $complainedAboutUsers[$name] ) ) {
+                                               $complainedAboutUsers[$name] = true;
+                                               $this->error(
+                                                       "User name \"$name\" is usable, cannot create an anonymous actor for it."
+                                                       . " Run maintenance/cleanupUsersWithNoId.php to fix this situation.\n"
+                                               );
+                                       }
+                                       unset( $keep[$index] );
+                                       $countErrors++;
+                               } else {
+                                       $needActors[$name] = 0;
+                               }
+                       }
+               }
+               $rows = array_intersect_key( $rows, $keep );
+
+               if ( $needActors ) {
+                       $dbw->insert(
+                               'actor',
+                               array_map( function ( $v ) {
+                                       return [
+                                               'actor_name' => $v,
+                                       ];
+                               }, array_keys( $needActors ) ),
+                               __METHOD__
+                       );
+                       $countActors += $dbw->affectedRows();
+
+                       $res = $dbw->select(
+                               'actor',
+                               [ 'actor_id', 'actor_name' ],
+                               [ 'actor_name' => array_keys( $needActors ) ],
+                               __METHOD__
+                       );
+                       foreach ( $res as $row ) {
+                               $needActors[$row->actor_name] = $row->actor_id;
+                       }
+                       foreach ( $rows as $row ) {
+                               if ( $row->actor_id === null ) {
+                                       $row->actor_id = $needActors[$row->$nameField];
+                               }
+                       }
+               }
+
+               return $countActors;
+       }
+
+       /**
+        * Migrate actors in a table.
+        *
+        * Assumes any row with the actor field non-zero have already been migrated.
+        * Blanks the name field when migrating.
+        *
+        * @param string $table Table to migrate
+        * @param string|string[] $primaryKey Primary key of the table.
+        * @param string $userField User ID field name
+        * @param string $nameField User name field name
+        * @param string $actorField Actor field name
+        * @return int Number of errors
+        */
+       protected function migrate( $table, $primaryKey, $userField, $nameField, $actorField ) {
+               $complainedAboutUsers = [];
+
+               $primaryKey = (array)$primaryKey;
+               $pkFilter = array_flip( $primaryKey );
+               $this->output(
+                       "Beginning migration of $table.$userField and $table.$nameField to $table.$actorField\n"
+               );
+               wfWaitForSlaves();
+
+               $dbw = $this->getDB( DB_MASTER );
+               $actorIdSubquery = $this->makeActorIdSubquery( $dbw, $userField, $nameField );
+               $next = '1=1';
+               $countUpdated = 0;
+               $countActors = 0;
+               $countErrors = 0;
+               while ( true ) {
+                       // Fetch the rows needing update
+                       $res = $dbw->select(
+                               $table,
+                               array_merge( $primaryKey, [ $userField, $nameField, 'actor_id' => $actorIdSubquery ] ),
+                               [
+                                       $actorField => 0,
+                                       $next,
+                               ],
+                               __METHOD__,
+                               [
+                                       'ORDER BY' => $primaryKey,
+                                       'LIMIT' => $this->mBatchSize,
+                               ]
+                       );
+                       if ( !$res->numRows() ) {
+                               break;
+                       }
+
+                       // Insert new actors for rows that need one
+                       $rows = iterator_to_array( $res );
+                       $lastRow = end( $rows );
+                       $countActors += $this->addActorsForRows(
+                               $dbw, $nameField, $rows, $complainedAboutUsers, $countErrors
+                       );
+
+                       // Update the existing rows
+                       foreach ( $rows as $row ) {
+                               if ( !$row->actor_id ) {
+                                       list( , $display ) = $this->makeNextCond( $dbw, $primaryKey, $row );
+                                       $this->error(
+                                               "Could not make actor for row with $display "
+                                               . "$userField={$row->$userField} $nameField={$row->$nameField}\n"
+                                       );
+                                       $countErrors++;
+                                       continue;
+                               }
+                               $dbw->update(
+                                       $table,
+                                       [
+                                               $actorField => $row->actor_id,
+                                       ],
+                                       array_intersect_key( (array)$row, $pkFilter ) + [
+                                               $actorField => 0
+                                       ],
+                                       __METHOD__
+                               );
+                               $countUpdated += $dbw->affectedRows();
+                       }
+
+                       list( $next, $display ) = $this->makeNextCond( $dbw, $primaryKey, $lastRow );
+                       $this->output( "... $display\n" );
+                       wfWaitForSlaves();
+               }
+
+               $this->output(
+                       "Completed migration, updated $countUpdated row(s) with $countActors new actor(s), "
+                       . "$countErrors error(s)\n"
+               );
+               return $countErrors;
+       }
+
+       /**
+        * Migrate actors in a table to a temporary table.
+        *
+        * Assumes the new table is named "{$table}_actor_temp", and it has two
+        * columns, in order, being the primary key of the original table and the
+        * actor ID field.
+        * Blanks the name field when migrating.
+        *
+        * @param string $table Table to migrate
+        * @param string $primaryKey Primary key of the table.
+        * @param array $extra Extra fields to copy
+        * @param string $userField User ID field name
+        * @param string $nameField User name field name
+        * @param string $newPrimaryKey Primary key of the new table.
+        * @param string $actorField Actor field name
+        */
+       protected function migrateToTemp(
+               $table, $primaryKey, $extra, $userField, $nameField, $newPrimaryKey, $actorField
+       ) {
+               $complainedAboutUsers = [];
+
+               $newTable = $table . '_actor_temp';
+               $this->output(
+                       "Beginning migration of $table.$userField and $table.$nameField to $newTable.$actorField\n"
+               );
+               wfWaitForSlaves();
+
+               $dbw = $this->getDB( DB_MASTER );
+               $actorIdSubquery = $this->makeActorIdSubquery( $dbw, $userField, $nameField );
+               $next = [];
+               $countUpdated = 0;
+               $countActors = 0;
+               $countErrors = 0;
+               while ( true ) {
+                       // Fetch the rows needing update
+                       $res = $dbw->select(
+                               [ $table, $newTable ],
+                               [ $primaryKey, $userField, $nameField, 'actor_id' => $actorIdSubquery ] + $extra,
+                               [ $newPrimaryKey => null ] + $next,
+                               __METHOD__,
+                               [
+                                       'ORDER BY' => $primaryKey,
+                                       'LIMIT' => $this->mBatchSize,
+                               ],
+                               [
+                                       $newTable => [ 'LEFT JOIN', "{$primaryKey}={$newPrimaryKey}" ],
+                               ]
+                       );
+                       if ( !$res->numRows() ) {
+                               break;
+                       }
+
+                       // Insert new actors for rows that need one
+                       $rows = iterator_to_array( $res );
+                       $lastRow = end( $rows );
+                       $countActors += $this->addActorsForRows(
+                               $dbw, $nameField, $rows, $complainedAboutUsers, $countErrors
+                       );
+
+                       // Update rows
+                       if ( $rows ) {
+                               $inserts = [];
+                               $updates = [];
+                               foreach ( $rows as $row ) {
+                                       if ( !$row->actor_id ) {
+                                               list( , $display ) = $this->makeNextCond( $dbw, [ $primaryKey ], $row );
+                                               $this->error(
+                                                       "Could not make actor for row with $display "
+                                                       . "$userField={$row->$userField} $nameField={$row->$nameField}\n"
+                                               );
+                                               $countErrors++;
+                                               continue;
+                                       }
+                                       $ins = [
+                                               $newPrimaryKey => $row->$primaryKey,
+                                               $actorField => $row->actor_id,
+                                       ];
+                                       foreach ( $extra as $to => $from ) {
+                                               $ins[$to] = $row->$to; // It's aliased
+                                       }
+                                       $inserts[] = $ins;
+                                       $updates[] = $row->$primaryKey;
+                               }
+                               $this->beginTransaction( $dbw, __METHOD__ );
+                               $dbw->insert( $newTable, $inserts, __METHOD__ );
+                               $countUpdated += $dbw->affectedRows();
+                               $this->commitTransaction( $dbw, __METHOD__ );
+                       }
+
+                       // Calculate the "next" condition
+                       list( $n, $display ) = $this->makeNextCond( $dbw, [ $primaryKey ], $lastRow );
+                       $next = [ $n ];
+                       $this->output( "... $display\n" );
+               }
+
+               $this->output(
+                       "Completed migration, updated $countUpdated row(s) with $countActors new actor(s), "
+                       . "$countErrors error(s)\n"
+               );
+               return $countErrors;
+       }
+
+       /**
+        * Migrate actors in the log_search table.
+        * @return int Number of errors
+        */
+       protected function migrateLogSearch() {
+               $complainedAboutUsers = [];
+
+               $primaryKey = [ 'ls_field', 'ls_value' ];
+               $pkFilter = array_flip( $primaryKey );
+               $this->output( "Beginning migration of log_search\n" );
+               wfWaitForSlaves();
+
+               $dbw = $this->getDB( DB_MASTER );
+               $countUpdated = 0;
+               $countActors = 0;
+               $countErrors = 0;
+
+               $next = '1=1';
+               while ( true ) {
+                       // Fetch the rows needing update
+                       $res = $dbw->select(
+                               [
+                                       'ls' => $dbw->buildSelectSubquery(
+                                               'log_search',
+                                               'ls_value',
+                                               [
+                                                       'ls_field' => 'target_author_id',
+                                                       $next
+                                               ],
+                                               __METHOD__,
+                                               [
+                                                       'DISTINCT',
+                                                       'ORDER BY' => [ 'ls_value' ],
+                                                       'LIMIT' => $this->mBatchSize,
+                                               ]
+                                       ),
+                                       'actor'
+                               ],
+                               [
+                                       'ls_field' => $dbw->addQuotes( 'target_author_id' ),
+                                       'ls_value',
+                                       'actor_id'
+                               ],
+                               [],
+                               __METHOD__,
+                               [],
+                               [ 'actor' => [ 'LEFT JOIN', 'ls_value = ' . $dbw->buildStringCast( 'actor_user' ) ] ]
+                       );
+                       if ( !$res->numRows() ) {
+                               break;
+                       }
+
+                       // Update the rows
+                       $del = [];
+                       foreach ( $res as $row ) {
+                               $lastRow = $row;
+                               if ( !$row->actor_id ) {
+                                       list( , $display ) = $this->makeNextCond( $dbw, $primaryKey, $row );
+                                       $this->error( "No actor for row with $display\n" );
+                                       $countErrors++;
+                                       continue;
+                               }
+                               $dbw->update(
+                                       'log_search',
+                                       [
+                                               'ls_field' => 'target_author_actor',
+                                               'ls_value' => $row->actor_id,
+                                       ],
+                                       [
+                                               'ls_field' => $row->ls_field,
+                                               'ls_value' => $row->ls_value,
+                                       ],
+                                       __METHOD__,
+                                       [ 'IGNORE' ]
+                               );
+                               $countUpdated += $dbw->affectedRows();
+                               $del[] = $row->ls_value;
+                       }
+                       if ( $del ) {
+                               $dbw->delete(
+                                       'log_search', [ 'ls_field' => 'target_author_id', 'ls_value' => $del ], __METHOD__
+                               );
+                               $countUpdated += $dbw->affectedRows();
+                       }
+
+                       list( $next, $display ) = $this->makeNextCond( $dbw, $primaryKey, $lastRow );
+                       $this->output( "... $display\n" );
+                       wfWaitForSlaves();
+               }
+
+               $next = '1=1';
+               while ( true ) {
+                       // Fetch the rows needing update
+                       $res = $dbw->select(
+                               [
+                                       'ls' => $dbw->buildSelectSubquery(
+                                               'log_search',
+                                               'ls_value',
+                                               [
+                                                       'ls_field' => 'target_author_ip',
+                                                       $next
+                                               ],
+                                               __METHOD__,
+                                               [
+                                                       'DISTINCT',
+                                                       'ORDER BY' => [ 'ls_value' ],
+                                                       'LIMIT' => $this->mBatchSize,
+                                               ]
+                                       ),
+                                       'actor'
+                               ],
+                               [
+                                       'ls_field' => $dbw->addQuotes( 'target_author_ip' ),
+                                       'ls_value',
+                                       'actor_id'
+                               ],
+                               [],
+                               __METHOD__,
+                               [],
+                               [ 'actor' => [ 'LEFT JOIN', 'ls_value = actor_name' ] ]
+                       );
+                       if ( !$res->numRows() ) {
+                               break;
+                       }
+
+                       // Insert new actors for rows that need one
+                       $rows = iterator_to_array( $res );
+                       $lastRow = end( $rows );
+                       $countActors += $this->addActorsForRows(
+                               $dbw, 'ls_value', $rows, $complainedAboutUsers, $countErrors
+                       );
+
+                       // Update the rows
+                       $del = [];
+                       foreach ( $rows as $row ) {
+                               if ( !$row->actor_id ) {
+                                       list( , $display ) = $this->makeNextCond( $dbw, $primaryKey, $row );
+                                       $this->error( "Could not make actor for row with $display\n" );
+                                       $countErrors++;
+                                       continue;
+                               }
+                               $dbw->update(
+                                       'log_search',
+                                       [
+                                               'ls_field' => 'target_author_actor',
+                                               'ls_value' => $row->actor_id,
+                                       ],
+                                       [
+                                               'ls_field' => $row->ls_field,
+                                               'ls_value' => $row->ls_value,
+                                       ],
+                                       __METHOD__,
+                                       [ 'IGNORE' ]
+                               );
+                               $countUpdated += $dbw->affectedRows();
+                               $del[] = $row->ls_value;
+                       }
+                       if ( $del ) {
+                               $dbw->delete(
+                                       'log_search', [ 'ls_field' => 'target_author_ip', 'ls_value' => $del ], __METHOD__
+                               );
+                               $countUpdated += $dbw->affectedRows();
+                       }
+
+                       list( $next, $display ) = $this->makeNextCond( $dbw, $primaryKey, $lastRow );
+                       $this->output( "... $display\n" );
+                       wfWaitForSlaves();
+               }
+
+               $this->output(
+                       "Completed migration, updated $countUpdated row(s) with $countActors new actor(s), "
+                       . "$countErrors error(s)\n"
+               );
+               return $countErrors;
+       }
+}
index f5a1e44..bc15b57 100644 (file)
  * @ingroup Maintenance
  */
 
-use Wikimedia\Rdbms\IDatabase;
+require_once __DIR__ . '/includes/MigrateActors.php';
 
-require_once __DIR__ . '/Maintenance.php';
-
-/**
- * Maintenance script that migrates actors from pre-1.31 columns to the
- * 'actor' table
- *
- * @ingroup Maintenance
- */
-class MigrateActors extends LoggedUpdateMaintenance {
-       public function __construct() {
-               parent::__construct();
-               $this->addDescription( 'Migrates actors from pre-1.31 columns to the \'actor\' table' );
-               $this->setBatchSize( 100 );
-       }
-
-       protected function getUpdateKey() {
-               return __CLASS__;
-       }
-
-       protected function doDBUpdates() {
-               global $wgActorTableSchemaMigrationStage;
-
-               if ( !( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) ) {
-                       $this->output(
-                               "...cannot update while \$wgActorTableSchemaMigrationStage lacks SCHEMA_COMPAT_WRITE_NEW\n"
-                       );
-                       return false;
-               }
-
-               $this->output( "Creating actor entries for all registered users\n" );
-               $end = 0;
-               $dbw = $this->getDB( DB_MASTER );
-               $max = $dbw->selectField( 'user', 'MAX(user_id)', '', __METHOD__ );
-               $count = 0;
-               while ( $end < $max ) {
-                       $start = $end + 1;
-                       $end = min( $start + $this->mBatchSize, $max );
-                       $this->output( "... $start - $end\n" );
-                       $dbw->insertSelect(
-                               'actor',
-                               'user',
-                               [ 'actor_user' => 'user_id', 'actor_name' => 'user_name' ],
-                               [ "user_id >= $start", "user_id <= $end" ],
-                               __METHOD__,
-                               [ 'IGNORE' ],
-                               [ 'ORDER BY' => [ 'user_id' ] ]
-                       );
-                       $count += $dbw->affectedRows();
-                       wfWaitForSlaves();
-               }
-               $this->output( "Completed actor creation, added $count new actor(s)\n" );
-
-               $errors = 0;
-               $errors += $this->migrateToTemp(
-                       'revision', 'rev_id', [ 'revactor_timestamp' => 'rev_timestamp', 'revactor_page' => 'rev_page' ],
-                       'rev_user', 'rev_user_text', 'revactor_rev', 'revactor_actor'
-               );
-               $errors += $this->migrate( 'archive', 'ar_id', 'ar_user', 'ar_user_text', 'ar_actor' );
-               $errors += $this->migrate( 'ipblocks', 'ipb_id', 'ipb_by', 'ipb_by_text', 'ipb_by_actor' );
-               $errors += $this->migrate( 'image', 'img_name', 'img_user', 'img_user_text', 'img_actor' );
-               $errors += $this->migrate(
-                       'oldimage', [ 'oi_name', 'oi_timestamp' ], 'oi_user', 'oi_user_text', 'oi_actor'
-               );
-               $errors += $this->migrate( 'filearchive', 'fa_id', 'fa_user', 'fa_user_text', 'fa_actor' );
-               $errors += $this->migrate( 'recentchanges', 'rc_id', 'rc_user', 'rc_user_text', 'rc_actor' );
-               $errors += $this->migrate( 'logging', 'log_id', 'log_user', 'log_user_text', 'log_actor' );
-
-               $errors += $this->migrateLogSearch();
-
-               return $errors === 0;
-       }
-
-       /**
-        * Calculate a "next" condition and a display string
-        * @param IDatabase $dbw
-        * @param string[] $primaryKey Primary key of the table.
-        * @param object $row Database row
-        * @return array [ string $next, string $display ]
-        */
-       private function makeNextCond( $dbw, $primaryKey, $row ) {
-               $next = '';
-               $display = [];
-               for ( $i = count( $primaryKey ) - 1; $i >= 0; $i-- ) {
-                       $field = $primaryKey[$i];
-                       $display[] = $field . '=' . $row->$field;
-                       $value = $dbw->addQuotes( $row->$field );
-                       if ( $next === '' ) {
-                               $next = "$field > $value";
-                       } else {
-                               $next = "$field > $value OR $field = $value AND ($next)";
-                       }
-               }
-               $display = implode( ' ', array_reverse( $display ) );
-               return [ $next, $display ];
-       }
-
-       /**
-        * Make the subqueries for `actor_id`
-        * @param IDatabase $dbw
-        * @param string $userField User ID field name
-        * @param string $nameField User name field name
-        * @return string SQL fragment
-        */
-       private function makeActorIdSubquery( $dbw, $userField, $nameField ) {
-               $idSubquery = $dbw->buildSelectSubquery(
-                       'actor',
-                       'actor_id',
-                       [ "$userField = actor_user" ],
-                       __METHOD__
-               );
-               $nameSubquery = $dbw->buildSelectSubquery(
-                       'actor',
-                       'actor_id',
-                       [ "$nameField = actor_name" ],
-                       __METHOD__
-               );
-               return "CASE WHEN $userField = 0 OR $userField IS NULL THEN $nameSubquery ELSE $idSubquery END";
-       }
-
-       /**
-        * Add actors for anons in a set of rows
-        * @param IDatabase $dbw
-        * @param string $nameField
-        * @param object[] &$rows
-        * @param array &$complainedAboutUsers
-        * @param int &$countErrors
-        * @return int Count of actors inserted
-        */
-       private function addActorsForRows(
-               IDatabase $dbw, $nameField, array &$rows, array &$complainedAboutUsers, &$countErrors
-       ) {
-               $needActors = [];
-               $countActors = 0;
-
-               $keep = [];
-               foreach ( $rows as $index => $row ) {
-                       $keep[$index] = true;
-                       if ( $row->actor_id === null ) {
-                               // All registered users should have an actor_id already. So
-                               // if we have a usable name here, it means they didn't run
-                               // maintenance/cleanupUsersWithNoId.php
-                               $name = $row->$nameField;
-                               if ( User::isUsableName( $name ) ) {
-                                       if ( !isset( $complainedAboutUsers[$name] ) ) {
-                                               $complainedAboutUsers[$name] = true;
-                                               $this->error(
-                                                       "User name \"$name\" is usable, cannot create an anonymous actor for it."
-                                                       . " Run maintenance/cleanupUsersWithNoId.php to fix this situation.\n"
-                                               );
-                                       }
-                                       unset( $keep[$index] );
-                                       $countErrors++;
-                               } else {
-                                       $needActors[$name] = 0;
-                               }
-                       }
-               }
-               $rows = array_intersect_key( $rows, $keep );
-
-               if ( $needActors ) {
-                       $dbw->insert(
-                               'actor',
-                               array_map( function ( $v ) {
-                                       return [
-                                               'actor_name' => $v,
-                                       ];
-                               }, array_keys( $needActors ) ),
-                               __METHOD__
-                       );
-                       $countActors += $dbw->affectedRows();
-
-                       $res = $dbw->select(
-                               'actor',
-                               [ 'actor_id', 'actor_name' ],
-                               [ 'actor_name' => array_keys( $needActors ) ],
-                               __METHOD__
-                       );
-                       foreach ( $res as $row ) {
-                               $needActors[$row->actor_name] = $row->actor_id;
-                       }
-                       foreach ( $rows as $row ) {
-                               if ( $row->actor_id === null ) {
-                                       $row->actor_id = $needActors[$row->$nameField];
-                               }
-                       }
-               }
-
-               return $countActors;
-       }
-
-       /**
-        * Migrate actors in a table.
-        *
-        * Assumes any row with the actor field non-zero have already been migrated.
-        * Blanks the name field when migrating.
-        *
-        * @param string $table Table to migrate
-        * @param string|string[] $primaryKey Primary key of the table.
-        * @param string $userField User ID field name
-        * @param string $nameField User name field name
-        * @param string $actorField Actor field name
-        * @return int Number of errors
-        */
-       protected function migrate( $table, $primaryKey, $userField, $nameField, $actorField ) {
-               $complainedAboutUsers = [];
-
-               $primaryKey = (array)$primaryKey;
-               $pkFilter = array_flip( $primaryKey );
-               $this->output(
-                       "Beginning migration of $table.$userField and $table.$nameField to $table.$actorField\n"
-               );
-               wfWaitForSlaves();
-
-               $dbw = $this->getDB( DB_MASTER );
-               $actorIdSubquery = $this->makeActorIdSubquery( $dbw, $userField, $nameField );
-               $next = '1=1';
-               $countUpdated = 0;
-               $countActors = 0;
-               $countErrors = 0;
-               while ( true ) {
-                       // Fetch the rows needing update
-                       $res = $dbw->select(
-                               $table,
-                               array_merge( $primaryKey, [ $userField, $nameField, 'actor_id' => $actorIdSubquery ] ),
-                               [
-                                       $actorField => 0,
-                                       $next,
-                               ],
-                               __METHOD__,
-                               [
-                                       'ORDER BY' => $primaryKey,
-                                       'LIMIT' => $this->mBatchSize,
-                               ]
-                       );
-                       if ( !$res->numRows() ) {
-                               break;
-                       }
-
-                       // Insert new actors for rows that need one
-                       $rows = iterator_to_array( $res );
-                       $lastRow = end( $rows );
-                       $countActors += $this->addActorsForRows(
-                               $dbw, $nameField, $rows, $complainedAboutUsers, $countErrors
-                       );
-
-                       // Update the existing rows
-                       foreach ( $rows as $row ) {
-                               if ( !$row->actor_id ) {
-                                       list( , $display ) = $this->makeNextCond( $dbw, $primaryKey, $row );
-                                       $this->error(
-                                               "Could not make actor for row with $display "
-                                               . "$userField={$row->$userField} $nameField={$row->$nameField}\n"
-                                       );
-                                       $countErrors++;
-                                       continue;
-                               }
-                               $dbw->update(
-                                       $table,
-                                       [
-                                               $actorField => $row->actor_id,
-                                       ],
-                                       array_intersect_key( (array)$row, $pkFilter ) + [
-                                               $actorField => 0
-                                       ],
-                                       __METHOD__
-                               );
-                               $countUpdated += $dbw->affectedRows();
-                       }
-
-                       list( $next, $display ) = $this->makeNextCond( $dbw, $primaryKey, $lastRow );
-                       $this->output( "... $display\n" );
-                       wfWaitForSlaves();
-               }
-
-               $this->output(
-                       "Completed migration, updated $countUpdated row(s) with $countActors new actor(s), "
-                       . "$countErrors error(s)\n"
-               );
-               return $countErrors;
-       }
-
-       /**
-        * Migrate actors in a table to a temporary table.
-        *
-        * Assumes the new table is named "{$table}_actor_temp", and it has two
-        * columns, in order, being the primary key of the original table and the
-        * actor ID field.
-        * Blanks the name field when migrating.
-        *
-        * @param string $table Table to migrate
-        * @param string $primaryKey Primary key of the table.
-        * @param array $extra Extra fields to copy
-        * @param string $userField User ID field name
-        * @param string $nameField User name field name
-        * @param string $newPrimaryKey Primary key of the new table.
-        * @param string $actorField Actor field name
-        */
-       protected function migrateToTemp(
-               $table, $primaryKey, $extra, $userField, $nameField, $newPrimaryKey, $actorField
-       ) {
-               $complainedAboutUsers = [];
-
-               $newTable = $table . '_actor_temp';
-               $this->output(
-                       "Beginning migration of $table.$userField and $table.$nameField to $newTable.$actorField\n"
-               );
-               wfWaitForSlaves();
-
-               $dbw = $this->getDB( DB_MASTER );
-               $actorIdSubquery = $this->makeActorIdSubquery( $dbw, $userField, $nameField );
-               $next = [];
-               $countUpdated = 0;
-               $countActors = 0;
-               $countErrors = 0;
-               while ( true ) {
-                       // Fetch the rows needing update
-                       $res = $dbw->select(
-                               [ $table, $newTable ],
-                               [ $primaryKey, $userField, $nameField, 'actor_id' => $actorIdSubquery ] + $extra,
-                               [ $newPrimaryKey => null ] + $next,
-                               __METHOD__,
-                               [
-                                       'ORDER BY' => $primaryKey,
-                                       'LIMIT' => $this->mBatchSize,
-                               ],
-                               [
-                                       $newTable => [ 'LEFT JOIN', "{$primaryKey}={$newPrimaryKey}" ],
-                               ]
-                       );
-                       if ( !$res->numRows() ) {
-                               break;
-                       }
-
-                       // Insert new actors for rows that need one
-                       $rows = iterator_to_array( $res );
-                       $lastRow = end( $rows );
-                       $countActors += $this->addActorsForRows(
-                               $dbw, $nameField, $rows, $complainedAboutUsers, $countErrors
-                       );
-
-                       // Update rows
-                       if ( $rows ) {
-                               $inserts = [];
-                               $updates = [];
-                               foreach ( $rows as $row ) {
-                                       if ( !$row->actor_id ) {
-                                               list( , $display ) = $this->makeNextCond( $dbw, [ $primaryKey ], $row );
-                                               $this->error(
-                                                       "Could not make actor for row with $display "
-                                                       . "$userField={$row->$userField} $nameField={$row->$nameField}\n"
-                                               );
-                                               $countErrors++;
-                                               continue;
-                                       }
-                                       $ins = [
-                                               $newPrimaryKey => $row->$primaryKey,
-                                               $actorField => $row->actor_id,
-                                       ];
-                                       foreach ( $extra as $to => $from ) {
-                                               $ins[$to] = $row->$to; // It's aliased
-                                       }
-                                       $inserts[] = $ins;
-                                       $updates[] = $row->$primaryKey;
-                               }
-                               $this->beginTransaction( $dbw, __METHOD__ );
-                               $dbw->insert( $newTable, $inserts, __METHOD__ );
-                               $countUpdated += $dbw->affectedRows();
-                               $this->commitTransaction( $dbw, __METHOD__ );
-                       }
-
-                       // Calculate the "next" condition
-                       list( $n, $display ) = $this->makeNextCond( $dbw, [ $primaryKey ], $lastRow );
-                       $next = [ $n ];
-                       $this->output( "... $display\n" );
-               }
-
-               $this->output(
-                       "Completed migration, updated $countUpdated row(s) with $countActors new actor(s), "
-                       . "$countErrors error(s)\n"
-               );
-               return $countErrors;
-       }
-
-       /**
-        * Migrate actors in the log_search table.
-        * @return int Number of errors
-        */
-       protected function migrateLogSearch() {
-               $complainedAboutUsers = [];
-
-               $primaryKey = [ 'ls_field', 'ls_value' ];
-               $pkFilter = array_flip( $primaryKey );
-               $this->output( "Beginning migration of log_search\n" );
-               wfWaitForSlaves();
-
-               $dbw = $this->getDB( DB_MASTER );
-               $countUpdated = 0;
-               $countActors = 0;
-               $countErrors = 0;
-
-               $next = '1=1';
-               while ( true ) {
-                       // Fetch the rows needing update
-                       $res = $dbw->select(
-                               [
-                                       'ls' => $dbw->buildSelectSubquery(
-                                               'log_search',
-                                               'ls_value',
-                                               [
-                                                       'ls_field' => 'target_author_id',
-                                                       $next
-                                               ],
-                                               __METHOD__,
-                                               [
-                                                       'DISTINCT',
-                                                       'ORDER BY' => [ 'ls_value' ],
-                                                       'LIMIT' => $this->mBatchSize,
-                                               ]
-                                       ),
-                                       'actor'
-                               ],
-                               [
-                                       'ls_field' => $dbw->addQuotes( 'target_author_id' ),
-                                       'ls_value',
-                                       'actor_id'
-                               ],
-                               [],
-                               __METHOD__,
-                               [],
-                               [ 'actor' => [ 'LEFT JOIN', 'ls_value = ' . $dbw->buildStringCast( 'actor_user' ) ] ]
-                       );
-                       if ( !$res->numRows() ) {
-                               break;
-                       }
-
-                       // Update the rows
-                       $del = [];
-                       foreach ( $res as $row ) {
-                               $lastRow = $row;
-                               if ( !$row->actor_id ) {
-                                       list( , $display ) = $this->makeNextCond( $dbw, $primaryKey, $row );
-                                       $this->error( "No actor for row with $display\n" );
-                                       $countErrors++;
-                                       continue;
-                               }
-                               $dbw->update(
-                                       'log_search',
-                                       [
-                                               'ls_field' => 'target_author_actor',
-                                               'ls_value' => $row->actor_id,
-                                       ],
-                                       [
-                                               'ls_field' => $row->ls_field,
-                                               'ls_value' => $row->ls_value,
-                                       ],
-                                       __METHOD__,
-                                       [ 'IGNORE' ]
-                               );
-                               $countUpdated += $dbw->affectedRows();
-                               $del[] = $row->ls_value;
-                       }
-                       if ( $del ) {
-                               $dbw->delete(
-                                       'log_search', [ 'ls_field' => 'target_author_id', 'ls_value' => $del ], __METHOD__
-                               );
-                               $countUpdated += $dbw->affectedRows();
-                       }
-
-                       list( $next, $display ) = $this->makeNextCond( $dbw, $primaryKey, $lastRow );
-                       $this->output( "... $display\n" );
-                       wfWaitForSlaves();
-               }
-
-               $next = '1=1';
-               while ( true ) {
-                       // Fetch the rows needing update
-                       $res = $dbw->select(
-                               [
-                                       'ls' => $dbw->buildSelectSubquery(
-                                               'log_search',
-                                               'ls_value',
-                                               [
-                                                       'ls_field' => 'target_author_ip',
-                                                       $next
-                                               ],
-                                               __METHOD__,
-                                               [
-                                                       'DISTINCT',
-                                                       'ORDER BY' => [ 'ls_value' ],
-                                                       'LIMIT' => $this->mBatchSize,
-                                               ]
-                                       ),
-                                       'actor'
-                               ],
-                               [
-                                       'ls_field' => $dbw->addQuotes( 'target_author_ip' ),
-                                       'ls_value',
-                                       'actor_id'
-                               ],
-                               [],
-                               __METHOD__,
-                               [],
-                               [ 'actor' => [ 'LEFT JOIN', 'ls_value = actor_name' ] ]
-                       );
-                       if ( !$res->numRows() ) {
-                               break;
-                       }
-
-                       // Insert new actors for rows that need one
-                       $rows = iterator_to_array( $res );
-                       $lastRow = end( $rows );
-                       $countActors += $this->addActorsForRows(
-                               $dbw, 'ls_value', $rows, $complainedAboutUsers, $countErrors
-                       );
-
-                       // Update the rows
-                       $del = [];
-                       foreach ( $rows as $row ) {
-                               if ( !$row->actor_id ) {
-                                       list( , $display ) = $this->makeNextCond( $dbw, $primaryKey, $row );
-                                       $this->error( "Could not make actor for row with $display\n" );
-                                       $countErrors++;
-                                       continue;
-                               }
-                               $dbw->update(
-                                       'log_search',
-                                       [
-                                               'ls_field' => 'target_author_actor',
-                                               'ls_value' => $row->actor_id,
-                                       ],
-                                       [
-                                               'ls_field' => $row->ls_field,
-                                               'ls_value' => $row->ls_value,
-                                       ],
-                                       __METHOD__,
-                                       [ 'IGNORE' ]
-                               );
-                               $countUpdated += $dbw->affectedRows();
-                               $del[] = $row->ls_value;
-                       }
-                       if ( $del ) {
-                               $dbw->delete(
-                                       'log_search', [ 'ls_field' => 'target_author_ip', 'ls_value' => $del ], __METHOD__
-                               );
-                               $countUpdated += $dbw->affectedRows();
-                       }
-
-                       list( $next, $display ) = $this->makeNextCond( $dbw, $primaryKey, $lastRow );
-                       $this->output( "... $display\n" );
-                       wfWaitForSlaves();
-               }
-
-               $this->output(
-                       "Completed migration, updated $countUpdated row(s) with $countActors new actor(s), "
-                       . "$countErrors error(s)\n"
-               );
-               return $countErrors;
-       }
-}
-
-$maintClass = "MigrateActors";
+$maintClass = MigrateActors::class;
 require_once RUN_MAINTENANCE_IF_MAIN;
index ee74bb3..7004102 100644 (file)
@@ -69,8 +69,8 @@ jquery:
 
 jquery.client:
   type: tar
-  src: https://registry.npmjs.org/jquery-client/-/jquery-client-2.0.1.tgz
-  integrity: sha256-tizJojJ55YYdKh67Zj/ho/9IAkDDA2UGKpcNvzn96Zs=
+  src: https://registry.npmjs.org/jquery-client/-/jquery-client-2.0.2.tgz
+  integrity: sha256-8c8nBbBykHEMc4I7ksdKJvvw/P7WkaC2X46RTPdz/pw=
   dest:
     package/AUTHORS.txt:
     package/jquery.client.js:
index e143750..387e74e 100644 (file)
@@ -1,7 +1,8 @@
 {
   "private": true,
   "scripts": {
-    "test": "grunt test",
+    "build": "grunt minify",
+    "test": "grunt lint",
     "qunit": "grunt qunit",
     "doc": "jsduck",
     "postdoc": "grunt copy:jsduck",
@@ -20,7 +21,7 @@
     "grunt-karma": "3.0.0",
     "grunt-stylelint": "0.10.1",
     "grunt-svgmin": "5.0.0",
-    "karma": "3.0.0",
+    "karma": "3.1.4",
     "karma-chrome-launcher": "2.2.0",
     "karma-firefox-launcher": "1.1.0",
     "karma-mocha-reporter": "2.2.5",
index a34634f..d62f3e3 100644 (file)
@@ -1800,17 +1800,20 @@ return [
                ],
        ],
        'mediawiki.rcfilters.filters.dm' => [
-               'scripts' => [
-                       'resources/src/mediawiki.rcfilters/mw.rcfilters.js',
-                       'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js',
-                       'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js',
-                       'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js',
-                       'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js',
-                       'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueryItemModel.js',
-                       'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js',
-                       'resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js',
-                       'resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js',
-                       'resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js',
+               'localBasePath' => "$IP/resources/src/mediawiki.rcfilters",
+               'remoteBasePath' => "$wgResourceBasePath/resources/src/mediawiki.rcfilters",
+               'packageFiles' => [
+                       'mw.rcfilters.js',
+                       'Controller.js',
+                       'UriProcessor.js',
+                       'dm/ChangesListViewModel.js',
+                       'dm/FilterGroup.js',
+                       'dm/FilterItem.js',
+                       'dm/FiltersViewModel.js',
+                       'dm/ItemModel.js',
+                       'dm/SavedQueriesModel.js',
+                       'dm/SavedQueryItemModel.js',
+                       'config.json' => [ 'config' => [ 'StructuredChangeFiltersLiveUpdatePollingRate' ] ],
                ],
                'dependencies' => [
                        'mediawiki.String',
@@ -1827,79 +1830,82 @@ return [
                ],
        ],
        'mediawiki.rcfilters.filters.ui' => [
-               'scripts' => [
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.GroupWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CheckboxInputWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ItemMenuOptionWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuOptionWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuSectionOptionWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.TagItemWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagItemWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuHeaderWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MainWrapperWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ViewSwitchWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ValuePickerWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitPopupWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DatePopupWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListItemWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemHighlightButton.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightPopupWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightColorPickerWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.LiveUpdateButtonWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MarkSeenButtonWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RcTopSectionWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTopSectionWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclToOrFromWidget.js',
-                       'resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.WatchlistTopSectionWidget.js',
-                       'resources/src/mediawiki.rcfilters/mw.rcfilters.HighlightColors.js',
-                       'resources/src/mediawiki.rcfilters/mw.rcfilters.init.js',
+               'localBasePath' => "$IP/resources/src/mediawiki.rcfilters",
+               'remoteBasePath' => "$wgResourceBasePath/resources/src/mediawiki.rcfilters",
+               'packageFiles' => [
+                       'mw.rcfilters.init.js',
+                       'HighlightColors.js',
+                       'ui/GroupWidget.js',
+                       'ui/CheckboxInputWidget.js',
+                       'ui/FilterTagMultiselectWidget.js',
+                       'ui/ItemMenuOptionWidget.js',
+                       'ui/FilterMenuOptionWidget.js',
+                       'ui/FilterMenuSectionOptionWidget.js',
+                       'ui/TagItemWidget.js',
+                       'ui/FilterTagItemWidget.js',
+                       'ui/FilterMenuHeaderWidget.js',
+                       'ui/MenuSelectWidget.js',
+                       'ui/MainWrapperWidget.js',
+                       'ui/ViewSwitchWidget.js',
+                       'ui/ValuePickerWidget.js',
+                       'ui/ChangesLimitPopupWidget.js',
+                       'ui/ChangesLimitAndDateButtonWidget.js',
+                       'ui/DatePopupWidget.js',
+                       'ui/FilterWrapperWidget.js',
+                       'ui/ChangesListWrapperWidget.js',
+                       'ui/SavedLinksListWidget.js',
+                       'ui/SavedLinksListItemWidget.js',
+                       'ui/SaveFiltersPopupButtonWidget.js',
+                       'ui/FormWrapperWidget.js',
+                       'ui/FilterItemHighlightButton.js',
+                       'ui/HighlightPopupWidget.js',
+                       'ui/HighlightColorPickerWidget.js',
+                       'ui/LiveUpdateButtonWidget.js',
+                       'ui/MarkSeenButtonWidget.js',
+                       'ui/RcTopSectionWidget.js',
+                       'ui/RclTopSectionWidget.js',
+                       'ui/RclTargetPageWidget.js',
+                       'ui/RclToOrFromWidget.js',
+                       'ui/WatchlistTopSectionWidget.js',
+                       'config.json' => [ 'callback' => 'ChangesListSpecialPage::getRcFiltersConfigVars' ],
                ],
                'styles' => [
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.mixins.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.variables.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.Overlay.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterTagMultiselectWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ItemMenuOptionWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuOptionWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuSectionOptionWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.TagItemWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuHeaderWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.MenuSelectWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ViewSwitchWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ValuePickerWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ChangesLimitPopupWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.DatePopupWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ChangesListWrapperWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.HighlightColorPickerWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemHighlightButton.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListItemWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.LiveUpdateButtonWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RcTopSectionWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RclToOrFromWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.RclTargetPageWidget.less',
-                       'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.WatchlistTopSectionWidget.less',
+                       'styles/mw.rcfilters.mixins.less',
+                       'styles/mw.rcfilters.variables.less',
+                       'styles/mw.rcfilters.ui.less',
+                       'styles/mw.rcfilters.ui.Overlay.less',
+                       'styles/mw.rcfilters.ui.FilterTagMultiselectWidget.less',
+                       'styles/mw.rcfilters.ui.ItemMenuOptionWidget.less',
+                       'styles/mw.rcfilters.ui.FilterMenuOptionWidget.less',
+                       'styles/mw.rcfilters.ui.FilterMenuSectionOptionWidget.less',
+                       'styles/mw.rcfilters.ui.TagItemWidget.less',
+                       'styles/mw.rcfilters.ui.FilterMenuHeaderWidget.less',
+                       'styles/mw.rcfilters.ui.MenuSelectWidget.less',
+                       'styles/mw.rcfilters.ui.ViewSwitchWidget.less',
+                       'styles/mw.rcfilters.ui.ValuePickerWidget.less',
+                       'styles/mw.rcfilters.ui.ChangesLimitPopupWidget.less',
+                       'styles/mw.rcfilters.ui.DatePopupWidget.less',
+                       'styles/mw.rcfilters.ui.FilterWrapperWidget.less',
+                       'styles/mw.rcfilters.ui.ChangesListWrapperWidget.less',
+                       'styles/mw.rcfilters.ui.HighlightColorPickerWidget.less',
+                       'styles/mw.rcfilters.ui.FilterItemHighlightButton.less',
+                       'styles/mw.rcfilters.ui.SavedLinksListWidget.less',
+                       'styles/mw.rcfilters.ui.SavedLinksListItemWidget.less',
+                       'styles/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.less',
+                       'styles/mw.rcfilters.ui.LiveUpdateButtonWidget.less',
+                       'styles/mw.rcfilters.ui.RcTopSectionWidget.less',
+                       'styles/mw.rcfilters.ui.RclToOrFromWidget.less',
+                       'styles/mw.rcfilters.ui.RclTargetPageWidget.less',
+                       'styles/mw.rcfilters.ui.WatchlistTopSectionWidget.less',
                ],
                'skinStyles' => [
                        'vector' => [
-                               'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.Overlay.vector.less',
+                               'styles/mw.rcfilters.ui.Overlay.vector.less',
                        ],
                        'monobook' => [
-                               'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.Overlay.monobook.less',
-                               'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.CapsuleItemWidget.monobook.less',
-                               'resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuOptionWidget.monobook.less',
+                               'styles/mw.rcfilters.ui.Overlay.monobook.less',
+                               'styles/mw.rcfilters.ui.CapsuleItemWidget.monobook.less',
+                               'styles/mw.rcfilters.ui.FilterMenuOptionWidget.monobook.less',
                        ],
                ],
                'messages' => [
index 9f186ca..1061a7d 100644 (file)
@@ -1,9 +1,10 @@
-Trevor Parscal <trevorparscal@gmail.com>
-Timo Tijhof <krinklemail@gmail.com>
-Roan Kattouw <roan.kattouw@gmail.com>
-Derk-Jan Hartman <hartman.wiki@gmail.com>
+Alexander Monk <krenair@gmail.com>
 Bartosz Dziewoński <matma.rex@gmail.com>
-Rob Moen <rmoen@wikimedia.org>
+Brion Vibber <brion@wikimedia.org>
+Derk-Jan Hartman <hartman@videolan.org>
 Ed Sanders <esanders@wikimedia.org>
-Alex Monk <krenair@gmail.com>
 James D. Forrester <jforrester@wikimedia.org>
+Roan Kattouw <roan.kattouw@gmail.com>
+Rob Moen <rmoen@mediawiki.org>
+Timo Tijhof <krinklemail@gmail.com>
+Trevor Parscal <trevorparscal@gmail.com>
index cfe2d29..79f6174 100644 (file)
@@ -1,8 +1,8 @@
 /*!
- * jQuery Client v2.0.1
+ * jQuery Client v2.0.2
  * https://www.mediawiki.org/wiki/JQuery_Client
  *
- * Copyright 2010-2015 jquery-client maintainers and other contributors.
+ * Copyright 2010-2019 jquery-client maintainers and other contributors.
  * Released under the MIT license
  * http://jquery-client.mit-license.org
  */
@@ -13,7 +13,7 @@
  * @class jQuery.client
  * @singleton
  */
-( function ( $ ) {
+( function () {
 
        /**
         * @private
@@ -51,6 +51,7 @@
                                return profileCache[ nav.userAgent + '|' + nav.platform ];
                        }
 
+                       // eslint-disable-next-line vars-on-top
                        var
                                versionNumber,
                                key = nav.userAgent + '|' + nav.platform,
                                        [ 'Minefield', 'Firefox' ],
                                        // This helps keep different versions consistent
                                        [ 'Navigator', 'Netscape' ],
-                                       // This prevents version extraction issues, otherwise translation would happen later
+                                       // This prevents version extraction issues,
+                                       // otherwise translation would happen later
                                        [ 'PLAYSTATION 3', 'PS3' ]
                                ],
-                               // Strings which precede a version number in a user agent string - combined and used as
-                               // match 1 in version detection
+                               // Strings which precede a version number in a user agent string - combined and
+                               // used as match 1 in version detection
                                versionPrefixes = [
                                        'camino', 'chrome', 'firefox', 'iceweasel', 'netscape', 'netscape6', 'opera', 'version', 'konqueror',
                                        'lynx', 'msie', 'safari', 'ps3', 'android'
                                ],
-                               // Used as matches 2, 3 and 4 in version extraction - 3 is used as actual version number
+                               // Used as matches 2, 3 and 4 in version extraction - 3 is used as actual
+                               // version number
                                versionSuffix = '(\\/|\\;?\\s|)([a-z0-9\\.\\+]*?)(\\;|dev|rel|\\)|\\s|$)',
                                // Names of known browsers
                                names = [
                                // Translations for conforming operating system names
                                platformTranslations = [ [ 'sunos', 'solaris' ], [ 'wow64', 'win' ] ],
 
-                               /**
-                                * Performs multiple replacements on a string
-                                * @ignore
-                                */
+                               // Performs multiple replacements on a string
                                translate = function ( source, translations ) {
                                        var i;
                                        for ( i = 0; i < translations.length; i++ ) {
                                platform = uk,
                                version = x;
 
-                       if ( match = new RegExp( '(' + wildUserAgents.join( '|' ) + ')' ).exec( ua ) ) {
-                               // Takes a userAgent string and translates given text into something we can more easily work with
+                       if ( ( match = new RegExp( '(' + wildUserAgents.join( '|' ) + ')' ).exec( ua ) ) ) {
+                               // Takes a userAgent string and translates given text into something we can more
+                               // easily work with
                                ua = translate( ua, userAgentTranslations );
                        }
                        // Everything will be in lowercase from now on
                        ua = ua.toLowerCase();
 
+                       // Firefox Mobile: Remove 'Android' identifier so it matches to 'Firefox' instead
+                       if ( ua.match( /android/ ) && ua.match( /firefox/ ) ) {
+                               ua = ua.replace( new RegExp( 'android' + versionSuffix ), '' );
+                       }
+
                        // Extraction
 
-                       if ( match = new RegExp( '(' + names.join( '|' ) + ')' ).exec( ua ) ) {
+                       if ( ( match = new RegExp( '(' + names.join( '|' ) + ')' ).exec( ua ) ) ) {
                                name = translate( match[ 1 ], nameTranslations );
                        }
-                       if ( match = new RegExp( '(' + layouts.join( '|' ) + ')' ).exec( ua ) ) {
+                       if ( ( match = new RegExp( '(' + layouts.join( '|' ) + ')' ).exec( ua ) ) ) {
                                layout = translate( match[ 1 ], layoutTranslations );
                        }
-                       if ( match = new RegExp( '(' + layoutVersions.join( '|' ) + ')\\/(\\d+)' ).exec( ua ) ) {
+                       if ( ( match = new RegExp( '(' + layoutVersions.join( '|' ) + ')\\/(\\d+)' ).exec( ua ) ) ) {
                                layoutversion = parseInt( match[ 2 ], 10 );
                        }
-                       if ( match = new RegExp( '(' + platforms.join( '|' ) + ')' ).exec( nav.platform.toLowerCase() ) ) {
+                       if ( ( match = new RegExp( '(' + platforms.join( '|' ) + ')' ).exec( nav.platform.toLowerCase() ) ) ) {
                                platform = translate( match[ 1 ], platformTranslations );
                        }
-                       if ( match = new RegExp( '(' + versionPrefixes.join( '|' ) + ')' + versionSuffix ).exec( ua ) ) {
+                       if ( ( match = new RegExp( '(' + versionPrefixes.join( '|' ) + ')' + versionSuffix ).exec( ua ) ) ) {
                                version = match[ 3 ];
                        }
 
                                layoutversion = parseInt( match[ 1 ], 10 );
                        }
                        // And Amazon Silk's lies about being Android on mobile or Safari on desktop
-                       if ( match = ua.match( /\bsilk\/([0-9.\-_]*)/ ) ) {
+                       if ( ( match = ua.match( /\bsilk\/([0-9.\-_]*)/ ) ) ) {
                                if ( match[ 1 ] ) {
                                        name = 'silk';
                                        version = match[ 1 ];
                        versionNumber = parseFloat( version, 10 ) || 0.0;
 
                        // Caching
-
-                       return profileCache[ key ] = {
+                       profileCache[ key ] = {
                                name: name,
                                layout: layout,
                                layoutVersion: layoutversion,
                                versionBase: ( version !== x ? Math.floor( versionNumber ).toString() : x ),
                                versionNumber: versionNumber
                        };
+
+                       return profileCache[ key ];
                },
 
                /**
                 *
                 * @param {Object} map Browser support map
                 * @param {Object} [profile] A client-profile object
-                * @param {boolean} [exactMatchOnly=false] Only return true if the browser is matched, otherwise
-                * returns true if the browser is not found.
+                * @param {boolean} [exactMatchOnly=false] Only return true if the browser is matched,
+                *  otherwise returns true if the browser is not found.
                 *
                 * @return {boolean} The current browser is in the support map
                 */
                test: function ( map, profile, exactMatchOnly ) {
-                       /* eslint-disable no-eval */
-
                        var conditions, dir, i, op, val, j, pieceVersion, pieceVal, compare;
                        profile = $.isPlainObject( profile ) ? profile : $.client.profile();
                        if ( map.ltr && map.rtl ) {
-                               dir = $( 'body' ).is( '.rtl' ) ? 'rtl' : 'ltr';
+                               dir = $( document.body ).is( '.rtl' ) ? 'rtl' : 'ltr';
                                map = map[ dir ];
                        }
-                       // Check over each browser condition to determine if we are running in a compatible client
+                       // Check over each browser condition to determine if we are running in a
+                       // compatible client
                        if ( typeof map !== 'object' || map[ profile.name ] === undefined ) {
                                // Not found, return true if exactMatchOnly not set, false otherwise
                                return !exactMatchOnly;
                                op = conditions[ i ][ 0 ];
                                val = conditions[ i ][ 1 ];
                                if ( typeof val === 'string' ) {
-                                       // Perform a component-wise comparison of versions, similar to PHP's version_compare
-                                       // but simpler. '1.11' is larger than '1.2'.
+                                       // Perform a component-wise comparison of versions, similar to
+                                       // PHP's version_compare but simpler. '1.11' is larger than '1.2'.
                                        pieceVersion = profile.version.toString().split( '.' );
                                        pieceVal = val.split( '.' );
                                        // Extend with zeroes to equal length
                                                }
                                        }
                                        // compare will be -1, 0 or 1, depending on comparison result
+                                       // eslint-disable-next-line no-eval
                                        if ( !( eval( String( compare + op + '0' ) ) ) ) {
                                                return false;
                                        }
                                } else if ( typeof val === 'number' ) {
+                                       // eslint-disable-next-line no-eval
                                        if ( !( eval( 'profile.versionNumber' + op + val ) ) ) {
                                                return false;
                                        }
                        return true;
                }
        };
-}( jQuery ) );
+}() );
index 9974e2b..24806b5 100644 (file)
                        fileReader = new FileReader();
                        fileReader.onload = function () {
                                var fileStr, arr, i, metadata,
-                                       jpegmeta = mw.loader.require( 'mediawiki.libs.jpegmeta' );
+                                       jpegmeta = require( 'mediawiki.libs.jpegmeta' );
 
                                if ( typeof fileReader.result === 'string' ) {
                                        fileStr = fileReader.result;
index 8fed695..277034b 100644 (file)
                        return text;
                },
 
-               setSpecialCharacters: function ( data ) {
-                       this.specialCharacters = data;
-               },
-
                /**
                 * Formats language tags according the BCP 47 standard.
                 * See LanguageCode::bcp47 for the PHP implementation.
index ba8a233..6674adb 100644 (file)
@@ -1,5 +1,9 @@
 ( function () {
        var specialCharacters = require( './specialcharacters.json' );
-       mw.language.setSpecialCharacters( specialCharacters );
+       // Deprecated since 1.33
+       mw.log.deprecate( mw.language, 'specialCharacters', specialCharacters,
+               'Use require( \'mediawiki.language.specialCharacters\' ) instead',
+               'mw.language.specialCharacters'
+       );
        module.exports = specialCharacters;
 }() );
index a28bd8f..849e8f2 100644 (file)
@@ -1,12 +1,6 @@
 /* global JpegMeta */
-( function () {
 
-       // Export as module
-       module.exports = function ( fileReaderResult, fileName ) {
-               return new JpegMeta.JpegFile( fileReaderResult, fileName );
-       };
-
-       // Back-compat: Also expose via mw.lib
-       // @deprecated since 1.31
-       mw.log.deprecate( mw.libs, 'jpegmeta', module.exports );
-}() );
+// Export as module
+module.exports = function ( fileReaderResult, fileName ) {
+       return new JpegMeta.JpegFile( fileReaderResult, fileName );
+};
diff --git a/resources/src/mediawiki.rcfilters/Controller.js b/resources/src/mediawiki.rcfilters/Controller.js
new file mode 100644 (file)
index 0000000..56a95eb
--- /dev/null
@@ -0,0 +1,1230 @@
+( function () {
+
+       var byteLength = require( 'mediawiki.String' ).byteLength,
+               UriProcessor = require( './UriProcessor.js' ),
+               Controller;
+
+       /* eslint no-underscore-dangle: "off" */
+       /**
+        * Controller for the filters in Recent Changes
+        * @class mw.rcfilters.Controller
+        *
+        * @constructor
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
+        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel Changes list view model
+        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+        * @param {Object} config Additional configuration
+        * @cfg {string} savedQueriesPreferenceName Where to save the saved queries
+        * @cfg {string} daysPreferenceName Preference name for the days filter
+        * @cfg {string} limitPreferenceName Preference name for the limit filter
+        * @cfg {string} collapsedPreferenceName Preference name for collapsing and showing
+        *  the active filters area
+        * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
+        *  title normalization to separate title subpage/parts into the target= url
+        *  parameter
+        */
+       Controller = function MwRcfiltersController( filtersModel, changesListModel, savedQueriesModel, config ) {
+               this.filtersModel = filtersModel;
+               this.changesListModel = changesListModel;
+               this.savedQueriesModel = savedQueriesModel;
+               this.savedQueriesPreferenceName = config.savedQueriesPreferenceName;
+               this.daysPreferenceName = config.daysPreferenceName;
+               this.limitPreferenceName = config.limitPreferenceName;
+               this.collapsedPreferenceName = config.collapsedPreferenceName;
+               this.normalizeTarget = !!config.normalizeTarget;
+
+               this.pollingRate = require( './config.json' ).StructuredChangeFiltersLiveUpdatePollingRate;
+
+               this.requestCounter = {};
+               this.baseFilterState = {};
+               this.uriProcessor = null;
+               this.initialized = false;
+               this.wereSavedQueriesSaved = false;
+
+               this.prevLoggedItems = [];
+
+               this.FILTER_CHANGE = 'filterChange';
+               this.SHOW_NEW_CHANGES = 'showNewChanges';
+               this.LIVE_UPDATE = 'liveUpdate';
+       };
+
+       /* Initialization */
+       OO.initClass( Controller );
+
+       /**
+        * Initialize the filter and parameter states
+        *
+        * @param {Array} filterStructure Filter definition and structure for the model
+        * @param {Object} [namespaceStructure] Namespace definition
+        * @param {Object} [tagList] Tag definition
+        * @param {Object} [conditionalViews] Conditional view definition
+        */
+       Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList, conditionalViews ) {
+               var parsedSavedQueries, pieces,
+                       displayConfig = mw.config.get( 'StructuredChangeFiltersDisplayConfig' ),
+                       defaultSavedQueryExists = mw.config.get( 'wgStructuredChangeFiltersDefaultSavedQueryExists' ),
+                       controller = this,
+                       views = $.extend( true, {}, conditionalViews ),
+                       items = [],
+                       uri = new mw.Uri();
+
+               // Prepare views
+               if ( namespaceStructure ) {
+                       items = [];
+                       // eslint-disable-next-line jquery/no-each-util
+                       $.each( namespaceStructure, function ( namespaceID, label ) {
+                               // Build and clean up the individual namespace items definition
+                               items.push( {
+                                       name: namespaceID,
+                                       label: label || mw.msg( 'blanknamespace' ),
+                                       description: '',
+                                       identifiers: [
+                                               mw.Title.isTalkNamespace( namespaceID ) ?
+                                                       'talk' : 'subject'
+                                       ],
+                                       cssClass: 'mw-changeslist-ns-' + namespaceID
+                               } );
+                       } );
+
+                       views.namespaces = {
+                               title: mw.msg( 'namespaces' ),
+                               trigger: ':',
+                               groups: [ {
+                                       // Group definition (single group)
+                                       name: 'namespace', // parameter name is singular
+                                       type: 'string_options',
+                                       title: mw.msg( 'namespaces' ),
+                                       labelPrefixKey: { default: 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
+                                       separator: ';',
+                                       fullCoverage: true,
+                                       filters: items
+                               } ]
+                       };
+                       views.invert = {
+                               groups: [
+                                       {
+                                               name: 'invertGroup',
+                                               type: 'boolean',
+                                               hidden: true,
+                                               filters: [ {
+                                                       name: 'invert',
+                                                       default: '0'
+                                               } ]
+                                       } ]
+                       };
+               }
+               if ( tagList ) {
+                       views.tags = {
+                               title: mw.msg( 'rcfilters-view-tags' ),
+                               trigger: '#',
+                               groups: [ {
+                                       // Group definition (single group)
+                                       name: 'tagfilter', // Parameter name
+                                       type: 'string_options',
+                                       title: 'rcfilters-view-tags', // Message key
+                                       labelPrefixKey: 'rcfilters-tag-prefix-tags',
+                                       separator: '|',
+                                       fullCoverage: false,
+                                       filters: tagList
+                               } ]
+                       };
+               }
+
+               // Add parameter range operations
+               views.range = {
+                       groups: [
+                               {
+                                       name: 'limit',
+                                       type: 'single_option',
+                                       title: '', // Because it's a hidden group, this title actually appears nowhere
+                                       hidden: true,
+                                       allowArbitrary: true,
+                                       // FIXME: $.isNumeric is deprecated
+                                       validate: $.isNumeric,
+                                       range: {
+                                               min: 0, // The server normalizes negative numbers to 0 results
+                                               max: 1000
+                                       },
+                                       sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
+                                       default: mw.user.options.get( this.limitPreferenceName, displayConfig.limitDefault ),
+                                       sticky: true,
+                                       filters: displayConfig.limitArray.map( function ( num ) {
+                                               return controller._createFilterDataFromNumber( num, num );
+                                       } )
+                               },
+                               {
+                                       name: 'days',
+                                       type: 'single_option',
+                                       title: '', // Because it's a hidden group, this title actually appears nowhere
+                                       hidden: true,
+                                       allowArbitrary: true,
+                                       // FIXME: $.isNumeric is deprecated
+                                       validate: $.isNumeric,
+                                       range: {
+                                               min: 0,
+                                               max: displayConfig.maxDays
+                                       },
+                                       sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
+                                       numToLabelFunc: function ( i ) {
+                                               return Number( i ) < 1 ?
+                                                       ( Number( i ) * 24 ).toFixed( 2 ) :
+                                                       Number( i );
+                                       },
+                                       default: mw.user.options.get( this.daysPreferenceName, displayConfig.daysDefault ),
+                                       sticky: true,
+                                       filters: [
+                                               // Hours (1, 2, 6, 12)
+                                               0.04166, 0.0833, 0.25, 0.5
+                                       // Days
+                                       ].concat( displayConfig.daysArray )
+                                               .map( function ( num ) {
+                                                       return controller._createFilterDataFromNumber(
+                                                               num,
+                                                               // Convert fractions of days to number of hours for the labels
+                                                               num < 1 ? Math.round( num * 24 ) : num
+                                                       );
+                                               } )
+                               }
+                       ]
+               };
+
+               views.display = {
+                       groups: [
+                               {
+                                       name: 'display',
+                                       type: 'boolean',
+                                       title: '', // Because it's a hidden group, this title actually appears nowhere
+                                       hidden: true,
+                                       sticky: true,
+                                       filters: [
+                                               {
+                                                       name: 'enhanced',
+                                                       default: String( mw.user.options.get( 'usenewrc', 0 ) )
+                                               }
+                                       ]
+                               }
+                       ]
+               };
+
+               // Before we do anything, we need to see if we require additional items in the
+               // groups that have 'AllowArbitrary'. For the moment, those are only single_option
+               // groups; if we ever expand it, this might need further generalization:
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( views, function ( viewName, viewData ) {
+                       viewData.groups.forEach( function ( groupData ) {
+                               var extraValues = [];
+                               if ( groupData.allowArbitrary ) {
+                                       // If the value in the URI isn't in the group, add it
+                                       if ( uri.query[ groupData.name ] !== undefined ) {
+                                               extraValues.push( uri.query[ groupData.name ] );
+                                       }
+                                       // If the default value isn't in the group, add it
+                                       if ( groupData.default !== undefined ) {
+                                               extraValues.push( String( groupData.default ) );
+                                       }
+                                       controller.addNumberValuesToGroup( groupData, extraValues );
+                               }
+                       } );
+               } );
+
+               // Initialize the model
+               this.filtersModel.initializeFilters( filterStructure, views );
+
+               this.uriProcessor = new UriProcessor(
+                       this.filtersModel,
+                       { normalizeTarget: this.normalizeTarget }
+               );
+
+               if ( !mw.user.isAnon() ) {
+                       try {
+                               parsedSavedQueries = JSON.parse( mw.user.options.get( this.savedQueriesPreferenceName ) || '{}' );
+                       } catch ( err ) {
+                               parsedSavedQueries = {};
+                       }
+
+                       // Initialize saved queries
+                       this.savedQueriesModel.initialize( parsedSavedQueries );
+                       if ( this.savedQueriesModel.isConverted() ) {
+                               // Since we know we converted, we're going to re-save
+                               // the queries so they are now migrated to the new format
+                               this._saveSavedQueries();
+                       }
+               }
+
+               if ( defaultSavedQueryExists ) {
+                       // This came from the server, meaning that we have a default
+                       // saved query, but the server could not load it, probably because
+                       // it was pre-conversion to the new format.
+                       // We need to load this query again
+                       this.applySavedQuery( this.savedQueriesModel.getDefault() );
+               } else {
+                       // There are either recognized parameters in the URL
+                       // or there are none, but there is also no default
+                       // saved query (so defaults are from the backend)
+                       // We want to update the state but not fetch results
+                       // again
+                       this.updateStateFromUrl( false );
+
+                       pieces = this._extractChangesListInfo( $( '#mw-content-text' ) );
+
+                       // Update the changes list with the existing data
+                       // so it gets processed
+                       this.changesListModel.update(
+                               pieces.changes,
+                               pieces.fieldset,
+                               pieces.noResultsDetails,
+                               true // We're using existing DOM elements
+                       );
+               }
+
+               this.initialized = true;
+               this.switchView( 'default' );
+
+               if ( this.pollingRate ) {
+                       this._scheduleLiveUpdate();
+               }
+       };
+
+       /**
+        * Check if the controller has finished initializing.
+        * @return {boolean} Controller is initialized
+        */
+       Controller.prototype.isInitialized = function () {
+               return this.initialized;
+       };
+
+       /**
+        * Extracts information from the changes list DOM
+        *
+        * @param {jQuery} $root Root DOM to find children from
+        * @param {boolean} [statusCode] Server response status code
+        * @return {Object} Information about changes list
+        * @return {Object|string} return.changes Changes list, or 'NO_RESULTS' if there are no results
+        *   (either normally or as an error)
+        * @return {string} [return.noResultsDetails] 'NO_RESULTS_NORMAL' for a normal 0-result set,
+        *   'NO_RESULTS_TIMEOUT' for no results due to a timeout, or omitted for more than 0 results
+        * @return {jQuery} return.fieldset Fieldset
+        */
+       Controller.prototype._extractChangesListInfo = function ( $root, statusCode ) {
+               var info,
+                       $changesListContents = $root.find( '.mw-changeslist' ).first().contents(),
+                       areResults = !!$changesListContents.length,
+                       checkForLogout = !areResults && statusCode === 200;
+
+               // We check if user logged out on different tab/browser or the session has expired.
+               // 205 status code returned from the server, which indicates that we need to reload the page
+               // is not usable on WL page, because we get redirected to login page, which gives 200 OK
+               // status code (if everything else goes well).
+               // Bug: T177717
+               if ( checkForLogout && !!$root.find( '#wpName1' ).length ) {
+                       location.reload( false );
+                       return;
+               }
+
+               info = {
+                       changes: $changesListContents.length ? $changesListContents : 'NO_RESULTS',
+                       fieldset: $root.find( 'fieldset.cloptions' ).first()
+               };
+
+               if ( !areResults ) {
+                       if ( $root.find( '.mw-changeslist-timeout' ).length ) {
+                               info.noResultsDetails = 'NO_RESULTS_TIMEOUT';
+                       } else if ( $root.find( '.mw-changeslist-notargetpage' ).length ) {
+                               info.noResultsDetails = 'NO_RESULTS_NO_TARGET_PAGE';
+                       } else if ( $root.find( '.mw-changeslist-invalidtargetpage' ).length ) {
+                               info.noResultsDetails = 'NO_RESULTS_INVALID_TARGET_PAGE';
+                       } else {
+                               info.noResultsDetails = 'NO_RESULTS_NORMAL';
+                       }
+               }
+
+               return info;
+       };
+
+       /**
+        * Create filter data from a number, for the filters that are numerical value
+        *
+        * @param {number} num Number
+        * @param {number} numForDisplay Number for the label
+        * @return {Object} Filter data
+        */
+       Controller.prototype._createFilterDataFromNumber = function ( num, numForDisplay ) {
+               return {
+                       name: String( num ),
+                       label: mw.language.convertNumber( numForDisplay )
+               };
+       };
+
+       /**
+        * Add an arbitrary values to groups that allow arbitrary values
+        *
+        * @param {Object} groupData Group data
+        * @param {string|string[]} arbitraryValues An array of arbitrary values to add to the group
+        */
+       Controller.prototype.addNumberValuesToGroup = function ( groupData, arbitraryValues ) {
+               var controller = this,
+                       normalizeWithinRange = function ( range, val ) {
+                               if ( val < range.min ) {
+                                       return range.min; // Min
+                               } else if ( val >= range.max ) {
+                                       return range.max; // Max
+                               }
+                               return val;
+                       };
+
+               arbitraryValues = Array.isArray( arbitraryValues ) ? arbitraryValues : [ arbitraryValues ];
+
+               // Normalize the arbitrary values and the default value for a range
+               if ( groupData.range ) {
+                       arbitraryValues = arbitraryValues.map( function ( val ) {
+                               return normalizeWithinRange( groupData.range, val );
+                       } );
+
+                       // Normalize the default, since that's user defined
+                       if ( groupData.default !== undefined ) {
+                               groupData.default = String( normalizeWithinRange( groupData.range, groupData.default ) );
+                       }
+               }
+
+               // This is only true for single_option group
+               // We assume these are the only groups that will allow for
+               // arbitrary, since it doesn't make any sense for the other
+               // groups.
+               arbitraryValues.forEach( function ( val ) {
+                       if (
+                               // If the group allows for arbitrary data
+                               groupData.allowArbitrary &&
+                               // and it is single_option (or string_options, but we
+                               // don't have cases of those yet, nor do we plan to)
+                               groupData.type === 'single_option' &&
+                               // and, if there is a validate method and it passes on
+                               // the data
+                               ( !groupData.validate || groupData.validate( val ) ) &&
+                               // but if that value isn't already in the definition
+                               groupData.filters
+                                       .map( function ( filterData ) {
+                                               return String( filterData.name );
+                                       } )
+                                       .indexOf( String( val ) ) === -1
+                       ) {
+                               // Add the filter information
+                               groupData.filters.push( controller._createFilterDataFromNumber(
+                                       val,
+                                       groupData.numToLabelFunc ?
+                                               groupData.numToLabelFunc( val ) :
+                                               val
+                               ) );
+
+                               // If there's a sort function set up, re-sort the values
+                               if ( groupData.sortFunc ) {
+                                       groupData.filters.sort( groupData.sortFunc );
+                               }
+                       }
+               } );
+       };
+
+       /**
+        * Reset to default filters
+        */
+       Controller.prototype.resetToDefaults = function () {
+               var params = this._getDefaultParams();
+               if ( this.applyParamChange( params ) ) {
+                       // Only update the changes list if there was a change to actual filters
+                       this.updateChangesList();
+               } else {
+                       this.uriProcessor.updateURL( params );
+               }
+       };
+
+       /**
+        * Check whether the default values of the filters are all false.
+        *
+        * @return {boolean} Defaults are all false
+        */
+       Controller.prototype.areDefaultsEmpty = function () {
+               return $.isEmptyObject( this._getDefaultParams() );
+       };
+
+       /**
+        * Empty all selected filters
+        */
+       Controller.prototype.emptyFilters = function () {
+               var highlightedFilterNames = this.filtersModel.getHighlightedItems()
+                       .map( function ( filterItem ) { return { name: filterItem.getName() }; } );
+
+               if ( this.applyParamChange( {} ) ) {
+                       // Only update the changes list if there was a change to actual filters
+                       this.updateChangesList();
+               } else {
+                       this.uriProcessor.updateURL();
+               }
+
+               if ( highlightedFilterNames ) {
+                       this._trackHighlight( 'clearAll', highlightedFilterNames );
+               }
+       };
+
+       /**
+        * Update the selected state of a filter
+        *
+        * @param {string} filterName Filter name
+        * @param {boolean} [isSelected] Filter selected state
+        */
+       Controller.prototype.toggleFilterSelect = function ( filterName, isSelected ) {
+               var filterItem = this.filtersModel.getItemByName( filterName );
+
+               if ( !filterItem ) {
+                       // If no filter was found, break
+                       return;
+               }
+
+               isSelected = isSelected === undefined ? !filterItem.isSelected() : isSelected;
+
+               if ( filterItem.isSelected() !== isSelected ) {
+                       this.filtersModel.toggleFilterSelected( filterName, isSelected );
+
+                       this.updateChangesList();
+
+                       // Check filter interactions
+                       this.filtersModel.reassessFilterInteractions( filterItem );
+               }
+       };
+
+       /**
+        * Clear both highlight and selection of a filter
+        *
+        * @param {string} filterName Name of the filter item
+        */
+       Controller.prototype.clearFilter = function ( filterName ) {
+               var filterItem = this.filtersModel.getItemByName( filterName ),
+                       isHighlighted = filterItem.isHighlighted(),
+                       isSelected = filterItem.isSelected();
+
+               if ( isSelected || isHighlighted ) {
+                       this.filtersModel.clearHighlightColor( filterName );
+                       this.filtersModel.toggleFilterSelected( filterName, false );
+
+                       if ( isSelected ) {
+                               // Only update the changes list if the filter changed
+                               // its selection state. If it only changed its highlight
+                               // then don't reload
+                               this.updateChangesList();
+                       }
+
+                       this.filtersModel.reassessFilterInteractions( filterItem );
+
+                       // Log filter grouping
+                       this.trackFilterGroupings( 'removefilter' );
+               }
+
+               if ( isHighlighted ) {
+                       this._trackHighlight( 'clear', filterName );
+               }
+       };
+
+       /**
+        * Toggle the highlight feature on and off
+        */
+       Controller.prototype.toggleHighlight = function () {
+               this.filtersModel.toggleHighlight();
+               this.uriProcessor.updateURL();
+
+               if ( this.filtersModel.isHighlightEnabled() ) {
+                       mw.hook( 'RcFilters.highlight.enable' ).fire();
+               }
+       };
+
+       /**
+        * Toggle the namespaces inverted feature on and off
+        */
+       Controller.prototype.toggleInvertedNamespaces = function () {
+               this.filtersModel.toggleInvertedNamespaces();
+               if (
+                       this.filtersModel.getFiltersByView( 'namespaces' ).filter(
+                               function ( filterItem ) { return filterItem.isSelected(); }
+                       ).length
+               ) {
+                       // Only re-fetch results if there are namespace items that are actually selected
+                       this.updateChangesList();
+               } else {
+                       this.uriProcessor.updateURL();
+               }
+       };
+
+       /**
+        * Set the value of the 'showlinkedto' parameter
+        * @param {boolean} value
+        */
+       Controller.prototype.setShowLinkedTo = function ( value ) {
+               var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' ),
+                       showLinkedToItem = this.filtersModel.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' );
+
+               this.filtersModel.toggleFilterSelected( showLinkedToItem.getName(), value );
+               this.uriProcessor.updateURL();
+               // reload the results only when target is set
+               if ( targetItem.getValue() ) {
+                       this.updateChangesList();
+               }
+       };
+
+       /**
+        * Set the target page
+        * @param {string} page
+        */
+       Controller.prototype.setTargetPage = function ( page ) {
+               var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' );
+               targetItem.setValue( page );
+               this.uriProcessor.updateURL();
+               this.updateChangesList();
+       };
+
+       /**
+        * Set the highlight color for a filter item
+        *
+        * @param {string} filterName Name of the filter item
+        * @param {string} color Selected color
+        */
+       Controller.prototype.setHighlightColor = function ( filterName, color ) {
+               this.filtersModel.setHighlightColor( filterName, color );
+               this.uriProcessor.updateURL();
+               this._trackHighlight( 'set', { name: filterName, color: color } );
+       };
+
+       /**
+        * Clear highlight for a filter item
+        *
+        * @param {string} filterName Name of the filter item
+        */
+       Controller.prototype.clearHighlightColor = function ( filterName ) {
+               this.filtersModel.clearHighlightColor( filterName );
+               this.uriProcessor.updateURL();
+               this._trackHighlight( 'clear', filterName );
+       };
+
+       /**
+        * Enable or disable live updates.
+        * @param {boolean} enable True to enable, false to disable
+        */
+       Controller.prototype.toggleLiveUpdate = function ( enable ) {
+               this.changesListModel.toggleLiveUpdate( enable );
+               if ( this.changesListModel.getLiveUpdate() && this.changesListModel.getNewChangesExist() ) {
+                       this.updateChangesList( null, this.LIVE_UPDATE );
+               }
+       };
+
+       /**
+        * Set a timeout for the next live update.
+        * @private
+        */
+       Controller.prototype._scheduleLiveUpdate = function () {
+               setTimeout( this._doLiveUpdate.bind( this ), this.pollingRate * 1000 );
+       };
+
+       /**
+        * Perform a live update.
+        * @private
+        */
+       Controller.prototype._doLiveUpdate = function () {
+               if ( !this._shouldCheckForNewChanges() ) {
+                       // skip this turn and check back later
+                       this._scheduleLiveUpdate();
+                       return;
+               }
+
+               this._checkForNewChanges()
+                       .then( function ( statusCode ) {
+                               // no result is 204 with the 'peek' param
+                               // logged out is 205
+                               var newChanges = statusCode === 200;
+
+                               if ( !this._shouldCheckForNewChanges() ) {
+                                       // by the time the response is received,
+                                       // it may not be appropriate anymore
+                                       return;
+                               }
+
+                               // 205 is the status code returned from server when user's logged in/out
+                               // status is not matching while fetching live update changes.
+                               // This works only on Recent Changes page. For WL, look _extractChangesListInfo.
+                               // Bug: T177717
+                               if ( statusCode === 205 ) {
+                                       location.reload( false );
+                                       return;
+                               }
+
+                               if ( newChanges ) {
+                                       if ( this.changesListModel.getLiveUpdate() ) {
+                                               return this.updateChangesList( null, this.LIVE_UPDATE );
+                                       } else {
+                                               this.changesListModel.setNewChangesExist( true );
+                                       }
+                               }
+                       }.bind( this ) )
+                       .always( this._scheduleLiveUpdate.bind( this ) );
+       };
+
+       /**
+        * @return {boolean} It's appropriate to check for new changes now
+        * @private
+        */
+       Controller.prototype._shouldCheckForNewChanges = function () {
+               return !document.hidden &&
+                       !this.filtersModel.hasConflict() &&
+                       !this.changesListModel.getNewChangesExist() &&
+                       !this.updatingChangesList &&
+                       this.changesListModel.getNextFrom();
+       };
+
+       /**
+        * Check if new changes, newer than those currently shown, are available
+        *
+        * @return {jQuery.Promise} Promise object that resolves with a bool
+        *   specifying if there are new changes or not
+        *
+        * @private
+        */
+       Controller.prototype._checkForNewChanges = function () {
+               var params = {
+                       limit: 1,
+                       peek: 1, // bypasses ChangesList specific UI
+                       from: this.changesListModel.getNextFrom(),
+                       isAnon: mw.user.isAnon()
+               };
+               return this._queryChangesList( 'liveUpdate', params ).then(
+                       function ( data ) {
+                               return data.status;
+                       }
+               );
+       };
+
+       /**
+        * Show the new changes
+        *
+        * @return {jQuery.Promise} Promise object that resolves after
+        * fetching and showing the new changes
+        */
+       Controller.prototype.showNewChanges = function () {
+               return this.updateChangesList( null, this.SHOW_NEW_CHANGES );
+       };
+
+       /**
+        * Save the current model state as a saved query
+        *
+        * @param {string} [label] Label of the saved query
+        * @param {boolean} [setAsDefault=false] This query should be set as the default
+        */
+       Controller.prototype.saveCurrentQuery = function ( label, setAsDefault ) {
+               // Add item
+               this.savedQueriesModel.addNewQuery(
+                       label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
+                       this.filtersModel.getCurrentParameterState( true ),
+                       setAsDefault
+               );
+
+               // Save item
+               this._saveSavedQueries();
+       };
+
+       /**
+        * Remove a saved query
+        *
+        * @param {string} queryID Query id
+        */
+       Controller.prototype.removeSavedQuery = function ( queryID ) {
+               this.savedQueriesModel.removeQuery( queryID );
+
+               this._saveSavedQueries();
+       };
+
+       /**
+        * Rename a saved query
+        *
+        * @param {string} queryID Query id
+        * @param {string} newLabel New label for the query
+        */
+       Controller.prototype.renameSavedQuery = function ( queryID, newLabel ) {
+               var queryItem = this.savedQueriesModel.getItemByID( queryID );
+
+               if ( queryItem ) {
+                       queryItem.updateLabel( newLabel );
+               }
+               this._saveSavedQueries();
+       };
+
+       /**
+        * Set a saved query as default
+        *
+        * @param {string} queryID Query Id. If null is given, default
+        *  query is reset.
+        */
+       Controller.prototype.setDefaultSavedQuery = function ( queryID ) {
+               this.savedQueriesModel.setDefault( queryID );
+               this._saveSavedQueries();
+       };
+
+       /**
+        * Load a saved query
+        *
+        * @param {string} queryID Query id
+        */
+       Controller.prototype.applySavedQuery = function ( queryID ) {
+               var currentMatchingQuery,
+                       params = this.savedQueriesModel.getItemParams( queryID );
+
+               currentMatchingQuery = this.findQueryMatchingCurrentState();
+
+               if (
+                       currentMatchingQuery &&
+                       currentMatchingQuery.getID() === queryID
+               ) {
+                       // If the query we want to load is the one that is already
+                       // loaded, don't reload it
+                       return;
+               }
+
+               if ( this.applyParamChange( params ) ) {
+                       // Update changes list only if there was a difference in filter selection
+                       this.updateChangesList();
+               } else {
+                       this.uriProcessor.updateURL( params );
+               }
+
+               // Log filter grouping
+               this.trackFilterGroupings( 'savedfilters' );
+       };
+
+       /**
+        * Check whether the current filter and highlight state exists
+        * in the saved queries model.
+        *
+        * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
+        */
+       Controller.prototype.findQueryMatchingCurrentState = function () {
+               return this.savedQueriesModel.findMatchingQuery(
+                       this.filtersModel.getCurrentParameterState( true )
+               );
+       };
+
+       /**
+        * Save the current state of the saved queries model with all
+        * query item representation in the user settings.
+        */
+       Controller.prototype._saveSavedQueries = function () {
+               var stringified, oldPrefValue,
+                       backupPrefName = this.savedQueriesPreferenceName + '-versionbackup',
+                       state = this.savedQueriesModel.getState();
+
+               // Stringify state
+               stringified = JSON.stringify( state );
+
+               if ( byteLength( stringified ) > 65535 ) {
+                       // Sanity check, since the preference can only hold that.
+                       return;
+               }
+
+               if ( !this.wereSavedQueriesSaved && this.savedQueriesModel.isConverted() ) {
+                       // The queries were converted from the previous version
+                       // Keep the old string in the [prefname]-versionbackup
+                       oldPrefValue = mw.user.options.get( this.savedQueriesPreferenceName );
+
+                       // Save the old preference in the backup preference
+                       new mw.Api().saveOption( backupPrefName, oldPrefValue );
+                       // Update the preference for this session
+                       mw.user.options.set( backupPrefName, oldPrefValue );
+               }
+
+               // Save the preference
+               new mw.Api().saveOption( this.savedQueriesPreferenceName, stringified );
+               // Update the preference for this session
+               mw.user.options.set( this.savedQueriesPreferenceName, stringified );
+
+               // Tag as already saved so we don't do this again
+               this.wereSavedQueriesSaved = true;
+       };
+
+       /**
+        * Update sticky preferences with current model state
+        */
+       Controller.prototype.updateStickyPreferences = function () {
+               // Update default sticky values with selected, whether they came from
+               // the initial defaults or from the URL value that is being normalized
+               this.updateDaysDefault( this.filtersModel.getGroup( 'days' ).findSelectedItems()[ 0 ].getParamName() );
+               this.updateLimitDefault( this.filtersModel.getGroup( 'limit' ).findSelectedItems()[ 0 ].getParamName() );
+
+               // TODO: Make these automatic by having the model go over sticky
+               // items and update their default values automatically
+       };
+
+       /**
+        * Update the limit default value
+        *
+        * @param {number} newValue New value
+        */
+       Controller.prototype.updateLimitDefault = function ( newValue ) {
+               this.updateNumericPreference( this.limitPreferenceName, newValue );
+       };
+
+       /**
+        * Update the days default value
+        *
+        * @param {number} newValue New value
+        */
+       Controller.prototype.updateDaysDefault = function ( newValue ) {
+               this.updateNumericPreference( this.daysPreferenceName, newValue );
+       };
+
+       /**
+        * Update the group by page default value
+        *
+        * @param {boolean} newValue New value
+        */
+       Controller.prototype.updateGroupByPageDefault = function ( newValue ) {
+               this.updateNumericPreference( 'usenewrc', Number( newValue ) );
+       };
+
+       /**
+        * Update the collapsed state value
+        *
+        * @param {boolean} isCollapsed Filter area is collapsed
+        */
+       Controller.prototype.updateCollapsedState = function ( isCollapsed ) {
+               this.updateNumericPreference( this.collapsedPreferenceName, Number( isCollapsed ) );
+       };
+
+       /**
+        * Update a numeric preference with a new value
+        *
+        * @param {string} prefName Preference name
+        * @param {number|string} newValue New value
+        */
+       Controller.prototype.updateNumericPreference = function ( prefName, newValue ) {
+               // FIXME: $.isNumeric is deprecated
+               // eslint-disable-next-line jquery/no-is-numeric
+               if ( !$.isNumeric( newValue ) ) {
+                       return;
+               }
+
+               newValue = Number( newValue );
+
+               if ( mw.user.options.get( prefName ) !== newValue ) {
+                       // Save the preference
+                       new mw.Api().saveOption( prefName, newValue );
+                       // Update the preference for this session
+                       mw.user.options.set( prefName, newValue );
+               }
+       };
+
+       /**
+        * Synchronize the URL with the current state of the filters
+        * without adding an history entry.
+        */
+       Controller.prototype.replaceUrl = function () {
+               this.uriProcessor.updateURL();
+       };
+
+       /**
+        * Update filter state (selection and highlighting) based
+        * on current URL values.
+        *
+        * @param {boolean} [fetchChangesList=true] Fetch new results into the changes
+        *  list based on the updated model.
+        */
+       Controller.prototype.updateStateFromUrl = function ( fetchChangesList ) {
+               fetchChangesList = fetchChangesList === undefined ? true : !!fetchChangesList;
+
+               this.uriProcessor.updateModelBasedOnQuery();
+
+               // Update the sticky preferences, in case we received a value
+               // from the URL
+               this.updateStickyPreferences();
+
+               // Only update and fetch new results if it is requested
+               if ( fetchChangesList ) {
+                       this.updateChangesList();
+               }
+       };
+
+       /**
+        * Update the list of changes and notify the model
+        *
+        * @param {Object} [params] Extra parameters to add to the API call
+        * @param {string} [updateMode='filterChange'] One of 'filterChange', 'liveUpdate', 'showNewChanges', 'markSeen'
+        * @return {jQuery.Promise} Promise that is resolved when the update is complete
+        */
+       Controller.prototype.updateChangesList = function ( params, updateMode ) {
+               updateMode = updateMode === undefined ? this.FILTER_CHANGE : updateMode;
+
+               if ( updateMode === this.FILTER_CHANGE ) {
+                       this.uriProcessor.updateURL( params );
+               }
+               if ( updateMode === this.FILTER_CHANGE || updateMode === this.SHOW_NEW_CHANGES ) {
+                       this.changesListModel.invalidate();
+               }
+               this.changesListModel.setNewChangesExist( false );
+               this.updatingChangesList = true;
+               return this._fetchChangesList()
+                       .then(
+                               // Success
+                               function ( pieces ) {
+                                       var $changesListContent = pieces.changes,
+                                               $fieldset = pieces.fieldset;
+                                       this.changesListModel.update(
+                                               $changesListContent,
+                                               $fieldset,
+                                               pieces.noResultsDetails,
+                                               false,
+                                               // separator between old and new changes
+                                               updateMode === this.SHOW_NEW_CHANGES || updateMode === this.LIVE_UPDATE
+                                       );
+                               }.bind( this )
+                               // Do nothing for failure
+                       )
+                       .always( function () {
+                               this.updatingChangesList = false;
+                       }.bind( this ) );
+       };
+
+       /**
+        * Get an object representing the default parameter state, whether
+        * it is from the model defaults or from the saved queries.
+        *
+        * @return {Object} Default parameters
+        */
+       Controller.prototype._getDefaultParams = function () {
+               if ( this.savedQueriesModel.getDefault() ) {
+                       return this.savedQueriesModel.getDefaultParams();
+               } else {
+                       return this.filtersModel.getDefaultParams();
+               }
+       };
+
+       /**
+        * Query the list of changes from the server for the current filters
+        *
+        * @param {string} counterId Id for this request. To allow concurrent requests
+        *  not to invalidate each other.
+        * @param {Object} [params={}] Parameters to add to the query
+        *
+        * @return {jQuery.Promise} Promise object resolved with { content, status }
+        */
+       Controller.prototype._queryChangesList = function ( counterId, params ) {
+               var uri = this.uriProcessor.getUpdatedUri(),
+                       stickyParams = this.filtersModel.getStickyParamsValues(),
+                       requestId,
+                       latestRequest;
+
+               params = params || {};
+               params.action = 'render'; // bypasses MW chrome
+
+               uri.extend( params );
+
+               this.requestCounter[ counterId ] = this.requestCounter[ counterId ] || 0;
+               requestId = ++this.requestCounter[ counterId ];
+               latestRequest = function () {
+                       return requestId === this.requestCounter[ counterId ];
+               }.bind( this );
+
+               // Sticky parameters override the URL params
+               // this is to make sure that whether we represent
+               // the sticky params in the URL or not (they may
+               // be normalized out) the sticky parameters are
+               // always being sent to the server with their
+               // current/default values
+               uri.extend( stickyParams );
+
+               return $.ajax( uri.toString(), { contentType: 'html' } )
+                       .then(
+                               function ( content, message, jqXHR ) {
+                                       if ( !latestRequest() ) {
+                                               return $.Deferred().reject();
+                                       }
+                                       return {
+                                               content: content,
+                                               status: jqXHR.status
+                                       };
+                               },
+                               // RC returns 404 when there is no results
+                               function ( jqXHR ) {
+                                       if ( latestRequest() ) {
+                                               return $.Deferred().resolve(
+                                                       {
+                                                               content: jqXHR.responseText,
+                                                               status: jqXHR.status
+                                                       }
+                                               ).promise();
+                                       }
+                               }
+                       );
+       };
+
+       /**
+        * Fetch the list of changes from the server for the current filters
+        *
+        * @return {jQuery.Promise} Promise object that will resolve with the changes list
+        *  and the fieldset.
+        */
+       Controller.prototype._fetchChangesList = function () {
+               return this._queryChangesList( 'updateChangesList' )
+                       .then(
+                               function ( data ) {
+                                       var $parsed;
+
+                                       // Status code 0 is not HTTP status code,
+                                       // but is valid value of XMLHttpRequest status.
+                                       // It is used for variety of network errors, for example
+                                       // when an AJAX call was cancelled before getting the response
+                                       if ( data && data.status === 0 ) {
+                                               return {
+                                                       changes: 'NO_RESULTS',
+                                                       // We need empty result set, to avoid exceptions because of undefined value
+                                                       fieldset: $( [] ),
+                                                       noResultsDetails: 'NO_RESULTS_NETWORK_ERROR'
+                                               };
+                                       }
+
+                                       $parsed = $( '<div>' ).append( $( $.parseHTML(
+                                               data ? data.content : ''
+                                       ) ) );
+
+                                       return this._extractChangesListInfo( $parsed, data.status );
+                               }.bind( this )
+                       );
+       };
+
+       /**
+        * Track usage of highlight feature
+        *
+        * @param {string} action
+        * @param {Array|Object|string} filters
+        */
+       Controller.prototype._trackHighlight = function ( action, filters ) {
+               filters = typeof filters === 'string' ? { name: filters } : filters;
+               filters = !Array.isArray( filters ) ? [ filters ] : filters;
+               mw.track(
+                       'event.ChangesListHighlights',
+                       {
+                               action: action,
+                               filters: filters,
+                               userId: mw.user.getId()
+                       }
+               );
+       };
+
+       /**
+        * Track filter grouping usage
+        *
+        * @param {string} action Action taken
+        */
+       Controller.prototype.trackFilterGroupings = function ( action ) {
+               var controller = this,
+                       rightNow = new Date().getTime(),
+                       randomIdentifier = String( mw.user.sessionId() ) + String( rightNow ) + String( Math.random() ),
+                       // Get all current filters
+                       filters = this.filtersModel.findSelectedItems().map( function ( item ) {
+                               return item.getName();
+                       } );
+
+               action = action || 'filtermenu';
+
+               // Check if these filters were the ones we just logged previously
+               // (Don't log the same grouping twice, in case the user opens/closes)
+               // the menu without action, or with the same result
+               if (
+                       // Only log if the two arrays are different in size
+                       filters.length !== this.prevLoggedItems.length ||
+                       // Or if any filters are not the same as the cached filters
+                       filters.some( function ( filterName ) {
+                               return controller.prevLoggedItems.indexOf( filterName ) === -1;
+                       } ) ||
+                       // Or if any cached filters are not the same as given filters
+                       this.prevLoggedItems.some( function ( filterName ) {
+                               return filters.indexOf( filterName ) === -1;
+                       } )
+               ) {
+                       filters.forEach( function ( filterName ) {
+                               mw.track(
+                                       'event.ChangesListFilterGrouping',
+                                       {
+                                               action: action,
+                                               groupIdentifier: randomIdentifier,
+                                               filter: filterName,
+                                               userId: mw.user.getId()
+                                       }
+                               );
+                       } );
+
+                       // Cache the filter names
+                       this.prevLoggedItems = filters;
+               }
+       };
+
+       /**
+        * Apply a change of parameters to the model state, and check whether
+        * the new state is different than the old state.
+        *
+        * @param  {Object} newParamState New parameter state to apply
+        * @return {boolean} New applied model state is different than the previous state
+        */
+       Controller.prototype.applyParamChange = function ( newParamState ) {
+               var after,
+                       before = this.filtersModel.getSelectedState();
+
+               this.filtersModel.updateStateFromParams( newParamState );
+
+               after = this.filtersModel.getSelectedState();
+
+               return !OO.compare( before, after );
+       };
+
+       /**
+        * Mark all changes as seen on Watchlist
+        */
+       Controller.prototype.markAllChangesAsSeen = function () {
+               var api = new mw.Api();
+               api.postWithToken( 'csrf', {
+                       formatversion: 2,
+                       action: 'setnotificationtimestamp',
+                       entirewatchlist: true
+               } ).then( function () {
+                       this.updateChangesList( null, 'markSeen' );
+               }.bind( this ) );
+       };
+
+       /**
+        * Set the current search for the system.
+        *
+        * @param {string} searchQuery Search query, including triggers
+        */
+       Controller.prototype.setSearch = function ( searchQuery ) {
+               this.filtersModel.setSearch( searchQuery );
+       };
+
+       /**
+        * Switch the view by changing the search query trigger
+        * without changing the search term
+        *
+        * @param  {string} view View to change to
+        */
+       Controller.prototype.switchView = function ( view ) {
+               this.setSearch(
+                       this.filtersModel.getViewTrigger( view ) +
+                       this.filtersModel.removeViewTriggers( this.filtersModel.getSearch() )
+               );
+       };
+
+       /**
+        * Reset the search for a specific view. This means we null the search query
+        * and replace it with the relevant trigger for the requested view
+        *
+        * @param  {string} [view='default'] View to change to
+        */
+       Controller.prototype.resetSearchForView = function ( view ) {
+               view = view || 'default';
+
+               this.setSearch(
+                       this.filtersModel.getViewTrigger( view )
+               );
+       };
+
+       module.exports = Controller;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/HighlightColors.js b/resources/src/mediawiki.rcfilters/HighlightColors.js
new file mode 100644 (file)
index 0000000..a4ef73b
--- /dev/null
@@ -0,0 +1,12 @@
+( function () {
+       /**
+        * Supported highlight colors.
+        * Warning: These are also hardcoded in "styles/mw.rcfilters.variables.less"
+        *
+        * @member mw.rcfilters
+        * @property {string[]}
+        */
+       var HighlightColors = [ 'c1', 'c2', 'c3', 'c4', 'c5' ];
+
+       module.exports = HighlightColors;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/UriProcessor.js b/resources/src/mediawiki.rcfilters/UriProcessor.js
new file mode 100644 (file)
index 0000000..37874d5
--- /dev/null
@@ -0,0 +1,296 @@
+( function () {
+       /* eslint no-underscore-dangle: "off" */
+       /**
+        * URI Processor for RCFilters
+        *
+        * @class mw.rcfilters.UriProcessor
+        *
+        * @constructor
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
+        * @param {Object} [config] Configuration object
+        * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
+        *  title normalization to separate title subpage/parts into the target= url
+        *  parameter
+        */
+       var UriProcessor = function MwRcfiltersController( filtersModel, config ) {
+               config = config || {};
+               this.filtersModel = filtersModel;
+
+               this.normalizeTarget = !!config.normalizeTarget;
+       };
+
+       /* Initialization */
+       OO.initClass( UriProcessor );
+
+       /* Static methods */
+
+       /**
+        * Replace the url history through replaceState
+        *
+        * @param {mw.Uri} newUri New URI to replace
+        */
+       UriProcessor.static.replaceState = function ( newUri ) {
+               window.history.replaceState(
+                       { tag: 'rcfilters' },
+                       document.title,
+                       newUri.toString()
+               );
+       };
+
+       /**
+        * Push the url to history through pushState
+        *
+        * @param {mw.Uri} newUri New URI to push
+        */
+       UriProcessor.static.pushState = function ( newUri ) {
+               window.history.pushState(
+                       { tag: 'rcfilters' },
+                       document.title,
+                       newUri.toString()
+               );
+       };
+
+       /* Methods */
+
+       /**
+        * Get the version that this URL query is tagged with.
+        *
+        * @param {Object} [uriQuery] URI query
+        * @return {number} URL version
+        */
+       UriProcessor.prototype.getVersion = function ( uriQuery ) {
+               uriQuery = uriQuery || new mw.Uri().query;
+
+               return Number( uriQuery.urlversion || 1 );
+       };
+
+       /**
+        * Get an updated mw.Uri object based on the model state
+        *
+        * @param {mw.Uri} [uri] An external URI to build the new uri
+        *  with. This is mainly for tests, to be able to supply external query
+        *  parameters and make sure they are retained.
+        * @return {mw.Uri} Updated Uri
+        */
+       UriProcessor.prototype.getUpdatedUri = function ( uri ) {
+               var normalizedUri = this._normalizeTargetInUri( uri || new mw.Uri() ),
+                       unrecognizedParams = this.getUnrecognizedParams( normalizedUri.query );
+
+               normalizedUri.query = this.filtersModel.getMinimizedParamRepresentation(
+                       $.extend(
+                               true,
+                               {},
+                               normalizedUri.query,
+                               // The representation must be expanded so it can
+                               // override the uri query params but we then output
+                               // a minimized version for the entire URI representation
+                               // for the method
+                               this.filtersModel.getExpandedParamRepresentation()
+                       )
+               );
+
+               // Reapply unrecognized params and url version
+               normalizedUri.query = $.extend(
+                       true,
+                       {},
+                       normalizedUri.query,
+                       unrecognizedParams,
+                       { urlversion: '2' }
+               );
+
+               return normalizedUri;
+       };
+
+       /**
+        * Move the subpage to the target parameter
+        *
+        * @param {mw.Uri} uri
+        * @return {mw.Uri}
+        * @private
+        */
+       UriProcessor.prototype._normalizeTargetInUri = function ( uri ) {
+               var parts,
+                       // matches [/wiki/]SpecialNS:RCL/[Namespace:]Title/Subpage/Subsubpage/etc
+                       re = /^((?:\/.+?\/)?.*?:.*?)\/(.*)$/;
+
+               if ( !this.normalizeTarget ) {
+                       return uri;
+               }
+
+               // target in title param
+               if ( uri.query.title ) {
+                       parts = uri.query.title.match( re );
+                       if ( parts ) {
+                               uri.query.title = parts[ 1 ];
+                               uri.query.target = parts[ 2 ];
+                       }
+               }
+
+               // target in path
+               parts = mw.Uri.decode( uri.path ).match( re );
+               if ( parts ) {
+                       uri.path = parts[ 1 ];
+                       uri.query.target = parts[ 2 ];
+               }
+
+               return uri;
+       };
+
+       /**
+        * Get an object representing given parameters that are unrecognized by the model
+        *
+        * @param  {Object} params Full params object
+        * @return {Object} Unrecognized params
+        */
+       UriProcessor.prototype.getUnrecognizedParams = function ( params ) {
+               // Start with full representation
+               var givenParamNames = Object.keys( params ),
+                       unrecognizedParams = $.extend( true, {}, params );
+
+               // Extract unrecognized parameters
+               Object.keys( this.filtersModel.getEmptyParameterState() ).forEach( function ( paramName ) {
+                       // Remove recognized params
+                       if ( givenParamNames.indexOf( paramName ) > -1 ) {
+                               delete unrecognizedParams[ paramName ];
+                       }
+               } );
+
+               return unrecognizedParams;
+       };
+
+       /**
+        * Update the URL of the page to reflect current filters
+        *
+        * This should not be called directly from outside the controller.
+        * If an action requires changing the URL, it should either use the
+        * highlighting actions below, or call #updateChangesList which does
+        * the uri corrections already.
+        *
+        * @param {Object} [params] Extra parameters to add to the API call
+        */
+       UriProcessor.prototype.updateURL = function ( params ) {
+               var currentUri = new mw.Uri(),
+                       updatedUri = this.getUpdatedUri();
+
+               updatedUri.extend( params || {} );
+
+               if (
+                       this.getVersion( currentUri.query ) !== 2 ||
+                       this.isNewState( currentUri.query, updatedUri.query )
+               ) {
+                       this.constructor.static.replaceState( updatedUri );
+               }
+       };
+
+       /**
+        * Update the filters model based on the URI query
+        * This happens on initialization, and from this moment on,
+        * we consider the system synchronized, and the model serves
+        * as the source of truth for the URL.
+        *
+        * This methods should only be called once on initialization.
+        * After initialization, the model updates the URL, not the
+        * other way around.
+        *
+        * @param {Object} [uriQuery] URI query
+        */
+       UriProcessor.prototype.updateModelBasedOnQuery = function ( uriQuery ) {
+               uriQuery = uriQuery || this._normalizeTargetInUri( new mw.Uri() ).query;
+               this.filtersModel.updateStateFromParams(
+                       this._getNormalizedQueryParams( uriQuery )
+               );
+       };
+
+       /**
+        * Compare two URI queries to decide whether they are different
+        * enough to represent a new state.
+        *
+        * @param {Object} currentUriQuery Current Uri query
+        * @param {Object} updatedUriQuery Updated Uri query
+        * @return {boolean} This is a new state
+        */
+       UriProcessor.prototype.isNewState = function ( currentUriQuery, updatedUriQuery ) {
+               var currentParamState, updatedParamState,
+                       notEquivalent = function ( obj1, obj2 ) {
+                               var keys = Object.keys( obj1 ).concat( Object.keys( obj2 ) );
+                               return keys.some( function ( key ) {
+                                       return obj1[ key ] != obj2[ key ]; // eslint-disable-line eqeqeq
+                               } );
+                       };
+
+               // Compare states instead of parameters
+               // This will allow us to always have a proper check of whether
+               // the requested new url is one to change or not, regardless of
+               // actual parameter visibility/representation in the URL
+               currentParamState = $.extend(
+                       true,
+                       {},
+                       this.filtersModel.getMinimizedParamRepresentation( currentUriQuery ),
+                       this.getUnrecognizedParams( currentUriQuery )
+               );
+               updatedParamState = $.extend(
+                       true,
+                       {},
+                       this.filtersModel.getMinimizedParamRepresentation( updatedUriQuery ),
+                       this.getUnrecognizedParams( updatedUriQuery )
+               );
+
+               return notEquivalent( currentParamState, updatedParamState );
+       };
+
+       /**
+        * Check whether the given query has parameters that are
+        * recognized as parameters we should load the system with
+        *
+        * @param {mw.Uri} [uriQuery] Given URI query
+        * @return {boolean} Query contains valid recognized parameters
+        */
+       UriProcessor.prototype.doesQueryContainRecognizedParams = function ( uriQuery ) {
+               var anyValidInUrl,
+                       validParameterNames = Object.keys( this.filtersModel.getEmptyParameterState() );
+
+               uriQuery = uriQuery || new mw.Uri().query;
+
+               anyValidInUrl = Object.keys( uriQuery ).some( function ( parameter ) {
+                       return validParameterNames.indexOf( parameter ) > -1;
+               } );
+
+               // URL version 2 is allowed to be empty or within nonrecognized params
+               return anyValidInUrl || this.getVersion( uriQuery ) === 2;
+       };
+
+       /**
+        * Get the adjusted URI params based on the url version
+        * If the urlversion is not 2, the parameters are merged with
+        * the model's defaults.
+        * Always merge in the hidden parameter defaults.
+        *
+        * @private
+        * @param {Object} uriQuery Current URI query
+        * @return {Object} Normalized parameters
+        */
+       UriProcessor.prototype._getNormalizedQueryParams = function ( uriQuery ) {
+               // Check whether we are dealing with urlversion=2
+               // If we are, we do not merge the initial request with
+               // defaults. Not having urlversion=2 means we need to
+               // reproduce the server-side request and merge the
+               // requested parameters (or starting state) with the
+               // wiki default.
+               // Any subsequent change of the URL through the RCFilters
+               // system will receive 'urlversion=2'
+               var base = this.getVersion( uriQuery ) === 2 ?
+                       {} :
+                       this.filtersModel.getDefaultParams();
+
+               return $.extend(
+                       true,
+                       {},
+                       this.filtersModel.getMinimizedParamRepresentation(
+                               $.extend( true, {}, base, uriQuery )
+                       ),
+                       { urlversion: '2' }
+               );
+       };
+
+       module.exports = UriProcessor;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/ChangesListViewModel.js b/resources/src/mediawiki.rcfilters/dm/ChangesListViewModel.js
new file mode 100644 (file)
index 0000000..64d2e79
--- /dev/null
@@ -0,0 +1,169 @@
+( function () {
+       /**
+        * View model for the changes list
+        *
+        * @class mw.rcfilters.dm.ChangesListViewModel
+        * @mixins OO.EventEmitter
+        *
+        * @param {jQuery} $initialFieldset The initial server-generated legacy form content
+        * @constructor
+        */
+       var ChangesListViewModel = function MwRcfiltersDmChangesListViewModel( $initialFieldset ) {
+               // Mixin constructor
+               OO.EventEmitter.call( this );
+
+               this.valid = true;
+               this.newChangesExist = false;
+               this.liveUpdate = false;
+               this.unseenWatchedChanges = false;
+
+               this.extractNextFrom( $initialFieldset );
+       };
+
+       /* Initialization */
+       OO.initClass( ChangesListViewModel );
+       OO.mixinClass( ChangesListViewModel, OO.EventEmitter );
+
+       /* Events */
+
+       /**
+        * @event invalidate
+        *
+        * The list of changes is now invalid (out of date)
+        */
+
+       /**
+        * @event update
+        * @param {jQuery|string} $changesListContent List of changes
+        * @param {jQuery} $fieldset Server-generated form
+        * @param {string} noResultsDetails Type of no result error
+        * @param {boolean} isInitialDOM Whether the previous dom variables are from the initial page load
+        * @param {boolean} fromLiveUpdate These are new changes fetched via Live Update
+        *
+        * The list of changes has been updated
+        */
+
+       /**
+        * @event newChangesExist
+        * @param {boolean} newChangesExist
+        *
+        * The existence of changes newer than those currently displayed has changed.
+        */
+
+       /**
+        * @event liveUpdateChange
+        * @param {boolean} enable
+        *
+        * The state of the 'live update' feature has changed.
+        */
+
+       /* Methods */
+
+       /**
+        * Invalidate the list of changes
+        *
+        * @fires invalidate
+        */
+       ChangesListViewModel.prototype.invalidate = function () {
+               if ( this.valid ) {
+                       this.valid = false;
+                       this.emit( 'invalidate' );
+               }
+       };
+
+       /**
+        * Update the model with an updated list of changes
+        *
+        * @param {jQuery|string} changesListContent
+        * @param {jQuery} $fieldset
+        * @param {string} noResultsDetails Type of no result error
+        * @param {boolean} [isInitialDOM] Using the initial (already attached) DOM elements
+        * @param {boolean} [separateOldAndNew] Whether a logical separation between old and new changes is needed
+        * @fires update
+        */
+       ChangesListViewModel.prototype.update = function ( changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ) {
+               var from = this.nextFrom;
+               this.valid = true;
+               this.extractNextFrom( $fieldset );
+               this.checkForUnseenWatchedChanges( changesListContent );
+               this.emit( 'update', changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ? from : null );
+       };
+
+       /**
+        * Specify whether new changes exist
+        *
+        * @param {boolean} newChangesExist
+        * @fires newChangesExist
+        */
+       ChangesListViewModel.prototype.setNewChangesExist = function ( newChangesExist ) {
+               if ( newChangesExist !== this.newChangesExist ) {
+                       this.newChangesExist = newChangesExist;
+                       this.emit( 'newChangesExist', newChangesExist );
+               }
+       };
+
+       /**
+        * @return {boolean} Whether new changes exist
+        */
+       ChangesListViewModel.prototype.getNewChangesExist = function () {
+               return this.newChangesExist;
+       };
+
+       /**
+        * Extract the value of the 'from' parameter from a link in the field set
+        *
+        * @param {jQuery} $fieldset
+        */
+       ChangesListViewModel.prototype.extractNextFrom = function ( $fieldset ) {
+               var data = $fieldset.find( '.rclistfrom > a, .wlinfo' ).data( 'params' );
+               if ( data && data.from ) {
+                       this.nextFrom = data.from;
+               }
+       };
+
+       /**
+        * @return {string} The 'from' parameter that can be used to query new changes
+        */
+       ChangesListViewModel.prototype.getNextFrom = function () {
+               return this.nextFrom;
+       };
+
+       /**
+        * Toggle the 'live update' feature on/off
+        *
+        * @param {boolean} enable
+        */
+       ChangesListViewModel.prototype.toggleLiveUpdate = function ( enable ) {
+               enable = enable === undefined ? !this.liveUpdate : enable;
+               if ( enable !== this.liveUpdate ) {
+                       this.liveUpdate = enable;
+                       this.emit( 'liveUpdateChange', this.liveUpdate );
+               }
+       };
+
+       /**
+        * @return {boolean} The 'live update' feature is enabled
+        */
+       ChangesListViewModel.prototype.getLiveUpdate = function () {
+               return this.liveUpdate;
+       };
+
+       /**
+        * Check if some of the given changes watched and unseen
+        *
+        * @param {jQuery|string} changeslistContent
+        */
+       ChangesListViewModel.prototype.checkForUnseenWatchedChanges = function ( changeslistContent ) {
+               this.unseenWatchedChanges = changeslistContent !== 'NO_RESULTS' &&
+                       changeslistContent.find( '.mw-changeslist-line-watched' ).length > 0;
+       };
+
+       /**
+        * @return {boolean} Whether some of the current changes are watched and unseen
+        */
+       ChangesListViewModel.prototype.hasUnseenWatchedChanges = function () {
+               return this.unseenWatchedChanges;
+       };
+
+       module.exports = ChangesListViewModel;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/FilterGroup.js b/resources/src/mediawiki.rcfilters/dm/FilterGroup.js
new file mode 100644 (file)
index 0000000..831e6eb
--- /dev/null
@@ -0,0 +1,994 @@
+( function () {
+       var FilterItem = require( './FilterItem.js' ),
+               FilterGroup;
+
+       /**
+        * View model for a filter group
+        *
+        * @class mw.rcfilters.dm.FilterGroup
+        * @mixins OO.EventEmitter
+        * @mixins OO.EmitterList
+        *
+        * @constructor
+        * @param {string} name Group name
+        * @param {Object} [config] Configuration options
+        * @cfg {string} [type='send_unselected_if_any'] Group type
+        * @cfg {string} [view='default'] Name of the display group this group
+        *  is a part of.
+        * @cfg {boolean} [sticky] This group is 'sticky'. It is synchronized
+        *  with a preference, does not participate in Saved Queries, and is
+        *  not shown in the active filters area.
+        * @cfg {string} [title] Group title
+        * @cfg {boolean} [hidden] This group is hidden from the regular menu views
+        *  and the active filters area.
+        * @cfg {boolean} [allowArbitrary] Allows for an arbitrary value to be added to the
+        *  group from the URL, even if it wasn't initially set up.
+        * @cfg {number} [range] An object defining minimum and maximum values for numeric
+        *  groups. { min: x, max: y }
+        * @cfg {number} [minValue] Minimum value for numeric groups
+        * @cfg {string} [separator='|'] Value separator for 'string_options' groups
+        * @cfg {boolean} [active] Group is active
+        * @cfg {boolean} [fullCoverage] This filters in this group collectively cover all results
+        * @cfg {Object} [conflicts] Defines the conflicts for this filter group
+        * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
+        *  group. If the prefix has 'invert' state, the parameter is expected to be an object
+        *  with 'default' and 'inverted' as keys.
+        * @cfg {Object} [whatsThis] Defines the messages that should appear for the 'what's this' popup
+        * @cfg {string} [whatsThis.header] The header of the whatsThis popup message
+        * @cfg {string} [whatsThis.body] The body of the whatsThis popup message
+        * @cfg {string} [whatsThis.url] The url for the link in the whatsThis popup message
+        * @cfg {string} [whatsThis.linkMessage] The text for the link in the whatsThis popup message
+        * @cfg {boolean} [visible=true] The visibility of the group
+        */
+       FilterGroup = function MwRcfiltersDmFilterGroup( name, config ) {
+               config = config || {};
+
+               // Mixin constructor
+               OO.EventEmitter.call( this );
+               OO.EmitterList.call( this );
+
+               this.name = name;
+               this.type = config.type || 'send_unselected_if_any';
+               this.view = config.view || 'default';
+               this.sticky = !!config.sticky;
+               this.title = config.title || name;
+               this.hidden = !!config.hidden;
+               this.allowArbitrary = !!config.allowArbitrary;
+               this.numericRange = config.range;
+               this.separator = config.separator || '|';
+               this.labelPrefixKey = config.labelPrefixKey;
+               this.visible = config.visible === undefined ? true : !!config.visible;
+
+               this.currSelected = null;
+               this.active = !!config.active;
+               this.fullCoverage = !!config.fullCoverage;
+
+               this.whatsThis = config.whatsThis || {};
+
+               this.conflicts = config.conflicts || {};
+               this.defaultParams = {};
+               this.defaultFilters = {};
+
+               this.aggregate( { update: 'filterItemUpdate' } );
+               this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } );
+       };
+
+       /* Initialization */
+       OO.initClass( FilterGroup );
+       OO.mixinClass( FilterGroup, OO.EventEmitter );
+       OO.mixinClass( FilterGroup, OO.EmitterList );
+
+       /* Events */
+
+       /**
+        * @event update
+        *
+        * Group state has been updated
+        */
+
+       /* Methods */
+
+       /**
+        * Initialize the group and create its filter items
+        *
+        * @param {Object} filterDefinition Filter definition for this group
+        * @param {string|Object} [groupDefault] Definition of the group default
+        */
+       FilterGroup.prototype.initializeFilters = function ( filterDefinition, groupDefault ) {
+               var defaultParam,
+                       supersetMap = {},
+                       model = this,
+                       items = [];
+
+               filterDefinition.forEach( function ( filter ) {
+                       // Instantiate an item
+                       var subsetNames = [],
+                               filterItem = new FilterItem( filter.name, model, {
+                                       group: model.getName(),
+                                       label: filter.label || filter.name,
+                                       description: filter.description || '',
+                                       labelPrefixKey: model.labelPrefixKey,
+                                       cssClass: filter.cssClass,
+                                       identifiers: filter.identifiers,
+                                       defaultHighlightColor: filter.defaultHighlightColor
+                               } );
+
+                       if ( filter.subset ) {
+                               filter.subset = filter.subset.map( function ( el ) {
+                                       return el.filter;
+                               } );
+
+                               subsetNames = [];
+
+                               filter.subset.forEach( function ( subsetFilterName ) {
+                                       // Subsets (unlike conflicts) are always inside the same group
+                                       // We can re-map the names of the filters we are getting from
+                                       // the subsets with the group prefix
+                                       var subsetName = model.getPrefixedName( subsetFilterName );
+                                       // For convenience, we should store each filter's "supersets" -- these are
+                                       // the filters that have that item in their subset list. This will just
+                                       // make it easier to go through whether the item has any other items
+                                       // that affect it (and are selected) at any given time
+                                       supersetMap[ subsetName ] = supersetMap[ subsetName ] || [];
+                                       mw.rcfilters.utils.addArrayElementsUnique(
+                                               supersetMap[ subsetName ],
+                                               filterItem.getName()
+                                       );
+
+                                       // Translate subset param name to add the group name, so we
+                                       // get consistent naming. We know that subsets are only within
+                                       // the same group
+                                       subsetNames.push( subsetName );
+                               } );
+
+                               // Set translated subset
+                               filterItem.setSubset( subsetNames );
+                       }
+
+                       items.push( filterItem );
+
+                       // Store default parameter state; in this case, default is defined per filter
+                       if (
+                               model.getType() === 'send_unselected_if_any' ||
+                               model.getType() === 'boolean'
+                       ) {
+                               // Store the default parameter state
+                               // For this group type, parameter values are direct
+                               // We need to convert from a boolean to a string ('1' and '0')
+                               model.defaultParams[ filter.name ] = String( Number( filter.default || 0 ) );
+                       } else if ( model.getType() === 'any_value' ) {
+                               model.defaultParams[ filter.name ] = filter.default;
+                       }
+               } );
+
+               // Add items
+               this.addItems( items );
+
+               // Now that we have all items, we can apply the superset map
+               this.getItems().forEach( function ( filterItem ) {
+                       filterItem.setSuperset( supersetMap[ filterItem.getName() ] );
+               } );
+
+               // Store default parameter state; in this case, default is defined per the
+               // entire group, given by groupDefault method parameter
+               if ( this.getType() === 'string_options' ) {
+                       // Store the default parameter group state
+                       // For this group, the parameter is group name and value is the names
+                       // of selected items
+                       this.defaultParams[ this.getName() ] = mw.rcfilters.utils.normalizeParamOptions(
+                               // Current values
+                               groupDefault ?
+                                       groupDefault.split( this.getSeparator() ) :
+                                       [],
+                               // Legal values
+                               this.getItems().map( function ( item ) {
+                                       return item.getParamName();
+                               } )
+                       ).join( this.getSeparator() );
+               } else if ( this.getType() === 'single_option' ) {
+                       defaultParam = groupDefault !== undefined ?
+                               groupDefault : this.getItems()[ 0 ].getParamName();
+
+                       // For this group, the parameter is the group name,
+                       // and a single item can be selected: default or first item
+                       this.defaultParams[ this.getName() ] = defaultParam;
+               }
+
+               // add highlights to defaultParams
+               this.getItems().forEach( function ( filterItem ) {
+                       if ( filterItem.isHighlighted() ) {
+                               this.defaultParams[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor();
+                       }
+               }.bind( this ) );
+
+               // Store default filter state based on default params
+               this.defaultFilters = this.getFilterRepresentation( this.getDefaultParams() );
+
+               // Check for filters that should be initially selected by their default value
+               if ( this.isSticky() ) {
+                       // eslint-disable-next-line jquery/no-each-util
+                       $.each( this.defaultFilters, function ( filterName, filterValue ) {
+                               model.getItemByName( filterName ).toggleSelected( filterValue );
+                       } );
+               }
+
+               // Verify that single_option group has at least one item selected
+               if (
+                       this.getType() === 'single_option' &&
+                       this.findSelectedItems().length === 0
+               ) {
+                       defaultParam = groupDefault !== undefined ?
+                               groupDefault : this.getItems()[ 0 ].getParamName();
+
+                       // Single option means there must be a single option
+                       // selected, so we have to either select the default
+                       // or select the first option
+                       this.selectItemByParamName( defaultParam );
+               }
+       };
+
+       /**
+        * Respond to filterItem update event
+        *
+        * @param {mw.rcfilters.dm.FilterItem} item Updated filter item
+        * @fires update
+        */
+       FilterGroup.prototype.onFilterItemUpdate = function ( item ) {
+               // Update state
+               var changed = false,
+                       active = this.areAnySelected(),
+                       model = this;
+
+               if ( this.getType() === 'single_option' ) {
+                       // This group must have one item selected always
+                       // and must never have more than one item selected at a time
+                       if ( this.findSelectedItems().length === 0 ) {
+                               // Nothing is selected anymore
+                               // Select the default or the first item
+                               this.currSelected = this.getItemByParamName( this.defaultParams[ this.getName() ] ) ||
+                                       this.getItems()[ 0 ];
+                               this.currSelected.toggleSelected( true );
+                               changed = true;
+                       } else if ( this.findSelectedItems().length > 1 ) {
+                               // There is more than one item selected
+                               // This should only happen if the item given
+                               // is the one that is selected, so unselect
+                               // all items that is not it
+                               this.findSelectedItems().forEach( function ( itemModel ) {
+                                       // Note that in case the given item is actually
+                                       // not selected, this loop will end up unselecting
+                                       // all items, which would trigger the case above
+                                       // when the last item is unselected anyways
+                                       var selected = itemModel.getName() === item.getName() &&
+                                               item.isSelected();
+
+                                       itemModel.toggleSelected( selected );
+                                       if ( selected ) {
+                                               model.currSelected = itemModel;
+                                       }
+                               } );
+                               changed = true;
+                       }
+               }
+
+               if ( this.isSticky() ) {
+                       // If this group is sticky, then change the default according to the
+                       // current selection.
+                       this.defaultParams = this.getParamRepresentation( this.getSelectedState() );
+               }
+
+               if (
+                       changed ||
+                       this.active !== active ||
+                       this.currSelected !== item
+               ) {
+                       this.active = active;
+                       this.currSelected = item;
+
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Get group active state
+        *
+        * @return {boolean} Active state
+        */
+       FilterGroup.prototype.isActive = function () {
+               return this.active;
+       };
+
+       /**
+        * Get group hidden state
+        *
+        * @return {boolean} Hidden state
+        */
+       FilterGroup.prototype.isHidden = function () {
+               return this.hidden;
+       };
+
+       /**
+        * Get group allow arbitrary state
+        *
+        * @return {boolean} Group allows an arbitrary value from the URL
+        */
+       FilterGroup.prototype.isAllowArbitrary = function () {
+               return this.allowArbitrary;
+       };
+
+       /**
+        * Get group maximum value for numeric groups
+        *
+        * @return {number|null} Group max value
+        */
+       FilterGroup.prototype.getMaxValue = function () {
+               return this.numericRange && this.numericRange.max !== undefined ?
+                       this.numericRange.max : null;
+       };
+
+       /**
+        * Get group minimum value for numeric groups
+        *
+        * @return {number|null} Group max value
+        */
+       FilterGroup.prototype.getMinValue = function () {
+               return this.numericRange && this.numericRange.min !== undefined ?
+                       this.numericRange.min : null;
+       };
+
+       /**
+        * Get group name
+        *
+        * @return {string} Group name
+        */
+       FilterGroup.prototype.getName = function () {
+               return this.name;
+       };
+
+       /**
+        * Get the default param state of this group
+        *
+        * @return {Object} Default param state
+        */
+       FilterGroup.prototype.getDefaultParams = function () {
+               return this.defaultParams;
+       };
+
+       /**
+        * Get the default filter state of this group
+        *
+        * @return {Object} Default filter state
+        */
+       FilterGroup.prototype.getDefaultFilters = function () {
+               return this.defaultFilters;
+       };
+
+       /**
+        * This is for a single_option and string_options group types
+        * it returns the value of the default
+        *
+        * @return {string} Value of the default
+        */
+       FilterGroup.prototype.getDefaulParamValue = function () {
+               return this.defaultParams[ this.getName() ];
+       };
+       /**
+        * Get the messags defining the 'whats this' popup for this group
+        *
+        * @return {Object} What's this messages
+        */
+       FilterGroup.prototype.getWhatsThis = function () {
+               return this.whatsThis;
+       };
+
+       /**
+        * Check whether this group has a 'what's this' message
+        *
+        * @return {boolean} This group has a what's this message
+        */
+       FilterGroup.prototype.hasWhatsThis = function () {
+               return !!this.whatsThis.body;
+       };
+
+       /**
+        * Get the conflicts associated with the entire group.
+        * Conflict object is set up by filter name keys and conflict
+        * definition. For example:
+        * [
+        *     {
+        *         filterName: {
+        *             filter: filterName,
+        *             group: group1
+        *         }
+        *     },
+        *     {
+        *         filterName2: {
+        *             filter: filterName2,
+        *             group: group2
+        *         }
+        *     }
+        * ]
+        * @return {Object} Conflict definition
+        */
+       FilterGroup.prototype.getConflicts = function () {
+               return this.conflicts;
+       };
+
+       /**
+        * Set conflicts for this group. See #getConflicts for the expected
+        * structure of the definition.
+        *
+        * @param {Object} conflicts Conflicts for this group
+        */
+       FilterGroup.prototype.setConflicts = function ( conflicts ) {
+               this.conflicts = conflicts;
+       };
+
+       /**
+        * Set conflicts for each filter item in the group based on the
+        * given conflict map
+        *
+        * @param {Object} conflicts Object representing the conflict map,
+        *  keyed by the item name, where its value is an object for all its conflicts
+        */
+       FilterGroup.prototype.setFilterConflicts = function ( conflicts ) {
+               this.getItems().forEach( function ( filterItem ) {
+                       if ( conflicts[ filterItem.getName() ] ) {
+                               filterItem.setConflicts( conflicts[ filterItem.getName() ] );
+                       }
+               } );
+       };
+
+       /**
+        * Check whether this item has a potential conflict with the given item
+        *
+        * This checks whether the given item is in the list of conflicts of
+        * the current item, but makes no judgment about whether the conflict
+        * is currently at play (either one of the items may not be selected)
+        *
+        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
+        * @return {boolean} This item has a conflict with the given item
+        */
+       FilterGroup.prototype.existsInConflicts = function ( filterItem ) {
+               return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
+       };
+
+       /**
+        * Check whether there are any items selected
+        *
+        * @return {boolean} Any items in the group are selected
+        */
+       FilterGroup.prototype.areAnySelected = function () {
+               return this.getItems().some( function ( filterItem ) {
+                       return filterItem.isSelected();
+               } );
+       };
+
+       /**
+        * Check whether all items selected
+        *
+        * @return {boolean} All items are selected
+        */
+       FilterGroup.prototype.areAllSelected = function () {
+               var selected = [],
+                       unselected = [];
+
+               this.getItems().forEach( function ( filterItem ) {
+                       if ( filterItem.isSelected() ) {
+                               selected.push( filterItem );
+                       } else {
+                               unselected.push( filterItem );
+                       }
+               } );
+
+               if ( unselected.length === 0 ) {
+                       return true;
+               }
+
+               // check if every unselected is a subset of a selected
+               return unselected.every( function ( unselectedFilterItem ) {
+                       return selected.some( function ( selectedFilterItem ) {
+                               return selectedFilterItem.existsInSubset( unselectedFilterItem.getName() );
+                       } );
+               } );
+       };
+
+       /**
+        * Get all selected items in this group
+        *
+        * @param {mw.rcfilters.dm.FilterItem} [excludeItem] Item to exclude from the list
+        * @return {mw.rcfilters.dm.FilterItem[]} Selected items
+        */
+       FilterGroup.prototype.findSelectedItems = function ( excludeItem ) {
+               var excludeName = ( excludeItem && excludeItem.getName() ) || '';
+
+               return this.getItems().filter( function ( item ) {
+                       return item.getName() !== excludeName && item.isSelected();
+               } );
+       };
+
+       /**
+        * Check whether all selected items are in conflict with the given item
+        *
+        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
+        * @return {boolean} All selected items are in conflict with this item
+        */
+       FilterGroup.prototype.areAllSelectedInConflictWith = function ( filterItem ) {
+               var selectedItems = this.findSelectedItems( filterItem );
+
+               return selectedItems.length > 0 &&
+                       (
+                               // The group as a whole is in conflict with this item
+                               this.existsInConflicts( filterItem ) ||
+                               // All selected items are in conflict individually
+                               selectedItems.every( function ( selectedFilter ) {
+                                       return selectedFilter.existsInConflicts( filterItem );
+                               } )
+                       );
+       };
+
+       /**
+        * Check whether any of the selected items are in conflict with the given item
+        *
+        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
+        * @return {boolean} Any of the selected items are in conflict with this item
+        */
+       FilterGroup.prototype.areAnySelectedInConflictWith = function ( filterItem ) {
+               var selectedItems = this.findSelectedItems( filterItem );
+
+               return selectedItems.length > 0 && (
+                       // The group as a whole is in conflict with this item
+                       this.existsInConflicts( filterItem ) ||
+                       // Any selected items are in conflict individually
+                       selectedItems.some( function ( selectedFilter ) {
+                               return selectedFilter.existsInConflicts( filterItem );
+                       } )
+               );
+       };
+
+       /**
+        * Get the parameter representation from this group
+        *
+        * @param {Object} [filterRepresentation] An object defining the state
+        *  of the filters in this group, keyed by their name and current selected
+        *  state value.
+        * @return {Object} Parameter representation
+        */
+       FilterGroup.prototype.getParamRepresentation = function ( filterRepresentation ) {
+               var values,
+                       areAnySelected = false,
+                       buildFromCurrentState = !filterRepresentation,
+                       defaultFilters = this.getDefaultFilters(),
+                       result = {},
+                       model = this,
+                       filterParamNames = {},
+                       getSelectedParameter = function ( filters ) {
+                               var item,
+                                       selected = [];
+
+                               // Find if any are selected
+                               // eslint-disable-next-line jquery/no-each-util
+                               $.each( filters, function ( name, value ) {
+                                       if ( value ) {
+                                               selected.push( name );
+                                       }
+                               } );
+
+                               item = model.getItemByName( selected[ 0 ] );
+                               return ( item && item.getParamName() ) || '';
+                       };
+
+               filterRepresentation = filterRepresentation || {};
+
+               // Create or complete the filterRepresentation definition
+               this.getItems().forEach( function ( item ) {
+                       // Map filter names to their parameter names
+                       filterParamNames[ item.getName() ] = item.getParamName();
+
+                       if ( buildFromCurrentState ) {
+                               // This means we have not been given a filter representation
+                               // so we are building one based on current state
+                               filterRepresentation[ item.getName() ] = item.getValue();
+                       } else if ( filterRepresentation[ item.getName() ] === undefined ) {
+                               // We are given a filter representation, but we have to make
+                               // sure that we fill in the missing filters if there are any
+                               // we will assume they are all falsey
+                               if ( model.isSticky() ) {
+                                       filterRepresentation[ item.getName() ] = !!defaultFilters[ item.getName() ];
+                               } else {
+                                       filterRepresentation[ item.getName() ] = false;
+                               }
+                       }
+
+                       if ( filterRepresentation[ item.getName() ] ) {
+                               areAnySelected = true;
+                       }
+               } );
+
+               // Build result
+               if (
+                       this.getType() === 'send_unselected_if_any' ||
+                       this.getType() === 'boolean' ||
+                       this.getType() === 'any_value'
+               ) {
+                       // First, check if any of the items are selected at all.
+                       // If none is selected, we're treating it as if they are
+                       // all false
+
+                       // Go over the items and define the correct values
+                       // eslint-disable-next-line jquery/no-each-util
+                       $.each( filterRepresentation, function ( name, value ) {
+                               // We must store all parameter values as strings '0' or '1'
+                               if ( model.getType() === 'send_unselected_if_any' ) {
+                                       result[ filterParamNames[ name ] ] = areAnySelected ?
+                                               String( Number( !value ) ) :
+                                               '0';
+                               } else if ( model.getType() === 'boolean' ) {
+                                       // Representation is straight-forward and direct from
+                                       // the parameter value to the filter state
+                                       result[ filterParamNames[ name ] ] = String( Number( !!value ) );
+                               } else if ( model.getType() === 'any_value' ) {
+                                       result[ filterParamNames[ name ] ] = value;
+                               }
+                       } );
+               } else if ( this.getType() === 'string_options' ) {
+                       values = [];
+
+                       // eslint-disable-next-line jquery/no-each-util
+                       $.each( filterRepresentation, function ( name, value ) {
+                               // Collect values
+                               if ( value ) {
+                                       values.push( filterParamNames[ name ] );
+                               }
+                       } );
+
+                       result[ this.getName() ] = ( values.length === Object.keys( filterRepresentation ).length ) ?
+                               'all' : values.join( this.getSeparator() );
+               } else if ( this.getType() === 'single_option' ) {
+                       result[ this.getName() ] = getSelectedParameter( filterRepresentation );
+               }
+
+               return result;
+       };
+
+       /**
+        * Get the filter representation this group would provide
+        * based on given parameter states.
+        *
+        * @param {Object} [paramRepresentation] An object defining a parameter
+        *  state to translate the filter state from. If not given, an object
+        *  representing all filters as falsey is returned; same as if the parameter
+        *  given were an empty object, or had some of the filters missing.
+        * @return {Object} Filter representation
+        */
+       FilterGroup.prototype.getFilterRepresentation = function ( paramRepresentation ) {
+               var areAnySelected, paramValues, item, currentValue,
+                       oneWasSelected = false,
+                       defaultParams = this.getDefaultParams(),
+                       expandedParams = $.extend( true, {}, paramRepresentation ),
+                       model = this,
+                       paramToFilterMap = {},
+                       result = {};
+
+               if ( this.isSticky() ) {
+                       // If the group is sticky, check if all parameters are represented
+                       // and for those that aren't represented, add them with their default
+                       // values
+                       paramRepresentation = $.extend( true, {}, this.getDefaultParams(), paramRepresentation );
+               }
+
+               paramRepresentation = paramRepresentation || {};
+               if (
+                       this.getType() === 'send_unselected_if_any' ||
+                       this.getType() === 'boolean' ||
+                       this.getType() === 'any_value'
+               ) {
+                       // Go over param representation; map and check for selections
+                       this.getItems().forEach( function ( filterItem ) {
+                               var paramName = filterItem.getParamName();
+
+                               expandedParams[ paramName ] = paramRepresentation[ paramName ] || '0';
+                               paramToFilterMap[ paramName ] = filterItem;
+
+                               if ( Number( paramRepresentation[ filterItem.getParamName() ] ) ) {
+                                       areAnySelected = true;
+                               }
+                       } );
+
+                       // eslint-disable-next-line jquery/no-each-util
+                       $.each( expandedParams, function ( paramName, paramValue ) {
+                               var filterItem = paramToFilterMap[ paramName ];
+
+                               if ( model.getType() === 'send_unselected_if_any' ) {
+                                       // Flip the definition between the parameter
+                                       // state and the filter state
+                                       // This is what the 'toggleSelected' value of the filter is
+                                       result[ filterItem.getName() ] = areAnySelected ?
+                                               !Number( paramValue ) :
+                                               // Otherwise, there are no selected items in the
+                                               // group, which means the state is false
+                                               false;
+                               } else if ( model.getType() === 'boolean' ) {
+                                       // Straight-forward definition of state
+                                       result[ filterItem.getName() ] = !!Number( paramRepresentation[ filterItem.getParamName() ] );
+                               } else if ( model.getType() === 'any_value' ) {
+                                       result[ filterItem.getName() ] = paramRepresentation[ filterItem.getParamName() ];
+                               }
+                       } );
+               } else if ( this.getType() === 'string_options' ) {
+                       currentValue = paramRepresentation[ this.getName() ] || '';
+
+                       // Normalize the given parameter values
+                       paramValues = mw.rcfilters.utils.normalizeParamOptions(
+                               // Given
+                               currentValue.split(
+                                       this.getSeparator()
+                               ),
+                               // Allowed values
+                               this.getItems().map( function ( filterItem ) {
+                                       return filterItem.getParamName();
+                               } )
+                       );
+                       // Translate the parameter values into a filter selection state
+                       this.getItems().forEach( function ( filterItem ) {
+                               // All true (either because all values are written or the term 'all' is written)
+                               // is the same as all filters set to true
+                               result[ filterItem.getName() ] = (
+                                       // If it is the word 'all'
+                                       paramValues.length === 1 && paramValues[ 0 ] === 'all' ||
+                                       // All values are written
+                                       paramValues.length === model.getItemCount()
+                               ) ?
+                                       true :
+                                       // Otherwise, the filter is selected only if it appears in the parameter values
+                                       paramValues.indexOf( filterItem.getParamName() ) > -1;
+                       } );
+               } else if ( this.getType() === 'single_option' ) {
+                       // There is parameter that fits a single filter and if not, get the default
+                       this.getItems().forEach( function ( filterItem ) {
+                               var selected = filterItem.getParamName() === paramRepresentation[ model.getName() ];
+
+                               result[ filterItem.getName() ] = selected;
+                               oneWasSelected = oneWasSelected || selected;
+                       } );
+               }
+
+               // Go over result and make sure all filters are represented.
+               // If any filters are missing, they will get a falsey value
+               this.getItems().forEach( function ( filterItem ) {
+                       if ( result[ filterItem.getName() ] === undefined ) {
+                               result[ filterItem.getName() ] = this.getFalsyValue();
+                       }
+               }.bind( this ) );
+
+               // Make sure that at least one option is selected in
+               // single_option groups, no matter what path was taken
+               // If none was selected by the given definition, then
+               // we need to select the one in the base state -- either
+               // the default given, or the first item
+               if (
+                       this.getType() === 'single_option' &&
+                       !oneWasSelected
+               ) {
+                       item = this.getItems()[ 0 ];
+                       if ( defaultParams[ this.getName() ] ) {
+                               item = this.getItemByParamName( defaultParams[ this.getName() ] );
+                       }
+
+                       result[ item.getName() ] = true;
+               }
+
+               return result;
+       };
+
+       /**
+        * @return {*} The appropriate falsy value for this group type
+        */
+       FilterGroup.prototype.getFalsyValue = function () {
+               return this.getType() === 'any_value' ? '' : false;
+       };
+
+       /**
+        * Get current selected state of all filter items in this group
+        *
+        * @return {Object} Selected state
+        */
+       FilterGroup.prototype.getSelectedState = function () {
+               var state = {};
+
+               this.getItems().forEach( function ( filterItem ) {
+                       state[ filterItem.getName() ] = filterItem.getValue();
+               } );
+
+               return state;
+       };
+
+       /**
+        * Get item by its filter name
+        *
+        * @param {string} filterName Filter name
+        * @return {mw.rcfilters.dm.FilterItem} Filter item
+        */
+       FilterGroup.prototype.getItemByName = function ( filterName ) {
+               return this.getItems().filter( function ( item ) {
+                       return item.getName() === filterName;
+               } )[ 0 ];
+       };
+
+       /**
+        * Select an item by its parameter name
+        *
+        * @param {string} paramName Filter parameter name
+        */
+       FilterGroup.prototype.selectItemByParamName = function ( paramName ) {
+               this.getItems().forEach( function ( item ) {
+                       item.toggleSelected( item.getParamName() === String( paramName ) );
+               } );
+       };
+
+       /**
+        * Get item by its parameter name
+        *
+        * @param {string} paramName Parameter name
+        * @return {mw.rcfilters.dm.FilterItem} Filter item
+        */
+       FilterGroup.prototype.getItemByParamName = function ( paramName ) {
+               return this.getItems().filter( function ( item ) {
+                       return item.getParamName() === String( paramName );
+               } )[ 0 ];
+       };
+
+       /**
+        * Get group type
+        *
+        * @return {string} Group type
+        */
+       FilterGroup.prototype.getType = function () {
+               return this.type;
+       };
+
+       /**
+        * Check whether this group is represented by a single parameter
+        * or whether each item is its own parameter
+        *
+        * @return {boolean} This group is a single parameter
+        */
+       FilterGroup.prototype.isPerGroupRequestParameter = function () {
+               return (
+                       this.getType() === 'string_options' ||
+                       this.getType() === 'single_option'
+               );
+       };
+
+       /**
+        * Get display group
+        *
+        * @return {string} Display group
+        */
+       FilterGroup.prototype.getView = function () {
+               return this.view;
+       };
+
+       /**
+        * Get the prefix used for the filter names inside this group.
+        *
+        * @param {string} [name] Filter name to prefix
+        * @return {string} Group prefix
+        */
+       FilterGroup.prototype.getNamePrefix = function () {
+               return this.getName() + '__';
+       };
+
+       /**
+        * Get a filter name with the prefix used for the filter names inside this group.
+        *
+        * @param {string} name Filter name to prefix
+        * @return {string} Group prefix
+        */
+       FilterGroup.prototype.getPrefixedName = function ( name ) {
+               return this.getNamePrefix() + name;
+       };
+
+       /**
+        * Get group's title
+        *
+        * @return {string} Title
+        */
+       FilterGroup.prototype.getTitle = function () {
+               return this.title;
+       };
+
+       /**
+        * Get group's values separator
+        *
+        * @return {string} Values separator
+        */
+       FilterGroup.prototype.getSeparator = function () {
+               return this.separator;
+       };
+
+       /**
+        * Check whether the group is defined as full coverage
+        *
+        * @return {boolean} Group is full coverage
+        */
+       FilterGroup.prototype.isFullCoverage = function () {
+               return this.fullCoverage;
+       };
+
+       /**
+        * Check whether the group is defined as sticky default
+        *
+        * @return {boolean} Group is sticky default
+        */
+       FilterGroup.prototype.isSticky = function () {
+               return this.sticky;
+       };
+
+       /**
+        * Normalize a value given to this group. This is mostly for correcting
+        * arbitrary values for 'single option' groups, given by the user settings
+        * or the URL that can go outside the limits that are allowed.
+        *
+        * @param  {string} value Given value
+        * @return {string} Corrected value
+        */
+       FilterGroup.prototype.normalizeArbitraryValue = function ( value ) {
+               if (
+                       this.getType() === 'single_option' &&
+                       this.isAllowArbitrary()
+               ) {
+                       if (
+                               this.getMaxValue() !== null &&
+                               value > this.getMaxValue()
+                       ) {
+                               // Change the value to the actual max value
+                               return String( this.getMaxValue() );
+                       } else if (
+                               this.getMinValue() !== null &&
+                               value < this.getMinValue()
+                       ) {
+                               // Change the value to the actual min value
+                               return String( this.getMinValue() );
+                       }
+               }
+
+               return value;
+       };
+
+       /**
+        * Toggle the visibility of this group
+        *
+        * @param {boolean} [isVisible] Item is visible
+        */
+       FilterGroup.prototype.toggleVisible = function ( isVisible ) {
+               isVisible = isVisible === undefined ? !this.visible : isVisible;
+
+               if ( this.visible !== isVisible ) {
+                       this.visible = isVisible;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Check whether the group is visible
+        *
+        * @return {boolean} Group is visible
+        */
+       FilterGroup.prototype.isVisible = function () {
+               return this.visible;
+       };
+
+       /**
+        * Set the visibility of the items under this group by the given items array
+        *
+        * @param {mw.rcfilters.dm.ItemModel[]} visibleItems An array of visible items
+        */
+       FilterGroup.prototype.setVisibleItems = function ( visibleItems ) {
+               this.getItems().forEach( function ( itemModel ) {
+                       itemModel.toggleVisible( visibleItems.indexOf( itemModel ) !== -1 );
+               } );
+       };
+
+       module.exports = FilterGroup;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/FilterItem.js b/resources/src/mediawiki.rcfilters/dm/FilterItem.js
new file mode 100644 (file)
index 0000000..3e11d1e
--- /dev/null
@@ -0,0 +1,406 @@
+( function () {
+       var ItemModel = require( './ItemModel.js' ),
+               FilterItem;
+
+       /**
+        * Filter item model
+        *
+        * @class mw.rcfilters.dm.FilterItem
+        * @extends mw.rcfilters.dm.ItemModel
+        *
+        * @constructor
+        * @param {string} param Filter param name
+        * @param {mw.rcfilters.dm.FilterGroup} groupModel Filter group model
+        * @param {Object} config Configuration object
+        * @cfg {string[]} [excludes=[]] A list of filter names this filter, if
+        *  selected, makes inactive.
+        * @cfg {string[]} [subset] Defining the names of filters that are a subset of this filter
+        * @cfg {Object} [conflicts] Defines the conflicts for this filter
+        * @cfg {boolean} [visible=true] The visibility of the group
+        */
+       FilterItem = function MwRcfiltersDmFilterItem( param, groupModel, config ) {
+               config = config || {};
+
+               this.groupModel = groupModel;
+
+               // Parent
+               FilterItem.parent.call( this, param, $.extend( {
+                       namePrefix: this.groupModel.getNamePrefix()
+               }, config ) );
+               // Mixin constructor
+               OO.EventEmitter.call( this );
+
+               // Interaction definitions
+               this.subset = config.subset || [];
+               this.conflicts = config.conflicts || {};
+               this.superset = [];
+               this.visible = config.visible === undefined ? true : !!config.visible;
+
+               // Interaction states
+               this.included = false;
+               this.conflicted = false;
+               this.fullyCovered = false;
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( FilterItem, ItemModel );
+
+       /* Methods */
+
+       /**
+        * Return the representation of the state of this item.
+        *
+        * @return {Object} State of the object
+        */
+       FilterItem.prototype.getState = function () {
+               return {
+                       selected: this.isSelected(),
+                       included: this.isIncluded(),
+                       conflicted: this.isConflicted(),
+                       fullyCovered: this.isFullyCovered()
+               };
+       };
+
+       /**
+        * Get the message for the display area for the currently active conflict
+        *
+        * @private
+        * @return {string} Conflict result message key
+        */
+       FilterItem.prototype.getCurrentConflictResultMessage = function () {
+               var details = {};
+
+               // First look in filter's own conflicts
+               details = this.getConflictDetails( this.getOwnConflicts(), 'globalDescription' );
+               if ( !details.message ) {
+                       // Fall back onto conflicts in the group
+                       details = this.getConflictDetails( this.getGroupModel().getConflicts(), 'globalDescription' );
+               }
+
+               return details.message;
+       };
+
+       /**
+        * Get the details of the active conflict on this filter
+        *
+        * @private
+        * @param {Object} conflicts Conflicts to examine
+        * @param {string} [key='contextDescription'] Message key
+        * @return {Object} Object with conflict message and conflict items
+        * @return {string} return.message Conflict message
+        * @return {string[]} return.names Conflicting item labels
+        */
+       FilterItem.prototype.getConflictDetails = function ( conflicts, key ) {
+               var group,
+                       conflictMessage = '',
+                       itemLabels = [];
+
+               key = key || 'contextDescription';
+
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( conflicts, function ( filterName, conflict ) {
+                       if ( !conflict.item.isSelected() ) {
+                               return;
+                       }
+
+                       if ( !conflictMessage ) {
+                               conflictMessage = conflict[ key ];
+                               group = conflict.group;
+                       }
+
+                       if ( group === conflict.group ) {
+                               itemLabels.push( mw.msg( 'quotation-marks', conflict.item.getLabel() ) );
+                       }
+               } );
+
+               return {
+                       message: conflictMessage,
+                       names: itemLabels
+               };
+
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterItem.prototype.getStateMessage = function () {
+               var messageKey, details, superset,
+                       affectingItems = [];
+
+               if ( this.isSelected() ) {
+                       if ( this.isConflicted() ) {
+                               // First look in filter's own conflicts
+                               details = this.getConflictDetails( this.getOwnConflicts() );
+                               if ( !details.message ) {
+                                       // Fall back onto conflicts in the group
+                                       details = this.getConflictDetails( this.getGroupModel().getConflicts() );
+                               }
+
+                               messageKey = details.message;
+                               affectingItems = details.names;
+                       } else if ( this.isIncluded() && !this.isHighlighted() ) {
+                               // We only show the 'no effect' full-coverage message
+                               // if the item is also not highlighted. See T161273
+                               superset = this.getSuperset();
+                               // For this message we need to collect the affecting superset
+                               affectingItems = this.getGroupModel().findSelectedItems( this )
+                                       .filter( function ( item ) {
+                                               return superset.indexOf( item.getName() ) !== -1;
+                                       } )
+                                       .map( function ( item ) {
+                                               return mw.msg( 'quotation-marks', item.getLabel() );
+                                       } );
+
+                               messageKey = 'rcfilters-state-message-subset';
+                       } else if ( this.isFullyCovered() && !this.isHighlighted() ) {
+                               affectingItems = this.getGroupModel().findSelectedItems( this )
+                                       .map( function ( item ) {
+                                               return mw.msg( 'quotation-marks', item.getLabel() );
+                                       } );
+
+                               messageKey = 'rcfilters-state-message-fullcoverage';
+                       }
+               }
+
+               if ( messageKey ) {
+                       // Build message
+                       return mw.msg(
+                               messageKey,
+                               mw.language.listToText( affectingItems ),
+                               affectingItems.length
+                       );
+               }
+
+               // Display description
+               return this.getDescription();
+       };
+
+       /**
+        * Get the model of the group this filter belongs to
+        *
+        * @return {mw.rcfilters.dm.FilterGroup} Filter group model
+        */
+       FilterItem.prototype.getGroupModel = function () {
+               return this.groupModel;
+       };
+
+       /**
+        * Get the group name this filter belongs to
+        *
+        * @return {string} Filter group name
+        */
+       FilterItem.prototype.getGroupName = function () {
+               return this.groupModel.getName();
+       };
+
+       /**
+        * Get filter subset
+        * This is a list of filter names that are defined to be included
+        * when this filter is selected.
+        *
+        * @return {string[]} Filter subset
+        */
+       FilterItem.prototype.getSubset = function () {
+               return this.subset;
+       };
+
+       /**
+        * Get filter superset
+        * This is a generated list of filters that define this filter
+        * to be included when either of them is selected.
+        *
+        * @return {string[]} Filter superset
+        */
+       FilterItem.prototype.getSuperset = function () {
+               return this.superset;
+       };
+
+       /**
+        * Check whether the filter is currently in a conflict state
+        *
+        * @return {boolean} Filter is in conflict state
+        */
+       FilterItem.prototype.isConflicted = function () {
+               return this.conflicted;
+       };
+
+       /**
+        * Check whether the filter is currently in an already included subset
+        *
+        * @return {boolean} Filter is in an already-included subset
+        */
+       FilterItem.prototype.isIncluded = function () {
+               return this.included;
+       };
+
+       /**
+        * Check whether the filter is currently fully covered
+        *
+        * @return {boolean} Filter is in fully-covered state
+        */
+       FilterItem.prototype.isFullyCovered = function () {
+               return this.fullyCovered;
+       };
+
+       /**
+        * Get all conflicts associated with this filter or its group
+        *
+        * Conflict object is set up by filter name keys and conflict
+        * definition. For example:
+        *
+        *  {
+        *      filterName: {
+        *          filter: filterName,
+        *          group: group1,
+        *          label: itemLabel,
+        *          item: itemModel
+        *      }
+        *      filterName2: {
+        *          filter: filterName2,
+        *          group: group2
+        *          label: itemLabel2,
+        *          item: itemModel2
+        *      }
+        *  }
+        *
+        * @return {Object} Filter conflicts
+        */
+       FilterItem.prototype.getConflicts = function () {
+               return $.extend( {}, this.conflicts, this.getGroupModel().getConflicts() );
+       };
+
+       /**
+        * Get the conflicts associated with this filter
+        *
+        * @return {Object} Filter conflicts
+        */
+       FilterItem.prototype.getOwnConflicts = function () {
+               return this.conflicts;
+       };
+
+       /**
+        * Set conflicts for this filter. See #getConflicts for the expected
+        * structure of the definition.
+        *
+        * @param {Object} conflicts Conflicts for this filter
+        */
+       FilterItem.prototype.setConflicts = function ( conflicts ) {
+               this.conflicts = conflicts || {};
+       };
+
+       /**
+        * Set filter superset
+        *
+        * @param {string[]} superset Filter superset
+        */
+       FilterItem.prototype.setSuperset = function ( superset ) {
+               this.superset = superset || [];
+       };
+
+       /**
+        * Set filter subset
+        *
+        * @param {string[]} subset Filter subset
+        */
+       FilterItem.prototype.setSubset = function ( subset ) {
+               this.subset = subset || [];
+       };
+
+       /**
+        * Check whether a filter exists in the subset list for this filter
+        *
+        * @param {string} filterName Filter name
+        * @return {boolean} Filter name is in the subset list
+        */
+       FilterItem.prototype.existsInSubset = function ( filterName ) {
+               return this.subset.indexOf( filterName ) > -1;
+       };
+
+       /**
+        * Check whether this item has a potential conflict with the given item
+        *
+        * This checks whether the given item is in the list of conflicts of
+        * the current item, but makes no judgment about whether the conflict
+        * is currently at play (either one of the items may not be selected)
+        *
+        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
+        * @return {boolean} This item has a conflict with the given item
+        */
+       FilterItem.prototype.existsInConflicts = function ( filterItem ) {
+               return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
+       };
+
+       /**
+        * Set the state of this filter as being conflicted
+        * (This means any filters in its conflicts are selected)
+        *
+        * @param {boolean} [conflicted] Filter is in conflict state
+        * @fires update
+        */
+       FilterItem.prototype.toggleConflicted = function ( conflicted ) {
+               conflicted = conflicted === undefined ? !this.conflicted : conflicted;
+
+               if ( this.conflicted !== conflicted ) {
+                       this.conflicted = conflicted;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Set the state of this filter as being already included
+        * (This means any filters in its superset are selected)
+        *
+        * @param {boolean} [included] Filter is included as part of a subset
+        * @fires update
+        */
+       FilterItem.prototype.toggleIncluded = function ( included ) {
+               included = included === undefined ? !this.included : included;
+
+               if ( this.included !== included ) {
+                       this.included = included;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Toggle the fully covered state of the item
+        *
+        * @param {boolean} [isFullyCovered] Filter is fully covered
+        * @fires update
+        */
+       FilterItem.prototype.toggleFullyCovered = function ( isFullyCovered ) {
+               isFullyCovered = isFullyCovered === undefined ? !this.fullycovered : isFullyCovered;
+
+               if ( this.fullyCovered !== isFullyCovered ) {
+                       this.fullyCovered = isFullyCovered;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Toggle the visibility of this item
+        *
+        * @param {boolean} [isVisible] Item is visible
+        */
+       FilterItem.prototype.toggleVisible = function ( isVisible ) {
+               isVisible = isVisible === undefined ? !this.visible : !!isVisible;
+
+               if ( this.visible !== isVisible ) {
+                       this.visible = isVisible;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Check whether the item is visible
+        *
+        * @return {boolean} Item is visible
+        */
+       FilterItem.prototype.isVisible = function () {
+               return this.visible;
+       };
+
+       module.exports = FilterItem;
+
+}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/FiltersViewModel.js b/resources/src/mediawiki.rcfilters/dm/FiltersViewModel.js
new file mode 100644 (file)
index 0000000..d89bb28
--- /dev/null
@@ -0,0 +1,1302 @@
+( function () {
+       var FilterGroup = require( './FilterGroup.js' ),
+               FilterItem = require( './FilterItem.js' ),
+               FiltersViewModel;
+
+       /**
+        * View model for the filters selection and display
+        *
+        * @class mw.rcfilters.dm.FiltersViewModel
+        * @mixins OO.EventEmitter
+        * @mixins OO.EmitterList
+        *
+        * @constructor
+        */
+       FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
+               // Mixin constructor
+               OO.EventEmitter.call( this );
+               OO.EmitterList.call( this );
+
+               this.groups = {};
+               this.defaultParams = {};
+               this.highlightEnabled = false;
+               this.parameterMap = {};
+               this.emptyParameterState = null;
+
+               this.views = {};
+               this.currentView = 'default';
+               this.searchQuery = null;
+
+               // Events
+               this.aggregate( { update: 'filterItemUpdate' } );
+               this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
+       };
+
+       /* Initialization */
+       OO.initClass( FiltersViewModel );
+       OO.mixinClass( FiltersViewModel, OO.EventEmitter );
+       OO.mixinClass( FiltersViewModel, OO.EmitterList );
+
+       /* Events */
+
+       /**
+        * @event initialize
+        *
+        * Filter list is initialized
+        */
+
+       /**
+        * @event update
+        *
+        * Model has been updated
+        */
+
+       /**
+        * @event itemUpdate
+        * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
+        *
+        * Filter item has changed
+        */
+
+       /**
+        * @event highlightChange
+        * @param {boolean} Highlight feature is enabled
+        *
+        * Highlight feature has been toggled enabled or disabled
+        */
+
+       /* Methods */
+
+       /**
+        * Re-assess the states of filter items based on the interactions between them
+        *
+        * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
+        *  method will go over the state of all items
+        */
+       FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
+               var allSelected,
+                       model = this,
+                       iterationItems = item !== undefined ? [ item ] : this.getItems();
+
+               iterationItems.forEach( function ( checkedItem ) {
+                       var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
+                               groupModel = checkedItem.getGroupModel();
+
+                       // Check for subsets (included filters) plus the item itself:
+                       allCheckedItems.forEach( function ( filterItemName ) {
+                               var itemInSubset = model.getItemByName( filterItemName );
+
+                               itemInSubset.toggleIncluded(
+                                       // If any of itemInSubset's supersets are selected, this item
+                                       // is included
+                                       itemInSubset.getSuperset().some( function ( supersetName ) {
+                                               return ( model.getItemByName( supersetName ).isSelected() );
+                                       } )
+                               );
+                       } );
+
+                       // Update coverage for the changed group
+                       if ( groupModel.isFullCoverage() ) {
+                               allSelected = groupModel.areAllSelected();
+                               groupModel.getItems().forEach( function ( filterItem ) {
+                                       filterItem.toggleFullyCovered( allSelected );
+                               } );
+                       }
+               } );
+
+               // Check for conflicts
+               // In this case, we must go over all items, since
+               // conflicts are bidirectional and depend not only on
+               // individual items, but also on the selected states of
+               // the groups they're in.
+               this.getItems().forEach( function ( filterItem ) {
+                       var inConflict = false,
+                               filterItemGroup = filterItem.getGroupModel();
+
+                       // For each item, see if that item is still conflicting
+                       // eslint-disable-next-line jquery/no-each-util
+                       $.each( model.groups, function ( groupName, groupModel ) {
+                               if ( filterItem.getGroupName() === groupName ) {
+                                       // Check inside the group
+                                       inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
+                               } else {
+                                       // According to the spec, if two items conflict from two different
+                                       // groups, the conflict only lasts if the groups **only have selected
+                                       // items that are conflicting**. If a group has selected items that
+                                       // are conflicting and non-conflicting, the scope of the result has
+                                       // expanded enough to completely remove the conflict.
+
+                                       // For example, see two groups with conflicts:
+                                       // userExpLevel: [
+                                       //   {
+                                       //     name: 'experienced',
+                                       //     conflicts: [ 'unregistered' ]
+                                       //   }
+                                       // ],
+                                       // registration: [
+                                       //   {
+                                       //     name: 'registered',
+                                       //   },
+                                       //   {
+                                       //     name: 'unregistered',
+                                       //   }
+                                       // ]
+                                       // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
+                                       // because, inherently, 'experienced' filter only includes registered users, and so
+                                       // both filters are in conflict with one another.
+                                       // However, the minute we select 'registered', the scope of our results
+                                       // has expanded to no longer have a conflict with 'experienced' filter, and
+                                       // so the conflict is removed.
+
+                                       // In our case, we need to check if the entire group conflicts with
+                                       // the entire item's group, so we follow the above spec
+                                       inConflict = (
+                                               // The foreign group is in conflict with this item
+                                               groupModel.areAllSelectedInConflictWith( filterItem ) &&
+                                               // Every selected member of the item's own group is also
+                                               // in conflict with the other group
+                                               filterItemGroup.findSelectedItems().every( function ( otherGroupItem ) {
+                                                       return groupModel.areAllSelectedInConflictWith( otherGroupItem );
+                                               } )
+                                       );
+                               }
+
+                               // If we're in conflict, this will return 'false' which
+                               // will break the loop. Otherwise, we're not in conflict
+                               // and the loop continues
+                               return !inConflict;
+                       } );
+
+                       // Toggle the item state
+                       filterItem.toggleConflicted( inConflict );
+               } );
+       };
+
+       /**
+        * Get whether the model has any conflict in its items
+        *
+        * @return {boolean} There is a conflict
+        */
+       FiltersViewModel.prototype.hasConflict = function () {
+               return this.getItems().some( function ( filterItem ) {
+                       return filterItem.isSelected() && filterItem.isConflicted();
+               } );
+       };
+
+       /**
+        * Get the first item with a current conflict
+        *
+        * @return {mw.rcfilters.dm.FilterItem} Conflicted item
+        */
+       FiltersViewModel.prototype.getFirstConflictedItem = function () {
+               var conflictedItem;
+
+               this.getItems().forEach( function ( filterItem ) {
+                       if ( filterItem.isSelected() && filterItem.isConflicted() ) {
+                               conflictedItem = filterItem;
+                               return false;
+                       }
+               } );
+
+               return conflictedItem;
+       };
+
+       /**
+        * Set filters and preserve a group relationship based on
+        * the definition given by an object
+        *
+        * @param {Array} filterGroups Filters definition
+        * @param {Object} [views] Extra views definition
+        *  Expected in the following format:
+        *  {
+        *     namespaces: {
+        *       label: 'namespaces', // Message key
+        *       trigger: ':',
+        *       groups: [
+        *         {
+        *            // Group info
+        *            name: 'namespaces' // Parameter name
+        *            title: 'namespaces' // Message key
+        *            type: 'string_options',
+        *            separator: ';',
+        *            labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
+        *            fullCoverage: true
+        *            items: []
+        *         }
+        *       ]
+        *     }
+        *  }
+        */
+       FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
+               var filterConflictResult, groupConflictResult,
+                       allViews = {},
+                       model = this,
+                       items = [],
+                       groupConflictMap = {},
+                       filterConflictMap = {},
+                       /*!
+                        * Expand a conflict definition from group name to
+                        * the list of all included filters in that group.
+                        * We do this so that the direct relationship in the
+                        * models are consistently item->items rather than
+                        * mixing item->group with item->item.
+                        *
+                        * @param {Object} obj Conflict definition
+                        * @return {Object} Expanded conflict definition
+                        */
+                       expandConflictDefinitions = function ( obj ) {
+                               var result = {};
+
+                               // eslint-disable-next-line jquery/no-each-util
+                               $.each( obj, function ( key, conflicts ) {
+                                       var filterName,
+                                               adjustedConflicts = {};
+
+                                       conflicts.forEach( function ( conflict ) {
+                                               var filter;
+
+                                               if ( conflict.filter ) {
+                                                       filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
+                                                       filter = model.getItemByName( filterName );
+
+                                                       // Rename
+                                                       adjustedConflicts[ filterName ] = $.extend(
+                                                               {},
+                                                               conflict,
+                                                               {
+                                                                       filter: filterName,
+                                                                       item: filter
+                                                               }
+                                                       );
+                                               } else {
+                                                       // This conflict is for an entire group. Split it up to
+                                                       // represent each filter
+
+                                                       // Get the relevant group items
+                                                       model.groups[ conflict.group ].getItems().forEach( function ( groupItem ) {
+                                                               // Rebuild the conflict
+                                                               adjustedConflicts[ groupItem.getName() ] = $.extend(
+                                                                       {},
+                                                                       conflict,
+                                                                       {
+                                                                               filter: groupItem.getName(),
+                                                                               item: groupItem
+                                                                       }
+                                                               );
+                                                       } );
+                                               }
+                                       } );
+
+                                       result[ key ] = adjustedConflicts;
+                               } );
+
+                               return result;
+                       };
+
+               // Reset
+               this.clearItems();
+               this.groups = {};
+               this.views = {};
+
+               // Clone
+               filterGroups = OO.copy( filterGroups );
+
+               // Normalize definition from the server
+               filterGroups.forEach( function ( data ) {
+                       var i;
+                       // What's this information needs to be normalized
+                       data.whatsThis = {
+                               body: data.whatsThisBody,
+                               header: data.whatsThisHeader,
+                               linkText: data.whatsThisLinkText,
+                               url: data.whatsThisUrl
+                       };
+
+                       // Title is a msg-key
+                       data.title = data.title ? mw.msg( data.title ) : data.name;
+
+                       // Filters are given to us with msg-keys, we need
+                       // to translate those before we hand them off
+                       for ( i = 0; i < data.filters.length; i++ ) {
+                               data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
+                               data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
+                       }
+               } );
+
+               // Collect views
+               allViews = $.extend( true, {
+                       default: {
+                               title: mw.msg( 'rcfilters-filterlist-title' ),
+                               groups: filterGroups
+                       }
+               }, views );
+
+               // Go over all views
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( allViews, function ( viewName, viewData ) {
+                       // Define the view
+                       model.views[ viewName ] = {
+                               name: viewData.name,
+                               title: viewData.title,
+                               trigger: viewData.trigger
+                       };
+
+                       // Go over groups
+                       viewData.groups.forEach( function ( groupData ) {
+                               var group = groupData.name;
+
+                               if ( !model.groups[ group ] ) {
+                                       model.groups[ group ] = new FilterGroup(
+                                               group,
+                                               $.extend( true, {}, groupData, { view: viewName } )
+                                       );
+                               }
+
+                               model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
+                               items = items.concat( model.groups[ group ].getItems() );
+
+                               // Prepare conflicts
+                               if ( groupData.conflicts ) {
+                                       // Group conflicts
+                                       groupConflictMap[ group ] = groupData.conflicts;
+                               }
+
+                               groupData.filters.forEach( function ( itemData ) {
+                                       var filterItem = model.groups[ group ].getItemByParamName( itemData.name );
+                                       // Filter conflicts
+                                       if ( itemData.conflicts ) {
+                                               filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
+                                       }
+                               } );
+                       } );
+               } );
+
+               // Add item references to the model, for lookup
+               this.addItems( items );
+
+               // Expand conflicts
+               groupConflictResult = expandConflictDefinitions( groupConflictMap );
+               filterConflictResult = expandConflictDefinitions( filterConflictMap );
+
+               // Set conflicts for groups
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( groupConflictResult, function ( group, conflicts ) {
+                       model.groups[ group ].setConflicts( conflicts );
+               } );
+
+               // Set conflicts for items
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( filterConflictResult, function ( filterName, conflicts ) {
+                       var filterItem = model.getItemByName( filterName );
+                       // set conflicts for items in the group
+                       filterItem.setConflicts( conflicts );
+               } );
+
+               // Create a map between known parameters and their models
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.groups, function ( group, groupModel ) {
+                       if (
+                               groupModel.getType() === 'send_unselected_if_any' ||
+                               groupModel.getType() === 'boolean' ||
+                               groupModel.getType() === 'any_value'
+                       ) {
+                               // Individual filters
+                               groupModel.getItems().forEach( function ( filterItem ) {
+                                       model.parameterMap[ filterItem.getParamName() ] = filterItem;
+                               } );
+                       } else if (
+                               groupModel.getType() === 'string_options' ||
+                               groupModel.getType() === 'single_option'
+                       ) {
+                               // Group
+                               model.parameterMap[ groupModel.getName() ] = groupModel;
+                       }
+               } );
+
+               this.setSearch( '' );
+
+               this.updateHighlightedState();
+
+               // Finish initialization
+               this.emit( 'initialize' );
+       };
+
+       /**
+        * Update filter view model state based on a parameter object
+        *
+        * @param {Object} params Parameters object
+        */
+       FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
+               var filtersValue;
+               // For arbitrary numeric single_option values make sure the values
+               // are normalized to fit within the limits
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
+                       params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
+               } );
+
+               // Update filter values
+               filtersValue = this.getFiltersFromParameters( params );
+               Object.keys( filtersValue ).forEach( function ( filterName ) {
+                       this.getItemByName( filterName ).setValue( filtersValue[ filterName ] );
+               }.bind( this ) );
+
+               // Update highlight state
+               this.getItemsSupportingHighlights().forEach( function ( filterItem ) {
+                       var color = params[ filterItem.getName() + '_color' ];
+                       if ( color ) {
+                               filterItem.setHighlightColor( color );
+                       } else {
+                               filterItem.clearHighlightColor();
+                       }
+               } );
+               this.updateHighlightedState();
+
+               // Check all filter interactions
+               this.reassessFilterInteractions();
+       };
+
+       /**
+        * Get a representation of an empty (falsey) parameter state
+        *
+        * @return {Object} Empty parameter state
+        */
+       FiltersViewModel.prototype.getEmptyParameterState = function () {
+               if ( !this.emptyParameterState ) {
+                       this.emptyParameterState = $.extend(
+                               true,
+                               {},
+                               this.getParametersFromFilters( {} ),
+                               this.getEmptyHighlightParameters()
+                       );
+               }
+               return this.emptyParameterState;
+       };
+
+       /**
+        * Get a representation of only the non-falsey parameters
+        *
+        * @param {Object} [parameters] A given parameter state to minimize. If not given the current
+        *  state of the system will be used.
+        * @return {Object} Empty parameter state
+        */
+       FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
+               var result = {};
+
+               parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
+
+               // Params
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.getEmptyParameterState(), function ( param, value ) {
+                       if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
+                               result[ param ] = parameters[ param ];
+                       }
+               } );
+
+               // Highlights
+               Object.keys( this.getEmptyHighlightParameters() ).forEach( function ( param ) {
+                       if ( parameters[ param ] ) {
+                               // If a highlight parameter is not undefined and not null
+                               // add it to the result
+                               result[ param ] = parameters[ param ];
+                       }
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get a representation of the full parameter list, including all base values
+        *
+        * @return {Object} Full parameter representation
+        */
+       FiltersViewModel.prototype.getExpandedParamRepresentation = function () {
+               return $.extend(
+                       true,
+                       {},
+                       this.getEmptyParameterState(),
+                       this.getCurrentParameterState()
+               );
+       };
+
+       /**
+        * Get a parameter representation of the current state of the model
+        *
+        * @param {boolean} [removeStickyParams] Remove sticky filters from final result
+        * @return {Object} Parameter representation of the current state of the model
+        */
+       FiltersViewModel.prototype.getCurrentParameterState = function ( removeStickyParams ) {
+               var state = this.getMinimizedParamRepresentation( $.extend(
+                       true,
+                       {},
+                       this.getParametersFromFilters( this.getSelectedState() ),
+                       this.getHighlightParameters()
+               ) );
+
+               if ( removeStickyParams ) {
+                       state = this.removeStickyParams( state );
+               }
+
+               return state;
+       };
+
+       /**
+        * Delete sticky parameters from given object.
+        *
+        * @param {Object} paramState Parameter state
+        * @return {Object} Parameter state without sticky parameters
+        */
+       FiltersViewModel.prototype.removeStickyParams = function ( paramState ) {
+               this.getStickyParams().forEach( function ( paramName ) {
+                       delete paramState[ paramName ];
+               } );
+
+               return paramState;
+       };
+
+       /**
+        * Turn the highlight feature on or off
+        */
+       FiltersViewModel.prototype.updateHighlightedState = function () {
+               this.toggleHighlight( this.getHighlightedItems().length > 0 );
+       };
+
+       /**
+        * Get the object that defines groups by their name.
+        *
+        * @return {Object} Filter groups
+        */
+       FiltersViewModel.prototype.getFilterGroups = function () {
+               return this.groups;
+       };
+
+       /**
+        * Get the object that defines groups that match a certain view by their name.
+        *
+        * @param {string} [view] Requested view. If not given, uses current view
+        * @return {Object} Filter groups matching a display group
+        */
+       FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
+               var result = {};
+
+               view = view || this.getCurrentView();
+
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.groups, function ( groupName, groupModel ) {
+                       if ( groupModel.getView() === view ) {
+                               result[ groupName ] = groupModel;
+                       }
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get an array of filters matching the given display group.
+        *
+        * @param {string} [view] Requested view. If not given, uses current view
+        * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
+        */
+       FiltersViewModel.prototype.getFiltersByView = function ( view ) {
+               var groups,
+                       result = [];
+
+               view = view || this.getCurrentView();
+
+               groups = this.getFilterGroupsByView( view );
+
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( groups, function ( groupName, groupModel ) {
+                       result = result.concat( groupModel.getItems() );
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get the trigger for the requested view.
+        *
+        * @param {string} view View name
+        * @return {string} View trigger, if exists
+        */
+       FiltersViewModel.prototype.getViewTrigger = function ( view ) {
+               return ( this.views[ view ] && this.views[ view ].trigger ) || '';
+       };
+
+       /**
+        * Get the value of a specific parameter
+        *
+        * @param {string} name Parameter name
+        * @return {number|string} Parameter value
+        */
+       FiltersViewModel.prototype.getParamValue = function ( name ) {
+               return this.parameters[ name ];
+       };
+
+       /**
+        * Get the current selected state of the filters
+        *
+        * @param {boolean} [onlySelected] return an object containing only the filters with a value
+        * @return {Object} Filters selected state
+        */
+       FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) {
+               var i,
+                       items = this.getItems(),
+                       result = {};
+
+               for ( i = 0; i < items.length; i++ ) {
+                       if ( !onlySelected || items[ i ].getValue() ) {
+                               result[ items[ i ].getName() ] = items[ i ].getValue();
+                       }
+               }
+
+               return result;
+       };
+
+       /**
+        * Get the current full state of the filters
+        *
+        * @return {Object} Filters full state
+        */
+       FiltersViewModel.prototype.getFullState = function () {
+               var i,
+                       items = this.getItems(),
+                       result = {};
+
+               for ( i = 0; i < items.length; i++ ) {
+                       result[ items[ i ].getName() ] = {
+                               selected: items[ i ].isSelected(),
+                               conflicted: items[ i ].isConflicted(),
+                               included: items[ i ].isIncluded()
+                       };
+               }
+
+               return result;
+       };
+
+       /**
+        * Get an object representing default parameters state
+        *
+        * @return {Object} Default parameter values
+        */
+       FiltersViewModel.prototype.getDefaultParams = function () {
+               var result = {};
+
+               // Get default filter state
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.groups, function ( name, model ) {
+                       if ( !model.isSticky() ) {
+                               $.extend( true, result, model.getDefaultParams() );
+                       }
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get a parameter representation of all sticky parameters
+        *
+        * @return {Object} Sticky parameter values
+        */
+       FiltersViewModel.prototype.getStickyParams = function () {
+               var result = [];
+
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.groups, function ( name, model ) {
+                       if ( model.isSticky() ) {
+                               if ( model.isPerGroupRequestParameter() ) {
+                                       result.push( name );
+                               } else {
+                                       // Each filter is its own param
+                                       result = result.concat( model.getItems().map( function ( filterItem ) {
+                                               return filterItem.getParamName();
+                                       } ) );
+                               }
+                       }
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get a parameter representation of all sticky parameters
+        *
+        * @return {Object} Sticky parameter values
+        */
+       FiltersViewModel.prototype.getStickyParamsValues = function () {
+               var result = {};
+
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.groups, function ( name, model ) {
+                       if ( model.isSticky() ) {
+                               $.extend( true, result, model.getParamRepresentation() );
+                       }
+               } );
+
+               return result;
+       };
+
+       /**
+        * Analyze the groups and their filters and output an object representing
+        * the state of the parameters they represent.
+        *
+        * @param {Object} [filterDefinition] An object defining the filter values,
+        *  keyed by filter names.
+        * @return {Object} Parameter state object
+        */
+       FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
+               var groupItemDefinition,
+                       result = {},
+                       groupItems = this.getFilterGroups();
+
+               if ( filterDefinition ) {
+                       groupItemDefinition = {};
+                       // Filter definition is "flat", but in effect
+                       // each group needs to tell us its result based
+                       // on the values in it. We need to split this list
+                       // back into groupings so we can "feed" it to the
+                       // loop below, and we need to expand it so it includes
+                       // all filters (set to false)
+                       this.getItems().forEach( function ( filterItem ) {
+                               groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
+                               groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] );
+                       } );
+               }
+
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( groupItems, function ( group, model ) {
+                       $.extend(
+                               result,
+                               model.getParamRepresentation(
+                                       groupItemDefinition ?
+                                               groupItemDefinition[ group ] : null
+                               )
+                       );
+               } );
+
+               return result;
+       };
+
+       /**
+        * This is the opposite of the #getParametersFromFilters method; this goes over
+        * the given parameters and translates into a selected/unselected value in the filters.
+        *
+        * @param {Object} params Parameters query object
+        * @return {Object} Filter state object
+        */
+       FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
+               var groupMap = {},
+                       model = this,
+                       result = {};
+
+               // Go over the given parameters, break apart to groupings
+               // The resulting object represents the group with its parameter
+               // values. For example:
+               // {
+               //    group1: {
+               //       param1: "1",
+               //       param2: "0",
+               //       param3: "1"
+               //    },
+               //    group2: "param4|param5"
+               // }
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( params, function ( paramName, paramValue ) {
+                       var groupName,
+                               itemOrGroup = model.parameterMap[ paramName ];
+
+                       if ( itemOrGroup ) {
+                               groupName = itemOrGroup instanceof FilterItem ?
+                                       itemOrGroup.getGroupName() : itemOrGroup.getName();
+
+                               groupMap[ groupName ] = groupMap[ groupName ] || {};
+                               groupMap[ groupName ][ paramName ] = paramValue;
+                       }
+               } );
+
+               // Go over all groups, so we make sure we get the complete output
+               // even if the parameters don't include a certain group
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.groups, function ( groupName, groupModel ) {
+                       result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get the highlight parameters based on current filter configuration
+        *
+        * @return {Object} Object where keys are `<filter name>_color` and values
+        *                  are the selected highlight colors.
+        */
+       FiltersViewModel.prototype.getHighlightParameters = function () {
+               var highlightEnabled = this.isHighlightEnabled(),
+                       result = {};
+
+               this.getItems().forEach( function ( filterItem ) {
+                       if ( filterItem.isHighlightSupported() ) {
+                               result[ filterItem.getName() + '_color' ] = highlightEnabled && filterItem.isHighlighted() ?
+                                       filterItem.getHighlightColor() :
+                                       null;
+                       }
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get an object representing the complete empty state of highlights
+        *
+        * @return {Object} Object containing all the highlight parameters set to their negative value
+        */
+       FiltersViewModel.prototype.getEmptyHighlightParameters = function () {
+               var result = {};
+
+               this.getItems().forEach( function ( filterItem ) {
+                       if ( filterItem.isHighlightSupported() ) {
+                               result[ filterItem.getName() + '_color' ] = null;
+                       }
+               } );
+
+               return result;
+       };
+
+       /**
+        * Get an array of currently applied highlight colors
+        *
+        * @return {string[]} Currently applied highlight colors
+        */
+       FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
+               var result = [];
+
+               if ( this.isHighlightEnabled() ) {
+                       this.getHighlightedItems().forEach( function ( filterItem ) {
+                               var color = filterItem.getHighlightColor();
+
+                               if ( result.indexOf( color ) === -1 ) {
+                                       result.push( color );
+                               }
+                       } );
+               }
+
+               return result;
+       };
+
+       /**
+        * Sanitize value group of a string_option groups type
+        * Remove duplicates and make sure to only use valid
+        * values.
+        *
+        * @private
+        * @param {string} groupName Group name
+        * @param {string[]} valueArray Array of values
+        * @return {string[]} Array of valid values
+        */
+       FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
+               var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
+                       return filterItem.getParamName();
+               } );
+
+               return mw.rcfilters.utils.normalizeParamOptions( valueArray, validNames );
+       };
+
+       /**
+        * Check whether no visible filter is selected.
+        *
+        * Filter groups that are hidden or sticky are not shown in the
+        * active filters area and therefore not included in this check.
+        *
+        * @return {boolean} No visible filter is selected
+        */
+       FiltersViewModel.prototype.areVisibleFiltersEmpty = function () {
+               // Check if there are either any selected items or any items
+               // that have highlight enabled
+               return !this.getItems().some( function ( filterItem ) {
+                       var visible = !filterItem.getGroupModel().isSticky() && !filterItem.getGroupModel().isHidden(),
+                               active = ( filterItem.isSelected() || filterItem.isHighlighted() );
+                       return visible && active;
+               } );
+       };
+
+       /**
+        * Check whether the invert state is a valid one. A valid invert state is one where
+        * there are actual namespaces selected.
+        *
+        * This is done to compare states to previous ones that may have had the invert model
+        * selected but effectively had no namespaces, so are not effectively different than
+        * ones where invert is not selected.
+        *
+        * @return {boolean} Invert is effectively selected
+        */
+       FiltersViewModel.prototype.areNamespacesEffectivelyInverted = function () {
+               return this.getInvertModel().isSelected() &&
+                       this.findSelectedItems().some( function ( itemModel ) {
+                               return itemModel.getGroupModel().getName() === 'namespace';
+                       } );
+       };
+
+       /**
+        * Get the item that matches the given name
+        *
+        * @param {string} name Filter name
+        * @return {mw.rcfilters.dm.FilterItem} Filter item
+        */
+       FiltersViewModel.prototype.getItemByName = function ( name ) {
+               return this.getItems().filter( function ( item ) {
+                       return name === item.getName();
+               } )[ 0 ];
+       };
+
+       /**
+        * Set all filters to false or empty/all
+        * This is equivalent to display all.
+        */
+       FiltersViewModel.prototype.emptyAllFilters = function () {
+               this.getItems().forEach( function ( filterItem ) {
+                       if ( !filterItem.getGroupModel().isSticky() ) {
+                               this.toggleFilterSelected( filterItem.getName(), false );
+                       }
+               }.bind( this ) );
+       };
+
+       /**
+        * Toggle selected state of one item
+        *
+        * @param {string} name Name of the filter item
+        * @param {boolean} [isSelected] Filter selected state
+        */
+       FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
+               var item = this.getItemByName( name );
+
+               if ( item ) {
+                       item.toggleSelected( isSelected );
+               }
+       };
+
+       /**
+        * Toggle selected state of items by their names
+        *
+        * @param {Object} filterDef Filter definitions
+        */
+       FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
+               Object.keys( filterDef ).forEach( function ( name ) {
+                       this.toggleFilterSelected( name, filterDef[ name ] );
+               }.bind( this ) );
+       };
+
+       /**
+        * Get a group model from its name
+        *
+        * @param {string} groupName Group name
+        * @return {mw.rcfilters.dm.FilterGroup} Group model
+        */
+       FiltersViewModel.prototype.getGroup = function ( groupName ) {
+               return this.groups[ groupName ];
+       };
+
+       /**
+        * Get all filters within a specified group by its name
+        *
+        * @param {string} groupName Group name
+        * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
+        */
+       FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
+               return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
+       };
+
+       /**
+        * Find items whose labels match the given string
+        *
+        * @param {string} query Search string
+        * @param {boolean} [returnFlat] Return a flat array. If false, the result
+        *  is an object whose keys are the group names and values are an array of
+        *  filters per group. If set to true, returns an array of filters regardless
+        *  of their groups.
+        * @return {Object} An object of items to show
+        *  arranged by their group names
+        */
+       FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
+               var i, searchIsEmpty,
+                       groupTitle,
+                       result = {},
+                       flatResult = [],
+                       view = this.getViewByTrigger( query.substr( 0, 1 ) ),
+                       items = this.getFiltersByView( view );
+
+               // Normalize so we can search strings regardless of case and view
+               query = query.trim().toLowerCase();
+               if ( view !== 'default' ) {
+                       query = query.substr( 1 );
+               }
+               // Trim again to also intercept cases where the spaces were after the trigger
+               // eg: '#   str'
+               query = query.trim();
+
+               // Check if the search if actually empty; this can be a problem when
+               // we use prefixes to denote different views
+               searchIsEmpty = query.length === 0;
+
+               // item label starting with the query string
+               for ( i = 0; i < items.length; i++ ) {
+                       if (
+                               searchIsEmpty ||
+                               items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
+                               (
+                                       // For tags, we want the parameter name to be included in the search
+                                       view === 'tags' &&
+                                       items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
+                               )
+                       ) {
+                               result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
+                               result[ items[ i ].getGroupName() ].push( items[ i ] );
+                               flatResult.push( items[ i ] );
+                       }
+               }
+
+               if ( $.isEmptyObject( result ) ) {
+                       // item containing the query string in their label, description, or group title
+                       for ( i = 0; i < items.length; i++ ) {
+                               groupTitle = items[ i ].getGroupModel().getTitle();
+                               if (
+                                       searchIsEmpty ||
+                                       items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
+                                       items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
+                                       groupTitle.toLowerCase().indexOf( query ) > -1 ||
+                                       (
+                                               // For tags, we want the parameter name to be included in the search
+                                               view === 'tags' &&
+                                               items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
+                                       )
+                               ) {
+                                       result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
+                                       result[ items[ i ].getGroupName() ].push( items[ i ] );
+                                       flatResult.push( items[ i ] );
+                               }
+                       }
+               }
+
+               return returnFlat ? flatResult : result;
+       };
+
+       /**
+        * Get items that are highlighted
+        *
+        * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
+        */
+       FiltersViewModel.prototype.getHighlightedItems = function () {
+               return this.getItems().filter( function ( filterItem ) {
+                       return filterItem.isHighlightSupported() &&
+                               filterItem.getHighlightColor();
+               } );
+       };
+
+       /**
+        * Get items that allow highlights even if they're not currently highlighted
+        *
+        * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
+        */
+       FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
+               return this.getItems().filter( function ( filterItem ) {
+                       return filterItem.isHighlightSupported();
+               } );
+       };
+
+       /**
+        * Get all selected items
+        *
+        * @return {mw.rcfilters.dm.FilterItem[]} Selected items
+        */
+       FiltersViewModel.prototype.findSelectedItems = function () {
+               var allSelected = [];
+
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
+                       allSelected = allSelected.concat( groupModel.findSelectedItems() );
+               } );
+
+               return allSelected;
+       };
+
+       /**
+        * Get the current view
+        *
+        * @return {string} Current view
+        */
+       FiltersViewModel.prototype.getCurrentView = function () {
+               return this.currentView;
+       };
+
+       /**
+        * Get the label for the current view
+        *
+        * @param {string} viewName View name
+        * @return {string} Label for the current view
+        */
+       FiltersViewModel.prototype.getViewTitle = function ( viewName ) {
+               viewName = viewName || this.getCurrentView();
+
+               return this.views[ viewName ] && this.views[ viewName ].title;
+       };
+
+       /**
+        * Get the view that fits the given trigger
+        *
+        * @param {string} trigger Trigger
+        * @return {string} Name of view
+        */
+       FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
+               var result = 'default';
+
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( this.views, function ( name, data ) {
+                       if ( data.trigger === trigger ) {
+                               result = name;
+                       }
+               } );
+
+               return result;
+       };
+
+       /**
+        * Return a version of the given string that is without any
+        * view triggers.
+        *
+        * @param {string} str Given string
+        * @return {string} Result
+        */
+       FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
+               if ( this.getViewFromString( str ) !== 'default' ) {
+                       str = str.substr( 1 );
+               }
+
+               return str;
+       };
+
+       /**
+        * Get the view from the given string by a trigger, if it exists
+        *
+        * @param {string} str Given string
+        * @return {string} View name
+        */
+       FiltersViewModel.prototype.getViewFromString = function ( str ) {
+               return this.getViewByTrigger( str.substr( 0, 1 ) );
+       };
+
+       /**
+        * Set the current search for the system.
+        * This also dictates what items and groups are visible according
+        * to the search in #findMatches
+        *
+        * @param {string} searchQuery Search query, including triggers
+        * @fires searchChange
+        */
+       FiltersViewModel.prototype.setSearch = function ( searchQuery ) {
+               var visibleGroups, visibleGroupNames;
+
+               if ( this.searchQuery !== searchQuery ) {
+                       // Check if the view changed
+                       this.switchView( this.getViewFromString( searchQuery ) );
+
+                       visibleGroups = this.findMatches( searchQuery );
+                       visibleGroupNames = Object.keys( visibleGroups );
+
+                       // Update visibility of items and groups
+                       // eslint-disable-next-line jquery/no-each-util
+                       $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
+                               // Check if the group is visible at all
+                               groupModel.toggleVisible( visibleGroupNames.indexOf( groupName ) !== -1 );
+                               groupModel.setVisibleItems( visibleGroups[ groupName ] || [] );
+                       } );
+
+                       this.searchQuery = searchQuery;
+                       this.emit( 'searchChange', this.searchQuery );
+               }
+       };
+
+       /**
+        * Get the current search
+        *
+        * @return {string} Current search query
+        */
+       FiltersViewModel.prototype.getSearch = function () {
+               return this.searchQuery;
+       };
+
+       /**
+        * Switch the current view
+        *
+        * @private
+        * @param {string} view View name
+        */
+       FiltersViewModel.prototype.switchView = function ( view ) {
+               if ( this.views[ view ] && this.currentView !== view ) {
+                       this.currentView = view;
+               }
+       };
+
+       /**
+        * Toggle the highlight feature on and off.
+        * Propagate the change to filter items.
+        *
+        * @param {boolean} enable Highlight should be enabled
+        * @fires highlightChange
+        */
+       FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
+               enable = enable === undefined ? !this.highlightEnabled : enable;
+
+               if ( this.highlightEnabled !== enable ) {
+                       this.highlightEnabled = enable;
+                       this.emit( 'highlightChange', this.highlightEnabled );
+               }
+       };
+
+       /**
+        * Check if the highlight feature is enabled
+        * @return {boolean}
+        */
+       FiltersViewModel.prototype.isHighlightEnabled = function () {
+               return !!this.highlightEnabled;
+       };
+
+       /**
+        * Toggle the inverted namespaces property on and off.
+        * Propagate the change to namespace filter items.
+        *
+        * @param {boolean} enable Inverted property is enabled
+        */
+       FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
+               this.toggleFilterSelected( this.getInvertModel().getName(), enable );
+       };
+
+       /**
+        * Get the model object that represents the 'invert' filter
+        *
+        * @return {mw.rcfilters.dm.FilterItem}
+        */
+       FiltersViewModel.prototype.getInvertModel = function () {
+               return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' );
+       };
+
+       /**
+        * Set highlight color for a specific filter item
+        *
+        * @param {string} filterName Name of the filter item
+        * @param {string} color Selected color
+        */
+       FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
+               this.getItemByName( filterName ).setHighlightColor( color );
+       };
+
+       /**
+        * Clear highlight for a specific filter item
+        *
+        * @param {string} filterName Name of the filter item
+        */
+       FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
+               this.getItemByName( filterName ).clearHighlightColor();
+       };
+
+       module.exports = FiltersViewModel;
+
+}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/ItemModel.js b/resources/src/mediawiki.rcfilters/dm/ItemModel.js
new file mode 100644 (file)
index 0000000..2dc578e
--- /dev/null
@@ -0,0 +1,276 @@
+( function () {
+       /**
+        * RCFilter base item model
+        *
+        * @class mw.rcfilters.dm.ItemModel
+        * @mixins OO.EventEmitter
+        *
+        * @constructor
+        * @param {string} param Filter param name
+        * @param {Object} config Configuration object
+        * @cfg {string} [label] The label for the filter
+        * @cfg {string} [description] The description of the filter
+        * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
+        *  group. If the prefix has 'invert' state, the parameter is expected to be an object
+        *  with 'default' and 'inverted' as keys.
+        * @cfg {boolean} [active=true] The filter is active and affecting the result
+        * @cfg {boolean} [selected] The item is selected
+        * @cfg {*} [value] The value of this item
+        * @cfg {string} [namePrefix='item_'] A prefix to add to the param name to act as a unique
+        *  identifier
+        * @cfg {string} [cssClass] The class identifying the results that match this filter
+        * @cfg {string[]} [identifiers] An array of identifiers for this item. They will be
+        *  added and considered in the view.
+        * @cfg {string} [defaultHighlightColor=null] If set, highlight this filter by default with this color
+        */
+       var ItemModel = function MwRcfiltersDmItemModel( param, config ) {
+               config = config || {};
+
+               // Mixin constructor
+               OO.EventEmitter.call( this );
+
+               this.param = param;
+               this.namePrefix = config.namePrefix || 'item_';
+               this.name = this.namePrefix + param;
+
+               this.label = config.label || this.name;
+               this.labelPrefixKey = config.labelPrefixKey;
+               this.description = config.description || '';
+               this.setValue( config.value || config.selected );
+
+               this.identifiers = config.identifiers || [];
+
+               // Highlight
+               this.cssClass = config.cssClass;
+               this.highlightColor = config.defaultHighlightColor || null;
+       };
+
+       /* Initialization */
+
+       OO.initClass( ItemModel );
+       OO.mixinClass( ItemModel, OO.EventEmitter );
+
+       /* Events */
+
+       /**
+        * @event update
+        *
+        * The state of this filter has changed
+        */
+
+       /* Methods */
+
+       /**
+        * Return the representation of the state of this item.
+        *
+        * @return {Object} State of the object
+        */
+       ItemModel.prototype.getState = function () {
+               return {
+                       selected: this.isSelected()
+               };
+       };
+
+       /**
+        * Get the name of this filter
+        *
+        * @return {string} Filter name
+        */
+       ItemModel.prototype.getName = function () {
+               return this.name;
+       };
+
+       /**
+        * Get the message key to use to wrap the label. This message takes the label as a parameter.
+        *
+        * @param {boolean} inverted Whether this item should be considered inverted
+        * @return {string|null} Message key, or null if no message
+        */
+       ItemModel.prototype.getLabelMessageKey = function ( inverted ) {
+               if ( this.labelPrefixKey ) {
+                       if ( typeof this.labelPrefixKey === 'string' ) {
+                               return this.labelPrefixKey;
+                       }
+                       return this.labelPrefixKey[
+                               // Only use inverted-prefix if the item is selected
+                               // Highlight-only an inverted item makes no sense
+                               inverted && this.isSelected() ?
+                                       'inverted' : 'default'
+                       ];
+               }
+               return null;
+       };
+
+       /**
+        * Get the param name or value of this filter
+        *
+        * @return {string} Filter param name
+        */
+       ItemModel.prototype.getParamName = function () {
+               return this.param;
+       };
+
+       /**
+        * Get the message representing the state of this model.
+        *
+        * @return {string} State message
+        */
+       ItemModel.prototype.getStateMessage = function () {
+               // Display description
+               return this.getDescription();
+       };
+
+       /**
+        * Get the label of this filter
+        *
+        * @return {string} Filter label
+        */
+       ItemModel.prototype.getLabel = function () {
+               return this.label;
+       };
+
+       /**
+        * Get the description of this filter
+        *
+        * @return {string} Filter description
+        */
+       ItemModel.prototype.getDescription = function () {
+               return this.description;
+       };
+
+       /**
+        * Get the default value of this filter
+        *
+        * @return {boolean} Filter default
+        */
+       ItemModel.prototype.getDefault = function () {
+               return this.default;
+       };
+
+       /**
+        * Get the selected state of this filter
+        *
+        * @return {boolean} Filter is selected
+        */
+       ItemModel.prototype.isSelected = function () {
+               return !!this.value;
+       };
+
+       /**
+        * Toggle the selected state of the item
+        *
+        * @param {boolean} [isSelected] Filter is selected
+        * @fires update
+        */
+       ItemModel.prototype.toggleSelected = function ( isSelected ) {
+               isSelected = isSelected === undefined ? !this.isSelected() : isSelected;
+               this.setValue( isSelected );
+       };
+
+       /**
+        * Get the value
+        *
+        * @return {*}
+        */
+       ItemModel.prototype.getValue = function () {
+               return this.value;
+       };
+
+       /**
+        * Convert a given value to the appropriate representation based on group type
+        *
+        * @param {*} value
+        * @return {*}
+        */
+       ItemModel.prototype.coerceValue = function ( value ) {
+               return this.getGroupModel().getType() === 'any_value' ? value : !!value;
+       };
+
+       /**
+        * Set the value
+        *
+        * @param {*} newValue
+        */
+       ItemModel.prototype.setValue = function ( newValue ) {
+               newValue = this.coerceValue( newValue );
+               if ( this.value !== newValue ) {
+                       this.value = newValue;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Set the highlight color
+        *
+        * @param {string|null} highlightColor
+        */
+       ItemModel.prototype.setHighlightColor = function ( highlightColor ) {
+               if ( !this.isHighlightSupported() ) {
+                       return;
+               }
+               // If the highlight color on the item and in the parameter is null/undefined, return early.
+               if ( !this.highlightColor && !highlightColor ) {
+                       return;
+               }
+
+               if ( this.highlightColor !== highlightColor ) {
+                       this.highlightColor = highlightColor;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Clear the highlight color
+        */
+       ItemModel.prototype.clearHighlightColor = function () {
+               this.setHighlightColor( null );
+       };
+
+       /**
+        * Get the highlight color, or null if none is configured
+        *
+        * @return {string|null}
+        */
+       ItemModel.prototype.getHighlightColor = function () {
+               return this.highlightColor;
+       };
+
+       /**
+        * Get the CSS class that matches changes that fit this filter
+        * or null if none is configured
+        *
+        * @return {string|null}
+        */
+       ItemModel.prototype.getCssClass = function () {
+               return this.cssClass;
+       };
+
+       /**
+        * Get the item's identifiers
+        *
+        * @return {string[]}
+        */
+       ItemModel.prototype.getIdentifiers = function () {
+               return this.identifiers;
+       };
+
+       /**
+        * Check if the highlight feature is supported for this filter
+        *
+        * @return {boolean}
+        */
+       ItemModel.prototype.isHighlightSupported = function () {
+               return !!this.getCssClass();
+       };
+
+       /**
+        * Check if the filter is currently highlighted
+        *
+        * @return {boolean}
+        */
+       ItemModel.prototype.isHighlighted = function () {
+               return !!this.getHighlightColor();
+       };
+
+       module.exports = ItemModel;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/SavedQueriesModel.js b/resources/src/mediawiki.rcfilters/dm/SavedQueriesModel.js
new file mode 100644 (file)
index 0000000..34c57dd
--- /dev/null
@@ -0,0 +1,415 @@
+( function () {
+       var SavedQueryItemModel = require( './SavedQueryItemModel.js' ),
+               SavedQueriesModel;
+
+       /**
+        * View model for saved queries
+        *
+        * @class mw.rcfilters.dm.SavedQueriesModel
+        * @mixins OO.EventEmitter
+        * @mixins OO.EmitterList
+        *
+        * @constructor
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters model
+        * @param {Object} [config] Configuration options
+        * @cfg {string} [default] Default query ID
+        */
+       SavedQueriesModel = function MwRcfiltersDmSavedQueriesModel( filtersModel, config ) {
+               config = config || {};
+
+               // Mixin constructor
+               OO.EventEmitter.call( this );
+               OO.EmitterList.call( this );
+
+               this.default = config.default;
+               this.filtersModel = filtersModel;
+               this.converted = false;
+
+               // Events
+               this.aggregate( { update: 'itemUpdate' } );
+       };
+
+       /* Initialization */
+
+       OO.initClass( SavedQueriesModel );
+       OO.mixinClass( SavedQueriesModel, OO.EventEmitter );
+       OO.mixinClass( SavedQueriesModel, OO.EmitterList );
+
+       /* Events */
+
+       /**
+        * @event initialize
+        *
+        * Model is initialized
+        */
+
+       /**
+        * @event itemUpdate
+        * @param {mw.rcfilters.dm.SavedQueryItemModel} Changed item
+        *
+        * An item has changed
+        */
+
+       /**
+        * @event default
+        * @param {string} New default ID
+        *
+        * The default has changed
+        */
+
+       /* Methods */
+
+       /**
+        * Initialize the saved queries model by reading it from the user's settings.
+        * The structure of the saved queries is:
+        * {
+        *    version: (string) Version number; if version 2, the query represents
+        *             parameters. Otherwise, the older version represented filters
+        *             and needs to be readjusted,
+        *    default: (string) Query ID
+        *    queries:{
+        *       query_id_1: {
+        *          data:{
+        *             filters: (Object) Minimal definition of the filters
+        *             highlights: (Object) Definition of the highlights
+        *          },
+        *          label: (optional) Name of this query
+        *       }
+        *    }
+        * }
+        *
+        * @param {Object} [savedQueries] An object with the saved queries with
+        *  the above structure.
+        * @fires initialize
+        */
+       SavedQueriesModel.prototype.initialize = function ( savedQueries ) {
+               var model = this;
+
+               savedQueries = savedQueries || {};
+
+               this.clearItems();
+               this.default = null;
+               this.converted = false;
+
+               if ( savedQueries.version !== '2' ) {
+                       // Old version dealt with filter names. We need to migrate to the new structure
+                       // The new structure:
+                       // {
+                       //   version: (string) '2',
+                       //   default: (string) Query ID,
+                       //   queries: {
+                       //     query_id: {
+                       //       label: (string) Name of the query
+                       //       data: {
+                       //         params: (object) Representing all the parameter states
+                       //         highlights: (object) Representing all the filter highlight states
+                       //     }
+                       //   }
+                       // }
+                       // eslint-disable-next-line jquery/no-each-util
+                       $.each( savedQueries.queries || {}, function ( id, obj ) {
+                               if ( obj.data && obj.data.filters ) {
+                                       obj.data = model.convertToParameters( obj.data );
+                               }
+                       } );
+
+                       this.converted = true;
+                       savedQueries.version = '2';
+               }
+
+               // Initialize the query items
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( savedQueries.queries || {}, function ( id, obj ) {
+                       var normalizedData = obj.data,
+                               isDefault = String( savedQueries.default ) === String( id );
+
+                       if ( normalizedData && normalizedData.params ) {
+                               // Backwards-compat fix: Remove sticky parameters from
+                               // the given data, if they exist
+                               normalizedData.params = model.filtersModel.removeStickyParams( normalizedData.params );
+
+                               // Correct the invert state for effective selection
+                               if ( normalizedData.params.invert && !normalizedData.params.namespace ) {
+                                       delete normalizedData.params.invert;
+                               }
+
+                               model.cleanupHighlights( normalizedData );
+
+                               id = String( id );
+
+                               // Skip the addNewQuery method because we don't want to unnecessarily manipulate
+                               // the given saved queries unless we literally intend to (like in backwards compat fixes)
+                               // And the addNewQuery method also uses a minimization routine that checks for the
+                               // validity of items and minimizes the query. This isn't necessary for queries loaded
+                               // from the backend, and has the risk of removing values if they're temporarily
+                               // invalid (example: if we temporarily removed a cssClass from a filter in the backend)
+                               model.addItems( [
+                                       new SavedQueryItemModel(
+                                               id,
+                                               obj.label,
+                                               normalizedData,
+                                               { default: isDefault }
+                                       )
+                               ] );
+
+                               if ( isDefault ) {
+                                       model.default = id;
+                               }
+                       }
+               } );
+
+               this.emit( 'initialize' );
+       };
+
+       /**
+        * Clean up highlight parameters.
+        * 'highlight' used to be stored, it's not inferred based on the presence of absence of
+        * filter colors.
+        *
+        * @param {Object} data Saved query data
+        */
+       SavedQueriesModel.prototype.cleanupHighlights = function ( data ) {
+               if (
+                       data.params.highlight === '0' &&
+                       data.highlights && Object.keys( data.highlights ).length
+               ) {
+                       data.highlights = {};
+               }
+               delete data.params.highlight;
+       };
+
+       /**
+        * Convert from representation of filters to representation of parameters
+        *
+        * @param {Object} data Query data
+        * @return {Object} New converted query data
+        */
+       SavedQueriesModel.prototype.convertToParameters = function ( data ) {
+               var newData = {},
+                       defaultFilters = this.filtersModel.getFiltersFromParameters( this.filtersModel.getDefaultParams() ),
+                       fullFilterRepresentation = $.extend( true, {}, defaultFilters, data.filters ),
+                       highlightEnabled = data.highlights.highlight;
+
+               delete data.highlights.highlight;
+
+               // Filters
+               newData.params = this.filtersModel.getMinimizedParamRepresentation(
+                       this.filtersModel.getParametersFromFilters( fullFilterRepresentation )
+               );
+
+               // Highlights: appending _color to keys
+               newData.highlights = {};
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( data.highlights, function ( highlightedFilterName, value ) {
+                       if ( value ) {
+                               newData.highlights[ highlightedFilterName + '_color' ] = data.highlights[ highlightedFilterName ];
+                       }
+               } );
+
+               // Add highlight
+               newData.params.highlight = String( Number( highlightEnabled || 0 ) );
+
+               return newData;
+       };
+
+       /**
+        * Add a query item
+        *
+        * @param {string} label Label for the new query
+        * @param {Object} fulldata Full data representation for the new query, combining highlights and filters
+        * @param {boolean} isDefault Item is default
+        * @param {string} [id] Query ID, if exists. If this isn't given, a random
+        *  new ID will be created.
+        * @return {string} ID of the newly added query
+        */
+       SavedQueriesModel.prototype.addNewQuery = function ( label, fulldata, isDefault, id ) {
+               var normalizedData = { params: {}, highlights: {} },
+                       highlightParamNames = Object.keys( this.filtersModel.getEmptyHighlightParameters() ),
+                       randomID = String( id || ( new Date() ).getTime() ),
+                       data = this.filtersModel.getMinimizedParamRepresentation( fulldata );
+
+               // Split highlight/params
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( data, function ( param, value ) {
+                       if ( param !== 'highlight' && highlightParamNames.indexOf( param ) > -1 ) {
+                               normalizedData.highlights[ param ] = value;
+                       } else {
+                               normalizedData.params[ param ] = value;
+                       }
+               } );
+
+               // Correct the invert state for effective selection
+               if ( normalizedData.params.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
+                       delete normalizedData.params.invert;
+               }
+
+               // Add item
+               this.addItems( [
+                       new SavedQueryItemModel(
+                               randomID,
+                               label,
+                               normalizedData,
+                               { default: isDefault }
+                       )
+               ] );
+
+               if ( isDefault ) {
+                       this.setDefault( randomID );
+               }
+
+               return randomID;
+       };
+
+       /**
+        * Remove query from model
+        *
+        * @param {string} queryID Query ID
+        */
+       SavedQueriesModel.prototype.removeQuery = function ( queryID ) {
+               var query = this.getItemByID( queryID );
+
+               if ( query ) {
+                       // Check if this item was the default
+                       if ( String( this.getDefault() ) === String( queryID ) ) {
+                               // Nulify the default
+                               this.setDefault( null );
+                       }
+
+                       this.removeItems( [ query ] );
+               }
+       };
+
+       /**
+        * Get an item that matches the requested query
+        *
+        * @param {Object} fullQueryComparison Object representing all filters and highlights to compare
+        * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
+        */
+       SavedQueriesModel.prototype.findMatchingQuery = function ( fullQueryComparison ) {
+               // Minimize before comparison
+               fullQueryComparison = this.filtersModel.getMinimizedParamRepresentation( fullQueryComparison );
+
+               // Correct the invert state for effective selection
+               if ( fullQueryComparison.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
+                       delete fullQueryComparison.invert;
+               }
+
+               return this.getItems().filter( function ( item ) {
+                       return OO.compare(
+                               item.getCombinedData(),
+                               fullQueryComparison
+                       );
+               } )[ 0 ];
+       };
+
+       /**
+        * Get query by its identifier
+        *
+        * @param {string} queryID Query identifier
+        * @return {mw.rcfilters.dm.SavedQueryItemModel|undefined} Item matching
+        *  the search. Undefined if not found.
+        */
+       SavedQueriesModel.prototype.getItemByID = function ( queryID ) {
+               return this.getItems().filter( function ( item ) {
+                       return item.getID() === queryID;
+               } )[ 0 ];
+       };
+
+       /**
+        * Get the full data representation of the default query, if it exists
+        *
+        * @return {Object|null} Representation of the default params if exists.
+        *  Null if default doesn't exist or if the user is not logged in.
+        */
+       SavedQueriesModel.prototype.getDefaultParams = function () {
+               return ( !mw.user.isAnon() && this.getItemParams( this.getDefault() ) ) || {};
+       };
+
+       /**
+        * Get a full parameter representation of an item data
+        *
+        * @param  {Object} queryID Query ID
+        * @return {Object} Parameter representation
+        */
+       SavedQueriesModel.prototype.getItemParams = function ( queryID ) {
+               var item = this.getItemByID( queryID ),
+                       data = item ? item.getData() : {};
+
+               return !$.isEmptyObject( data ) ? this.buildParamsFromData( data ) : {};
+       };
+
+       /**
+        * Build a full parameter representation given item data and model sticky values state
+        *
+        * @param  {Object} data Item data
+        * @return {Object} Full param representation
+        */
+       SavedQueriesModel.prototype.buildParamsFromData = function ( data ) {
+               data = data || {};
+               // Return parameter representation
+               return this.filtersModel.getMinimizedParamRepresentation( $.extend( true, {},
+                       data.params,
+                       data.highlights
+               ) );
+       };
+
+       /**
+        * Get the object representing the state of the entire model and items
+        *
+        * @return {Object} Object representing the state of the model and items
+        */
+       SavedQueriesModel.prototype.getState = function () {
+               var obj = { queries: {}, version: '2' };
+
+               // Translate the items to the saved object
+               this.getItems().forEach( function ( item ) {
+                       obj.queries[ item.getID() ] = item.getState();
+               } );
+
+               if ( this.getDefault() ) {
+                       obj.default = this.getDefault();
+               }
+
+               return obj;
+       };
+
+       /**
+        * Set a default query. Null to unset default.
+        *
+        * @param {string} itemID Query identifier
+        * @fires default
+        */
+       SavedQueriesModel.prototype.setDefault = function ( itemID ) {
+               if ( this.default !== itemID ) {
+                       this.default = itemID;
+
+                       // Set for individual itens
+                       this.getItems().forEach( function ( item ) {
+                               item.toggleDefault( item.getID() === itemID );
+                       } );
+
+                       this.emit( 'default', itemID );
+               }
+       };
+
+       /**
+        * Get the default query ID
+        *
+        * @return {string} Default query identifier
+        */
+       SavedQueriesModel.prototype.getDefault = function () {
+               return this.default;
+       };
+
+       /**
+        * Check if the saved queries were converted
+        *
+        * @return {boolean} Saved queries were converted from the previous
+        *  version to the new version
+        */
+       SavedQueriesModel.prototype.isConverted = function () {
+               return this.converted;
+       };
+
+       module.exports = SavedQueriesModel;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/SavedQueryItemModel.js b/resources/src/mediawiki.rcfilters/dm/SavedQueryItemModel.js
new file mode 100644 (file)
index 0000000..1774391
--- /dev/null
@@ -0,0 +1,127 @@
+( function () {
+       /**
+        * View model for a single saved query
+        *
+        * @class mw.rcfilters.dm.SavedQueryItemModel
+        * @mixins OO.EventEmitter
+        *
+        * @constructor
+        * @param {string} id Unique identifier
+        * @param {string} label Saved query label
+        * @param {Object} data Saved query data
+        * @param {Object} [config] Configuration options
+        * @cfg {boolean} [default] This item is the default
+        */
+       var SavedQueryItemModel = function MwRcfiltersDmSavedQueriesModel( id, label, data, config ) {
+               config = config || {};
+
+               // Mixin constructor
+               OO.EventEmitter.call( this );
+
+               this.id = id;
+               this.label = label;
+               this.data = data;
+               this.default = !!config.default;
+       };
+
+       /* Initialization */
+
+       OO.initClass( SavedQueryItemModel );
+       OO.mixinClass( SavedQueryItemModel, OO.EventEmitter );
+
+       /* Events */
+
+       /**
+        * @event update
+        *
+        * Model has been updated
+        */
+
+       /* Methods */
+
+       /**
+        * Get an object representing the state of this item
+        *
+        * @return {Object} Object representing the current data state
+        *  of the object
+        */
+       SavedQueryItemModel.prototype.getState = function () {
+               return {
+                       data: this.getData(),
+                       label: this.getLabel()
+               };
+       };
+
+       /**
+        * Get the query's identifier
+        *
+        * @return {string} Query identifier
+        */
+       SavedQueryItemModel.prototype.getID = function () {
+               return this.id;
+       };
+
+       /**
+        * Get query label
+        *
+        * @return {string} Query label
+        */
+       SavedQueryItemModel.prototype.getLabel = function () {
+               return this.label;
+       };
+
+       /**
+        * Update the query label
+        *
+        * @param {string} newLabel New label
+        */
+       SavedQueryItemModel.prototype.updateLabel = function ( newLabel ) {
+               if ( newLabel && this.label !== newLabel ) {
+                       this.label = newLabel;
+                       this.emit( 'update' );
+               }
+       };
+
+       /**
+        * Get query data
+        *
+        * @return {Object} Object representing parameter and highlight data
+        */
+       SavedQueryItemModel.prototype.getData = function () {
+               return this.data;
+       };
+
+       /**
+        * Get the combined data of this item as a flat object of parameters
+        *
+        * @return {Object} Combined parameter data
+        */
+       SavedQueryItemModel.prototype.getCombinedData = function () {
+               return $.extend( true, {}, this.data.params, this.data.highlights );
+       };
+
+       /**
+        * Check whether this item is the default
+        *
+        * @return {boolean} Query is set to be default
+        */
+       SavedQueryItemModel.prototype.isDefault = function () {
+               return this.default;
+       };
+
+       /**
+        * Toggle the default state of this query item
+        *
+        * @param {boolean} isDefault Query is default
+        */
+       SavedQueryItemModel.prototype.toggleDefault = function ( isDefault ) {
+               isDefault = isDefault === undefined ? !this.default : isDefault;
+
+               if ( this.default !== isDefault ) {
+                       this.default = isDefault;
+                       this.emit( 'update' );
+               }
+       };
+
+       module.exports = SavedQueryItemModel;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js
deleted file mode 100644 (file)
index e51829c..0000000
+++ /dev/null
@@ -1,166 +0,0 @@
-( function () {
-       /**
-        * View model for the changes list
-        *
-        * @mixins OO.EventEmitter
-        *
-        * @param {jQuery} $initialFieldset The initial server-generated legacy form content
-        * @constructor
-        */
-       mw.rcfilters.dm.ChangesListViewModel = function MwRcfiltersDmChangesListViewModel( $initialFieldset ) {
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-
-               this.valid = true;
-               this.newChangesExist = false;
-               this.liveUpdate = false;
-               this.unseenWatchedChanges = false;
-
-               this.extractNextFrom( $initialFieldset );
-       };
-
-       /* Initialization */
-       OO.initClass( mw.rcfilters.dm.ChangesListViewModel );
-       OO.mixinClass( mw.rcfilters.dm.ChangesListViewModel, OO.EventEmitter );
-
-       /* Events */
-
-       /**
-        * @event invalidate
-        *
-        * The list of changes is now invalid (out of date)
-        */
-
-       /**
-        * @event update
-        * @param {jQuery|string} $changesListContent List of changes
-        * @param {jQuery} $fieldset Server-generated form
-        * @param {string} noResultsDetails Type of no result error
-        * @param {boolean} isInitialDOM Whether the previous dom variables are from the initial page load
-        * @param {boolean} fromLiveUpdate These are new changes fetched via Live Update
-        *
-        * The list of changes has been updated
-        */
-
-       /**
-        * @event newChangesExist
-        * @param {boolean} newChangesExist
-        *
-        * The existence of changes newer than those currently displayed has changed.
-        */
-
-       /**
-        * @event liveUpdateChange
-        * @param {boolean} enable
-        *
-        * The state of the 'live update' feature has changed.
-        */
-
-       /* Methods */
-
-       /**
-        * Invalidate the list of changes
-        *
-        * @fires invalidate
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.invalidate = function () {
-               if ( this.valid ) {
-                       this.valid = false;
-                       this.emit( 'invalidate' );
-               }
-       };
-
-       /**
-        * Update the model with an updated list of changes
-        *
-        * @param {jQuery|string} changesListContent
-        * @param {jQuery} $fieldset
-        * @param {string} noResultsDetails Type of no result error
-        * @param {boolean} [isInitialDOM] Using the initial (already attached) DOM elements
-        * @param {boolean} [separateOldAndNew] Whether a logical separation between old and new changes is needed
-        * @fires update
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.update = function ( changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ) {
-               var from = this.nextFrom;
-               this.valid = true;
-               this.extractNextFrom( $fieldset );
-               this.checkForUnseenWatchedChanges( changesListContent );
-               this.emit( 'update', changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ? from : null );
-       };
-
-       /**
-        * Specify whether new changes exist
-        *
-        * @param {boolean} newChangesExist
-        * @fires newChangesExist
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.setNewChangesExist = function ( newChangesExist ) {
-               if ( newChangesExist !== this.newChangesExist ) {
-                       this.newChangesExist = newChangesExist;
-                       this.emit( 'newChangesExist', newChangesExist );
-               }
-       };
-
-       /**
-        * @return {boolean} Whether new changes exist
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.getNewChangesExist = function () {
-               return this.newChangesExist;
-       };
-
-       /**
-        * Extract the value of the 'from' parameter from a link in the field set
-        *
-        * @param {jQuery} $fieldset
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.extractNextFrom = function ( $fieldset ) {
-               var data = $fieldset.find( '.rclistfrom > a, .wlinfo' ).data( 'params' );
-               if ( data && data.from ) {
-                       this.nextFrom = data.from;
-               }
-       };
-
-       /**
-        * @return {string} The 'from' parameter that can be used to query new changes
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.getNextFrom = function () {
-               return this.nextFrom;
-       };
-
-       /**
-        * Toggle the 'live update' feature on/off
-        *
-        * @param {boolean} enable
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.toggleLiveUpdate = function ( enable ) {
-               enable = enable === undefined ? !this.liveUpdate : enable;
-               if ( enable !== this.liveUpdate ) {
-                       this.liveUpdate = enable;
-                       this.emit( 'liveUpdateChange', this.liveUpdate );
-               }
-       };
-
-       /**
-        * @return {boolean} The 'live update' feature is enabled
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.getLiveUpdate = function () {
-               return this.liveUpdate;
-       };
-
-       /**
-        * Check if some of the given changes watched and unseen
-        *
-        * @param {jQuery|string} changeslistContent
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.checkForUnseenWatchedChanges = function ( changeslistContent ) {
-               this.unseenWatchedChanges = changeslistContent !== 'NO_RESULTS' &&
-                       changeslistContent.find( '.mw-changeslist-line-watched' ).length > 0;
-       };
-
-       /**
-        * @return {boolean} Whether some of the current changes are watched and unseen
-        */
-       mw.rcfilters.dm.ChangesListViewModel.prototype.hasUnseenWatchedChanges = function () {
-               return this.unseenWatchedChanges;
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterGroup.js
deleted file mode 100644 (file)
index df2079e..0000000
+++ /dev/null
@@ -1,988 +0,0 @@
-( function () {
-       /**
-        * View model for a filter group
-        *
-        * @mixins OO.EventEmitter
-        * @mixins OO.EmitterList
-        *
-        * @constructor
-        * @param {string} name Group name
-        * @param {Object} [config] Configuration options
-        * @cfg {string} [type='send_unselected_if_any'] Group type
-        * @cfg {string} [view='default'] Name of the display group this group
-        *  is a part of.
-        * @cfg {boolean} [sticky] This group is 'sticky'. It is synchronized
-        *  with a preference, does not participate in Saved Queries, and is
-        *  not shown in the active filters area.
-        * @cfg {string} [title] Group title
-        * @cfg {boolean} [hidden] This group is hidden from the regular menu views
-        *  and the active filters area.
-        * @cfg {boolean} [allowArbitrary] Allows for an arbitrary value to be added to the
-        *  group from the URL, even if it wasn't initially set up.
-        * @cfg {number} [range] An object defining minimum and maximum values for numeric
-        *  groups. { min: x, max: y }
-        * @cfg {number} [minValue] Minimum value for numeric groups
-        * @cfg {string} [separator='|'] Value separator for 'string_options' groups
-        * @cfg {boolean} [active] Group is active
-        * @cfg {boolean} [fullCoverage] This filters in this group collectively cover all results
-        * @cfg {Object} [conflicts] Defines the conflicts for this filter group
-        * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
-        *  group. If the prefix has 'invert' state, the parameter is expected to be an object
-        *  with 'default' and 'inverted' as keys.
-        * @cfg {Object} [whatsThis] Defines the messages that should appear for the 'what's this' popup
-        * @cfg {string} [whatsThis.header] The header of the whatsThis popup message
-        * @cfg {string} [whatsThis.body] The body of the whatsThis popup message
-        * @cfg {string} [whatsThis.url] The url for the link in the whatsThis popup message
-        * @cfg {string} [whatsThis.linkMessage] The text for the link in the whatsThis popup message
-        * @cfg {boolean} [visible=true] The visibility of the group
-        */
-       mw.rcfilters.dm.FilterGroup = function MwRcfiltersDmFilterGroup( name, config ) {
-               config = config || {};
-
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-               OO.EmitterList.call( this );
-
-               this.name = name;
-               this.type = config.type || 'send_unselected_if_any';
-               this.view = config.view || 'default';
-               this.sticky = !!config.sticky;
-               this.title = config.title || name;
-               this.hidden = !!config.hidden;
-               this.allowArbitrary = !!config.allowArbitrary;
-               this.numericRange = config.range;
-               this.separator = config.separator || '|';
-               this.labelPrefixKey = config.labelPrefixKey;
-               this.visible = config.visible === undefined ? true : !!config.visible;
-
-               this.currSelected = null;
-               this.active = !!config.active;
-               this.fullCoverage = !!config.fullCoverage;
-
-               this.whatsThis = config.whatsThis || {};
-
-               this.conflicts = config.conflicts || {};
-               this.defaultParams = {};
-               this.defaultFilters = {};
-
-               this.aggregate( { update: 'filterItemUpdate' } );
-               this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } );
-       };
-
-       /* Initialization */
-       OO.initClass( mw.rcfilters.dm.FilterGroup );
-       OO.mixinClass( mw.rcfilters.dm.FilterGroup, OO.EventEmitter );
-       OO.mixinClass( mw.rcfilters.dm.FilterGroup, OO.EmitterList );
-
-       /* Events */
-
-       /**
-        * @event update
-        *
-        * Group state has been updated
-        */
-
-       /* Methods */
-
-       /**
-        * Initialize the group and create its filter items
-        *
-        * @param {Object} filterDefinition Filter definition for this group
-        * @param {string|Object} [groupDefault] Definition of the group default
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.initializeFilters = function ( filterDefinition, groupDefault ) {
-               var defaultParam,
-                       supersetMap = {},
-                       model = this,
-                       items = [];
-
-               filterDefinition.forEach( function ( filter ) {
-                       // Instantiate an item
-                       var subsetNames = [],
-                               filterItem = new mw.rcfilters.dm.FilterItem( filter.name, model, {
-                                       group: model.getName(),
-                                       label: filter.label || filter.name,
-                                       description: filter.description || '',
-                                       labelPrefixKey: model.labelPrefixKey,
-                                       cssClass: filter.cssClass,
-                                       identifiers: filter.identifiers,
-                                       defaultHighlightColor: filter.defaultHighlightColor
-                               } );
-
-                       if ( filter.subset ) {
-                               filter.subset = filter.subset.map( function ( el ) {
-                                       return el.filter;
-                               } );
-
-                               subsetNames = [];
-
-                               filter.subset.forEach( function ( subsetFilterName ) {
-                                       // Subsets (unlike conflicts) are always inside the same group
-                                       // We can re-map the names of the filters we are getting from
-                                       // the subsets with the group prefix
-                                       var subsetName = model.getPrefixedName( subsetFilterName );
-                                       // For convenience, we should store each filter's "supersets" -- these are
-                                       // the filters that have that item in their subset list. This will just
-                                       // make it easier to go through whether the item has any other items
-                                       // that affect it (and are selected) at any given time
-                                       supersetMap[ subsetName ] = supersetMap[ subsetName ] || [];
-                                       mw.rcfilters.utils.addArrayElementsUnique(
-                                               supersetMap[ subsetName ],
-                                               filterItem.getName()
-                                       );
-
-                                       // Translate subset param name to add the group name, so we
-                                       // get consistent naming. We know that subsets are only within
-                                       // the same group
-                                       subsetNames.push( subsetName );
-                               } );
-
-                               // Set translated subset
-                               filterItem.setSubset( subsetNames );
-                       }
-
-                       items.push( filterItem );
-
-                       // Store default parameter state; in this case, default is defined per filter
-                       if (
-                               model.getType() === 'send_unselected_if_any' ||
-                               model.getType() === 'boolean'
-                       ) {
-                               // Store the default parameter state
-                               // For this group type, parameter values are direct
-                               // We need to convert from a boolean to a string ('1' and '0')
-                               model.defaultParams[ filter.name ] = String( Number( filter.default || 0 ) );
-                       } else if ( model.getType() === 'any_value' ) {
-                               model.defaultParams[ filter.name ] = filter.default;
-                       }
-               } );
-
-               // Add items
-               this.addItems( items );
-
-               // Now that we have all items, we can apply the superset map
-               this.getItems().forEach( function ( filterItem ) {
-                       filterItem.setSuperset( supersetMap[ filterItem.getName() ] );
-               } );
-
-               // Store default parameter state; in this case, default is defined per the
-               // entire group, given by groupDefault method parameter
-               if ( this.getType() === 'string_options' ) {
-                       // Store the default parameter group state
-                       // For this group, the parameter is group name and value is the names
-                       // of selected items
-                       this.defaultParams[ this.getName() ] = mw.rcfilters.utils.normalizeParamOptions(
-                               // Current values
-                               groupDefault ?
-                                       groupDefault.split( this.getSeparator() ) :
-                                       [],
-                               // Legal values
-                               this.getItems().map( function ( item ) {
-                                       return item.getParamName();
-                               } )
-                       ).join( this.getSeparator() );
-               } else if ( this.getType() === 'single_option' ) {
-                       defaultParam = groupDefault !== undefined ?
-                               groupDefault : this.getItems()[ 0 ].getParamName();
-
-                       // For this group, the parameter is the group name,
-                       // and a single item can be selected: default or first item
-                       this.defaultParams[ this.getName() ] = defaultParam;
-               }
-
-               // add highlights to defaultParams
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( filterItem.isHighlighted() ) {
-                               this.defaultParams[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor();
-                       }
-               }.bind( this ) );
-
-               // Store default filter state based on default params
-               this.defaultFilters = this.getFilterRepresentation( this.getDefaultParams() );
-
-               // Check for filters that should be initially selected by their default value
-               if ( this.isSticky() ) {
-                       // eslint-disable-next-line jquery/no-each-util
-                       $.each( this.defaultFilters, function ( filterName, filterValue ) {
-                               model.getItemByName( filterName ).toggleSelected( filterValue );
-                       } );
-               }
-
-               // Verify that single_option group has at least one item selected
-               if (
-                       this.getType() === 'single_option' &&
-                       this.findSelectedItems().length === 0
-               ) {
-                       defaultParam = groupDefault !== undefined ?
-                               groupDefault : this.getItems()[ 0 ].getParamName();
-
-                       // Single option means there must be a single option
-                       // selected, so we have to either select the default
-                       // or select the first option
-                       this.selectItemByParamName( defaultParam );
-               }
-       };
-
-       /**
-        * Respond to filterItem update event
-        *
-        * @param {mw.rcfilters.dm.FilterItem} item Updated filter item
-        * @fires update
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.onFilterItemUpdate = function ( item ) {
-               // Update state
-               var changed = false,
-                       active = this.areAnySelected(),
-                       model = this;
-
-               if ( this.getType() === 'single_option' ) {
-                       // This group must have one item selected always
-                       // and must never have more than one item selected at a time
-                       if ( this.findSelectedItems().length === 0 ) {
-                               // Nothing is selected anymore
-                               // Select the default or the first item
-                               this.currSelected = this.getItemByParamName( this.defaultParams[ this.getName() ] ) ||
-                                       this.getItems()[ 0 ];
-                               this.currSelected.toggleSelected( true );
-                               changed = true;
-                       } else if ( this.findSelectedItems().length > 1 ) {
-                               // There is more than one item selected
-                               // This should only happen if the item given
-                               // is the one that is selected, so unselect
-                               // all items that is not it
-                               this.findSelectedItems().forEach( function ( itemModel ) {
-                                       // Note that in case the given item is actually
-                                       // not selected, this loop will end up unselecting
-                                       // all items, which would trigger the case above
-                                       // when the last item is unselected anyways
-                                       var selected = itemModel.getName() === item.getName() &&
-                                               item.isSelected();
-
-                                       itemModel.toggleSelected( selected );
-                                       if ( selected ) {
-                                               model.currSelected = itemModel;
-                                       }
-                               } );
-                               changed = true;
-                       }
-               }
-
-               if ( this.isSticky() ) {
-                       // If this group is sticky, then change the default according to the
-                       // current selection.
-                       this.defaultParams = this.getParamRepresentation( this.getSelectedState() );
-               }
-
-               if (
-                       changed ||
-                       this.active !== active ||
-                       this.currSelected !== item
-               ) {
-                       this.active = active;
-                       this.currSelected = item;
-
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Get group active state
-        *
-        * @return {boolean} Active state
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.isActive = function () {
-               return this.active;
-       };
-
-       /**
-        * Get group hidden state
-        *
-        * @return {boolean} Hidden state
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.isHidden = function () {
-               return this.hidden;
-       };
-
-       /**
-        * Get group allow arbitrary state
-        *
-        * @return {boolean} Group allows an arbitrary value from the URL
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.isAllowArbitrary = function () {
-               return this.allowArbitrary;
-       };
-
-       /**
-        * Get group maximum value for numeric groups
-        *
-        * @return {number|null} Group max value
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getMaxValue = function () {
-               return this.numericRange && this.numericRange.max !== undefined ?
-                       this.numericRange.max : null;
-       };
-
-       /**
-        * Get group minimum value for numeric groups
-        *
-        * @return {number|null} Group max value
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getMinValue = function () {
-               return this.numericRange && this.numericRange.min !== undefined ?
-                       this.numericRange.min : null;
-       };
-
-       /**
-        * Get group name
-        *
-        * @return {string} Group name
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getName = function () {
-               return this.name;
-       };
-
-       /**
-        * Get the default param state of this group
-        *
-        * @return {Object} Default param state
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getDefaultParams = function () {
-               return this.defaultParams;
-       };
-
-       /**
-        * Get the default filter state of this group
-        *
-        * @return {Object} Default filter state
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getDefaultFilters = function () {
-               return this.defaultFilters;
-       };
-
-       /**
-        * This is for a single_option and string_options group types
-        * it returns the value of the default
-        *
-        * @return {string} Value of the default
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getDefaulParamValue = function () {
-               return this.defaultParams[ this.getName() ];
-       };
-       /**
-        * Get the messags defining the 'whats this' popup for this group
-        *
-        * @return {Object} What's this messages
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getWhatsThis = function () {
-               return this.whatsThis;
-       };
-
-       /**
-        * Check whether this group has a 'what's this' message
-        *
-        * @return {boolean} This group has a what's this message
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.hasWhatsThis = function () {
-               return !!this.whatsThis.body;
-       };
-
-       /**
-        * Get the conflicts associated with the entire group.
-        * Conflict object is set up by filter name keys and conflict
-        * definition. For example:
-        * [
-        *     {
-        *         filterName: {
-        *             filter: filterName,
-        *             group: group1
-        *         }
-        *     },
-        *     {
-        *         filterName2: {
-        *             filter: filterName2,
-        *             group: group2
-        *         }
-        *     }
-        * ]
-        * @return {Object} Conflict definition
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getConflicts = function () {
-               return this.conflicts;
-       };
-
-       /**
-        * Set conflicts for this group. See #getConflicts for the expected
-        * structure of the definition.
-        *
-        * @param {Object} conflicts Conflicts for this group
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.setConflicts = function ( conflicts ) {
-               this.conflicts = conflicts;
-       };
-
-       /**
-        * Set conflicts for each filter item in the group based on the
-        * given conflict map
-        *
-        * @param {Object} conflicts Object representing the conflict map,
-        *  keyed by the item name, where its value is an object for all its conflicts
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.setFilterConflicts = function ( conflicts ) {
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( conflicts[ filterItem.getName() ] ) {
-                               filterItem.setConflicts( conflicts[ filterItem.getName() ] );
-                       }
-               } );
-       };
-
-       /**
-        * Check whether this item has a potential conflict with the given item
-        *
-        * This checks whether the given item is in the list of conflicts of
-        * the current item, but makes no judgment about whether the conflict
-        * is currently at play (either one of the items may not be selected)
-        *
-        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
-        * @return {boolean} This item has a conflict with the given item
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.existsInConflicts = function ( filterItem ) {
-               return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
-       };
-
-       /**
-        * Check whether there are any items selected
-        *
-        * @return {boolean} Any items in the group are selected
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.areAnySelected = function () {
-               return this.getItems().some( function ( filterItem ) {
-                       return filterItem.isSelected();
-               } );
-       };
-
-       /**
-        * Check whether all items selected
-        *
-        * @return {boolean} All items are selected
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.areAllSelected = function () {
-               var selected = [],
-                       unselected = [];
-
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( filterItem.isSelected() ) {
-                               selected.push( filterItem );
-                       } else {
-                               unselected.push( filterItem );
-                       }
-               } );
-
-               if ( unselected.length === 0 ) {
-                       return true;
-               }
-
-               // check if every unselected is a subset of a selected
-               return unselected.every( function ( unselectedFilterItem ) {
-                       return selected.some( function ( selectedFilterItem ) {
-                               return selectedFilterItem.existsInSubset( unselectedFilterItem.getName() );
-                       } );
-               } );
-       };
-
-       /**
-        * Get all selected items in this group
-        *
-        * @param {mw.rcfilters.dm.FilterItem} [excludeItem] Item to exclude from the list
-        * @return {mw.rcfilters.dm.FilterItem[]} Selected items
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.findSelectedItems = function ( excludeItem ) {
-               var excludeName = ( excludeItem && excludeItem.getName() ) || '';
-
-               return this.getItems().filter( function ( item ) {
-                       return item.getName() !== excludeName && item.isSelected();
-               } );
-       };
-
-       /**
-        * Check whether all selected items are in conflict with the given item
-        *
-        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
-        * @return {boolean} All selected items are in conflict with this item
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.areAllSelectedInConflictWith = function ( filterItem ) {
-               var selectedItems = this.findSelectedItems( filterItem );
-
-               return selectedItems.length > 0 &&
-                       (
-                               // The group as a whole is in conflict with this item
-                               this.existsInConflicts( filterItem ) ||
-                               // All selected items are in conflict individually
-                               selectedItems.every( function ( selectedFilter ) {
-                                       return selectedFilter.existsInConflicts( filterItem );
-                               } )
-                       );
-       };
-
-       /**
-        * Check whether any of the selected items are in conflict with the given item
-        *
-        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
-        * @return {boolean} Any of the selected items are in conflict with this item
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.areAnySelectedInConflictWith = function ( filterItem ) {
-               var selectedItems = this.findSelectedItems( filterItem );
-
-               return selectedItems.length > 0 && (
-                       // The group as a whole is in conflict with this item
-                       this.existsInConflicts( filterItem ) ||
-                       // Any selected items are in conflict individually
-                       selectedItems.some( function ( selectedFilter ) {
-                               return selectedFilter.existsInConflicts( filterItem );
-                       } )
-               );
-       };
-
-       /**
-        * Get the parameter representation from this group
-        *
-        * @param {Object} [filterRepresentation] An object defining the state
-        *  of the filters in this group, keyed by their name and current selected
-        *  state value.
-        * @return {Object} Parameter representation
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getParamRepresentation = function ( filterRepresentation ) {
-               var values,
-                       areAnySelected = false,
-                       buildFromCurrentState = !filterRepresentation,
-                       defaultFilters = this.getDefaultFilters(),
-                       result = {},
-                       model = this,
-                       filterParamNames = {},
-                       getSelectedParameter = function ( filters ) {
-                               var item,
-                                       selected = [];
-
-                               // Find if any are selected
-                               // eslint-disable-next-line jquery/no-each-util
-                               $.each( filters, function ( name, value ) {
-                                       if ( value ) {
-                                               selected.push( name );
-                                       }
-                               } );
-
-                               item = model.getItemByName( selected[ 0 ] );
-                               return ( item && item.getParamName() ) || '';
-                       };
-
-               filterRepresentation = filterRepresentation || {};
-
-               // Create or complete the filterRepresentation definition
-               this.getItems().forEach( function ( item ) {
-                       // Map filter names to their parameter names
-                       filterParamNames[ item.getName() ] = item.getParamName();
-
-                       if ( buildFromCurrentState ) {
-                               // This means we have not been given a filter representation
-                               // so we are building one based on current state
-                               filterRepresentation[ item.getName() ] = item.getValue();
-                       } else if ( filterRepresentation[ item.getName() ] === undefined ) {
-                               // We are given a filter representation, but we have to make
-                               // sure that we fill in the missing filters if there are any
-                               // we will assume they are all falsey
-                               if ( model.isSticky() ) {
-                                       filterRepresentation[ item.getName() ] = !!defaultFilters[ item.getName() ];
-                               } else {
-                                       filterRepresentation[ item.getName() ] = false;
-                               }
-                       }
-
-                       if ( filterRepresentation[ item.getName() ] ) {
-                               areAnySelected = true;
-                       }
-               } );
-
-               // Build result
-               if (
-                       this.getType() === 'send_unselected_if_any' ||
-                       this.getType() === 'boolean' ||
-                       this.getType() === 'any_value'
-               ) {
-                       // First, check if any of the items are selected at all.
-                       // If none is selected, we're treating it as if they are
-                       // all false
-
-                       // Go over the items and define the correct values
-                       // eslint-disable-next-line jquery/no-each-util
-                       $.each( filterRepresentation, function ( name, value ) {
-                               // We must store all parameter values as strings '0' or '1'
-                               if ( model.getType() === 'send_unselected_if_any' ) {
-                                       result[ filterParamNames[ name ] ] = areAnySelected ?
-                                               String( Number( !value ) ) :
-                                               '0';
-                               } else if ( model.getType() === 'boolean' ) {
-                                       // Representation is straight-forward and direct from
-                                       // the parameter value to the filter state
-                                       result[ filterParamNames[ name ] ] = String( Number( !!value ) );
-                               } else if ( model.getType() === 'any_value' ) {
-                                       result[ filterParamNames[ name ] ] = value;
-                               }
-                       } );
-               } else if ( this.getType() === 'string_options' ) {
-                       values = [];
-
-                       // eslint-disable-next-line jquery/no-each-util
-                       $.each( filterRepresentation, function ( name, value ) {
-                               // Collect values
-                               if ( value ) {
-                                       values.push( filterParamNames[ name ] );
-                               }
-                       } );
-
-                       result[ this.getName() ] = ( values.length === Object.keys( filterRepresentation ).length ) ?
-                               'all' : values.join( this.getSeparator() );
-               } else if ( this.getType() === 'single_option' ) {
-                       result[ this.getName() ] = getSelectedParameter( filterRepresentation );
-               }
-
-               return result;
-       };
-
-       /**
-        * Get the filter representation this group would provide
-        * based on given parameter states.
-        *
-        * @param {Object} [paramRepresentation] An object defining a parameter
-        *  state to translate the filter state from. If not given, an object
-        *  representing all filters as falsey is returned; same as if the parameter
-        *  given were an empty object, or had some of the filters missing.
-        * @return {Object} Filter representation
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getFilterRepresentation = function ( paramRepresentation ) {
-               var areAnySelected, paramValues, item, currentValue,
-                       oneWasSelected = false,
-                       defaultParams = this.getDefaultParams(),
-                       expandedParams = $.extend( true, {}, paramRepresentation ),
-                       model = this,
-                       paramToFilterMap = {},
-                       result = {};
-
-               if ( this.isSticky() ) {
-                       // If the group is sticky, check if all parameters are represented
-                       // and for those that aren't represented, add them with their default
-                       // values
-                       paramRepresentation = $.extend( true, {}, this.getDefaultParams(), paramRepresentation );
-               }
-
-               paramRepresentation = paramRepresentation || {};
-               if (
-                       this.getType() === 'send_unselected_if_any' ||
-                       this.getType() === 'boolean' ||
-                       this.getType() === 'any_value'
-               ) {
-                       // Go over param representation; map and check for selections
-                       this.getItems().forEach( function ( filterItem ) {
-                               var paramName = filterItem.getParamName();
-
-                               expandedParams[ paramName ] = paramRepresentation[ paramName ] || '0';
-                               paramToFilterMap[ paramName ] = filterItem;
-
-                               if ( Number( paramRepresentation[ filterItem.getParamName() ] ) ) {
-                                       areAnySelected = true;
-                               }
-                       } );
-
-                       // eslint-disable-next-line jquery/no-each-util
-                       $.each( expandedParams, function ( paramName, paramValue ) {
-                               var filterItem = paramToFilterMap[ paramName ];
-
-                               if ( model.getType() === 'send_unselected_if_any' ) {
-                                       // Flip the definition between the parameter
-                                       // state and the filter state
-                                       // This is what the 'toggleSelected' value of the filter is
-                                       result[ filterItem.getName() ] = areAnySelected ?
-                                               !Number( paramValue ) :
-                                               // Otherwise, there are no selected items in the
-                                               // group, which means the state is false
-                                               false;
-                               } else if ( model.getType() === 'boolean' ) {
-                                       // Straight-forward definition of state
-                                       result[ filterItem.getName() ] = !!Number( paramRepresentation[ filterItem.getParamName() ] );
-                               } else if ( model.getType() === 'any_value' ) {
-                                       result[ filterItem.getName() ] = paramRepresentation[ filterItem.getParamName() ];
-                               }
-                       } );
-               } else if ( this.getType() === 'string_options' ) {
-                       currentValue = paramRepresentation[ this.getName() ] || '';
-
-                       // Normalize the given parameter values
-                       paramValues = mw.rcfilters.utils.normalizeParamOptions(
-                               // Given
-                               currentValue.split(
-                                       this.getSeparator()
-                               ),
-                               // Allowed values
-                               this.getItems().map( function ( filterItem ) {
-                                       return filterItem.getParamName();
-                               } )
-                       );
-                       // Translate the parameter values into a filter selection state
-                       this.getItems().forEach( function ( filterItem ) {
-                               // All true (either because all values are written or the term 'all' is written)
-                               // is the same as all filters set to true
-                               result[ filterItem.getName() ] = (
-                                       // If it is the word 'all'
-                                       paramValues.length === 1 && paramValues[ 0 ] === 'all' ||
-                                       // All values are written
-                                       paramValues.length === model.getItemCount()
-                               ) ?
-                                       true :
-                                       // Otherwise, the filter is selected only if it appears in the parameter values
-                                       paramValues.indexOf( filterItem.getParamName() ) > -1;
-                       } );
-               } else if ( this.getType() === 'single_option' ) {
-                       // There is parameter that fits a single filter and if not, get the default
-                       this.getItems().forEach( function ( filterItem ) {
-                               var selected = filterItem.getParamName() === paramRepresentation[ model.getName() ];
-
-                               result[ filterItem.getName() ] = selected;
-                               oneWasSelected = oneWasSelected || selected;
-                       } );
-               }
-
-               // Go over result and make sure all filters are represented.
-               // If any filters are missing, they will get a falsey value
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( result[ filterItem.getName() ] === undefined ) {
-                               result[ filterItem.getName() ] = this.getFalsyValue();
-                       }
-               }.bind( this ) );
-
-               // Make sure that at least one option is selected in
-               // single_option groups, no matter what path was taken
-               // If none was selected by the given definition, then
-               // we need to select the one in the base state -- either
-               // the default given, or the first item
-               if (
-                       this.getType() === 'single_option' &&
-                       !oneWasSelected
-               ) {
-                       item = this.getItems()[ 0 ];
-                       if ( defaultParams[ this.getName() ] ) {
-                               item = this.getItemByParamName( defaultParams[ this.getName() ] );
-                       }
-
-                       result[ item.getName() ] = true;
-               }
-
-               return result;
-       };
-
-       /**
-        * @return {*} The appropriate falsy value for this group type
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getFalsyValue = function () {
-               return this.getType() === 'any_value' ? '' : false;
-       };
-
-       /**
-        * Get current selected state of all filter items in this group
-        *
-        * @return {Object} Selected state
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getSelectedState = function () {
-               var state = {};
-
-               this.getItems().forEach( function ( filterItem ) {
-                       state[ filterItem.getName() ] = filterItem.getValue();
-               } );
-
-               return state;
-       };
-
-       /**
-        * Get item by its filter name
-        *
-        * @param {string} filterName Filter name
-        * @return {mw.rcfilters.dm.FilterItem} Filter item
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getItemByName = function ( filterName ) {
-               return this.getItems().filter( function ( item ) {
-                       return item.getName() === filterName;
-               } )[ 0 ];
-       };
-
-       /**
-        * Select an item by its parameter name
-        *
-        * @param {string} paramName Filter parameter name
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.selectItemByParamName = function ( paramName ) {
-               this.getItems().forEach( function ( item ) {
-                       item.toggleSelected( item.getParamName() === String( paramName ) );
-               } );
-       };
-
-       /**
-        * Get item by its parameter name
-        *
-        * @param {string} paramName Parameter name
-        * @return {mw.rcfilters.dm.FilterItem} Filter item
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getItemByParamName = function ( paramName ) {
-               return this.getItems().filter( function ( item ) {
-                       return item.getParamName() === String( paramName );
-               } )[ 0 ];
-       };
-
-       /**
-        * Get group type
-        *
-        * @return {string} Group type
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getType = function () {
-               return this.type;
-       };
-
-       /**
-        * Check whether this group is represented by a single parameter
-        * or whether each item is its own parameter
-        *
-        * @return {boolean} This group is a single parameter
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.isPerGroupRequestParameter = function () {
-               return (
-                       this.getType() === 'string_options' ||
-                       this.getType() === 'single_option'
-               );
-       };
-
-       /**
-        * Get display group
-        *
-        * @return {string} Display group
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getView = function () {
-               return this.view;
-       };
-
-       /**
-        * Get the prefix used for the filter names inside this group.
-        *
-        * @param {string} [name] Filter name to prefix
-        * @return {string} Group prefix
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getNamePrefix = function () {
-               return this.getName() + '__';
-       };
-
-       /**
-        * Get a filter name with the prefix used for the filter names inside this group.
-        *
-        * @param {string} name Filter name to prefix
-        * @return {string} Group prefix
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getPrefixedName = function ( name ) {
-               return this.getNamePrefix() + name;
-       };
-
-       /**
-        * Get group's title
-        *
-        * @return {string} Title
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getTitle = function () {
-               return this.title;
-       };
-
-       /**
-        * Get group's values separator
-        *
-        * @return {string} Values separator
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.getSeparator = function () {
-               return this.separator;
-       };
-
-       /**
-        * Check whether the group is defined as full coverage
-        *
-        * @return {boolean} Group is full coverage
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.isFullCoverage = function () {
-               return this.fullCoverage;
-       };
-
-       /**
-        * Check whether the group is defined as sticky default
-        *
-        * @return {boolean} Group is sticky default
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.isSticky = function () {
-               return this.sticky;
-       };
-
-       /**
-        * Normalize a value given to this group. This is mostly for correcting
-        * arbitrary values for 'single option' groups, given by the user settings
-        * or the URL that can go outside the limits that are allowed.
-        *
-        * @param  {string} value Given value
-        * @return {string} Corrected value
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.normalizeArbitraryValue = function ( value ) {
-               if (
-                       this.getType() === 'single_option' &&
-                       this.isAllowArbitrary()
-               ) {
-                       if (
-                               this.getMaxValue() !== null &&
-                               value > this.getMaxValue()
-                       ) {
-                               // Change the value to the actual max value
-                               return String( this.getMaxValue() );
-                       } else if (
-                               this.getMinValue() !== null &&
-                               value < this.getMinValue()
-                       ) {
-                               // Change the value to the actual min value
-                               return String( this.getMinValue() );
-                       }
-               }
-
-               return value;
-       };
-
-       /**
-        * Toggle the visibility of this group
-        *
-        * @param {boolean} [isVisible] Item is visible
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.toggleVisible = function ( isVisible ) {
-               isVisible = isVisible === undefined ? !this.visible : isVisible;
-
-               if ( this.visible !== isVisible ) {
-                       this.visible = isVisible;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Check whether the group is visible
-        *
-        * @return {boolean} Group is visible
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.isVisible = function () {
-               return this.visible;
-       };
-
-       /**
-        * Set the visibility of the items under this group by the given items array
-        *
-        * @param {mw.rcfilters.dm.ItemModel[]} visibleItems An array of visible items
-        */
-       mw.rcfilters.dm.FilterGroup.prototype.setVisibleItems = function ( visibleItems ) {
-               this.getItems().forEach( function ( itemModel ) {
-                       itemModel.toggleVisible( visibleItems.indexOf( itemModel ) !== -1 );
-               } );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FilterItem.js
deleted file mode 100644 (file)
index dac61b2..0000000
+++ /dev/null
@@ -1,400 +0,0 @@
-( function () {
-       /**
-        * Filter item model
-        *
-        * @extends mw.rcfilters.dm.ItemModel
-        *
-        * @constructor
-        * @param {string} param Filter param name
-        * @param {mw.rcfilters.dm.FilterGroup} groupModel Filter group model
-        * @param {Object} config Configuration object
-        * @cfg {string[]} [excludes=[]] A list of filter names this filter, if
-        *  selected, makes inactive.
-        * @cfg {string[]} [subset] Defining the names of filters that are a subset of this filter
-        * @cfg {Object} [conflicts] Defines the conflicts for this filter
-        * @cfg {boolean} [visible=true] The visibility of the group
-        */
-       mw.rcfilters.dm.FilterItem = function MwRcfiltersDmFilterItem( param, groupModel, config ) {
-               config = config || {};
-
-               this.groupModel = groupModel;
-
-               // Parent
-               mw.rcfilters.dm.FilterItem.parent.call( this, param, $.extend( {
-                       namePrefix: this.groupModel.getNamePrefix()
-               }, config ) );
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-
-               // Interaction definitions
-               this.subset = config.subset || [];
-               this.conflicts = config.conflicts || {};
-               this.superset = [];
-               this.visible = config.visible === undefined ? true : !!config.visible;
-
-               // Interaction states
-               this.included = false;
-               this.conflicted = false;
-               this.fullyCovered = false;
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.dm.FilterItem, mw.rcfilters.dm.ItemModel );
-
-       /* Methods */
-
-       /**
-        * Return the representation of the state of this item.
-        *
-        * @return {Object} State of the object
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getState = function () {
-               return {
-                       selected: this.isSelected(),
-                       included: this.isIncluded(),
-                       conflicted: this.isConflicted(),
-                       fullyCovered: this.isFullyCovered()
-               };
-       };
-
-       /**
-        * Get the message for the display area for the currently active conflict
-        *
-        * @private
-        * @return {string} Conflict result message key
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getCurrentConflictResultMessage = function () {
-               var details = {};
-
-               // First look in filter's own conflicts
-               details = this.getConflictDetails( this.getOwnConflicts(), 'globalDescription' );
-               if ( !details.message ) {
-                       // Fall back onto conflicts in the group
-                       details = this.getConflictDetails( this.getGroupModel().getConflicts(), 'globalDescription' );
-               }
-
-               return details.message;
-       };
-
-       /**
-        * Get the details of the active conflict on this filter
-        *
-        * @private
-        * @param {Object} conflicts Conflicts to examine
-        * @param {string} [key='contextDescription'] Message key
-        * @return {Object} Object with conflict message and conflict items
-        * @return {string} return.message Conflict message
-        * @return {string[]} return.names Conflicting item labels
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getConflictDetails = function ( conflicts, key ) {
-               var group,
-                       conflictMessage = '',
-                       itemLabels = [];
-
-               key = key || 'contextDescription';
-
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( conflicts, function ( filterName, conflict ) {
-                       if ( !conflict.item.isSelected() ) {
-                               return;
-                       }
-
-                       if ( !conflictMessage ) {
-                               conflictMessage = conflict[ key ];
-                               group = conflict.group;
-                       }
-
-                       if ( group === conflict.group ) {
-                               itemLabels.push( mw.msg( 'quotation-marks', conflict.item.getLabel() ) );
-                       }
-               } );
-
-               return {
-                       message: conflictMessage,
-                       names: itemLabels
-               };
-
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getStateMessage = function () {
-               var messageKey, details, superset,
-                       affectingItems = [];
-
-               if ( this.isSelected() ) {
-                       if ( this.isConflicted() ) {
-                               // First look in filter's own conflicts
-                               details = this.getConflictDetails( this.getOwnConflicts() );
-                               if ( !details.message ) {
-                                       // Fall back onto conflicts in the group
-                                       details = this.getConflictDetails( this.getGroupModel().getConflicts() );
-                               }
-
-                               messageKey = details.message;
-                               affectingItems = details.names;
-                       } else if ( this.isIncluded() && !this.isHighlighted() ) {
-                               // We only show the 'no effect' full-coverage message
-                               // if the item is also not highlighted. See T161273
-                               superset = this.getSuperset();
-                               // For this message we need to collect the affecting superset
-                               affectingItems = this.getGroupModel().findSelectedItems( this )
-                                       .filter( function ( item ) {
-                                               return superset.indexOf( item.getName() ) !== -1;
-                                       } )
-                                       .map( function ( item ) {
-                                               return mw.msg( 'quotation-marks', item.getLabel() );
-                                       } );
-
-                               messageKey = 'rcfilters-state-message-subset';
-                       } else if ( this.isFullyCovered() && !this.isHighlighted() ) {
-                               affectingItems = this.getGroupModel().findSelectedItems( this )
-                                       .map( function ( item ) {
-                                               return mw.msg( 'quotation-marks', item.getLabel() );
-                                       } );
-
-                               messageKey = 'rcfilters-state-message-fullcoverage';
-                       }
-               }
-
-               if ( messageKey ) {
-                       // Build message
-                       return mw.msg(
-                               messageKey,
-                               mw.language.listToText( affectingItems ),
-                               affectingItems.length
-                       );
-               }
-
-               // Display description
-               return this.getDescription();
-       };
-
-       /**
-        * Get the model of the group this filter belongs to
-        *
-        * @return {mw.rcfilters.dm.FilterGroup} Filter group model
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getGroupModel = function () {
-               return this.groupModel;
-       };
-
-       /**
-        * Get the group name this filter belongs to
-        *
-        * @return {string} Filter group name
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getGroupName = function () {
-               return this.groupModel.getName();
-       };
-
-       /**
-        * Get filter subset
-        * This is a list of filter names that are defined to be included
-        * when this filter is selected.
-        *
-        * @return {string[]} Filter subset
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getSubset = function () {
-               return this.subset;
-       };
-
-       /**
-        * Get filter superset
-        * This is a generated list of filters that define this filter
-        * to be included when either of them is selected.
-        *
-        * @return {string[]} Filter superset
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getSuperset = function () {
-               return this.superset;
-       };
-
-       /**
-        * Check whether the filter is currently in a conflict state
-        *
-        * @return {boolean} Filter is in conflict state
-        */
-       mw.rcfilters.dm.FilterItem.prototype.isConflicted = function () {
-               return this.conflicted;
-       };
-
-       /**
-        * Check whether the filter is currently in an already included subset
-        *
-        * @return {boolean} Filter is in an already-included subset
-        */
-       mw.rcfilters.dm.FilterItem.prototype.isIncluded = function () {
-               return this.included;
-       };
-
-       /**
-        * Check whether the filter is currently fully covered
-        *
-        * @return {boolean} Filter is in fully-covered state
-        */
-       mw.rcfilters.dm.FilterItem.prototype.isFullyCovered = function () {
-               return this.fullyCovered;
-       };
-
-       /**
-        * Get all conflicts associated with this filter or its group
-        *
-        * Conflict object is set up by filter name keys and conflict
-        * definition. For example:
-        *
-        *  {
-        *      filterName: {
-        *          filter: filterName,
-        *          group: group1,
-        *          label: itemLabel,
-        *          item: itemModel
-        *      }
-        *      filterName2: {
-        *          filter: filterName2,
-        *          group: group2
-        *          label: itemLabel2,
-        *          item: itemModel2
-        *      }
-        *  }
-        *
-        * @return {Object} Filter conflicts
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getConflicts = function () {
-               return $.extend( {}, this.conflicts, this.getGroupModel().getConflicts() );
-       };
-
-       /**
-        * Get the conflicts associated with this filter
-        *
-        * @return {Object} Filter conflicts
-        */
-       mw.rcfilters.dm.FilterItem.prototype.getOwnConflicts = function () {
-               return this.conflicts;
-       };
-
-       /**
-        * Set conflicts for this filter. See #getConflicts for the expected
-        * structure of the definition.
-        *
-        * @param {Object} conflicts Conflicts for this filter
-        */
-       mw.rcfilters.dm.FilterItem.prototype.setConflicts = function ( conflicts ) {
-               this.conflicts = conflicts || {};
-       };
-
-       /**
-        * Set filter superset
-        *
-        * @param {string[]} superset Filter superset
-        */
-       mw.rcfilters.dm.FilterItem.prototype.setSuperset = function ( superset ) {
-               this.superset = superset || [];
-       };
-
-       /**
-        * Set filter subset
-        *
-        * @param {string[]} subset Filter subset
-        */
-       mw.rcfilters.dm.FilterItem.prototype.setSubset = function ( subset ) {
-               this.subset = subset || [];
-       };
-
-       /**
-        * Check whether a filter exists in the subset list for this filter
-        *
-        * @param {string} filterName Filter name
-        * @return {boolean} Filter name is in the subset list
-        */
-       mw.rcfilters.dm.FilterItem.prototype.existsInSubset = function ( filterName ) {
-               return this.subset.indexOf( filterName ) > -1;
-       };
-
-       /**
-        * Check whether this item has a potential conflict with the given item
-        *
-        * This checks whether the given item is in the list of conflicts of
-        * the current item, but makes no judgment about whether the conflict
-        * is currently at play (either one of the items may not be selected)
-        *
-        * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
-        * @return {boolean} This item has a conflict with the given item
-        */
-       mw.rcfilters.dm.FilterItem.prototype.existsInConflicts = function ( filterItem ) {
-               return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
-       };
-
-       /**
-        * Set the state of this filter as being conflicted
-        * (This means any filters in its conflicts are selected)
-        *
-        * @param {boolean} [conflicted] Filter is in conflict state
-        * @fires update
-        */
-       mw.rcfilters.dm.FilterItem.prototype.toggleConflicted = function ( conflicted ) {
-               conflicted = conflicted === undefined ? !this.conflicted : conflicted;
-
-               if ( this.conflicted !== conflicted ) {
-                       this.conflicted = conflicted;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Set the state of this filter as being already included
-        * (This means any filters in its superset are selected)
-        *
-        * @param {boolean} [included] Filter is included as part of a subset
-        * @fires update
-        */
-       mw.rcfilters.dm.FilterItem.prototype.toggleIncluded = function ( included ) {
-               included = included === undefined ? !this.included : included;
-
-               if ( this.included !== included ) {
-                       this.included = included;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Toggle the fully covered state of the item
-        *
-        * @param {boolean} [isFullyCovered] Filter is fully covered
-        * @fires update
-        */
-       mw.rcfilters.dm.FilterItem.prototype.toggleFullyCovered = function ( isFullyCovered ) {
-               isFullyCovered = isFullyCovered === undefined ? !this.fullycovered : isFullyCovered;
-
-               if ( this.fullyCovered !== isFullyCovered ) {
-                       this.fullyCovered = isFullyCovered;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Toggle the visibility of this item
-        *
-        * @param {boolean} [isVisible] Item is visible
-        */
-       mw.rcfilters.dm.FilterItem.prototype.toggleVisible = function ( isVisible ) {
-               isVisible = isVisible === undefined ? !this.visible : !!isVisible;
-
-               if ( this.visible !== isVisible ) {
-                       this.visible = isVisible;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Check whether the item is visible
-        *
-        * @return {boolean} Item is visible
-        */
-       mw.rcfilters.dm.FilterItem.prototype.isVisible = function () {
-               return this.visible;
-       };
-
-}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.FiltersViewModel.js
deleted file mode 100644 (file)
index 5d51d10..0000000
+++ /dev/null
@@ -1,1295 +0,0 @@
-( function () {
-       /**
-        * View model for the filters selection and display
-        *
-        * @mixins OO.EventEmitter
-        * @mixins OO.EmitterList
-        *
-        * @constructor
-        */
-       mw.rcfilters.dm.FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-               OO.EmitterList.call( this );
-
-               this.groups = {};
-               this.defaultParams = {};
-               this.highlightEnabled = false;
-               this.parameterMap = {};
-               this.emptyParameterState = null;
-
-               this.views = {};
-               this.currentView = 'default';
-               this.searchQuery = null;
-
-               // Events
-               this.aggregate( { update: 'filterItemUpdate' } );
-               this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
-       };
-
-       /* Initialization */
-       OO.initClass( mw.rcfilters.dm.FiltersViewModel );
-       OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EventEmitter );
-       OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EmitterList );
-
-       /* Events */
-
-       /**
-        * @event initialize
-        *
-        * Filter list is initialized
-        */
-
-       /**
-        * @event update
-        *
-        * Model has been updated
-        */
-
-       /**
-        * @event itemUpdate
-        * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
-        *
-        * Filter item has changed
-        */
-
-       /**
-        * @event highlightChange
-        * @param {boolean} Highlight feature is enabled
-        *
-        * Highlight feature has been toggled enabled or disabled
-        */
-
-       /* Methods */
-
-       /**
-        * Re-assess the states of filter items based on the interactions between them
-        *
-        * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
-        *  method will go over the state of all items
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
-               var allSelected,
-                       model = this,
-                       iterationItems = item !== undefined ? [ item ] : this.getItems();
-
-               iterationItems.forEach( function ( checkedItem ) {
-                       var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
-                               groupModel = checkedItem.getGroupModel();
-
-                       // Check for subsets (included filters) plus the item itself:
-                       allCheckedItems.forEach( function ( filterItemName ) {
-                               var itemInSubset = model.getItemByName( filterItemName );
-
-                               itemInSubset.toggleIncluded(
-                                       // If any of itemInSubset's supersets are selected, this item
-                                       // is included
-                                       itemInSubset.getSuperset().some( function ( supersetName ) {
-                                               return ( model.getItemByName( supersetName ).isSelected() );
-                                       } )
-                               );
-                       } );
-
-                       // Update coverage for the changed group
-                       if ( groupModel.isFullCoverage() ) {
-                               allSelected = groupModel.areAllSelected();
-                               groupModel.getItems().forEach( function ( filterItem ) {
-                                       filterItem.toggleFullyCovered( allSelected );
-                               } );
-                       }
-               } );
-
-               // Check for conflicts
-               // In this case, we must go over all items, since
-               // conflicts are bidirectional and depend not only on
-               // individual items, but also on the selected states of
-               // the groups they're in.
-               this.getItems().forEach( function ( filterItem ) {
-                       var inConflict = false,
-                               filterItemGroup = filterItem.getGroupModel();
-
-                       // For each item, see if that item is still conflicting
-                       // eslint-disable-next-line jquery/no-each-util
-                       $.each( model.groups, function ( groupName, groupModel ) {
-                               if ( filterItem.getGroupName() === groupName ) {
-                                       // Check inside the group
-                                       inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
-                               } else {
-                                       // According to the spec, if two items conflict from two different
-                                       // groups, the conflict only lasts if the groups **only have selected
-                                       // items that are conflicting**. If a group has selected items that
-                                       // are conflicting and non-conflicting, the scope of the result has
-                                       // expanded enough to completely remove the conflict.
-
-                                       // For example, see two groups with conflicts:
-                                       // userExpLevel: [
-                                       //   {
-                                       //     name: 'experienced',
-                                       //     conflicts: [ 'unregistered' ]
-                                       //   }
-                                       // ],
-                                       // registration: [
-                                       //   {
-                                       //     name: 'registered',
-                                       //   },
-                                       //   {
-                                       //     name: 'unregistered',
-                                       //   }
-                                       // ]
-                                       // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
-                                       // because, inherently, 'experienced' filter only includes registered users, and so
-                                       // both filters are in conflict with one another.
-                                       // However, the minute we select 'registered', the scope of our results
-                                       // has expanded to no longer have a conflict with 'experienced' filter, and
-                                       // so the conflict is removed.
-
-                                       // In our case, we need to check if the entire group conflicts with
-                                       // the entire item's group, so we follow the above spec
-                                       inConflict = (
-                                               // The foreign group is in conflict with this item
-                                               groupModel.areAllSelectedInConflictWith( filterItem ) &&
-                                               // Every selected member of the item's own group is also
-                                               // in conflict with the other group
-                                               filterItemGroup.findSelectedItems().every( function ( otherGroupItem ) {
-                                                       return groupModel.areAllSelectedInConflictWith( otherGroupItem );
-                                               } )
-                                       );
-                               }
-
-                               // If we're in conflict, this will return 'false' which
-                               // will break the loop. Otherwise, we're not in conflict
-                               // and the loop continues
-                               return !inConflict;
-                       } );
-
-                       // Toggle the item state
-                       filterItem.toggleConflicted( inConflict );
-               } );
-       };
-
-       /**
-        * Get whether the model has any conflict in its items
-        *
-        * @return {boolean} There is a conflict
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.hasConflict = function () {
-               return this.getItems().some( function ( filterItem ) {
-                       return filterItem.isSelected() && filterItem.isConflicted();
-               } );
-       };
-
-       /**
-        * Get the first item with a current conflict
-        *
-        * @return {mw.rcfilters.dm.FilterItem} Conflicted item
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getFirstConflictedItem = function () {
-               var conflictedItem;
-
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( filterItem.isSelected() && filterItem.isConflicted() ) {
-                               conflictedItem = filterItem;
-                               return false;
-                       }
-               } );
-
-               return conflictedItem;
-       };
-
-       /**
-        * Set filters and preserve a group relationship based on
-        * the definition given by an object
-        *
-        * @param {Array} filterGroups Filters definition
-        * @param {Object} [views] Extra views definition
-        *  Expected in the following format:
-        *  {
-        *     namespaces: {
-        *       label: 'namespaces', // Message key
-        *       trigger: ':',
-        *       groups: [
-        *         {
-        *            // Group info
-        *            name: 'namespaces' // Parameter name
-        *            title: 'namespaces' // Message key
-        *            type: 'string_options',
-        *            separator: ';',
-        *            labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
-        *            fullCoverage: true
-        *            items: []
-        *         }
-        *       ]
-        *     }
-        *  }
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
-               var filterConflictResult, groupConflictResult,
-                       allViews = {},
-                       model = this,
-                       items = [],
-                       groupConflictMap = {},
-                       filterConflictMap = {},
-                       /*!
-                        * Expand a conflict definition from group name to
-                        * the list of all included filters in that group.
-                        * We do this so that the direct relationship in the
-                        * models are consistently item->items rather than
-                        * mixing item->group with item->item.
-                        *
-                        * @param {Object} obj Conflict definition
-                        * @return {Object} Expanded conflict definition
-                        */
-                       expandConflictDefinitions = function ( obj ) {
-                               var result = {};
-
-                               // eslint-disable-next-line jquery/no-each-util
-                               $.each( obj, function ( key, conflicts ) {
-                                       var filterName,
-                                               adjustedConflicts = {};
-
-                                       conflicts.forEach( function ( conflict ) {
-                                               var filter;
-
-                                               if ( conflict.filter ) {
-                                                       filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
-                                                       filter = model.getItemByName( filterName );
-
-                                                       // Rename
-                                                       adjustedConflicts[ filterName ] = $.extend(
-                                                               {},
-                                                               conflict,
-                                                               {
-                                                                       filter: filterName,
-                                                                       item: filter
-                                                               }
-                                                       );
-                                               } else {
-                                                       // This conflict is for an entire group. Split it up to
-                                                       // represent each filter
-
-                                                       // Get the relevant group items
-                                                       model.groups[ conflict.group ].getItems().forEach( function ( groupItem ) {
-                                                               // Rebuild the conflict
-                                                               adjustedConflicts[ groupItem.getName() ] = $.extend(
-                                                                       {},
-                                                                       conflict,
-                                                                       {
-                                                                               filter: groupItem.getName(),
-                                                                               item: groupItem
-                                                                       }
-                                                               );
-                                                       } );
-                                               }
-                                       } );
-
-                                       result[ key ] = adjustedConflicts;
-                               } );
-
-                               return result;
-                       };
-
-               // Reset
-               this.clearItems();
-               this.groups = {};
-               this.views = {};
-
-               // Clone
-               filterGroups = OO.copy( filterGroups );
-
-               // Normalize definition from the server
-               filterGroups.forEach( function ( data ) {
-                       var i;
-                       // What's this information needs to be normalized
-                       data.whatsThis = {
-                               body: data.whatsThisBody,
-                               header: data.whatsThisHeader,
-                               linkText: data.whatsThisLinkText,
-                               url: data.whatsThisUrl
-                       };
-
-                       // Title is a msg-key
-                       data.title = data.title ? mw.msg( data.title ) : data.name;
-
-                       // Filters are given to us with msg-keys, we need
-                       // to translate those before we hand them off
-                       for ( i = 0; i < data.filters.length; i++ ) {
-                               data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
-                               data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
-                       }
-               } );
-
-               // Collect views
-               allViews = $.extend( true, {
-                       default: {
-                               title: mw.msg( 'rcfilters-filterlist-title' ),
-                               groups: filterGroups
-                       }
-               }, views );
-
-               // Go over all views
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( allViews, function ( viewName, viewData ) {
-                       // Define the view
-                       model.views[ viewName ] = {
-                               name: viewData.name,
-                               title: viewData.title,
-                               trigger: viewData.trigger
-                       };
-
-                       // Go over groups
-                       viewData.groups.forEach( function ( groupData ) {
-                               var group = groupData.name;
-
-                               if ( !model.groups[ group ] ) {
-                                       model.groups[ group ] = new mw.rcfilters.dm.FilterGroup(
-                                               group,
-                                               $.extend( true, {}, groupData, { view: viewName } )
-                                       );
-                               }
-
-                               model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
-                               items = items.concat( model.groups[ group ].getItems() );
-
-                               // Prepare conflicts
-                               if ( groupData.conflicts ) {
-                                       // Group conflicts
-                                       groupConflictMap[ group ] = groupData.conflicts;
-                               }
-
-                               groupData.filters.forEach( function ( itemData ) {
-                                       var filterItem = model.groups[ group ].getItemByParamName( itemData.name );
-                                       // Filter conflicts
-                                       if ( itemData.conflicts ) {
-                                               filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
-                                       }
-                               } );
-                       } );
-               } );
-
-               // Add item references to the model, for lookup
-               this.addItems( items );
-
-               // Expand conflicts
-               groupConflictResult = expandConflictDefinitions( groupConflictMap );
-               filterConflictResult = expandConflictDefinitions( filterConflictMap );
-
-               // Set conflicts for groups
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( groupConflictResult, function ( group, conflicts ) {
-                       model.groups[ group ].setConflicts( conflicts );
-               } );
-
-               // Set conflicts for items
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( filterConflictResult, function ( filterName, conflicts ) {
-                       var filterItem = model.getItemByName( filterName );
-                       // set conflicts for items in the group
-                       filterItem.setConflicts( conflicts );
-               } );
-
-               // Create a map between known parameters and their models
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.groups, function ( group, groupModel ) {
-                       if (
-                               groupModel.getType() === 'send_unselected_if_any' ||
-                               groupModel.getType() === 'boolean' ||
-                               groupModel.getType() === 'any_value'
-                       ) {
-                               // Individual filters
-                               groupModel.getItems().forEach( function ( filterItem ) {
-                                       model.parameterMap[ filterItem.getParamName() ] = filterItem;
-                               } );
-                       } else if (
-                               groupModel.getType() === 'string_options' ||
-                               groupModel.getType() === 'single_option'
-                       ) {
-                               // Group
-                               model.parameterMap[ groupModel.getName() ] = groupModel;
-                       }
-               } );
-
-               this.setSearch( '' );
-
-               this.updateHighlightedState();
-
-               // Finish initialization
-               this.emit( 'initialize' );
-       };
-
-       /**
-        * Update filter view model state based on a parameter object
-        *
-        * @param {Object} params Parameters object
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
-               var filtersValue;
-               // For arbitrary numeric single_option values make sure the values
-               // are normalized to fit within the limits
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
-                       params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
-               } );
-
-               // Update filter values
-               filtersValue = this.getFiltersFromParameters( params );
-               Object.keys( filtersValue ).forEach( function ( filterName ) {
-                       this.getItemByName( filterName ).setValue( filtersValue[ filterName ] );
-               }.bind( this ) );
-
-               // Update highlight state
-               this.getItemsSupportingHighlights().forEach( function ( filterItem ) {
-                       var color = params[ filterItem.getName() + '_color' ];
-                       if ( color ) {
-                               filterItem.setHighlightColor( color );
-                       } else {
-                               filterItem.clearHighlightColor();
-                       }
-               } );
-               this.updateHighlightedState();
-
-               // Check all filter interactions
-               this.reassessFilterInteractions();
-       };
-
-       /**
-        * Get a representation of an empty (falsey) parameter state
-        *
-        * @return {Object} Empty parameter state
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getEmptyParameterState = function () {
-               if ( !this.emptyParameterState ) {
-                       this.emptyParameterState = $.extend(
-                               true,
-                               {},
-                               this.getParametersFromFilters( {} ),
-                               this.getEmptyHighlightParameters()
-                       );
-               }
-               return this.emptyParameterState;
-       };
-
-       /**
-        * Get a representation of only the non-falsey parameters
-        *
-        * @param {Object} [parameters] A given parameter state to minimize. If not given the current
-        *  state of the system will be used.
-        * @return {Object} Empty parameter state
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
-               var result = {};
-
-               parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
-
-               // Params
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.getEmptyParameterState(), function ( param, value ) {
-                       if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
-                               result[ param ] = parameters[ param ];
-                       }
-               } );
-
-               // Highlights
-               Object.keys( this.getEmptyHighlightParameters() ).forEach( function ( param ) {
-                       if ( parameters[ param ] ) {
-                               // If a highlight parameter is not undefined and not null
-                               // add it to the result
-                               result[ param ] = parameters[ param ];
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get a representation of the full parameter list, including all base values
-        *
-        * @return {Object} Full parameter representation
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getExpandedParamRepresentation = function () {
-               return $.extend(
-                       true,
-                       {},
-                       this.getEmptyParameterState(),
-                       this.getCurrentParameterState()
-               );
-       };
-
-       /**
-        * Get a parameter representation of the current state of the model
-        *
-        * @param {boolean} [removeStickyParams] Remove sticky filters from final result
-        * @return {Object} Parameter representation of the current state of the model
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentParameterState = function ( removeStickyParams ) {
-               var state = this.getMinimizedParamRepresentation( $.extend(
-                       true,
-                       {},
-                       this.getParametersFromFilters( this.getSelectedState() ),
-                       this.getHighlightParameters()
-               ) );
-
-               if ( removeStickyParams ) {
-                       state = this.removeStickyParams( state );
-               }
-
-               return state;
-       };
-
-       /**
-        * Delete sticky parameters from given object.
-        *
-        * @param {Object} paramState Parameter state
-        * @return {Object} Parameter state without sticky parameters
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.removeStickyParams = function ( paramState ) {
-               this.getStickyParams().forEach( function ( paramName ) {
-                       delete paramState[ paramName ];
-               } );
-
-               return paramState;
-       };
-
-       /**
-        * Turn the highlight feature on or off
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.updateHighlightedState = function () {
-               this.toggleHighlight( this.getHighlightedItems().length > 0 );
-       };
-
-       /**
-        * Get the object that defines groups by their name.
-        *
-        * @return {Object} Filter groups
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroups = function () {
-               return this.groups;
-       };
-
-       /**
-        * Get the object that defines groups that match a certain view by their name.
-        *
-        * @param {string} [view] Requested view. If not given, uses current view
-        * @return {Object} Filter groups matching a display group
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
-               var result = {};
-
-               view = view || this.getCurrentView();
-
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.groups, function ( groupName, groupModel ) {
-                       if ( groupModel.getView() === view ) {
-                               result[ groupName ] = groupModel;
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get an array of filters matching the given display group.
-        *
-        * @param {string} [view] Requested view. If not given, uses current view
-        * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersByView = function ( view ) {
-               var groups,
-                       result = [];
-
-               view = view || this.getCurrentView();
-
-               groups = this.getFilterGroupsByView( view );
-
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( groups, function ( groupName, groupModel ) {
-                       result = result.concat( groupModel.getItems() );
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get the trigger for the requested view.
-        *
-        * @param {string} view View name
-        * @return {string} View trigger, if exists
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getViewTrigger = function ( view ) {
-               return ( this.views[ view ] && this.views[ view ].trigger ) || '';
-       };
-
-       /**
-        * Get the value of a specific parameter
-        *
-        * @param {string} name Parameter name
-        * @return {number|string} Parameter value
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getParamValue = function ( name ) {
-               return this.parameters[ name ];
-       };
-
-       /**
-        * Get the current selected state of the filters
-        *
-        * @param {boolean} [onlySelected] return an object containing only the filters with a value
-        * @return {Object} Filters selected state
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) {
-               var i,
-                       items = this.getItems(),
-                       result = {};
-
-               for ( i = 0; i < items.length; i++ ) {
-                       if ( !onlySelected || items[ i ].getValue() ) {
-                               result[ items[ i ].getName() ] = items[ i ].getValue();
-                       }
-               }
-
-               return result;
-       };
-
-       /**
-        * Get the current full state of the filters
-        *
-        * @return {Object} Filters full state
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getFullState = function () {
-               var i,
-                       items = this.getItems(),
-                       result = {};
-
-               for ( i = 0; i < items.length; i++ ) {
-                       result[ items[ i ].getName() ] = {
-                               selected: items[ i ].isSelected(),
-                               conflicted: items[ i ].isConflicted(),
-                               included: items[ i ].isIncluded()
-                       };
-               }
-
-               return result;
-       };
-
-       /**
-        * Get an object representing default parameters state
-        *
-        * @return {Object} Default parameter values
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultParams = function () {
-               var result = {};
-
-               // Get default filter state
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.groups, function ( name, model ) {
-                       if ( !model.isSticky() ) {
-                               $.extend( true, result, model.getDefaultParams() );
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get a parameter representation of all sticky parameters
-        *
-        * @return {Object} Sticky parameter values
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getStickyParams = function () {
-               var result = [];
-
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.groups, function ( name, model ) {
-                       if ( model.isSticky() ) {
-                               if ( model.isPerGroupRequestParameter() ) {
-                                       result.push( name );
-                               } else {
-                                       // Each filter is its own param
-                                       result = result.concat( model.getItems().map( function ( filterItem ) {
-                                               return filterItem.getParamName();
-                                       } ) );
-                               }
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get a parameter representation of all sticky parameters
-        *
-        * @return {Object} Sticky parameter values
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getStickyParamsValues = function () {
-               var result = {};
-
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.groups, function ( name, model ) {
-                       if ( model.isSticky() ) {
-                               $.extend( true, result, model.getParamRepresentation() );
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Analyze the groups and their filters and output an object representing
-        * the state of the parameters they represent.
-        *
-        * @param {Object} [filterDefinition] An object defining the filter values,
-        *  keyed by filter names.
-        * @return {Object} Parameter state object
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
-               var groupItemDefinition,
-                       result = {},
-                       groupItems = this.getFilterGroups();
-
-               if ( filterDefinition ) {
-                       groupItemDefinition = {};
-                       // Filter definition is "flat", but in effect
-                       // each group needs to tell us its result based
-                       // on the values in it. We need to split this list
-                       // back into groupings so we can "feed" it to the
-                       // loop below, and we need to expand it so it includes
-                       // all filters (set to false)
-                       this.getItems().forEach( function ( filterItem ) {
-                               groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
-                               groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] );
-                       } );
-               }
-
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( groupItems, function ( group, model ) {
-                       $.extend(
-                               result,
-                               model.getParamRepresentation(
-                                       groupItemDefinition ?
-                                               groupItemDefinition[ group ] : null
-                               )
-                       );
-               } );
-
-               return result;
-       };
-
-       /**
-        * This is the opposite of the #getParametersFromFilters method; this goes over
-        * the given parameters and translates into a selected/unselected value in the filters.
-        *
-        * @param {Object} params Parameters query object
-        * @return {Object} Filter state object
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
-               var groupMap = {},
-                       model = this,
-                       result = {};
-
-               // Go over the given parameters, break apart to groupings
-               // The resulting object represents the group with its parameter
-               // values. For example:
-               // {
-               //    group1: {
-               //       param1: "1",
-               //       param2: "0",
-               //       param3: "1"
-               //    },
-               //    group2: "param4|param5"
-               // }
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( params, function ( paramName, paramValue ) {
-                       var groupName,
-                               itemOrGroup = model.parameterMap[ paramName ];
-
-                       if ( itemOrGroup ) {
-                               groupName = itemOrGroup instanceof mw.rcfilters.dm.FilterItem ?
-                                       itemOrGroup.getGroupName() : itemOrGroup.getName();
-
-                               groupMap[ groupName ] = groupMap[ groupName ] || {};
-                               groupMap[ groupName ][ paramName ] = paramValue;
-                       }
-               } );
-
-               // Go over all groups, so we make sure we get the complete output
-               // even if the parameters don't include a certain group
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.groups, function ( groupName, groupModel ) {
-                       result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get the highlight parameters based on current filter configuration
-        *
-        * @return {Object} Object where keys are `<filter name>_color` and values
-        *                  are the selected highlight colors.
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightParameters = function () {
-               var highlightEnabled = this.isHighlightEnabled(),
-                       result = {};
-
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( filterItem.isHighlightSupported() ) {
-                               result[ filterItem.getName() + '_color' ] = highlightEnabled && filterItem.isHighlighted() ?
-                                       filterItem.getHighlightColor() :
-                                       null;
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get an object representing the complete empty state of highlights
-        *
-        * @return {Object} Object containing all the highlight parameters set to their negative value
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getEmptyHighlightParameters = function () {
-               var result = {};
-
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( filterItem.isHighlightSupported() ) {
-                               result[ filterItem.getName() + '_color' ] = null;
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Get an array of currently applied highlight colors
-        *
-        * @return {string[]} Currently applied highlight colors
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
-               var result = [];
-
-               if ( this.isHighlightEnabled() ) {
-                       this.getHighlightedItems().forEach( function ( filterItem ) {
-                               var color = filterItem.getHighlightColor();
-
-                               if ( result.indexOf( color ) === -1 ) {
-                                       result.push( color );
-                               }
-                       } );
-               }
-
-               return result;
-       };
-
-       /**
-        * Sanitize value group of a string_option groups type
-        * Remove duplicates and make sure to only use valid
-        * values.
-        *
-        * @private
-        * @param {string} groupName Group name
-        * @param {string[]} valueArray Array of values
-        * @return {string[]} Array of valid values
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
-               var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
-                       return filterItem.getParamName();
-               } );
-
-               return mw.rcfilters.utils.normalizeParamOptions( valueArray, validNames );
-       };
-
-       /**
-        * Check whether no visible filter is selected.
-        *
-        * Filter groups that are hidden or sticky are not shown in the
-        * active filters area and therefore not included in this check.
-        *
-        * @return {boolean} No visible filter is selected
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.areVisibleFiltersEmpty = function () {
-               // Check if there are either any selected items or any items
-               // that have highlight enabled
-               return !this.getItems().some( function ( filterItem ) {
-                       var visible = !filterItem.getGroupModel().isSticky() && !filterItem.getGroupModel().isHidden(),
-                               active = ( filterItem.isSelected() || filterItem.isHighlighted() );
-                       return visible && active;
-               } );
-       };
-
-       /**
-        * Check whether the invert state is a valid one. A valid invert state is one where
-        * there are actual namespaces selected.
-        *
-        * This is done to compare states to previous ones that may have had the invert model
-        * selected but effectively had no namespaces, so are not effectively different than
-        * ones where invert is not selected.
-        *
-        * @return {boolean} Invert is effectively selected
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.areNamespacesEffectivelyInverted = function () {
-               return this.getInvertModel().isSelected() &&
-                       this.findSelectedItems().some( function ( itemModel ) {
-                               return itemModel.getGroupModel().getName() === 'namespace';
-                       } );
-       };
-
-       /**
-        * Get the item that matches the given name
-        *
-        * @param {string} name Filter name
-        * @return {mw.rcfilters.dm.FilterItem} Filter item
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getItemByName = function ( name ) {
-               return this.getItems().filter( function ( item ) {
-                       return name === item.getName();
-               } )[ 0 ];
-       };
-
-       /**
-        * Set all filters to false or empty/all
-        * This is equivalent to display all.
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.emptyAllFilters = function () {
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( !filterItem.getGroupModel().isSticky() ) {
-                               this.toggleFilterSelected( filterItem.getName(), false );
-                       }
-               }.bind( this ) );
-       };
-
-       /**
-        * Toggle selected state of one item
-        *
-        * @param {string} name Name of the filter item
-        * @param {boolean} [isSelected] Filter selected state
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
-               var item = this.getItemByName( name );
-
-               if ( item ) {
-                       item.toggleSelected( isSelected );
-               }
-       };
-
-       /**
-        * Toggle selected state of items by their names
-        *
-        * @param {Object} filterDef Filter definitions
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
-               Object.keys( filterDef ).forEach( function ( name ) {
-                       this.toggleFilterSelected( name, filterDef[ name ] );
-               }.bind( this ) );
-       };
-
-       /**
-        * Get a group model from its name
-        *
-        * @param {string} groupName Group name
-        * @return {mw.rcfilters.dm.FilterGroup} Group model
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getGroup = function ( groupName ) {
-               return this.groups[ groupName ];
-       };
-
-       /**
-        * Get all filters within a specified group by its name
-        *
-        * @param {string} groupName Group name
-        * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
-               return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
-       };
-
-       /**
-        * Find items whose labels match the given string
-        *
-        * @param {string} query Search string
-        * @param {boolean} [returnFlat] Return a flat array. If false, the result
-        *  is an object whose keys are the group names and values are an array of
-        *  filters per group. If set to true, returns an array of filters regardless
-        *  of their groups.
-        * @return {Object} An object of items to show
-        *  arranged by their group names
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
-               var i, searchIsEmpty,
-                       groupTitle,
-                       result = {},
-                       flatResult = [],
-                       view = this.getViewByTrigger( query.substr( 0, 1 ) ),
-                       items = this.getFiltersByView( view );
-
-               // Normalize so we can search strings regardless of case and view
-               query = query.trim().toLowerCase();
-               if ( view !== 'default' ) {
-                       query = query.substr( 1 );
-               }
-               // Trim again to also intercept cases where the spaces were after the trigger
-               // eg: '#   str'
-               query = query.trim();
-
-               // Check if the search if actually empty; this can be a problem when
-               // we use prefixes to denote different views
-               searchIsEmpty = query.length === 0;
-
-               // item label starting with the query string
-               for ( i = 0; i < items.length; i++ ) {
-                       if (
-                               searchIsEmpty ||
-                               items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
-                               (
-                                       // For tags, we want the parameter name to be included in the search
-                                       view === 'tags' &&
-                                       items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
-                               )
-                       ) {
-                               result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
-                               result[ items[ i ].getGroupName() ].push( items[ i ] );
-                               flatResult.push( items[ i ] );
-                       }
-               }
-
-               if ( $.isEmptyObject( result ) ) {
-                       // item containing the query string in their label, description, or group title
-                       for ( i = 0; i < items.length; i++ ) {
-                               groupTitle = items[ i ].getGroupModel().getTitle();
-                               if (
-                                       searchIsEmpty ||
-                                       items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
-                                       items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
-                                       groupTitle.toLowerCase().indexOf( query ) > -1 ||
-                                       (
-                                               // For tags, we want the parameter name to be included in the search
-                                               view === 'tags' &&
-                                               items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
-                                       )
-                               ) {
-                                       result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
-                                       result[ items[ i ].getGroupName() ].push( items[ i ] );
-                                       flatResult.push( items[ i ] );
-                               }
-                       }
-               }
-
-               return returnFlat ? flatResult : result;
-       };
-
-       /**
-        * Get items that are highlighted
-        *
-        * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightedItems = function () {
-               return this.getItems().filter( function ( filterItem ) {
-                       return filterItem.isHighlightSupported() &&
-                               filterItem.getHighlightColor();
-               } );
-       };
-
-       /**
-        * Get items that allow highlights even if they're not currently highlighted
-        *
-        * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
-               return this.getItems().filter( function ( filterItem ) {
-                       return filterItem.isHighlightSupported();
-               } );
-       };
-
-       /**
-        * Get all selected items
-        *
-        * @return {mw.rcfilters.dm.FilterItem[]} Selected items
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.findSelectedItems = function () {
-               var allSelected = [];
-
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
-                       allSelected = allSelected.concat( groupModel.findSelectedItems() );
-               } );
-
-               return allSelected;
-       };
-
-       /**
-        * Get the current view
-        *
-        * @return {string} Current view
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentView = function () {
-               return this.currentView;
-       };
-
-       /**
-        * Get the label for the current view
-        *
-        * @param {string} viewName View name
-        * @return {string} Label for the current view
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getViewTitle = function ( viewName ) {
-               viewName = viewName || this.getCurrentView();
-
-               return this.views[ viewName ] && this.views[ viewName ].title;
-       };
-
-       /**
-        * Get the view that fits the given trigger
-        *
-        * @param {string} trigger Trigger
-        * @return {string} Name of view
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
-               var result = 'default';
-
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( this.views, function ( name, data ) {
-                       if ( data.trigger === trigger ) {
-                               result = name;
-                       }
-               } );
-
-               return result;
-       };
-
-       /**
-        * Return a version of the given string that is without any
-        * view triggers.
-        *
-        * @param {string} str Given string
-        * @return {string} Result
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
-               if ( this.getViewFromString( str ) !== 'default' ) {
-                       str = str.substr( 1 );
-               }
-
-               return str;
-       };
-
-       /**
-        * Get the view from the given string by a trigger, if it exists
-        *
-        * @param {string} str Given string
-        * @return {string} View name
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getViewFromString = function ( str ) {
-               return this.getViewByTrigger( str.substr( 0, 1 ) );
-       };
-
-       /**
-        * Set the current search for the system.
-        * This also dictates what items and groups are visible according
-        * to the search in #findMatches
-        *
-        * @param {string} searchQuery Search query, including triggers
-        * @fires searchChange
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.setSearch = function ( searchQuery ) {
-               var visibleGroups, visibleGroupNames;
-
-               if ( this.searchQuery !== searchQuery ) {
-                       // Check if the view changed
-                       this.switchView( this.getViewFromString( searchQuery ) );
-
-                       visibleGroups = this.findMatches( searchQuery );
-                       visibleGroupNames = Object.keys( visibleGroups );
-
-                       // Update visibility of items and groups
-                       // eslint-disable-next-line jquery/no-each-util
-                       $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
-                               // Check if the group is visible at all
-                               groupModel.toggleVisible( visibleGroupNames.indexOf( groupName ) !== -1 );
-                               groupModel.setVisibleItems( visibleGroups[ groupName ] || [] );
-                       } );
-
-                       this.searchQuery = searchQuery;
-                       this.emit( 'searchChange', this.searchQuery );
-               }
-       };
-
-       /**
-        * Get the current search
-        *
-        * @return {string} Current search query
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getSearch = function () {
-               return this.searchQuery;
-       };
-
-       /**
-        * Switch the current view
-        *
-        * @private
-        * @param {string} view View name
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.switchView = function ( view ) {
-               if ( this.views[ view ] && this.currentView !== view ) {
-                       this.currentView = view;
-               }
-       };
-
-       /**
-        * Toggle the highlight feature on and off.
-        * Propagate the change to filter items.
-        *
-        * @param {boolean} enable Highlight should be enabled
-        * @fires highlightChange
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
-               enable = enable === undefined ? !this.highlightEnabled : enable;
-
-               if ( this.highlightEnabled !== enable ) {
-                       this.highlightEnabled = enable;
-                       this.emit( 'highlightChange', this.highlightEnabled );
-               }
-       };
-
-       /**
-        * Check if the highlight feature is enabled
-        * @return {boolean}
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.isHighlightEnabled = function () {
-               return !!this.highlightEnabled;
-       };
-
-       /**
-        * Toggle the inverted namespaces property on and off.
-        * Propagate the change to namespace filter items.
-        *
-        * @param {boolean} enable Inverted property is enabled
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
-               this.toggleFilterSelected( this.getInvertModel().getName(), enable );
-       };
-
-       /**
-        * Get the model object that represents the 'invert' filter
-        *
-        * @return {mw.rcfilters.dm.FilterItem}
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.getInvertModel = function () {
-               return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' );
-       };
-
-       /**
-        * Set highlight color for a specific filter item
-        *
-        * @param {string} filterName Name of the filter item
-        * @param {string} color Selected color
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
-               this.getItemByName( filterName ).setHighlightColor( color );
-       };
-
-       /**
-        * Clear highlight for a specific filter item
-        *
-        * @param {string} filterName Name of the filter item
-        */
-       mw.rcfilters.dm.FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
-               this.getItemByName( filterName ).clearHighlightColor();
-       };
-
-}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ItemModel.js
deleted file mode 100644 (file)
index c3283c1..0000000
+++ /dev/null
@@ -1,273 +0,0 @@
-( function () {
-       /**
-        * RCFilter base item model
-        *
-        * @mixins OO.EventEmitter
-        *
-        * @constructor
-        * @param {string} param Filter param name
-        * @param {Object} config Configuration object
-        * @cfg {string} [label] The label for the filter
-        * @cfg {string} [description] The description of the filter
-        * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
-        *  group. If the prefix has 'invert' state, the parameter is expected to be an object
-        *  with 'default' and 'inverted' as keys.
-        * @cfg {boolean} [active=true] The filter is active and affecting the result
-        * @cfg {boolean} [selected] The item is selected
-        * @cfg {*} [value] The value of this item
-        * @cfg {string} [namePrefix='item_'] A prefix to add to the param name to act as a unique
-        *  identifier
-        * @cfg {string} [cssClass] The class identifying the results that match this filter
-        * @cfg {string[]} [identifiers] An array of identifiers for this item. They will be
-        *  added and considered in the view.
-        * @cfg {string} [defaultHighlightColor=null] If set, highlight this filter by default with this color
-        */
-       mw.rcfilters.dm.ItemModel = function MwRcfiltersDmItemModel( param, config ) {
-               config = config || {};
-
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-
-               this.param = param;
-               this.namePrefix = config.namePrefix || 'item_';
-               this.name = this.namePrefix + param;
-
-               this.label = config.label || this.name;
-               this.labelPrefixKey = config.labelPrefixKey;
-               this.description = config.description || '';
-               this.setValue( config.value || config.selected );
-
-               this.identifiers = config.identifiers || [];
-
-               // Highlight
-               this.cssClass = config.cssClass;
-               this.highlightColor = config.defaultHighlightColor || null;
-       };
-
-       /* Initialization */
-
-       OO.initClass( mw.rcfilters.dm.ItemModel );
-       OO.mixinClass( mw.rcfilters.dm.ItemModel, OO.EventEmitter );
-
-       /* Events */
-
-       /**
-        * @event update
-        *
-        * The state of this filter has changed
-        */
-
-       /* Methods */
-
-       /**
-        * Return the representation of the state of this item.
-        *
-        * @return {Object} State of the object
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getState = function () {
-               return {
-                       selected: this.isSelected()
-               };
-       };
-
-       /**
-        * Get the name of this filter
-        *
-        * @return {string} Filter name
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getName = function () {
-               return this.name;
-       };
-
-       /**
-        * Get the message key to use to wrap the label. This message takes the label as a parameter.
-        *
-        * @param {boolean} inverted Whether this item should be considered inverted
-        * @return {string|null} Message key, or null if no message
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getLabelMessageKey = function ( inverted ) {
-               if ( this.labelPrefixKey ) {
-                       if ( typeof this.labelPrefixKey === 'string' ) {
-                               return this.labelPrefixKey;
-                       }
-                       return this.labelPrefixKey[
-                               // Only use inverted-prefix if the item is selected
-                               // Highlight-only an inverted item makes no sense
-                               inverted && this.isSelected() ?
-                                       'inverted' : 'default'
-                       ];
-               }
-               return null;
-       };
-
-       /**
-        * Get the param name or value of this filter
-        *
-        * @return {string} Filter param name
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getParamName = function () {
-               return this.param;
-       };
-
-       /**
-        * Get the message representing the state of this model.
-        *
-        * @return {string} State message
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getStateMessage = function () {
-               // Display description
-               return this.getDescription();
-       };
-
-       /**
-        * Get the label of this filter
-        *
-        * @return {string} Filter label
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getLabel = function () {
-               return this.label;
-       };
-
-       /**
-        * Get the description of this filter
-        *
-        * @return {string} Filter description
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getDescription = function () {
-               return this.description;
-       };
-
-       /**
-        * Get the default value of this filter
-        *
-        * @return {boolean} Filter default
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getDefault = function () {
-               return this.default;
-       };
-
-       /**
-        * Get the selected state of this filter
-        *
-        * @return {boolean} Filter is selected
-        */
-       mw.rcfilters.dm.ItemModel.prototype.isSelected = function () {
-               return !!this.value;
-       };
-
-       /**
-        * Toggle the selected state of the item
-        *
-        * @param {boolean} [isSelected] Filter is selected
-        * @fires update
-        */
-       mw.rcfilters.dm.ItemModel.prototype.toggleSelected = function ( isSelected ) {
-               isSelected = isSelected === undefined ? !this.isSelected() : isSelected;
-               this.setValue( isSelected );
-       };
-
-       /**
-        * Get the value
-        *
-        * @return {*}
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getValue = function () {
-               return this.value;
-       };
-
-       /**
-        * Convert a given value to the appropriate representation based on group type
-        *
-        * @param {*} value
-        * @return {*}
-        */
-       mw.rcfilters.dm.ItemModel.prototype.coerceValue = function ( value ) {
-               return this.getGroupModel().getType() === 'any_value' ? value : !!value;
-       };
-
-       /**
-        * Set the value
-        *
-        * @param {*} newValue
-        */
-       mw.rcfilters.dm.ItemModel.prototype.setValue = function ( newValue ) {
-               newValue = this.coerceValue( newValue );
-               if ( this.value !== newValue ) {
-                       this.value = newValue;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Set the highlight color
-        *
-        * @param {string|null} highlightColor
-        */
-       mw.rcfilters.dm.ItemModel.prototype.setHighlightColor = function ( highlightColor ) {
-               if ( !this.isHighlightSupported() ) {
-                       return;
-               }
-               // If the highlight color on the item and in the parameter is null/undefined, return early.
-               if ( !this.highlightColor && !highlightColor ) {
-                       return;
-               }
-
-               if ( this.highlightColor !== highlightColor ) {
-                       this.highlightColor = highlightColor;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Clear the highlight color
-        */
-       mw.rcfilters.dm.ItemModel.prototype.clearHighlightColor = function () {
-               this.setHighlightColor( null );
-       };
-
-       /**
-        * Get the highlight color, or null if none is configured
-        *
-        * @return {string|null}
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getHighlightColor = function () {
-               return this.highlightColor;
-       };
-
-       /**
-        * Get the CSS class that matches changes that fit this filter
-        * or null if none is configured
-        *
-        * @return {string|null}
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getCssClass = function () {
-               return this.cssClass;
-       };
-
-       /**
-        * Get the item's identifiers
-        *
-        * @return {string[]}
-        */
-       mw.rcfilters.dm.ItemModel.prototype.getIdentifiers = function () {
-               return this.identifiers;
-       };
-
-       /**
-        * Check if the highlight feature is supported for this filter
-        *
-        * @return {boolean}
-        */
-       mw.rcfilters.dm.ItemModel.prototype.isHighlightSupported = function () {
-               return !!this.getCssClass();
-       };
-
-       /**
-        * Check if the filter is currently highlighted
-        *
-        * @return {boolean}
-        */
-       mw.rcfilters.dm.ItemModel.prototype.isHighlighted = function () {
-               return !!this.getHighlightColor();
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueriesModel.js
deleted file mode 100644 (file)
index adf3fbb..0000000
+++ /dev/null
@@ -1,410 +0,0 @@
-( function () {
-       /**
-        * View model for saved queries
-        *
-        * @class
-        * @mixins OO.EventEmitter
-        * @mixins OO.EmitterList
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters model
-        * @param {Object} [config] Configuration options
-        * @cfg {string} [default] Default query ID
-        */
-       mw.rcfilters.dm.SavedQueriesModel = function MwRcfiltersDmSavedQueriesModel( filtersModel, config ) {
-               config = config || {};
-
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-               OO.EmitterList.call( this );
-
-               this.default = config.default;
-               this.filtersModel = filtersModel;
-               this.converted = false;
-
-               // Events
-               this.aggregate( { update: 'itemUpdate' } );
-       };
-
-       /* Initialization */
-
-       OO.initClass( mw.rcfilters.dm.SavedQueriesModel );
-       OO.mixinClass( mw.rcfilters.dm.SavedQueriesModel, OO.EventEmitter );
-       OO.mixinClass( mw.rcfilters.dm.SavedQueriesModel, OO.EmitterList );
-
-       /* Events */
-
-       /**
-        * @event initialize
-        *
-        * Model is initialized
-        */
-
-       /**
-        * @event itemUpdate
-        * @param {mw.rcfilters.dm.SavedQueryItemModel} Changed item
-        *
-        * An item has changed
-        */
-
-       /**
-        * @event default
-        * @param {string} New default ID
-        *
-        * The default has changed
-        */
-
-       /* Methods */
-
-       /**
-        * Initialize the saved queries model by reading it from the user's settings.
-        * The structure of the saved queries is:
-        * {
-        *    version: (string) Version number; if version 2, the query represents
-        *             parameters. Otherwise, the older version represented filters
-        *             and needs to be readjusted,
-        *    default: (string) Query ID
-        *    queries:{
-        *       query_id_1: {
-        *          data:{
-        *             filters: (Object) Minimal definition of the filters
-        *             highlights: (Object) Definition of the highlights
-        *          },
-        *          label: (optional) Name of this query
-        *       }
-        *    }
-        * }
-        *
-        * @param {Object} [savedQueries] An object with the saved queries with
-        *  the above structure.
-        * @fires initialize
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.initialize = function ( savedQueries ) {
-               var model = this;
-
-               savedQueries = savedQueries || {};
-
-               this.clearItems();
-               this.default = null;
-               this.converted = false;
-
-               if ( savedQueries.version !== '2' ) {
-                       // Old version dealt with filter names. We need to migrate to the new structure
-                       // The new structure:
-                       // {
-                       //   version: (string) '2',
-                       //   default: (string) Query ID,
-                       //   queries: {
-                       //     query_id: {
-                       //       label: (string) Name of the query
-                       //       data: {
-                       //         params: (object) Representing all the parameter states
-                       //         highlights: (object) Representing all the filter highlight states
-                       //     }
-                       //   }
-                       // }
-                       // eslint-disable-next-line jquery/no-each-util
-                       $.each( savedQueries.queries || {}, function ( id, obj ) {
-                               if ( obj.data && obj.data.filters ) {
-                                       obj.data = model.convertToParameters( obj.data );
-                               }
-                       } );
-
-                       this.converted = true;
-                       savedQueries.version = '2';
-               }
-
-               // Initialize the query items
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( savedQueries.queries || {}, function ( id, obj ) {
-                       var normalizedData = obj.data,
-                               isDefault = String( savedQueries.default ) === String( id );
-
-                       if ( normalizedData && normalizedData.params ) {
-                               // Backwards-compat fix: Remove sticky parameters from
-                               // the given data, if they exist
-                               normalizedData.params = model.filtersModel.removeStickyParams( normalizedData.params );
-
-                               // Correct the invert state for effective selection
-                               if ( normalizedData.params.invert && !normalizedData.params.namespace ) {
-                                       delete normalizedData.params.invert;
-                               }
-
-                               model.cleanupHighlights( normalizedData );
-
-                               id = String( id );
-
-                               // Skip the addNewQuery method because we don't want to unnecessarily manipulate
-                               // the given saved queries unless we literally intend to (like in backwards compat fixes)
-                               // And the addNewQuery method also uses a minimization routine that checks for the
-                               // validity of items and minimizes the query. This isn't necessary for queries loaded
-                               // from the backend, and has the risk of removing values if they're temporarily
-                               // invalid (example: if we temporarily removed a cssClass from a filter in the backend)
-                               model.addItems( [
-                                       new mw.rcfilters.dm.SavedQueryItemModel(
-                                               id,
-                                               obj.label,
-                                               normalizedData,
-                                               { default: isDefault }
-                                       )
-                               ] );
-
-                               if ( isDefault ) {
-                                       model.default = id;
-                               }
-                       }
-               } );
-
-               this.emit( 'initialize' );
-       };
-
-       /**
-        * Clean up highlight parameters.
-        * 'highlight' used to be stored, it's not inferred based on the presence of absence of
-        * filter colors.
-        *
-        * @param {Object} data Saved query data
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.cleanupHighlights = function ( data ) {
-               if (
-                       data.params.highlight === '0' &&
-                       data.highlights && Object.keys( data.highlights ).length
-               ) {
-                       data.highlights = {};
-               }
-               delete data.params.highlight;
-       };
-
-       /**
-        * Convert from representation of filters to representation of parameters
-        *
-        * @param {Object} data Query data
-        * @return {Object} New converted query data
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.convertToParameters = function ( data ) {
-               var newData = {},
-                       defaultFilters = this.filtersModel.getFiltersFromParameters( this.filtersModel.getDefaultParams() ),
-                       fullFilterRepresentation = $.extend( true, {}, defaultFilters, data.filters ),
-                       highlightEnabled = data.highlights.highlight;
-
-               delete data.highlights.highlight;
-
-               // Filters
-               newData.params = this.filtersModel.getMinimizedParamRepresentation(
-                       this.filtersModel.getParametersFromFilters( fullFilterRepresentation )
-               );
-
-               // Highlights: appending _color to keys
-               newData.highlights = {};
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( data.highlights, function ( highlightedFilterName, value ) {
-                       if ( value ) {
-                               newData.highlights[ highlightedFilterName + '_color' ] = data.highlights[ highlightedFilterName ];
-                       }
-               } );
-
-               // Add highlight
-               newData.params.highlight = String( Number( highlightEnabled || 0 ) );
-
-               return newData;
-       };
-
-       /**
-        * Add a query item
-        *
-        * @param {string} label Label for the new query
-        * @param {Object} fulldata Full data representation for the new query, combining highlights and filters
-        * @param {boolean} isDefault Item is default
-        * @param {string} [id] Query ID, if exists. If this isn't given, a random
-        *  new ID will be created.
-        * @return {string} ID of the newly added query
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.addNewQuery = function ( label, fulldata, isDefault, id ) {
-               var normalizedData = { params: {}, highlights: {} },
-                       highlightParamNames = Object.keys( this.filtersModel.getEmptyHighlightParameters() ),
-                       randomID = String( id || ( new Date() ).getTime() ),
-                       data = this.filtersModel.getMinimizedParamRepresentation( fulldata );
-
-               // Split highlight/params
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( data, function ( param, value ) {
-                       if ( param !== 'highlight' && highlightParamNames.indexOf( param ) > -1 ) {
-                               normalizedData.highlights[ param ] = value;
-                       } else {
-                               normalizedData.params[ param ] = value;
-                       }
-               } );
-
-               // Correct the invert state for effective selection
-               if ( normalizedData.params.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
-                       delete normalizedData.params.invert;
-               }
-
-               // Add item
-               this.addItems( [
-                       new mw.rcfilters.dm.SavedQueryItemModel(
-                               randomID,
-                               label,
-                               normalizedData,
-                               { default: isDefault }
-                       )
-               ] );
-
-               if ( isDefault ) {
-                       this.setDefault( randomID );
-               }
-
-               return randomID;
-       };
-
-       /**
-        * Remove query from model
-        *
-        * @param {string} queryID Query ID
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.removeQuery = function ( queryID ) {
-               var query = this.getItemByID( queryID );
-
-               if ( query ) {
-                       // Check if this item was the default
-                       if ( String( this.getDefault() ) === String( queryID ) ) {
-                               // Nulify the default
-                               this.setDefault( null );
-                       }
-
-                       this.removeItems( [ query ] );
-               }
-       };
-
-       /**
-        * Get an item that matches the requested query
-        *
-        * @param {Object} fullQueryComparison Object representing all filters and highlights to compare
-        * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.findMatchingQuery = function ( fullQueryComparison ) {
-               // Minimize before comparison
-               fullQueryComparison = this.filtersModel.getMinimizedParamRepresentation( fullQueryComparison );
-
-               // Correct the invert state for effective selection
-               if ( fullQueryComparison.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
-                       delete fullQueryComparison.invert;
-               }
-
-               return this.getItems().filter( function ( item ) {
-                       return OO.compare(
-                               item.getCombinedData(),
-                               fullQueryComparison
-                       );
-               } )[ 0 ];
-       };
-
-       /**
-        * Get query by its identifier
-        *
-        * @param {string} queryID Query identifier
-        * @return {mw.rcfilters.dm.SavedQueryItemModel|undefined} Item matching
-        *  the search. Undefined if not found.
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.getItemByID = function ( queryID ) {
-               return this.getItems().filter( function ( item ) {
-                       return item.getID() === queryID;
-               } )[ 0 ];
-       };
-
-       /**
-        * Get the full data representation of the default query, if it exists
-        *
-        * @return {Object|null} Representation of the default params if exists.
-        *  Null if default doesn't exist or if the user is not logged in.
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.getDefaultParams = function () {
-               return ( !mw.user.isAnon() && this.getItemParams( this.getDefault() ) ) || {};
-       };
-
-       /**
-        * Get a full parameter representation of an item data
-        *
-        * @param  {Object} queryID Query ID
-        * @return {Object} Parameter representation
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.getItemParams = function ( queryID ) {
-               var item = this.getItemByID( queryID ),
-                       data = item ? item.getData() : {};
-
-               return !$.isEmptyObject( data ) ? this.buildParamsFromData( data ) : {};
-       };
-
-       /**
-        * Build a full parameter representation given item data and model sticky values state
-        *
-        * @param  {Object} data Item data
-        * @return {Object} Full param representation
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.buildParamsFromData = function ( data ) {
-               data = data || {};
-               // Return parameter representation
-               return this.filtersModel.getMinimizedParamRepresentation( $.extend( true, {},
-                       data.params,
-                       data.highlights
-               ) );
-       };
-
-       /**
-        * Get the object representing the state of the entire model and items
-        *
-        * @return {Object} Object representing the state of the model and items
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.getState = function () {
-               var obj = { queries: {}, version: '2' };
-
-               // Translate the items to the saved object
-               this.getItems().forEach( function ( item ) {
-                       obj.queries[ item.getID() ] = item.getState();
-               } );
-
-               if ( this.getDefault() ) {
-                       obj.default = this.getDefault();
-               }
-
-               return obj;
-       };
-
-       /**
-        * Set a default query. Null to unset default.
-        *
-        * @param {string} itemID Query identifier
-        * @fires default
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.setDefault = function ( itemID ) {
-               if ( this.default !== itemID ) {
-                       this.default = itemID;
-
-                       // Set for individual itens
-                       this.getItems().forEach( function ( item ) {
-                               item.toggleDefault( item.getID() === itemID );
-                       } );
-
-                       this.emit( 'default', itemID );
-               }
-       };
-
-       /**
-        * Get the default query ID
-        *
-        * @return {string} Default query identifier
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.getDefault = function () {
-               return this.default;
-       };
-
-       /**
-        * Check if the saved queries were converted
-        *
-        * @return {boolean} Saved queries were converted from the previous
-        *  version to the new version
-        */
-       mw.rcfilters.dm.SavedQueriesModel.prototype.isConverted = function () {
-               return this.converted;
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueryItemModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.SavedQueryItemModel.js
deleted file mode 100644 (file)
index 46344cb..0000000
+++ /dev/null
@@ -1,125 +0,0 @@
-( function () {
-       /**
-        * View model for a single saved query
-        *
-        * @class
-        * @mixins OO.EventEmitter
-        *
-        * @constructor
-        * @param {string} id Unique identifier
-        * @param {string} label Saved query label
-        * @param {Object} data Saved query data
-        * @param {Object} [config] Configuration options
-        * @cfg {boolean} [default] This item is the default
-        */
-       mw.rcfilters.dm.SavedQueryItemModel = function MwRcfiltersDmSavedQueriesModel( id, label, data, config ) {
-               config = config || {};
-
-               // Mixin constructor
-               OO.EventEmitter.call( this );
-
-               this.id = id;
-               this.label = label;
-               this.data = data;
-               this.default = !!config.default;
-       };
-
-       /* Initialization */
-
-       OO.initClass( mw.rcfilters.dm.SavedQueryItemModel );
-       OO.mixinClass( mw.rcfilters.dm.SavedQueryItemModel, OO.EventEmitter );
-
-       /* Events */
-
-       /**
-        * @event update
-        *
-        * Model has been updated
-        */
-
-       /* Methods */
-
-       /**
-        * Get an object representing the state of this item
-        *
-        * @return {Object} Object representing the current data state
-        *  of the object
-        */
-       mw.rcfilters.dm.SavedQueryItemModel.prototype.getState = function () {
-               return {
-                       data: this.getData(),
-                       label: this.getLabel()
-               };
-       };
-
-       /**
-        * Get the query's identifier
-        *
-        * @return {string} Query identifier
-        */
-       mw.rcfilters.dm.SavedQueryItemModel.prototype.getID = function () {
-               return this.id;
-       };
-
-       /**
-        * Get query label
-        *
-        * @return {string} Query label
-        */
-       mw.rcfilters.dm.SavedQueryItemModel.prototype.getLabel = function () {
-               return this.label;
-       };
-
-       /**
-        * Update the query label
-        *
-        * @param {string} newLabel New label
-        */
-       mw.rcfilters.dm.SavedQueryItemModel.prototype.updateLabel = function ( newLabel ) {
-               if ( newLabel && this.label !== newLabel ) {
-                       this.label = newLabel;
-                       this.emit( 'update' );
-               }
-       };
-
-       /**
-        * Get query data
-        *
-        * @return {Object} Object representing parameter and highlight data
-        */
-       mw.rcfilters.dm.SavedQueryItemModel.prototype.getData = function () {
-               return this.data;
-       };
-
-       /**
-        * Get the combined data of this item as a flat object of parameters
-        *
-        * @return {Object} Combined parameter data
-        */
-       mw.rcfilters.dm.SavedQueryItemModel.prototype.getCombinedData = function () {
-               return $.extend( true, {}, this.data.params, this.data.highlights );
-       };
-
-       /**
-        * Check whether this item is the default
-        *
-        * @return {boolean} Query is set to be default
-        */
-       mw.rcfilters.dm.SavedQueryItemModel.prototype.isDefault = function () {
-               return this.default;
-       };
-
-       /**
-        * Toggle the default state of this query item
-        *
-        * @param {boolean} isDefault Query is default
-        */
-       mw.rcfilters.dm.SavedQueryItemModel.prototype.toggleDefault = function ( isDefault ) {
-               isDefault = isDefault === undefined ? !this.default : isDefault;
-
-               if ( this.default !== isDefault ) {
-                       this.default = isDefault;
-                       this.emit( 'update' );
-               }
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.Controller.js
deleted file mode 100644 (file)
index 6eb8867..0000000
+++ /dev/null
@@ -1,1225 +0,0 @@
-( function () {
-
-       var byteLength = require( 'mediawiki.String' ).byteLength;
-
-       /* eslint no-underscore-dangle: "off" */
-       /**
-        * Controller for the filters in Recent Changes
-        * @class
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel Changes list view model
-        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
-        * @param {Object} config Additional configuration
-        * @cfg {string} savedQueriesPreferenceName Where to save the saved queries
-        * @cfg {string} daysPreferenceName Preference name for the days filter
-        * @cfg {string} limitPreferenceName Preference name for the limit filter
-        * @cfg {string} collapsedPreferenceName Preference name for collapsing and showing
-        *  the active filters area
-        * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
-        *  title normalization to separate title subpage/parts into the target= url
-        *  parameter
-        */
-       mw.rcfilters.Controller = function MwRcfiltersController( filtersModel, changesListModel, savedQueriesModel, config ) {
-               this.filtersModel = filtersModel;
-               this.changesListModel = changesListModel;
-               this.savedQueriesModel = savedQueriesModel;
-               this.savedQueriesPreferenceName = config.savedQueriesPreferenceName;
-               this.daysPreferenceName = config.daysPreferenceName;
-               this.limitPreferenceName = config.limitPreferenceName;
-               this.collapsedPreferenceName = config.collapsedPreferenceName;
-               this.normalizeTarget = !!config.normalizeTarget;
-
-               this.requestCounter = {};
-               this.baseFilterState = {};
-               this.uriProcessor = null;
-               this.initialized = false;
-               this.wereSavedQueriesSaved = false;
-
-               this.prevLoggedItems = [];
-
-               this.FILTER_CHANGE = 'filterChange';
-               this.SHOW_NEW_CHANGES = 'showNewChanges';
-               this.LIVE_UPDATE = 'liveUpdate';
-       };
-
-       /* Initialization */
-       OO.initClass( mw.rcfilters.Controller );
-
-       /**
-        * Initialize the filter and parameter states
-        *
-        * @param {Array} filterStructure Filter definition and structure for the model
-        * @param {Object} [namespaceStructure] Namespace definition
-        * @param {Object} [tagList] Tag definition
-        * @param {Object} [conditionalViews] Conditional view definition
-        */
-       mw.rcfilters.Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList, conditionalViews ) {
-               var parsedSavedQueries, pieces,
-                       displayConfig = mw.config.get( 'StructuredChangeFiltersDisplayConfig' ),
-                       defaultSavedQueryExists = mw.config.get( 'wgStructuredChangeFiltersDefaultSavedQueryExists' ),
-                       controller = this,
-                       views = $.extend( true, {}, conditionalViews ),
-                       items = [],
-                       uri = new mw.Uri();
-
-               // Prepare views
-               if ( namespaceStructure ) {
-                       items = [];
-                       // eslint-disable-next-line jquery/no-each-util
-                       $.each( namespaceStructure, function ( namespaceID, label ) {
-                               // Build and clean up the individual namespace items definition
-                               items.push( {
-                                       name: namespaceID,
-                                       label: label || mw.msg( 'blanknamespace' ),
-                                       description: '',
-                                       identifiers: [
-                                               mw.Title.isTalkNamespace( namespaceID ) ?
-                                                       'talk' : 'subject'
-                                       ],
-                                       cssClass: 'mw-changeslist-ns-' + namespaceID
-                               } );
-                       } );
-
-                       views.namespaces = {
-                               title: mw.msg( 'namespaces' ),
-                               trigger: ':',
-                               groups: [ {
-                                       // Group definition (single group)
-                                       name: 'namespace', // parameter name is singular
-                                       type: 'string_options',
-                                       title: mw.msg( 'namespaces' ),
-                                       labelPrefixKey: { default: 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
-                                       separator: ';',
-                                       fullCoverage: true,
-                                       filters: items
-                               } ]
-                       };
-                       views.invert = {
-                               groups: [
-                                       {
-                                               name: 'invertGroup',
-                                               type: 'boolean',
-                                               hidden: true,
-                                               filters: [ {
-                                                       name: 'invert',
-                                                       default: '0'
-                                               } ]
-                                       } ]
-                       };
-               }
-               if ( tagList ) {
-                       views.tags = {
-                               title: mw.msg( 'rcfilters-view-tags' ),
-                               trigger: '#',
-                               groups: [ {
-                                       // Group definition (single group)
-                                       name: 'tagfilter', // Parameter name
-                                       type: 'string_options',
-                                       title: 'rcfilters-view-tags', // Message key
-                                       labelPrefixKey: 'rcfilters-tag-prefix-tags',
-                                       separator: '|',
-                                       fullCoverage: false,
-                                       filters: tagList
-                               } ]
-                       };
-               }
-
-               // Add parameter range operations
-               views.range = {
-                       groups: [
-                               {
-                                       name: 'limit',
-                                       type: 'single_option',
-                                       title: '', // Because it's a hidden group, this title actually appears nowhere
-                                       hidden: true,
-                                       allowArbitrary: true,
-                                       // FIXME: $.isNumeric is deprecated
-                                       validate: $.isNumeric,
-                                       range: {
-                                               min: 0, // The server normalizes negative numbers to 0 results
-                                               max: 1000
-                                       },
-                                       sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
-                                       default: mw.user.options.get( this.limitPreferenceName, displayConfig.limitDefault ),
-                                       sticky: true,
-                                       filters: displayConfig.limitArray.map( function ( num ) {
-                                               return controller._createFilterDataFromNumber( num, num );
-                                       } )
-                               },
-                               {
-                                       name: 'days',
-                                       type: 'single_option',
-                                       title: '', // Because it's a hidden group, this title actually appears nowhere
-                                       hidden: true,
-                                       allowArbitrary: true,
-                                       // FIXME: $.isNumeric is deprecated
-                                       validate: $.isNumeric,
-                                       range: {
-                                               min: 0,
-                                               max: displayConfig.maxDays
-                                       },
-                                       sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
-                                       numToLabelFunc: function ( i ) {
-                                               return Number( i ) < 1 ?
-                                                       ( Number( i ) * 24 ).toFixed( 2 ) :
-                                                       Number( i );
-                                       },
-                                       default: mw.user.options.get( this.daysPreferenceName, displayConfig.daysDefault ),
-                                       sticky: true,
-                                       filters: [
-                                               // Hours (1, 2, 6, 12)
-                                               0.04166, 0.0833, 0.25, 0.5
-                                       // Days
-                                       ].concat( displayConfig.daysArray )
-                                               .map( function ( num ) {
-                                                       return controller._createFilterDataFromNumber(
-                                                               num,
-                                                               // Convert fractions of days to number of hours for the labels
-                                                               num < 1 ? Math.round( num * 24 ) : num
-                                                       );
-                                               } )
-                               }
-                       ]
-               };
-
-               views.display = {
-                       groups: [
-                               {
-                                       name: 'display',
-                                       type: 'boolean',
-                                       title: '', // Because it's a hidden group, this title actually appears nowhere
-                                       hidden: true,
-                                       sticky: true,
-                                       filters: [
-                                               {
-                                                       name: 'enhanced',
-                                                       default: String( mw.user.options.get( 'usenewrc', 0 ) )
-                                               }
-                                       ]
-                               }
-                       ]
-               };
-
-               // Before we do anything, we need to see if we require additional items in the
-               // groups that have 'AllowArbitrary'. For the moment, those are only single_option
-               // groups; if we ever expand it, this might need further generalization:
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( views, function ( viewName, viewData ) {
-                       viewData.groups.forEach( function ( groupData ) {
-                               var extraValues = [];
-                               if ( groupData.allowArbitrary ) {
-                                       // If the value in the URI isn't in the group, add it
-                                       if ( uri.query[ groupData.name ] !== undefined ) {
-                                               extraValues.push( uri.query[ groupData.name ] );
-                                       }
-                                       // If the default value isn't in the group, add it
-                                       if ( groupData.default !== undefined ) {
-                                               extraValues.push( String( groupData.default ) );
-                                       }
-                                       controller.addNumberValuesToGroup( groupData, extraValues );
-                               }
-                       } );
-               } );
-
-               // Initialize the model
-               this.filtersModel.initializeFilters( filterStructure, views );
-
-               this.uriProcessor = new mw.rcfilters.UriProcessor(
-                       this.filtersModel,
-                       { normalizeTarget: this.normalizeTarget }
-               );
-
-               if ( !mw.user.isAnon() ) {
-                       try {
-                               parsedSavedQueries = JSON.parse( mw.user.options.get( this.savedQueriesPreferenceName ) || '{}' );
-                       } catch ( err ) {
-                               parsedSavedQueries = {};
-                       }
-
-                       // Initialize saved queries
-                       this.savedQueriesModel.initialize( parsedSavedQueries );
-                       if ( this.savedQueriesModel.isConverted() ) {
-                               // Since we know we converted, we're going to re-save
-                               // the queries so they are now migrated to the new format
-                               this._saveSavedQueries();
-                       }
-               }
-
-               if ( defaultSavedQueryExists ) {
-                       // This came from the server, meaning that we have a default
-                       // saved query, but the server could not load it, probably because
-                       // it was pre-conversion to the new format.
-                       // We need to load this query again
-                       this.applySavedQuery( this.savedQueriesModel.getDefault() );
-               } else {
-                       // There are either recognized parameters in the URL
-                       // or there are none, but there is also no default
-                       // saved query (so defaults are from the backend)
-                       // We want to update the state but not fetch results
-                       // again
-                       this.updateStateFromUrl( false );
-
-                       pieces = this._extractChangesListInfo( $( '#mw-content-text' ) );
-
-                       // Update the changes list with the existing data
-                       // so it gets processed
-                       this.changesListModel.update(
-                               pieces.changes,
-                               pieces.fieldset,
-                               pieces.noResultsDetails,
-                               true // We're using existing DOM elements
-                       );
-               }
-
-               this.initialized = true;
-               this.switchView( 'default' );
-
-               this.pollingRate = mw.config.get( 'StructuredChangeFiltersLiveUpdatePollingRate' );
-               if ( this.pollingRate ) {
-                       this._scheduleLiveUpdate();
-               }
-       };
-
-       /**
-        * Check if the controller has finished initializing.
-        * @return {boolean} Controller is initialized
-        */
-       mw.rcfilters.Controller.prototype.isInitialized = function () {
-               return this.initialized;
-       };
-
-       /**
-        * Extracts information from the changes list DOM
-        *
-        * @param {jQuery} $root Root DOM to find children from
-        * @param {boolean} [statusCode] Server response status code
-        * @return {Object} Information about changes list
-        * @return {Object|string} return.changes Changes list, or 'NO_RESULTS' if there are no results
-        *   (either normally or as an error)
-        * @return {string} [return.noResultsDetails] 'NO_RESULTS_NORMAL' for a normal 0-result set,
-        *   'NO_RESULTS_TIMEOUT' for no results due to a timeout, or omitted for more than 0 results
-        * @return {jQuery} return.fieldset Fieldset
-        */
-       mw.rcfilters.Controller.prototype._extractChangesListInfo = function ( $root, statusCode ) {
-               var info,
-                       $changesListContents = $root.find( '.mw-changeslist' ).first().contents(),
-                       areResults = !!$changesListContents.length,
-                       checkForLogout = !areResults && statusCode === 200;
-
-               // We check if user logged out on different tab/browser or the session has expired.
-               // 205 status code returned from the server, which indicates that we need to reload the page
-               // is not usable on WL page, because we get redirected to login page, which gives 200 OK
-               // status code (if everything else goes well).
-               // Bug: T177717
-               if ( checkForLogout && !!$root.find( '#wpName1' ).length ) {
-                       location.reload( false );
-                       return;
-               }
-
-               info = {
-                       changes: $changesListContents.length ? $changesListContents : 'NO_RESULTS',
-                       fieldset: $root.find( 'fieldset.cloptions' ).first()
-               };
-
-               if ( !areResults ) {
-                       if ( $root.find( '.mw-changeslist-timeout' ).length ) {
-                               info.noResultsDetails = 'NO_RESULTS_TIMEOUT';
-                       } else if ( $root.find( '.mw-changeslist-notargetpage' ).length ) {
-                               info.noResultsDetails = 'NO_RESULTS_NO_TARGET_PAGE';
-                       } else if ( $root.find( '.mw-changeslist-invalidtargetpage' ).length ) {
-                               info.noResultsDetails = 'NO_RESULTS_INVALID_TARGET_PAGE';
-                       } else {
-                               info.noResultsDetails = 'NO_RESULTS_NORMAL';
-                       }
-               }
-
-               return info;
-       };
-
-       /**
-        * Create filter data from a number, for the filters that are numerical value
-        *
-        * @param {number} num Number
-        * @param {number} numForDisplay Number for the label
-        * @return {Object} Filter data
-        */
-       mw.rcfilters.Controller.prototype._createFilterDataFromNumber = function ( num, numForDisplay ) {
-               return {
-                       name: String( num ),
-                       label: mw.language.convertNumber( numForDisplay )
-               };
-       };
-
-       /**
-        * Add an arbitrary values to groups that allow arbitrary values
-        *
-        * @param {Object} groupData Group data
-        * @param {string|string[]} arbitraryValues An array of arbitrary values to add to the group
-        */
-       mw.rcfilters.Controller.prototype.addNumberValuesToGroup = function ( groupData, arbitraryValues ) {
-               var controller = this,
-                       normalizeWithinRange = function ( range, val ) {
-                               if ( val < range.min ) {
-                                       return range.min; // Min
-                               } else if ( val >= range.max ) {
-                                       return range.max; // Max
-                               }
-                               return val;
-                       };
-
-               arbitraryValues = Array.isArray( arbitraryValues ) ? arbitraryValues : [ arbitraryValues ];
-
-               // Normalize the arbitrary values and the default value for a range
-               if ( groupData.range ) {
-                       arbitraryValues = arbitraryValues.map( function ( val ) {
-                               return normalizeWithinRange( groupData.range, val );
-                       } );
-
-                       // Normalize the default, since that's user defined
-                       if ( groupData.default !== undefined ) {
-                               groupData.default = String( normalizeWithinRange( groupData.range, groupData.default ) );
-                       }
-               }
-
-               // This is only true for single_option group
-               // We assume these are the only groups that will allow for
-               // arbitrary, since it doesn't make any sense for the other
-               // groups.
-               arbitraryValues.forEach( function ( val ) {
-                       if (
-                               // If the group allows for arbitrary data
-                               groupData.allowArbitrary &&
-                               // and it is single_option (or string_options, but we
-                               // don't have cases of those yet, nor do we plan to)
-                               groupData.type === 'single_option' &&
-                               // and, if there is a validate method and it passes on
-                               // the data
-                               ( !groupData.validate || groupData.validate( val ) ) &&
-                               // but if that value isn't already in the definition
-                               groupData.filters
-                                       .map( function ( filterData ) {
-                                               return String( filterData.name );
-                                       } )
-                                       .indexOf( String( val ) ) === -1
-                       ) {
-                               // Add the filter information
-                               groupData.filters.push( controller._createFilterDataFromNumber(
-                                       val,
-                                       groupData.numToLabelFunc ?
-                                               groupData.numToLabelFunc( val ) :
-                                               val
-                               ) );
-
-                               // If there's a sort function set up, re-sort the values
-                               if ( groupData.sortFunc ) {
-                                       groupData.filters.sort( groupData.sortFunc );
-                               }
-                       }
-               } );
-       };
-
-       /**
-        * Reset to default filters
-        */
-       mw.rcfilters.Controller.prototype.resetToDefaults = function () {
-               var params = this._getDefaultParams();
-               if ( this.applyParamChange( params ) ) {
-                       // Only update the changes list if there was a change to actual filters
-                       this.updateChangesList();
-               } else {
-                       this.uriProcessor.updateURL( params );
-               }
-       };
-
-       /**
-        * Check whether the default values of the filters are all false.
-        *
-        * @return {boolean} Defaults are all false
-        */
-       mw.rcfilters.Controller.prototype.areDefaultsEmpty = function () {
-               return $.isEmptyObject( this._getDefaultParams() );
-       };
-
-       /**
-        * Empty all selected filters
-        */
-       mw.rcfilters.Controller.prototype.emptyFilters = function () {
-               var highlightedFilterNames = this.filtersModel.getHighlightedItems()
-                       .map( function ( filterItem ) { return { name: filterItem.getName() }; } );
-
-               if ( this.applyParamChange( {} ) ) {
-                       // Only update the changes list if there was a change to actual filters
-                       this.updateChangesList();
-               } else {
-                       this.uriProcessor.updateURL();
-               }
-
-               if ( highlightedFilterNames ) {
-                       this._trackHighlight( 'clearAll', highlightedFilterNames );
-               }
-       };
-
-       /**
-        * Update the selected state of a filter
-        *
-        * @param {string} filterName Filter name
-        * @param {boolean} [isSelected] Filter selected state
-        */
-       mw.rcfilters.Controller.prototype.toggleFilterSelect = function ( filterName, isSelected ) {
-               var filterItem = this.filtersModel.getItemByName( filterName );
-
-               if ( !filterItem ) {
-                       // If no filter was found, break
-                       return;
-               }
-
-               isSelected = isSelected === undefined ? !filterItem.isSelected() : isSelected;
-
-               if ( filterItem.isSelected() !== isSelected ) {
-                       this.filtersModel.toggleFilterSelected( filterName, isSelected );
-
-                       this.updateChangesList();
-
-                       // Check filter interactions
-                       this.filtersModel.reassessFilterInteractions( filterItem );
-               }
-       };
-
-       /**
-        * Clear both highlight and selection of a filter
-        *
-        * @param {string} filterName Name of the filter item
-        */
-       mw.rcfilters.Controller.prototype.clearFilter = function ( filterName ) {
-               var filterItem = this.filtersModel.getItemByName( filterName ),
-                       isHighlighted = filterItem.isHighlighted(),
-                       isSelected = filterItem.isSelected();
-
-               if ( isSelected || isHighlighted ) {
-                       this.filtersModel.clearHighlightColor( filterName );
-                       this.filtersModel.toggleFilterSelected( filterName, false );
-
-                       if ( isSelected ) {
-                               // Only update the changes list if the filter changed
-                               // its selection state. If it only changed its highlight
-                               // then don't reload
-                               this.updateChangesList();
-                       }
-
-                       this.filtersModel.reassessFilterInteractions( filterItem );
-
-                       // Log filter grouping
-                       this.trackFilterGroupings( 'removefilter' );
-               }
-
-               if ( isHighlighted ) {
-                       this._trackHighlight( 'clear', filterName );
-               }
-       };
-
-       /**
-        * Toggle the highlight feature on and off
-        */
-       mw.rcfilters.Controller.prototype.toggleHighlight = function () {
-               this.filtersModel.toggleHighlight();
-               this.uriProcessor.updateURL();
-
-               if ( this.filtersModel.isHighlightEnabled() ) {
-                       mw.hook( 'RcFilters.highlight.enable' ).fire();
-               }
-       };
-
-       /**
-        * Toggle the namespaces inverted feature on and off
-        */
-       mw.rcfilters.Controller.prototype.toggleInvertedNamespaces = function () {
-               this.filtersModel.toggleInvertedNamespaces();
-               if (
-                       this.filtersModel.getFiltersByView( 'namespaces' ).filter(
-                               function ( filterItem ) { return filterItem.isSelected(); }
-                       ).length
-               ) {
-                       // Only re-fetch results if there are namespace items that are actually selected
-                       this.updateChangesList();
-               } else {
-                       this.uriProcessor.updateURL();
-               }
-       };
-
-       /**
-        * Set the value of the 'showlinkedto' parameter
-        * @param {boolean} value
-        */
-       mw.rcfilters.Controller.prototype.setShowLinkedTo = function ( value ) {
-               var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' ),
-                       showLinkedToItem = this.filtersModel.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' );
-
-               this.filtersModel.toggleFilterSelected( showLinkedToItem.getName(), value );
-               this.uriProcessor.updateURL();
-               // reload the results only when target is set
-               if ( targetItem.getValue() ) {
-                       this.updateChangesList();
-               }
-       };
-
-       /**
-        * Set the target page
-        * @param {string} page
-        */
-       mw.rcfilters.Controller.prototype.setTargetPage = function ( page ) {
-               var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' );
-               targetItem.setValue( page );
-               this.uriProcessor.updateURL();
-               this.updateChangesList();
-       };
-
-       /**
-        * Set the highlight color for a filter item
-        *
-        * @param {string} filterName Name of the filter item
-        * @param {string} color Selected color
-        */
-       mw.rcfilters.Controller.prototype.setHighlightColor = function ( filterName, color ) {
-               this.filtersModel.setHighlightColor( filterName, color );
-               this.uriProcessor.updateURL();
-               this._trackHighlight( 'set', { name: filterName, color: color } );
-       };
-
-       /**
-        * Clear highlight for a filter item
-        *
-        * @param {string} filterName Name of the filter item
-        */
-       mw.rcfilters.Controller.prototype.clearHighlightColor = function ( filterName ) {
-               this.filtersModel.clearHighlightColor( filterName );
-               this.uriProcessor.updateURL();
-               this._trackHighlight( 'clear', filterName );
-       };
-
-       /**
-        * Enable or disable live updates.
-        * @param {boolean} enable True to enable, false to disable
-        */
-       mw.rcfilters.Controller.prototype.toggleLiveUpdate = function ( enable ) {
-               this.changesListModel.toggleLiveUpdate( enable );
-               if ( this.changesListModel.getLiveUpdate() && this.changesListModel.getNewChangesExist() ) {
-                       this.updateChangesList( null, this.LIVE_UPDATE );
-               }
-       };
-
-       /**
-        * Set a timeout for the next live update.
-        * @private
-        */
-       mw.rcfilters.Controller.prototype._scheduleLiveUpdate = function () {
-               setTimeout( this._doLiveUpdate.bind( this ), this.pollingRate * 1000 );
-       };
-
-       /**
-        * Perform a live update.
-        * @private
-        */
-       mw.rcfilters.Controller.prototype._doLiveUpdate = function () {
-               if ( !this._shouldCheckForNewChanges() ) {
-                       // skip this turn and check back later
-                       this._scheduleLiveUpdate();
-                       return;
-               }
-
-               this._checkForNewChanges()
-                       .then( function ( statusCode ) {
-                               // no result is 204 with the 'peek' param
-                               // logged out is 205
-                               var newChanges = statusCode === 200;
-
-                               if ( !this._shouldCheckForNewChanges() ) {
-                                       // by the time the response is received,
-                                       // it may not be appropriate anymore
-                                       return;
-                               }
-
-                               // 205 is the status code returned from server when user's logged in/out
-                               // status is not matching while fetching live update changes.
-                               // This works only on Recent Changes page. For WL, look _extractChangesListInfo.
-                               // Bug: T177717
-                               if ( statusCode === 205 ) {
-                                       location.reload( false );
-                                       return;
-                               }
-
-                               if ( newChanges ) {
-                                       if ( this.changesListModel.getLiveUpdate() ) {
-                                               return this.updateChangesList( null, this.LIVE_UPDATE );
-                                       } else {
-                                               this.changesListModel.setNewChangesExist( true );
-                                       }
-                               }
-                       }.bind( this ) )
-                       .always( this._scheduleLiveUpdate.bind( this ) );
-       };
-
-       /**
-        * @return {boolean} It's appropriate to check for new changes now
-        * @private
-        */
-       mw.rcfilters.Controller.prototype._shouldCheckForNewChanges = function () {
-               return !document.hidden &&
-                       !this.filtersModel.hasConflict() &&
-                       !this.changesListModel.getNewChangesExist() &&
-                       !this.updatingChangesList &&
-                       this.changesListModel.getNextFrom();
-       };
-
-       /**
-        * Check if new changes, newer than those currently shown, are available
-        *
-        * @return {jQuery.Promise} Promise object that resolves with a bool
-        *   specifying if there are new changes or not
-        *
-        * @private
-        */
-       mw.rcfilters.Controller.prototype._checkForNewChanges = function () {
-               var params = {
-                       limit: 1,
-                       peek: 1, // bypasses ChangesList specific UI
-                       from: this.changesListModel.getNextFrom(),
-                       isAnon: mw.user.isAnon()
-               };
-               return this._queryChangesList( 'liveUpdate', params ).then(
-                       function ( data ) {
-                               return data.status;
-                       }
-               );
-       };
-
-       /**
-        * Show the new changes
-        *
-        * @return {jQuery.Promise} Promise object that resolves after
-        * fetching and showing the new changes
-        */
-       mw.rcfilters.Controller.prototype.showNewChanges = function () {
-               return this.updateChangesList( null, this.SHOW_NEW_CHANGES );
-       };
-
-       /**
-        * Save the current model state as a saved query
-        *
-        * @param {string} [label] Label of the saved query
-        * @param {boolean} [setAsDefault=false] This query should be set as the default
-        */
-       mw.rcfilters.Controller.prototype.saveCurrentQuery = function ( label, setAsDefault ) {
-               // Add item
-               this.savedQueriesModel.addNewQuery(
-                       label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
-                       this.filtersModel.getCurrentParameterState( true ),
-                       setAsDefault
-               );
-
-               // Save item
-               this._saveSavedQueries();
-       };
-
-       /**
-        * Remove a saved query
-        *
-        * @param {string} queryID Query id
-        */
-       mw.rcfilters.Controller.prototype.removeSavedQuery = function ( queryID ) {
-               this.savedQueriesModel.removeQuery( queryID );
-
-               this._saveSavedQueries();
-       };
-
-       /**
-        * Rename a saved query
-        *
-        * @param {string} queryID Query id
-        * @param {string} newLabel New label for the query
-        */
-       mw.rcfilters.Controller.prototype.renameSavedQuery = function ( queryID, newLabel ) {
-               var queryItem = this.savedQueriesModel.getItemByID( queryID );
-
-               if ( queryItem ) {
-                       queryItem.updateLabel( newLabel );
-               }
-               this._saveSavedQueries();
-       };
-
-       /**
-        * Set a saved query as default
-        *
-        * @param {string} queryID Query Id. If null is given, default
-        *  query is reset.
-        */
-       mw.rcfilters.Controller.prototype.setDefaultSavedQuery = function ( queryID ) {
-               this.savedQueriesModel.setDefault( queryID );
-               this._saveSavedQueries();
-       };
-
-       /**
-        * Load a saved query
-        *
-        * @param {string} queryID Query id
-        */
-       mw.rcfilters.Controller.prototype.applySavedQuery = function ( queryID ) {
-               var currentMatchingQuery,
-                       params = this.savedQueriesModel.getItemParams( queryID );
-
-               currentMatchingQuery = this.findQueryMatchingCurrentState();
-
-               if (
-                       currentMatchingQuery &&
-                       currentMatchingQuery.getID() === queryID
-               ) {
-                       // If the query we want to load is the one that is already
-                       // loaded, don't reload it
-                       return;
-               }
-
-               if ( this.applyParamChange( params ) ) {
-                       // Update changes list only if there was a difference in filter selection
-                       this.updateChangesList();
-               } else {
-                       this.uriProcessor.updateURL( params );
-               }
-
-               // Log filter grouping
-               this.trackFilterGroupings( 'savedfilters' );
-       };
-
-       /**
-        * Check whether the current filter and highlight state exists
-        * in the saved queries model.
-        *
-        * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
-        */
-       mw.rcfilters.Controller.prototype.findQueryMatchingCurrentState = function () {
-               return this.savedQueriesModel.findMatchingQuery(
-                       this.filtersModel.getCurrentParameterState( true )
-               );
-       };
-
-       /**
-        * Save the current state of the saved queries model with all
-        * query item representation in the user settings.
-        */
-       mw.rcfilters.Controller.prototype._saveSavedQueries = function () {
-               var stringified, oldPrefValue,
-                       backupPrefName = this.savedQueriesPreferenceName + '-versionbackup',
-                       state = this.savedQueriesModel.getState();
-
-               // Stringify state
-               stringified = JSON.stringify( state );
-
-               if ( byteLength( stringified ) > 65535 ) {
-                       // Sanity check, since the preference can only hold that.
-                       return;
-               }
-
-               if ( !this.wereSavedQueriesSaved && this.savedQueriesModel.isConverted() ) {
-                       // The queries were converted from the previous version
-                       // Keep the old string in the [prefname]-versionbackup
-                       oldPrefValue = mw.user.options.get( this.savedQueriesPreferenceName );
-
-                       // Save the old preference in the backup preference
-                       new mw.Api().saveOption( backupPrefName, oldPrefValue );
-                       // Update the preference for this session
-                       mw.user.options.set( backupPrefName, oldPrefValue );
-               }
-
-               // Save the preference
-               new mw.Api().saveOption( this.savedQueriesPreferenceName, stringified );
-               // Update the preference for this session
-               mw.user.options.set( this.savedQueriesPreferenceName, stringified );
-
-               // Tag as already saved so we don't do this again
-               this.wereSavedQueriesSaved = true;
-       };
-
-       /**
-        * Update sticky preferences with current model state
-        */
-       mw.rcfilters.Controller.prototype.updateStickyPreferences = function () {
-               // Update default sticky values with selected, whether they came from
-               // the initial defaults or from the URL value that is being normalized
-               this.updateDaysDefault( this.filtersModel.getGroup( 'days' ).findSelectedItems()[ 0 ].getParamName() );
-               this.updateLimitDefault( this.filtersModel.getGroup( 'limit' ).findSelectedItems()[ 0 ].getParamName() );
-
-               // TODO: Make these automatic by having the model go over sticky
-               // items and update their default values automatically
-       };
-
-       /**
-        * Update the limit default value
-        *
-        * @param {number} newValue New value
-        */
-       mw.rcfilters.Controller.prototype.updateLimitDefault = function ( newValue ) {
-               this.updateNumericPreference( this.limitPreferenceName, newValue );
-       };
-
-       /**
-        * Update the days default value
-        *
-        * @param {number} newValue New value
-        */
-       mw.rcfilters.Controller.prototype.updateDaysDefault = function ( newValue ) {
-               this.updateNumericPreference( this.daysPreferenceName, newValue );
-       };
-
-       /**
-        * Update the group by page default value
-        *
-        * @param {boolean} newValue New value
-        */
-       mw.rcfilters.Controller.prototype.updateGroupByPageDefault = function ( newValue ) {
-               this.updateNumericPreference( 'usenewrc', Number( newValue ) );
-       };
-
-       /**
-        * Update the collapsed state value
-        *
-        * @param {boolean} isCollapsed Filter area is collapsed
-        */
-       mw.rcfilters.Controller.prototype.updateCollapsedState = function ( isCollapsed ) {
-               this.updateNumericPreference( this.collapsedPreferenceName, Number( isCollapsed ) );
-       };
-
-       /**
-        * Update a numeric preference with a new value
-        *
-        * @param {string} prefName Preference name
-        * @param {number|string} newValue New value
-        */
-       mw.rcfilters.Controller.prototype.updateNumericPreference = function ( prefName, newValue ) {
-               // FIXME: $.isNumeric is deprecated
-               // eslint-disable-next-line jquery/no-is-numeric
-               if ( !$.isNumeric( newValue ) ) {
-                       return;
-               }
-
-               newValue = Number( newValue );
-
-               if ( mw.user.options.get( prefName ) !== newValue ) {
-                       // Save the preference
-                       new mw.Api().saveOption( prefName, newValue );
-                       // Update the preference for this session
-                       mw.user.options.set( prefName, newValue );
-               }
-       };
-
-       /**
-        * Synchronize the URL with the current state of the filters
-        * without adding an history entry.
-        */
-       mw.rcfilters.Controller.prototype.replaceUrl = function () {
-               this.uriProcessor.updateURL();
-       };
-
-       /**
-        * Update filter state (selection and highlighting) based
-        * on current URL values.
-        *
-        * @param {boolean} [fetchChangesList=true] Fetch new results into the changes
-        *  list based on the updated model.
-        */
-       mw.rcfilters.Controller.prototype.updateStateFromUrl = function ( fetchChangesList ) {
-               fetchChangesList = fetchChangesList === undefined ? true : !!fetchChangesList;
-
-               this.uriProcessor.updateModelBasedOnQuery();
-
-               // Update the sticky preferences, in case we received a value
-               // from the URL
-               this.updateStickyPreferences();
-
-               // Only update and fetch new results if it is requested
-               if ( fetchChangesList ) {
-                       this.updateChangesList();
-               }
-       };
-
-       /**
-        * Update the list of changes and notify the model
-        *
-        * @param {Object} [params] Extra parameters to add to the API call
-        * @param {string} [updateMode='filterChange'] One of 'filterChange', 'liveUpdate', 'showNewChanges', 'markSeen'
-        * @return {jQuery.Promise} Promise that is resolved when the update is complete
-        */
-       mw.rcfilters.Controller.prototype.updateChangesList = function ( params, updateMode ) {
-               updateMode = updateMode === undefined ? this.FILTER_CHANGE : updateMode;
-
-               if ( updateMode === this.FILTER_CHANGE ) {
-                       this.uriProcessor.updateURL( params );
-               }
-               if ( updateMode === this.FILTER_CHANGE || updateMode === this.SHOW_NEW_CHANGES ) {
-                       this.changesListModel.invalidate();
-               }
-               this.changesListModel.setNewChangesExist( false );
-               this.updatingChangesList = true;
-               return this._fetchChangesList()
-                       .then(
-                               // Success
-                               function ( pieces ) {
-                                       var $changesListContent = pieces.changes,
-                                               $fieldset = pieces.fieldset;
-                                       this.changesListModel.update(
-                                               $changesListContent,
-                                               $fieldset,
-                                               pieces.noResultsDetails,
-                                               false,
-                                               // separator between old and new changes
-                                               updateMode === this.SHOW_NEW_CHANGES || updateMode === this.LIVE_UPDATE
-                                       );
-                               }.bind( this )
-                               // Do nothing for failure
-                       )
-                       .always( function () {
-                               this.updatingChangesList = false;
-                       }.bind( this ) );
-       };
-
-       /**
-        * Get an object representing the default parameter state, whether
-        * it is from the model defaults or from the saved queries.
-        *
-        * @return {Object} Default parameters
-        */
-       mw.rcfilters.Controller.prototype._getDefaultParams = function () {
-               if ( this.savedQueriesModel.getDefault() ) {
-                       return this.savedQueriesModel.getDefaultParams();
-               } else {
-                       return this.filtersModel.getDefaultParams();
-               }
-       };
-
-       /**
-        * Query the list of changes from the server for the current filters
-        *
-        * @param {string} counterId Id for this request. To allow concurrent requests
-        *  not to invalidate each other.
-        * @param {Object} [params={}] Parameters to add to the query
-        *
-        * @return {jQuery.Promise} Promise object resolved with { content, status }
-        */
-       mw.rcfilters.Controller.prototype._queryChangesList = function ( counterId, params ) {
-               var uri = this.uriProcessor.getUpdatedUri(),
-                       stickyParams = this.filtersModel.getStickyParamsValues(),
-                       requestId,
-                       latestRequest;
-
-               params = params || {};
-               params.action = 'render'; // bypasses MW chrome
-
-               uri.extend( params );
-
-               this.requestCounter[ counterId ] = this.requestCounter[ counterId ] || 0;
-               requestId = ++this.requestCounter[ counterId ];
-               latestRequest = function () {
-                       return requestId === this.requestCounter[ counterId ];
-               }.bind( this );
-
-               // Sticky parameters override the URL params
-               // this is to make sure that whether we represent
-               // the sticky params in the URL or not (they may
-               // be normalized out) the sticky parameters are
-               // always being sent to the server with their
-               // current/default values
-               uri.extend( stickyParams );
-
-               return $.ajax( uri.toString(), { contentType: 'html' } )
-                       .then(
-                               function ( content, message, jqXHR ) {
-                                       if ( !latestRequest() ) {
-                                               return $.Deferred().reject();
-                                       }
-                                       return {
-                                               content: content,
-                                               status: jqXHR.status
-                                       };
-                               },
-                               // RC returns 404 when there is no results
-                               function ( jqXHR ) {
-                                       if ( latestRequest() ) {
-                                               return $.Deferred().resolve(
-                                                       {
-                                                               content: jqXHR.responseText,
-                                                               status: jqXHR.status
-                                                       }
-                                               ).promise();
-                                       }
-                               }
-                       );
-       };
-
-       /**
-        * Fetch the list of changes from the server for the current filters
-        *
-        * @return {jQuery.Promise} Promise object that will resolve with the changes list
-        *  and the fieldset.
-        */
-       mw.rcfilters.Controller.prototype._fetchChangesList = function () {
-               return this._queryChangesList( 'updateChangesList' )
-                       .then(
-                               function ( data ) {
-                                       var $parsed;
-
-                                       // Status code 0 is not HTTP status code,
-                                       // but is valid value of XMLHttpRequest status.
-                                       // It is used for variety of network errors, for example
-                                       // when an AJAX call was cancelled before getting the response
-                                       if ( data && data.status === 0 ) {
-                                               return {
-                                                       changes: 'NO_RESULTS',
-                                                       // We need empty result set, to avoid exceptions because of undefined value
-                                                       fieldset: $( [] ),
-                                                       noResultsDetails: 'NO_RESULTS_NETWORK_ERROR'
-                                               };
-                                       }
-
-                                       $parsed = $( '<div>' ).append( $( $.parseHTML(
-                                               data ? data.content : ''
-                                       ) ) );
-
-                                       return this._extractChangesListInfo( $parsed, data.status );
-                               }.bind( this )
-                       );
-       };
-
-       /**
-        * Track usage of highlight feature
-        *
-        * @param {string} action
-        * @param {Array|Object|string} filters
-        */
-       mw.rcfilters.Controller.prototype._trackHighlight = function ( action, filters ) {
-               filters = typeof filters === 'string' ? { name: filters } : filters;
-               filters = !Array.isArray( filters ) ? [ filters ] : filters;
-               mw.track(
-                       'event.ChangesListHighlights',
-                       {
-                               action: action,
-                               filters: filters,
-                               userId: mw.user.getId()
-                       }
-               );
-       };
-
-       /**
-        * Track filter grouping usage
-        *
-        * @param {string} action Action taken
-        */
-       mw.rcfilters.Controller.prototype.trackFilterGroupings = function ( action ) {
-               var controller = this,
-                       rightNow = new Date().getTime(),
-                       randomIdentifier = String( mw.user.sessionId() ) + String( rightNow ) + String( Math.random() ),
-                       // Get all current filters
-                       filters = this.filtersModel.findSelectedItems().map( function ( item ) {
-                               return item.getName();
-                       } );
-
-               action = action || 'filtermenu';
-
-               // Check if these filters were the ones we just logged previously
-               // (Don't log the same grouping twice, in case the user opens/closes)
-               // the menu without action, or with the same result
-               if (
-                       // Only log if the two arrays are different in size
-                       filters.length !== this.prevLoggedItems.length ||
-                       // Or if any filters are not the same as the cached filters
-                       filters.some( function ( filterName ) {
-                               return controller.prevLoggedItems.indexOf( filterName ) === -1;
-                       } ) ||
-                       // Or if any cached filters are not the same as given filters
-                       this.prevLoggedItems.some( function ( filterName ) {
-                               return filters.indexOf( filterName ) === -1;
-                       } )
-               ) {
-                       filters.forEach( function ( filterName ) {
-                               mw.track(
-                                       'event.ChangesListFilterGrouping',
-                                       {
-                                               action: action,
-                                               groupIdentifier: randomIdentifier,
-                                               filter: filterName,
-                                               userId: mw.user.getId()
-                                       }
-                               );
-                       } );
-
-                       // Cache the filter names
-                       this.prevLoggedItems = filters;
-               }
-       };
-
-       /**
-        * Apply a change of parameters to the model state, and check whether
-        * the new state is different than the old state.
-        *
-        * @param  {Object} newParamState New parameter state to apply
-        * @return {boolean} New applied model state is different than the previous state
-        */
-       mw.rcfilters.Controller.prototype.applyParamChange = function ( newParamState ) {
-               var after,
-                       before = this.filtersModel.getSelectedState();
-
-               this.filtersModel.updateStateFromParams( newParamState );
-
-               after = this.filtersModel.getSelectedState();
-
-               return !OO.compare( before, after );
-       };
-
-       /**
-        * Mark all changes as seen on Watchlist
-        */
-       mw.rcfilters.Controller.prototype.markAllChangesAsSeen = function () {
-               var api = new mw.Api();
-               api.postWithToken( 'csrf', {
-                       formatversion: 2,
-                       action: 'setnotificationtimestamp',
-                       entirewatchlist: true
-               } ).then( function () {
-                       this.updateChangesList( null, 'markSeen' );
-               }.bind( this ) );
-       };
-
-       /**
-        * Set the current search for the system.
-        *
-        * @param {string} searchQuery Search query, including triggers
-        */
-       mw.rcfilters.Controller.prototype.setSearch = function ( searchQuery ) {
-               this.filtersModel.setSearch( searchQuery );
-       };
-
-       /**
-        * Switch the view by changing the search query trigger
-        * without changing the search term
-        *
-        * @param  {string} view View to change to
-        */
-       mw.rcfilters.Controller.prototype.switchView = function ( view ) {
-               this.setSearch(
-                       this.filtersModel.getViewTrigger( view ) +
-                       this.filtersModel.removeViewTriggers( this.filtersModel.getSearch() )
-               );
-       };
-
-       /**
-        * Reset the search for a specific view. This means we null the search query
-        * and replace it with the relevant trigger for the requested view
-        *
-        * @param  {string} [view='default'] View to change to
-        */
-       mw.rcfilters.Controller.prototype.resetSearchForView = function ( view ) {
-               view = view || 'default';
-
-               this.setSearch(
-                       this.filtersModel.getViewTrigger( view )
-               );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.HighlightColors.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.HighlightColors.js
deleted file mode 100644 (file)
index 6231f28..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-( function () {
-       /**
-        * Supported highlight colors.
-        * Warning: These are also hardcoded in "styles/mw.rcfilters.variables.less"
-        *
-        * @member mw.rcfilters
-        * @property {string[]}
-        */
-       mw.rcfilters.HighlightColors = [ 'c1', 'c2', 'c3', 'c4', 'c5' ];
-}() );
diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js
deleted file mode 100644 (file)
index 5344af4..0000000
+++ /dev/null
@@ -1,294 +0,0 @@
-( function () {
-       /* eslint no-underscore-dangle: "off" */
-       /**
-        * URI Processor for RCFilters
-        *
-        * @class
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
-        * @param {Object} [config] Configuration object
-        * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
-        *  title normalization to separate title subpage/parts into the target= url
-        *  parameter
-        */
-       mw.rcfilters.UriProcessor = function MwRcfiltersController( filtersModel, config ) {
-               config = config || {};
-               this.filtersModel = filtersModel;
-
-               this.normalizeTarget = !!config.normalizeTarget;
-       };
-
-       /* Initialization */
-       OO.initClass( mw.rcfilters.UriProcessor );
-
-       /* Static methods */
-
-       /**
-        * Replace the url history through replaceState
-        *
-        * @param {mw.Uri} newUri New URI to replace
-        */
-       mw.rcfilters.UriProcessor.static.replaceState = function ( newUri ) {
-               window.history.replaceState(
-                       { tag: 'rcfilters' },
-                       document.title,
-                       newUri.toString()
-               );
-       };
-
-       /**
-        * Push the url to history through pushState
-        *
-        * @param {mw.Uri} newUri New URI to push
-        */
-       mw.rcfilters.UriProcessor.static.pushState = function ( newUri ) {
-               window.history.pushState(
-                       { tag: 'rcfilters' },
-                       document.title,
-                       newUri.toString()
-               );
-       };
-
-       /* Methods */
-
-       /**
-        * Get the version that this URL query is tagged with.
-        *
-        * @param {Object} [uriQuery] URI query
-        * @return {number} URL version
-        */
-       mw.rcfilters.UriProcessor.prototype.getVersion = function ( uriQuery ) {
-               uriQuery = uriQuery || new mw.Uri().query;
-
-               return Number( uriQuery.urlversion || 1 );
-       };
-
-       /**
-        * Get an updated mw.Uri object based on the model state
-        *
-        * @param {mw.Uri} [uri] An external URI to build the new uri
-        *  with. This is mainly for tests, to be able to supply external query
-        *  parameters and make sure they are retained.
-        * @return {mw.Uri} Updated Uri
-        */
-       mw.rcfilters.UriProcessor.prototype.getUpdatedUri = function ( uri ) {
-               var normalizedUri = this._normalizeTargetInUri( uri || new mw.Uri() ),
-                       unrecognizedParams = this.getUnrecognizedParams( normalizedUri.query );
-
-               normalizedUri.query = this.filtersModel.getMinimizedParamRepresentation(
-                       $.extend(
-                               true,
-                               {},
-                               normalizedUri.query,
-                               // The representation must be expanded so it can
-                               // override the uri query params but we then output
-                               // a minimized version for the entire URI representation
-                               // for the method
-                               this.filtersModel.getExpandedParamRepresentation()
-                       )
-               );
-
-               // Reapply unrecognized params and url version
-               normalizedUri.query = $.extend(
-                       true,
-                       {},
-                       normalizedUri.query,
-                       unrecognizedParams,
-                       { urlversion: '2' }
-               );
-
-               return normalizedUri;
-       };
-
-       /**
-        * Move the subpage to the target parameter
-        *
-        * @param {mw.Uri} uri
-        * @return {mw.Uri}
-        * @private
-        */
-       mw.rcfilters.UriProcessor.prototype._normalizeTargetInUri = function ( uri ) {
-               var parts,
-                       // matches [/wiki/]SpecialNS:RCL/[Namespace:]Title/Subpage/Subsubpage/etc
-                       re = /^((?:\/.+?\/)?.*?:.*?)\/(.*)$/;
-
-               if ( !this.normalizeTarget ) {
-                       return uri;
-               }
-
-               // target in title param
-               if ( uri.query.title ) {
-                       parts = uri.query.title.match( re );
-                       if ( parts ) {
-                               uri.query.title = parts[ 1 ];
-                               uri.query.target = parts[ 2 ];
-                       }
-               }
-
-               // target in path
-               parts = mw.Uri.decode( uri.path ).match( re );
-               if ( parts ) {
-                       uri.path = parts[ 1 ];
-                       uri.query.target = parts[ 2 ];
-               }
-
-               return uri;
-       };
-
-       /**
-        * Get an object representing given parameters that are unrecognized by the model
-        *
-        * @param  {Object} params Full params object
-        * @return {Object} Unrecognized params
-        */
-       mw.rcfilters.UriProcessor.prototype.getUnrecognizedParams = function ( params ) {
-               // Start with full representation
-               var givenParamNames = Object.keys( params ),
-                       unrecognizedParams = $.extend( true, {}, params );
-
-               // Extract unrecognized parameters
-               Object.keys( this.filtersModel.getEmptyParameterState() ).forEach( function ( paramName ) {
-                       // Remove recognized params
-                       if ( givenParamNames.indexOf( paramName ) > -1 ) {
-                               delete unrecognizedParams[ paramName ];
-                       }
-               } );
-
-               return unrecognizedParams;
-       };
-
-       /**
-        * Update the URL of the page to reflect current filters
-        *
-        * This should not be called directly from outside the controller.
-        * If an action requires changing the URL, it should either use the
-        * highlighting actions below, or call #updateChangesList which does
-        * the uri corrections already.
-        *
-        * @param {Object} [params] Extra parameters to add to the API call
-        */
-       mw.rcfilters.UriProcessor.prototype.updateURL = function ( params ) {
-               var currentUri = new mw.Uri(),
-                       updatedUri = this.getUpdatedUri();
-
-               updatedUri.extend( params || {} );
-
-               if (
-                       this.getVersion( currentUri.query ) !== 2 ||
-                       this.isNewState( currentUri.query, updatedUri.query )
-               ) {
-                       this.constructor.static.replaceState( updatedUri );
-               }
-       };
-
-       /**
-        * Update the filters model based on the URI query
-        * This happens on initialization, and from this moment on,
-        * we consider the system synchronized, and the model serves
-        * as the source of truth for the URL.
-        *
-        * This methods should only be called once on initialization.
-        * After initialization, the model updates the URL, not the
-        * other way around.
-        *
-        * @param {Object} [uriQuery] URI query
-        */
-       mw.rcfilters.UriProcessor.prototype.updateModelBasedOnQuery = function ( uriQuery ) {
-               uriQuery = uriQuery || this._normalizeTargetInUri( new mw.Uri() ).query;
-               this.filtersModel.updateStateFromParams(
-                       this._getNormalizedQueryParams( uriQuery )
-               );
-       };
-
-       /**
-        * Compare two URI queries to decide whether they are different
-        * enough to represent a new state.
-        *
-        * @param {Object} currentUriQuery Current Uri query
-        * @param {Object} updatedUriQuery Updated Uri query
-        * @return {boolean} This is a new state
-        */
-       mw.rcfilters.UriProcessor.prototype.isNewState = function ( currentUriQuery, updatedUriQuery ) {
-               var currentParamState, updatedParamState,
-                       notEquivalent = function ( obj1, obj2 ) {
-                               var keys = Object.keys( obj1 ).concat( Object.keys( obj2 ) );
-                               return keys.some( function ( key ) {
-                                       return obj1[ key ] != obj2[ key ]; // eslint-disable-line eqeqeq
-                               } );
-                       };
-
-               // Compare states instead of parameters
-               // This will allow us to always have a proper check of whether
-               // the requested new url is one to change or not, regardless of
-               // actual parameter visibility/representation in the URL
-               currentParamState = $.extend(
-                       true,
-                       {},
-                       this.filtersModel.getMinimizedParamRepresentation( currentUriQuery ),
-                       this.getUnrecognizedParams( currentUriQuery )
-               );
-               updatedParamState = $.extend(
-                       true,
-                       {},
-                       this.filtersModel.getMinimizedParamRepresentation( updatedUriQuery ),
-                       this.getUnrecognizedParams( updatedUriQuery )
-               );
-
-               return notEquivalent( currentParamState, updatedParamState );
-       };
-
-       /**
-        * Check whether the given query has parameters that are
-        * recognized as parameters we should load the system with
-        *
-        * @param {mw.Uri} [uriQuery] Given URI query
-        * @return {boolean} Query contains valid recognized parameters
-        */
-       mw.rcfilters.UriProcessor.prototype.doesQueryContainRecognizedParams = function ( uriQuery ) {
-               var anyValidInUrl,
-                       validParameterNames = Object.keys( this.filtersModel.getEmptyParameterState() );
-
-               uriQuery = uriQuery || new mw.Uri().query;
-
-               anyValidInUrl = Object.keys( uriQuery ).some( function ( parameter ) {
-                       return validParameterNames.indexOf( parameter ) > -1;
-               } );
-
-               // URL version 2 is allowed to be empty or within nonrecognized params
-               return anyValidInUrl || this.getVersion( uriQuery ) === 2;
-       };
-
-       /**
-        * Get the adjusted URI params based on the url version
-        * If the urlversion is not 2, the parameters are merged with
-        * the model's defaults.
-        * Always merge in the hidden parameter defaults.
-        *
-        * @private
-        * @param {Object} uriQuery Current URI query
-        * @return {Object} Normalized parameters
-        */
-       mw.rcfilters.UriProcessor.prototype._getNormalizedQueryParams = function ( uriQuery ) {
-               // Check whether we are dealing with urlversion=2
-               // If we are, we do not merge the initial request with
-               // defaults. Not having urlversion=2 means we need to
-               // reproduce the server-side request and merge the
-               // requested parameters (or starting state) with the
-               // wiki default.
-               // Any subsequent change of the URL through the RCFilters
-               // system will receive 'urlversion=2'
-               var base = this.getVersion( uriQuery ) === 2 ?
-                       {} :
-                       this.filtersModel.getDefaultParams();
-
-               return $.extend(
-                       true,
-                       {},
-                       this.filtersModel.getMinimizedParamRepresentation(
-                               $.extend( true, {}, base, uriQuery )
-                       ),
-                       { urlversion: '2' }
-               );
-       };
-}() );
index f866aa4..a69dc55 100644 (file)
  * JavaScript for Special:RecentChanges
  */
 ( function () {
-       var rcfilters = {
-               /**
-                * @member mw.rcfilters
-                * @private
-                */
-               init: function () {
-                       var $topSection,
-                               mainWrapperWidget,
-                               conditionalViews = {},
-                               $initialFieldset = $( 'fieldset.cloptions' ),
-                               savedQueriesPreferenceName = mw.config.get( 'wgStructuredChangeFiltersSavedQueriesPreferenceName' ),
-                               daysPreferenceName = mw.config.get( 'wgStructuredChangeFiltersDaysPreferenceName' ),
-                               limitPreferenceName = mw.config.get( 'wgStructuredChangeFiltersLimitPreferenceName' ),
-                               activeFiltersCollapsedName = mw.config.get( 'wgStructuredChangeFiltersCollapsedPreferenceName' ),
-                               initialCollapsedState = mw.config.get( 'wgStructuredChangeFiltersCollapsedState' ),
-                               filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
-                               changesListModel = new mw.rcfilters.dm.ChangesListViewModel( $initialFieldset ),
-                               savedQueriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ),
-                               specialPage = mw.config.get( 'wgCanonicalSpecialPageName' ),
-                               controller = new mw.rcfilters.Controller(
-                                       filtersModel, changesListModel, savedQueriesModel,
-                                       {
-                                               savedQueriesPreferenceName: savedQueriesPreferenceName,
-                                               daysPreferenceName: daysPreferenceName,
-                                               limitPreferenceName: limitPreferenceName,
-                                               collapsedPreferenceName: activeFiltersCollapsedName,
-                                               normalizeTarget: specialPage === 'Recentchangeslinked'
-                                       }
-                               );
-
-                       // TODO: The changesListWrapperWidget should be able to initialize
-                       // after the model is ready.
-
-                       if ( specialPage === 'Recentchanges' ) {
-                               $topSection = $( '.mw-recentchanges-toplinks' ).detach();
-                       } else if ( specialPage === 'Watchlist' ) {
-                               $( '#contentSub, form#mw-watchlist-resetbutton' ).remove();
-                               $topSection = $( '.watchlistDetails' ).detach().contents();
-                       } else if ( specialPage === 'Recentchangeslinked' ) {
-                               conditionalViews.recentChangesLinked = {
-                                       groups: [
-                                               {
-                                                       name: 'page',
-                                                       type: 'any_value',
-                                                       title: '',
-                                                       hidden: true,
-                                                       sticky: true,
-                                                       filters: [
-                                                               {
-                                                                       name: 'target',
-                                                                       default: ''
-                                                               }
-                                                       ]
-                                               },
-                                               {
-                                                       name: 'toOrFrom',
-                                                       type: 'boolean',
-                                                       title: '',
-                                                       hidden: true,
-                                                       sticky: true,
-                                                       filters: [
-                                                               {
-                                                                       name: 'showlinkedto',
-                                                                       default: false
-                                                               }
-                                                       ]
-                                               }
-                                       ]
-                               };
-                       }
 
-                       mainWrapperWidget = new mw.rcfilters.ui.MainWrapperWidget(
-                               controller,
-                               filtersModel,
-                               savedQueriesModel,
-                               changesListModel,
+       mw.rcfilters.HighlightColors = require( './HighlightColors.js' );
+       mw.rcfilters.ui.MainWrapperWidget = require( './ui/MainWrapperWidget.js' );
+
+       /**
+        * Get list of namespaces and remove unused ones
+        *
+        * @member mw.rcfilters
+        * @private
+        *
+        * @param {Array} unusedNamespaces Names of namespaces to remove
+        * @return {Array} Filtered array of namespaces
+        */
+       function getNamespaces( unusedNamespaces ) {
+               var i, length, name, id,
+                       namespaceIds = mw.config.get( 'wgNamespaceIds' ),
+                       namespaces = mw.config.get( 'wgFormattedNamespaces' );
+
+               for ( i = 0, length = unusedNamespaces.length; i < length; i++ ) {
+                       name = unusedNamespaces[ i ];
+                       id = namespaceIds[ name.toLowerCase() ];
+                       delete namespaces[ id ];
+               }
+
+               return namespaces;
+       }
+
+       /**
+        * @member mw.rcfilters
+        * @private
+        */
+       function init() {
+               var $topSection,
+                       mainWrapperWidget,
+                       conditionalViews = {},
+                       $initialFieldset = $( 'fieldset.cloptions' ),
+                       savedQueriesPreferenceName = mw.config.get( 'wgStructuredChangeFiltersSavedQueriesPreferenceName' ),
+                       daysPreferenceName = mw.config.get( 'wgStructuredChangeFiltersDaysPreferenceName' ),
+                       limitPreferenceName = mw.config.get( 'wgStructuredChangeFiltersLimitPreferenceName' ),
+                       activeFiltersCollapsedName = mw.config.get( 'wgStructuredChangeFiltersCollapsedPreferenceName' ),
+                       initialCollapsedState = mw.config.get( 'wgStructuredChangeFiltersCollapsedState' ),
+                       filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
+                       changesListModel = new mw.rcfilters.dm.ChangesListViewModel( $initialFieldset ),
+                       savedQueriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ),
+                       specialPage = mw.config.get( 'wgCanonicalSpecialPageName' ),
+                       controller = new mw.rcfilters.Controller(
+                               filtersModel, changesListModel, savedQueriesModel,
                                {
-                                       $wrapper: $( 'body' ),
-                                       $topSection: $topSection,
-                                       $filtersContainer: $( '.rcfilters-container' ),
-                                       $changesListContainer: $( '.mw-changeslist, .mw-changeslist-empty' ),
-                                       $formContainer: $initialFieldset,
-                                       collapsed: initialCollapsedState
+                                       savedQueriesPreferenceName: savedQueriesPreferenceName,
+                                       daysPreferenceName: daysPreferenceName,
+                                       limitPreferenceName: limitPreferenceName,
+                                       collapsedPreferenceName: activeFiltersCollapsedName,
+                                       normalizeTarget: specialPage === 'Recentchangeslinked'
                                }
                        );
 
-                       // Remove the -loading class that may have been added on the server side.
-                       // If we are in fact going to load a default saved query, this .initialize()
-                       // call will do that and add the -loading class right back.
-                       $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
-
-                       controller.initialize(
-                               mw.config.get( 'wgStructuredChangeFilters' ),
-                               // All namespaces without Media namespace
-                               rcfilters.getNamespaces( [ 'Media' ] ),
-                               mw.config.get( 'wgRCFiltersChangeTags' ),
-                               conditionalViews
-                       );
+               // TODO: The changesListWrapperWidget should be able to initialize
+               // after the model is ready.
+
+               if ( specialPage === 'Recentchanges' ) {
+                       $topSection = $( '.mw-recentchanges-toplinks' ).detach();
+               } else if ( specialPage === 'Watchlist' ) {
+                       $( '#contentSub, form#mw-watchlist-resetbutton' ).remove();
+                       $topSection = $( '.watchlistDetails' ).detach().contents();
+               } else if ( specialPage === 'Recentchangeslinked' ) {
+                       conditionalViews.recentChangesLinked = {
+                               groups: [
+                                       {
+                                               name: 'page',
+                                               type: 'any_value',
+                                               title: '',
+                                               hidden: true,
+                                               sticky: true,
+                                               filters: [
+                                                       {
+                                                               name: 'target',
+                                                               default: ''
+                                                       }
+                                               ]
+                                       },
+                                       {
+                                               name: 'toOrFrom',
+                                               type: 'boolean',
+                                               title: '',
+                                               hidden: true,
+                                               sticky: true,
+                                               filters: [
+                                                       {
+                                                               name: 'showlinkedto',
+                                                               default: false
+                                                       }
+                                               ]
+                                       }
+                               ]
+                       };
+               }
+
+               mainWrapperWidget = new mw.rcfilters.ui.MainWrapperWidget(
+                       controller,
+                       filtersModel,
+                       savedQueriesModel,
+                       changesListModel,
+                       {
+                               $wrapper: $( 'body' ),
+                               $topSection: $topSection,
+                               $filtersContainer: $( '.rcfilters-container' ),
+                               $changesListContainer: $( '.mw-changeslist, .mw-changeslist-empty' ),
+                               $formContainer: $initialFieldset,
+                               collapsed: initialCollapsedState
+                       }
+               );
 
-                       mainWrapperWidget.initFormWidget( specialPage );
+               // Remove the -loading class that may have been added on the server side.
+               // If we are in fact going to load a default saved query, this .initialize()
+               // call will do that and add the -loading class right back.
+               $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
 
-                       $( 'a.mw-helplink' ).attr(
-                               'href',
-                               'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:New_filters_for_edit_review'
-                       );
+               controller.initialize(
+                       mw.config.get( 'wgStructuredChangeFilters' ),
+                       // All namespaces without Media namespace
+                       getNamespaces( [ 'Media' ] ),
+                       require( './config.json' ).RCFiltersChangeTags,
+                       conditionalViews
+               );
+
+               mainWrapperWidget.initFormWidget( specialPage );
 
-                       controller.replaceUrl();
+               $( 'a.mw-helplink' ).attr(
+                       'href',
+                       'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:New_filters_for_edit_review'
+               );
 
-                       mainWrapperWidget.setTopSection( specialPage );
+               controller.replaceUrl();
 
-                       /**
-                        * Fired when initialization of the filtering interface for changes list is complete.
-                        *
-                        * @event structuredChangeFilters_ui_initialized
-                        * @member mw.hook
-                        */
-                       mw.hook( 'structuredChangeFilters.ui.initialized' ).fire();
-               },
+               mainWrapperWidget.setTopSection( specialPage );
 
                /**
-                * Get list of namespaces and remove unused ones
+                * Fired when initialization of the filtering interface for changes list is complete.
                 *
-                * @member mw.rcfilters
-                * @private
-                *
-                * @param {Array} unusedNamespaces Names of namespaces to remove
-                * @return {Array} Filtered array of namespaces
+                * @event structuredChangeFilters_ui_initialized
+                * @member mw.hook
                 */
-               getNamespaces: function ( unusedNamespaces ) {
-                       var i, length, name, id,
-                               namespaceIds = mw.config.get( 'wgNamespaceIds' ),
-                               namespaces = mw.config.get( 'wgFormattedNamespaces' );
-
-                       for ( i = 0, length = unusedNamespaces.length; i < length; i++ ) {
-                               name = unusedNamespaces[ i ];
-                               id = namespaceIds[ name.toLowerCase() ];
-                               delete namespaces[ id ];
-                       }
-
-                       return namespaces;
-               }
-       };
+               mw.hook( 'structuredChangeFilters.ui.initialized' ).fire();
+       }
 
        // Import i18n messages from config
        mw.messages.set( mw.config.get( 'wgStructuredChangeFiltersMessages' ) );
 
        // Early execute of init
        if ( document.readyState === 'interactive' || document.readyState === 'complete' ) {
-               rcfilters.init();
+               init();
        } else {
-               $( rcfilters.init );
+               $( init );
        }
 
-       module.exports = rcfilters;
+       module.exports = mw.rcfilters;
 
 }() );
index f30c278..b32fb38 100644 (file)
@@ -4,7 +4,17 @@
         * @singleton
         */
        mw.rcfilters = {
-               dm: {},
+               Controller: require( './Controller.js' ),
+               UriProcessor: require( './UriProcessor.js' ),
+               dm: {
+                       ChangesListViewModel: require( './dm/ChangesListViewModel.js' ),
+                       FilterGroup: require( './dm/FilterGroup.js' ),
+                       FilterItem: require( './dm/FilterItem.js' ),
+                       FiltersViewModel: require( './dm/FiltersViewModel.js' ),
+                       ItemModel: require( './dm/ItemModel.js' ),
+                       SavedQueriesModel: require( './dm/SavedQueriesModel.js' ),
+                       SavedQueryItemModel: require( './dm/SavedQueryItemModel.js' )
+               },
                ui: {},
                utils: {
                        addArrayElementsUnique: function ( arr, elements ) {
@@ -46,4 +56,6 @@
                        }
                }
        };
+
+       module.exports = mw.rcfilters;
 }() );
diff --git a/resources/src/mediawiki.rcfilters/ui/ChangesLimitAndDateButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/ChangesLimitAndDateButtonWidget.js
new file mode 100644 (file)
index 0000000..23b05e8
--- /dev/null
@@ -0,0 +1,174 @@
+( function () {
+       var ChangesLimitPopupWidget = require( './ChangesLimitPopupWidget.js' ),
+               DatePopupWidget = require( './DatePopupWidget.js' ),
+               ChangesLimitAndDateButtonWidget;
+
+       /**
+        * Widget defining the button controlling the popup for the number of results
+        *
+        * @class mw.rcfilters.ui.ChangesLimitAndDateButtonWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller Controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+        * @param {Object} [config] Configuration object
+        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+        */
+       ChangesLimitAndDateButtonWidget = function MwRcfiltersUiChangesLimitWidget( controller, model, config ) {
+               config = config || {};
+
+               // Parent
+               ChangesLimitAndDateButtonWidget.parent.call( this, config );
+
+               this.controller = controller;
+               this.model = model;
+
+               this.$overlay = config.$overlay || this.$element;
+
+               this.button = null;
+               this.limitGroupModel = null;
+               this.groupByPageItemModel = null;
+               this.daysGroupModel = null;
+
+               this.model.connect( this, {
+                       initialize: 'onModelInitialize'
+               } );
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-changesLimitAndDateButtonWidget' );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( ChangesLimitAndDateButtonWidget, OO.ui.Widget );
+
+       /**
+        * Respond to model initialize event
+        */
+       ChangesLimitAndDateButtonWidget.prototype.onModelInitialize = function () {
+               var changesLimitPopupWidget, selectedItem, currentValue, datePopupWidget,
+                       displayGroupModel = this.model.getGroup( 'display' );
+
+               this.limitGroupModel = this.model.getGroup( 'limit' );
+               this.groupByPageItemModel = displayGroupModel.getItemByParamName( 'enhanced' );
+               this.daysGroupModel = this.model.getGroup( 'days' );
+
+               // HACK: We need the model to be ready before we populate the button
+               // and the widget, because we require the filter items for the
+               // limit and their events. This addition is only done after the
+               // model is initialized.
+               // Note: This will be fixed soon!
+               if ( this.limitGroupModel && this.daysGroupModel ) {
+                       changesLimitPopupWidget = new ChangesLimitPopupWidget(
+                               this.limitGroupModel,
+                               this.groupByPageItemModel
+                       );
+
+                       datePopupWidget = new DatePopupWidget(
+                               this.daysGroupModel,
+                               {
+                                       label: mw.msg( 'rcfilters-date-popup-title' )
+                               }
+                       );
+
+                       selectedItem = this.limitGroupModel.findSelectedItems()[ 0 ];
+                       currentValue = ( selectedItem && selectedItem.getLabel() ) ||
+                               mw.language.convertNumber( this.limitGroupModel.getDefaultParamValue() );
+
+                       this.button = new OO.ui.PopupButtonWidget( {
+                               icon: 'settings',
+                               indicator: 'down',
+                               label: mw.msg( 'rcfilters-limit-and-date-label', currentValue ),
+                               $overlay: this.$overlay,
+                               popup: {
+                                       width: 300,
+                                       padded: false,
+                                       anchor: false,
+                                       align: 'backwards',
+                                       $autoCloseIgnore: this.$overlay,
+                                       $content: $( '<div>' ).append(
+                                               // TODO: Merge ChangesLimitPopupWidget with DatePopupWidget into one common widget
+                                               changesLimitPopupWidget.$element,
+                                               datePopupWidget.$element
+                                       )
+                               }
+                       } );
+                       this.updateButtonLabel();
+
+                       // Events
+                       this.limitGroupModel.connect( this, { update: 'updateButtonLabel' } );
+                       this.daysGroupModel.connect( this, { update: 'updateButtonLabel' } );
+                       changesLimitPopupWidget.connect( this, {
+                               limit: 'onPopupLimit',
+                               groupByPage: 'onPopupGroupByPage'
+                       } );
+                       datePopupWidget.connect( this, { days: 'onPopupDays' } );
+
+                       this.$element.append( this.button.$element );
+               }
+       };
+
+       /**
+        * Respond to popup limit change event
+        *
+        * @param {string} filterName Chosen filter name
+        */
+       ChangesLimitAndDateButtonWidget.prototype.onPopupLimit = function ( filterName ) {
+               var item = this.limitGroupModel.getItemByName( filterName );
+
+               this.controller.toggleFilterSelect( filterName, true );
+               this.controller.updateLimitDefault( item.getParamName() );
+               this.button.popup.toggle( false );
+       };
+
+       /**
+        * Respond to popup limit change event
+        *
+        * @param {boolean} isGrouped The result set is grouped by page
+        */
+       ChangesLimitAndDateButtonWidget.prototype.onPopupGroupByPage = function ( isGrouped ) {
+               this.controller.toggleFilterSelect( this.groupByPageItemModel.getName(), isGrouped );
+               this.controller.updateGroupByPageDefault( isGrouped );
+               this.button.popup.toggle( false );
+       };
+
+       /**
+        * Respond to popup limit change event
+        *
+        * @param {string} filterName Chosen filter name
+        */
+       ChangesLimitAndDateButtonWidget.prototype.onPopupDays = function ( filterName ) {
+               var item = this.daysGroupModel.getItemByName( filterName );
+
+               this.controller.toggleFilterSelect( filterName, true );
+               this.controller.updateDaysDefault( item.getParamName() );
+               this.button.popup.toggle( false );
+       };
+
+       /**
+        * Respond to limit choose event
+        *
+        * @param {string} filterName Filter name
+        */
+       ChangesLimitAndDateButtonWidget.prototype.updateButtonLabel = function () {
+               var message,
+                       limit = this.limitGroupModel.findSelectedItems()[ 0 ],
+                       label = limit && limit.getLabel(),
+                       days = this.daysGroupModel.findSelectedItems()[ 0 ],
+                       daysParamName = Number( days.getParamName() ) < 1 ?
+                               'rcfilters-days-show-hours' :
+                               'rcfilters-days-show-days';
+
+               // Update the label
+               if ( label && days ) {
+                       message = mw.msg( 'rcfilters-limit-and-date-label', label,
+                               mw.msg( daysParamName, days.getLabel() )
+                       );
+                       this.button.setLabel( message );
+               }
+       };
+
+       module.exports = ChangesLimitAndDateButtonWidget;
+
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js b/resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js
new file mode 100644 (file)
index 0000000..d78c42b
--- /dev/null
@@ -0,0 +1,84 @@
+( function () {
+       var ValuePickerWidget = require( './ValuePickerWidget.js' ),
+               ChangesLimitPopupWidget;
+
+       /**
+        * Widget defining the popup to choose number of results
+        *
+        * @class mw.rcfilters.ui.ChangesLimitPopupWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.dm.FilterGroup} limitModel Group model for 'limit'
+        * @param {mw.rcfilters.dm.FilterItem} groupByPageItemModel Group model for 'limit'
+        * @param {Object} [config] Configuration object
+        */
+       ChangesLimitPopupWidget = function MwRcfiltersUiChangesLimitPopupWidget( limitModel, groupByPageItemModel, config ) {
+               config = config || {};
+
+               // Parent
+               ChangesLimitPopupWidget.parent.call( this, config );
+
+               this.limitModel = limitModel;
+               this.groupByPageItemModel = groupByPageItemModel;
+
+               this.valuePicker = new ValuePickerWidget(
+                       this.limitModel,
+                       {
+                               label: mw.msg( 'rcfilters-limit-title' )
+                       }
+               );
+
+               this.groupByPageCheckbox = new OO.ui.CheckboxInputWidget( {
+                       selected: this.groupByPageItemModel.isSelected()
+               } );
+
+               // Events
+               this.valuePicker.connect( this, { choose: [ 'emit', 'limit' ] } );
+               this.groupByPageCheckbox.connect( this, { change: [ 'emit', 'groupByPage' ] } );
+               this.groupByPageItemModel.connect( this, { update: 'onGroupByPageModelUpdate' } );
+
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-changesLimitPopupWidget' )
+                       .append(
+                               this.valuePicker.$element,
+                               new OO.ui.FieldLayout(
+                                       this.groupByPageCheckbox,
+                                       {
+                                               align: 'inline',
+                                               label: mw.msg( 'rcfilters-group-results-by-page' )
+                                       }
+                               ).$element
+                       );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( ChangesLimitPopupWidget, OO.ui.Widget );
+
+       /* Events */
+
+       /**
+        * @event limit
+        * @param {string} name Item name
+        *
+        * A limit item was chosen
+        */
+
+       /**
+        * @event groupByPage
+        * @param {boolean} isGrouped The results are grouped by page
+        *
+        * Results are grouped by page
+        */
+
+       /**
+        * Respond to group by page model update
+        */
+       ChangesLimitPopupWidget.prototype.onGroupByPageModelUpdate = function () {
+               this.groupByPageCheckbox.setSelected( this.groupByPageItemModel.isSelected() );
+       };
+
+       module.exports = ChangesLimitPopupWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/ChangesListWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/ChangesListWrapperWidget.js
new file mode 100644 (file)
index 0000000..361fe31
--- /dev/null
@@ -0,0 +1,388 @@
+( function () {
+       /**
+        * List of changes
+        *
+        * @class mw.rcfilters.ui.ChangesListWrapperWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel View model
+        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListViewModel View model
+        * @param {mw.rcfilters.Controller} controller
+        * @param {jQuery} $changesListRoot Root element of the changes list to attach to
+        * @param {Object} [config] Configuration object
+        */
+       var ChangesListWrapperWidget = function MwRcfiltersUiChangesListWrapperWidget(
+               filtersViewModel,
+               changesListViewModel,
+               controller,
+               $changesListRoot,
+               config
+       ) {
+               config = $.extend( {}, config, {
+                       $element: $changesListRoot
+               } );
+
+               // Parent
+               ChangesListWrapperWidget.parent.call( this, config );
+
+               this.filtersViewModel = filtersViewModel;
+               this.changesListViewModel = changesListViewModel;
+               this.controller = controller;
+               this.highlightClasses = null;
+
+               // Events
+               this.filtersViewModel.connect( this, {
+                       itemUpdate: 'onItemUpdate',
+                       highlightChange: 'onHighlightChange'
+               } );
+               this.changesListViewModel.connect( this, {
+                       invalidate: 'onModelInvalidate',
+                       update: 'onModelUpdate'
+               } );
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget' )
+                       // We handle our own display/hide of the empty results message
+                       // We keep the timeout class here and remove it later, since at this
+                       // stage it is still needed to identify that the timeout occurred.
+                       .removeClass( 'mw-changeslist-empty' );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( ChangesListWrapperWidget, OO.ui.Widget );
+
+       /**
+        * Get all available highlight classes
+        *
+        * @return {string[]} An array of available highlight class names
+        */
+       ChangesListWrapperWidget.prototype.getHighlightClasses = function () {
+               if ( !this.highlightClasses || !this.highlightClasses.length ) {
+                       this.highlightClasses = this.filtersViewModel.getItemsSupportingHighlights()
+                               .map( function ( filterItem ) {
+                                       return filterItem.getCssClass();
+                               } );
+               }
+
+               return this.highlightClasses;
+       };
+
+       /**
+        * Respond to the highlight feature being toggled on and off
+        *
+        * @param {boolean} highlightEnabled
+        */
+       ChangesListWrapperWidget.prototype.onHighlightChange = function ( highlightEnabled ) {
+               if ( highlightEnabled ) {
+                       this.applyHighlight();
+               } else {
+                       this.clearHighlight();
+               }
+       };
+
+       /**
+        * Respond to a filter item model update
+        */
+       ChangesListWrapperWidget.prototype.onItemUpdate = function () {
+               if ( this.controller.isInitialized() && this.filtersViewModel.isHighlightEnabled() ) {
+                       // this.controller.isInitialized() is still false during page load,
+                       // we don't want to clear/apply highlights at this stage.
+                       this.clearHighlight();
+                       this.applyHighlight();
+               }
+       };
+
+       /**
+        * Respond to changes list model invalidate
+        */
+       ChangesListWrapperWidget.prototype.onModelInvalidate = function () {
+               $( 'body' ).addClass( 'mw-rcfilters-ui-loading' );
+       };
+
+       /**
+        * Respond to changes list model update
+        *
+        * @param {jQuery|string} $changesListContent The content of the updated changes list
+        * @param {jQuery} $fieldset The content of the updated fieldset
+        * @param {string} noResultsDetails Type of no result error
+        * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
+        * @param {boolean} from Timestamp of the new changes
+        */
+       ChangesListWrapperWidget.prototype.onModelUpdate = function (
+               $changesListContent, $fieldset, noResultsDetails, isInitialDOM, from
+       ) {
+               var conflictItem,
+                       $message = $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results' ),
+                       isEmpty = $changesListContent === 'NO_RESULTS',
+                       // For enhanced mode, we have to load these modules, which are
+                       // not loaded for the 'regular' mode in the backend
+                       loaderPromise = mw.user.options.get( 'usenewrc' ) ?
+                               mw.loader.using( [ 'mediawiki.special.changeslist.enhanced', 'mediawiki.icon' ] ) :
+                               $.Deferred().resolve(),
+                       widget = this;
+
+               this.$element.toggleClass( 'mw-changeslist', !isEmpty );
+               if ( isEmpty ) {
+                       this.$element.empty();
+
+                       if ( this.filtersViewModel.hasConflict() ) {
+                               conflictItem = this.filtersViewModel.getFirstConflictedItem();
+
+                               $message
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-conflict' )
+                                                       .text( mw.message( 'rcfilters-noresults-conflict' ).text() ),
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-message' )
+                                                       .text( mw.message( conflictItem.getCurrentConflictResultMessage() ).text() )
+                                       );
+                       } else {
+                               $message
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-noresult' )
+                                                       .text( mw.msg( this.getMsgKeyForNoResults( noResultsDetails ) ) )
+                                       );
+
+                               // remove all classes matching mw-changeslist-*
+                               this.$element.removeClass( function ( elementIndex, allClasses ) {
+                                       return allClasses
+                                               .split( ' ' )
+                                               .filter( function ( className ) {
+                                                       return className.indexOf( 'mw-changeslist-' ) === 0;
+                                               } )
+                                               .join( ' ' );
+                               } );
+                       }
+
+                       this.$element.append( $message );
+               } else {
+                       if ( !isInitialDOM ) {
+                               this.$element.empty().append( $changesListContent );
+
+                               if ( from ) {
+                                       this.emphasizeNewChanges( from );
+                               }
+                       }
+
+                       // Apply highlight
+                       this.applyHighlight();
+
+               }
+
+               this.$element.prepend( $( '<div>' ).addClass( 'mw-changeslist-overlay' ) );
+
+               loaderPromise.done( function () {
+                       if ( !isInitialDOM && !isEmpty ) {
+                               // Make sure enhanced RC re-initializes correctly
+                               mw.hook( 'wikipage.content' ).fire( widget.$element );
+                       }
+
+                       $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
+               } );
+       };
+
+       /** Toggles overlay class on changes list
+        *
+        * @param {boolean} isVisible True if overlay should be visible
+        */
+       ChangesListWrapperWidget.prototype.toggleOverlay = function ( isVisible ) {
+               this.$element.toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget--overlaid', isVisible );
+       };
+
+       /**
+        * Map a reason for having no results to its message key
+        *
+        * @param {string} reason One of the NO_RESULTS_* "constant" that represent
+        *   a reason for having no results
+        * @return {string} Key for the message that explains why there is no results in this case
+        */
+       ChangesListWrapperWidget.prototype.getMsgKeyForNoResults = function ( reason ) {
+               var reasonMsgKeyMap = {
+                       NO_RESULTS_NORMAL: 'recentchanges-noresult',
+                       NO_RESULTS_TIMEOUT: 'recentchanges-timeout',
+                       NO_RESULTS_NETWORK_ERROR: 'recentchanges-network',
+                       NO_RESULTS_NO_TARGET_PAGE: 'recentchanges-notargetpage',
+                       NO_RESULTS_INVALID_TARGET_PAGE: 'allpagesbadtitle'
+               };
+               return reasonMsgKeyMap[ reason ];
+       };
+
+       /**
+        * Emphasize the elements (or groups) newer than the 'from' parameter
+        * @param {string} from Anything newer than this is considered 'new'
+        */
+       ChangesListWrapperWidget.prototype.emphasizeNewChanges = function ( from ) {
+               var $firstNew,
+                       $indicator,
+                       $newChanges = $( [] ),
+                       selector = this.inEnhancedMode() ?
+                               'table.mw-enhanced-rc[data-mw-ts]' :
+                               'li[data-mw-ts]',
+                       set = this.$element.find( selector ),
+                       length = set.length;
+
+               set.each( function ( index ) {
+                       var $this = $( this ),
+                               ts = $this.data( 'mw-ts' );
+
+                       if ( ts >= from ) {
+                               $newChanges = $newChanges.add( $this );
+                               $firstNew = $this;
+
+                               // guards against putting the marker after the last element
+                               if ( index === ( length - 1 ) ) {
+                                       $firstNew = null;
+                               }
+                       }
+               } );
+
+               if ( $firstNew ) {
+                       $indicator = $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-previousChangesIndicator' );
+
+                       $firstNew.after( $indicator );
+               }
+
+               // FIXME: Use CSS transition
+               // eslint-disable-next-line jquery/no-fade
+               $newChanges
+                       .hide()
+                       .fadeIn( 1000 );
+       };
+
+       /**
+        * In enhanced mode, we need to check whether the grouped results all have the
+        * same active highlights in order to see whether the "parent" of the group should
+        * be grey or highlighted normally.
+        *
+        * This is called every time highlights are applied.
+        */
+       ChangesListWrapperWidget.prototype.updateEnhancedParentHighlight = function () {
+               var activeHighlightClasses,
+                       $enhancedTopPageCell = this.$element.find( 'table.mw-enhanced-rc.mw-collapsible' );
+
+               activeHighlightClasses = this.filtersViewModel.getCurrentlyUsedHighlightColors().map( function ( color ) {
+                       return 'mw-rcfilters-highlight-color-' + color;
+               } );
+
+               // Go over top pages and their children, and figure out if all sub-pages have the
+               // same highlights between themselves. If they do, the parent should be highlighted
+               // with all colors. If classes are different, the parent should receive a grey
+               // background
+               $enhancedTopPageCell.each( function () {
+                       var firstChildClasses, $rowsWithDifferentHighlights,
+                               $table = $( this );
+
+                       // Collect the relevant classes from the first nested child
+                       firstChildClasses = activeHighlightClasses.filter( function ( className ) {
+                               return $table.find( 'tr:nth-child(2)' ).hasClass( className );
+                       } );
+                       // Filter the non-head rows and see if they all have the same classes
+                       // to the first row
+                       $rowsWithDifferentHighlights = $table.find( 'tr:not(:first-child)' ).filter( function () {
+                               var classesInThisRow,
+                                       $this = $( this );
+
+                               classesInThisRow = activeHighlightClasses.filter( function ( className ) {
+                                       return $this.hasClass( className );
+                               } );
+
+                               return !OO.compare( firstChildClasses, classesInThisRow );
+                       } );
+
+                       // If classes are different, tag the row for using grey color
+                       $table.find( 'tr:first-child' )
+                               .toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey', $rowsWithDifferentHighlights.length > 0 );
+               } );
+       };
+
+       /**
+        * @return {boolean} Whether the changes are grouped by page
+        */
+       ChangesListWrapperWidget.prototype.inEnhancedMode = function () {
+               var uri = new mw.Uri();
+               return ( uri.query.enhanced !== undefined && Number( uri.query.enhanced ) ) ||
+                       ( uri.query.enhanced === undefined && Number( mw.user.options.get( 'usenewrc' ) ) );
+       };
+
+       /**
+        * Apply color classes based on filters highlight configuration
+        */
+       ChangesListWrapperWidget.prototype.applyHighlight = function () {
+               if ( !this.filtersViewModel.isHighlightEnabled() ) {
+                       return;
+               }
+
+               this.filtersViewModel.getHighlightedItems().forEach( function ( filterItem ) {
+                       var $elements = this.$element.find( '.' + filterItem.getCssClass() );
+
+                       // Add highlight class to all highlighted list items
+                       $elements
+                               .addClass(
+                                       'mw-rcfilters-highlighted ' +
+                                       'mw-rcfilters-highlight-color-' + filterItem.getHighlightColor()
+                               );
+
+                       // Track the filters for each item in .data( 'highlightedFilters' )
+                       $elements.each( function () {
+                               var filters = $( this ).data( 'highlightedFilters' );
+                               if ( !filters ) {
+                                       filters = [];
+                                       $( this ).data( 'highlightedFilters', filters );
+                               }
+                               if ( filters.indexOf( filterItem.getLabel() ) === -1 ) {
+                                       filters.push( filterItem.getLabel() );
+                               }
+                       } );
+               }.bind( this ) );
+               // Apply a title to each highlighted item, with a list of filters
+               this.$element.find( '.mw-rcfilters-highlighted' ).each( function () {
+                       var filters = $( this ).data( 'highlightedFilters' );
+
+                       if ( filters && filters.length ) {
+                               $( this ).attr( 'title', mw.msg(
+                                       'rcfilters-highlighted-filters-list',
+                                       filters.join( mw.msg( 'comma-separator' ) )
+                               ) );
+                       }
+
+               } );
+               if ( this.inEnhancedMode() ) {
+                       this.updateEnhancedParentHighlight();
+               }
+
+               // Turn on highlights
+               this.$element.addClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
+       };
+
+       /**
+        * Remove all color classes
+        */
+       ChangesListWrapperWidget.prototype.clearHighlight = function () {
+               // Remove highlight classes
+               mw.rcfilters.HighlightColors.forEach( function ( color ) {
+                       this.$element
+                               .find( '.mw-rcfilters-highlight-color-' + color )
+                               .removeClass( 'mw-rcfilters-highlight-color-' + color );
+               }.bind( this ) );
+
+               this.$element.find( '.mw-rcfilters-highlighted' )
+                       .removeAttr( 'title' )
+                       .removeData( 'highlightedFilters' )
+                       .removeClass( 'mw-rcfilters-highlighted' );
+
+               // Remove grey from enhanced rows
+               this.$element.find( '.mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' )
+                       .removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' );
+
+               // Turn off highlights
+               this.$element.removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
+       };
+
+       module.exports = ChangesListWrapperWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/CheckboxInputWidget.js b/resources/src/mediawiki.rcfilters/ui/CheckboxInputWidget.js
new file mode 100644 (file)
index 0000000..490d54e
--- /dev/null
@@ -0,0 +1,66 @@
+( function () {
+       /**
+        * A widget representing a single toggle filter
+        *
+        * @class mw.rcfilters.ui.CheckboxInputWidget
+        * @extends OO.ui.CheckboxInputWidget
+        *
+        * @constructor
+        * @param {Object} config Configuration object
+        */
+       var CheckboxInputWidget = function MwRcfiltersUiCheckboxInputWidget( config ) {
+               config = config || {};
+
+               // Parent
+               CheckboxInputWidget.parent.call( this, config );
+
+               // Event
+               this.$input
+                       // HACK: This widget just pretends to be a checkbox for visual purposes.
+                       // In reality, all actions - setting to true or false, etc - are
+                       // decided by the model, and executed by the controller. This means
+                       // that we want to let the controller and model make the decision
+                       // of whether to check/uncheck this checkboxInputWidget, and for that,
+                       // we have to bypass the browser action that checks/unchecks it during
+                       // click.
+                       .on( 'click', false )
+                       .on( 'change', this.onUserChange.bind( this ) );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( CheckboxInputWidget, OO.ui.CheckboxInputWidget );
+
+       /* Events */
+
+       /**
+        * @event userChange
+        * @param {boolean} Current state of the checkbox
+        *
+        * The user has checked or unchecked this checkbox
+        */
+
+       /* Methods */
+
+       /**
+        * @inheritdoc
+        */
+       CheckboxInputWidget.prototype.onEdit = function () {
+               // Similarly to preventing defaults in 'click' event, we want
+               // to prevent this widget from deciding anything about its own
+               // state; it emits a change event and the model and controller
+               // make a decision about what its select state is.
+               // onEdit has a widget.$input.prop( 'checked' ) inside a setTimeout()
+               // so we really want to prevent that from messing with what
+               // the model decides the state of the widget is.
+       };
+
+       /**
+        * Respond to checkbox change by a user and emit 'userChange'.
+        */
+       CheckboxInputWidget.prototype.onUserChange = function () {
+               this.emit( 'userChange', this.$input.prop( 'checked' ) );
+       };
+
+       module.exports = CheckboxInputWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/DatePopupWidget.js b/resources/src/mediawiki.rcfilters/ui/DatePopupWidget.js
new file mode 100644 (file)
index 0000000..1ac0d49
--- /dev/null
@@ -0,0 +1,72 @@
+( function () {
+       var ValuePickerWidget = require( './ValuePickerWidget.js' ),
+               DatePopupWidget;
+
+       /**
+        * Widget defining the popup to choose date for the results
+        *
+        * @class mw.rcfilters.ui.DatePopupWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.dm.FilterGroup} model Group model for 'days'
+        * @param {Object} [config] Configuration object
+        */
+       DatePopupWidget = function MwRcfiltersUiDatePopupWidget( model, config ) {
+               config = config || {};
+
+               // Parent
+               DatePopupWidget.parent.call( this, config );
+               // Mixin constructors
+               OO.ui.mixin.LabelElement.call( this, config );
+
+               this.model = model;
+
+               this.hoursValuePicker = new ValuePickerWidget(
+                       this.model,
+                       {
+                               classes: [ 'mw-rcfilters-ui-datePopupWidget-hours' ],
+                               label: mw.msg( 'rcfilters-hours-title' ),
+                               itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) < 1; }
+                       }
+               );
+               this.daysValuePicker = new ValuePickerWidget(
+                       this.model,
+                       {
+                               classes: [ 'mw-rcfilters-ui-datePopupWidget-days' ],
+                               label: mw.msg( 'rcfilters-days-title' ),
+                               itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) >= 1; }
+                       }
+               );
+
+               // Events
+               this.hoursValuePicker.connect( this, { choose: [ 'emit', 'days' ] } );
+               this.daysValuePicker.connect( this, { choose: [ 'emit', 'days' ] } );
+
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-datePopupWidget' )
+                       .append(
+                               this.$label
+                                       .addClass( 'mw-rcfilters-ui-datePopupWidget-title' ),
+                               this.hoursValuePicker.$element,
+                               this.daysValuePicker.$element
+                       );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( DatePopupWidget, OO.ui.Widget );
+       OO.mixinClass( DatePopupWidget, OO.ui.mixin.LabelElement );
+
+       /* Events */
+
+       /**
+        * @event days
+        * @param {string} name Item name
+        *
+        * A days item was chosen
+        */
+
+       module.exports = DatePopupWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/FilterItemHighlightButton.js b/resources/src/mediawiki.rcfilters/ui/FilterItemHighlightButton.js
new file mode 100644 (file)
index 0000000..1327755
--- /dev/null
@@ -0,0 +1,85 @@
+( function () {
+       /**
+        * A button to configure highlight for a filter item
+        *
+        * @class mw.rcfilters.ui.FilterItemHighlightButton
+        * @extends OO.ui.PopupButtonWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller RCFilters controller
+        * @param {mw.rcfilters.dm.FilterItem} model Filter item model
+        * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
+        * @param {Object} [config] Configuration object
+        */
+       var FilterItemHighlightButton = function MwRcfiltersUiFilterItemHighlightButton( controller, model, highlightPopup, config ) {
+               config = config || {};
+
+               // Parent
+               FilterItemHighlightButton.parent.call( this, $.extend( true, {}, config, {
+                       icon: 'highlight',
+                       indicator: 'down'
+               } ) );
+
+               this.controller = controller;
+               this.model = model;
+               this.popup = highlightPopup;
+
+               // Event
+               this.model.connect( this, { update: 'updateUiBasedOnModel' } );
+               // This lives inside a MenuOptionWidget, which intercepts mousedown
+               // to select the item. We want to prevent that when we click the highlight
+               // button
+               this.$element.on( 'mousedown', function ( e ) {
+                       e.stopPropagation();
+               } );
+
+               this.updateUiBasedOnModel();
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-filterItemHighlightButton' );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( FilterItemHighlightButton, OO.ui.PopupButtonWidget );
+
+       /* Static Properties */
+
+       /**
+        * @static
+        */
+       FilterItemHighlightButton.static.cancelButtonMouseDownEvents = true;
+
+       /* Methods */
+
+       FilterItemHighlightButton.prototype.onAction = function () {
+               this.popup.setAssociatedButton( this );
+               this.popup.setFilterItem( this.model );
+
+               // Parent method
+               FilterItemHighlightButton.parent.prototype.onAction.call( this );
+       };
+
+       /**
+        * Respond to item model update event
+        */
+       FilterItemHighlightButton.prototype.updateUiBasedOnModel = function () {
+               var currentColor = this.model.getHighlightColor(),
+                       widget = this;
+
+               this.$icon.toggleClass(
+                       'mw-rcfilters-ui-filterItemHighlightButton-circle',
+                       currentColor !== null
+               );
+
+               mw.rcfilters.HighlightColors.forEach( function ( c ) {
+                       widget.$icon
+                               .toggleClass(
+                                       'mw-rcfilters-ui-filterItemHighlightButton-circle-color-' + c,
+                                       c === currentColor
+                               );
+               } );
+       };
+
+       module.exports = FilterItemHighlightButton;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/FilterMenuHeaderWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterMenuHeaderWidget.js
new file mode 100644 (file)
index 0000000..1396341
--- /dev/null
@@ -0,0 +1,184 @@
+( function () {
+       /**
+        * Menu header for the RCFilters filters menu
+        *
+        * @class mw.rcfilters.ui.FilterMenuHeaderWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller Controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+        * @param {Object} config Configuration object
+        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+        */
+       var FilterMenuHeaderWidget = function MwRcfiltersUiFilterMenuHeaderWidget( controller, model, config ) {
+               config = config || {};
+
+               this.controller = controller;
+               this.model = model;
+               this.$overlay = config.$overlay || this.$element;
+
+               // Parent
+               FilterMenuHeaderWidget.parent.call( this, config );
+               OO.ui.mixin.LabelElement.call( this, $.extend( {
+                       label: mw.msg( 'rcfilters-filterlist-title' ),
+                       $label: $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-title' )
+               }, config ) );
+
+               // "Back" to default view button
+               this.backButton = new OO.ui.ButtonWidget( {
+                       icon: 'previous',
+                       framed: false,
+                       title: mw.msg( 'rcfilters-view-return-to-default-tooltip' ),
+                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-backButton' ]
+               } );
+               this.backButton.toggle( this.model.getCurrentView() !== 'default' );
+
+               // Help icon for Tagged edits
+               this.helpIcon = new OO.ui.ButtonWidget( {
+                       icon: 'helpNotice',
+                       framed: false,
+                       title: mw.msg( 'rcfilters-view-tags-help-icon-tooltip' ),
+                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-helpIcon' ],
+                       href: mw.util.getUrl( 'Special:Tags' ),
+                       target: '_blank'
+               } );
+               this.helpIcon.toggle( this.model.getCurrentView() === 'tags' );
+
+               // Highlight button
+               this.highlightButton = new OO.ui.ToggleButtonWidget( {
+                       icon: 'highlight',
+                       label: mw.message( 'rcfilters-highlightbutton-title' ).text(),
+                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-hightlightButton' ]
+               } );
+
+               // Invert namespaces button
+               this.invertNamespacesButton = new OO.ui.ToggleButtonWidget( {
+                       icon: '',
+                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-invertNamespacesButton' ]
+               } );
+               this.invertNamespacesButton.toggle( this.model.getCurrentView() === 'namespaces' );
+
+               // Events
+               this.backButton.connect( this, { click: 'onBackButtonClick' } );
+               this.highlightButton
+                       .connect( this, { click: 'onHighlightButtonClick' } );
+               this.invertNamespacesButton
+                       .connect( this, { click: 'onInvertNamespacesButtonClick' } );
+               this.model.connect( this, {
+                       highlightChange: 'onModelHighlightChange',
+                       searchChange: 'onModelSearchChange',
+                       initialize: 'onModelInitialize'
+               } );
+               this.view = this.model.getCurrentView();
+
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget' )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-table' )
+                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header' )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-row' )
+                                                       .append(
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-back' )
+                                                                       .append( this.backButton.$element ),
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-title' )
+                                                                       .append( this.$label, this.helpIcon.$element ),
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-invert' )
+                                                                       .append( this.invertNamespacesButton.$element ),
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-highlight' )
+                                                                       .append( this.highlightButton.$element )
+                                                       )
+                                       )
+                       );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( FilterMenuHeaderWidget, OO.ui.Widget );
+       OO.mixinClass( FilterMenuHeaderWidget, OO.ui.mixin.LabelElement );
+
+       /* Methods */
+
+       /**
+        * Respond to model initialization event
+        *
+        * Note: need to wait for initialization before getting the invertModel
+        * and registering its update event. Creating all the models before the UI
+        * would help with that.
+        */
+       FilterMenuHeaderWidget.prototype.onModelInitialize = function () {
+               this.invertModel = this.model.getInvertModel();
+               this.updateInvertButton();
+               this.invertModel.connect( this, { update: 'updateInvertButton' } );
+       };
+
+       /**
+        * Respond to model update event
+        */
+       FilterMenuHeaderWidget.prototype.onModelSearchChange = function () {
+               var currentView = this.model.getCurrentView();
+
+               if ( this.view !== currentView ) {
+                       this.setLabel( this.model.getViewTitle( currentView ) );
+
+                       this.invertNamespacesButton.toggle( currentView === 'namespaces' );
+                       this.backButton.toggle( currentView !== 'default' );
+                       this.helpIcon.toggle( currentView === 'tags' );
+                       this.view = currentView;
+               }
+       };
+
+       /**
+        * Respond to model highlight change event
+        *
+        * @param {boolean} highlightEnabled Highlight is enabled
+        */
+       FilterMenuHeaderWidget.prototype.onModelHighlightChange = function ( highlightEnabled ) {
+               this.highlightButton.setActive( highlightEnabled );
+       };
+
+       /**
+        * Update the state of the invert button
+        */
+       FilterMenuHeaderWidget.prototype.updateInvertButton = function () {
+               this.invertNamespacesButton.setActive( this.invertModel.isSelected() );
+               this.invertNamespacesButton.setLabel(
+                       this.invertModel.isSelected() ?
+                               mw.msg( 'rcfilters-exclude-button-on' ) :
+                               mw.msg( 'rcfilters-exclude-button-off' )
+               );
+       };
+
+       FilterMenuHeaderWidget.prototype.onBackButtonClick = function () {
+               this.controller.switchView( 'default' );
+       };
+
+       /**
+        * Respond to highlight button click
+        */
+       FilterMenuHeaderWidget.prototype.onHighlightButtonClick = function () {
+               this.controller.toggleHighlight();
+       };
+
+       /**
+        * Respond to highlight button click
+        */
+       FilterMenuHeaderWidget.prototype.onInvertNamespacesButtonClick = function () {
+               this.controller.toggleInvertedNamespaces();
+       };
+
+       module.exports = FilterMenuHeaderWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/FilterMenuOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterMenuOptionWidget.js
new file mode 100644 (file)
index 0000000..4080f4d
--- /dev/null
@@ -0,0 +1,96 @@
+( function () {
+       var ItemMenuOptionWidget = require( './ItemMenuOptionWidget.js' ),
+               FilterMenuOptionWidget;
+
+       /**
+        * A widget representing a single toggle filter
+        *
+        * @class mw.rcfilters.ui.FilterMenuOptionWidget
+        * @extends mw.rcfilters.ui.ItemMenuOptionWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller RCFilters controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+        * @param {mw.rcfilters.dm.FilterItem} invertModel
+        * @param {mw.rcfilters.dm.FilterItem} itemModel Filter item model
+        * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker popup
+        * @param {Object} config Configuration object
+        */
+       FilterMenuOptionWidget = function MwRcfiltersUiFilterMenuOptionWidget(
+               controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
+       ) {
+               config = config || {};
+
+               this.controller = controller;
+               this.invertModel = invertModel;
+               this.model = itemModel;
+
+               // Parent
+               FilterMenuOptionWidget.parent.call( this, controller, filtersViewModel, this.invertModel, itemModel, highlightPopup, config );
+
+               // Event
+               this.model.getGroupModel().connect( this, { update: 'onGroupModelUpdate' } );
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-filterMenuOptionWidget' );
+       };
+
+       /* Initialization */
+       OO.inheritClass( FilterMenuOptionWidget, ItemMenuOptionWidget );
+
+       /* Static properties */
+
+       // We do our own scrolling to top
+       FilterMenuOptionWidget.static.scrollIntoViewOnSelect = false;
+
+       /* Methods */
+
+       /**
+        * @inheritdoc
+        */
+       FilterMenuOptionWidget.prototype.updateUiBasedOnState = function () {
+               // Parent
+               FilterMenuOptionWidget.parent.prototype.updateUiBasedOnState.call( this );
+
+               this.setCurrentMuteState();
+       };
+
+       /**
+        * Respond to item group model update event
+        */
+       FilterMenuOptionWidget.prototype.onGroupModelUpdate = function () {
+               this.setCurrentMuteState();
+       };
+
+       /**
+        * Set the current muted view of the widget based on its state
+        */
+       FilterMenuOptionWidget.prototype.setCurrentMuteState = function () {
+               if (
+                       this.model.getGroupModel().getView() === 'namespaces' &&
+                       this.invertModel.isSelected()
+               ) {
+                       // This is an inverted behavior than the other rules, specifically
+                       // for inverted namespaces
+                       this.setFlags( {
+                               muted: this.model.isSelected()
+                       } );
+               } else {
+                       this.setFlags( {
+                               muted: (
+                                       this.model.isConflicted() ||
+                                       (
+                                               // Item is also muted when any of the items in its group is active
+                                               this.model.getGroupModel().isActive() &&
+                                               // But it isn't selected
+                                               !this.model.isSelected() &&
+                                               // And also not included
+                                               !this.model.isIncluded()
+                                       )
+                               )
+                       } );
+               }
+       };
+
+       module.exports = FilterMenuOptionWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/FilterMenuSectionOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterMenuSectionOptionWidget.js
new file mode 100644 (file)
index 0000000..5b9e359
--- /dev/null
@@ -0,0 +1,127 @@
+( function () {
+       /**
+        * A widget representing a menu section for filter groups
+        *
+        * @class mw.rcfilters.ui.FilterMenuSectionOptionWidget
+        * @extends OO.ui.MenuSectionOptionWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller RCFilters controller
+        * @param {mw.rcfilters.dm.FilterGroup} model Filter group model
+        * @param {Object} config Configuration object
+        * @cfg {jQuery} [$overlay] Overlay
+        */
+       var FilterMenuSectionOptionWidget = function MwRcfiltersUiFilterMenuSectionOptionWidget( controller, model, config ) {
+               var whatsThisMessages,
+                       $header = $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header' ),
+                       $popupContent = $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content' );
+
+               config = config || {};
+
+               this.controller = controller;
+               this.model = model;
+               this.$overlay = config.$overlay || this.$element;
+
+               // Parent
+               FilterMenuSectionOptionWidget.parent.call( this, $.extend( {
+                       label: this.model.getTitle(),
+                       $label: $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header-title' )
+               }, config ) );
+
+               $header.append( this.$label );
+
+               if ( this.model.hasWhatsThis() ) {
+                       whatsThisMessages = this.model.getWhatsThis();
+
+                       // Create popup
+                       if ( whatsThisMessages.header ) {
+                               $popupContent.append(
+                                       ( new OO.ui.LabelWidget( {
+                                               label: mw.msg( whatsThisMessages.header ),
+                                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-header' ]
+                                       } ) ).$element
+                               );
+                       }
+                       if ( whatsThisMessages.body ) {
+                               $popupContent.append(
+                                       ( new OO.ui.LabelWidget( {
+                                               label: mw.msg( whatsThisMessages.body ),
+                                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-body' ]
+                                       } ) ).$element
+                               );
+                       }
+                       if ( whatsThisMessages.linkText && whatsThisMessages.url ) {
+                               $popupContent.append(
+                                       ( new OO.ui.ButtonWidget( {
+                                               framed: false,
+                                               flags: [ 'progressive' ],
+                                               href: whatsThisMessages.url,
+                                               label: mw.msg( whatsThisMessages.linkText ),
+                                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-link' ]
+                                       } ) ).$element
+                               );
+                       }
+
+                       // Add button
+                       this.whatsThisButton = new OO.ui.PopupButtonWidget( {
+                               framed: false,
+                               label: mw.msg( 'rcfilters-filterlist-whatsthis' ),
+                               $overlay: this.$overlay,
+                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton' ],
+                               flags: [ 'progressive' ],
+                               popup: {
+                                       padded: false,
+                                       align: 'center',
+                                       position: 'above',
+                                       $content: $popupContent,
+                                       classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup' ]
+                               }
+                       } );
+
+                       $header
+                               .append( this.whatsThisButton.$element );
+               }
+
+               // Events
+               this.model.connect( this, { update: 'updateUiBasedOnState' } );
+
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget' )
+                       .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-name-' + this.model.getName() )
+                       .append( $header );
+               this.updateUiBasedOnState();
+       };
+
+       /* Initialize */
+
+       OO.inheritClass( FilterMenuSectionOptionWidget, OO.ui.MenuSectionOptionWidget );
+
+       /* Methods */
+
+       /**
+        * Respond to model update event
+        */
+       FilterMenuSectionOptionWidget.prototype.updateUiBasedOnState = function () {
+               this.$element.toggleClass(
+                       'mw-rcfilters-ui-filterMenuSectionOptionWidget-active',
+                       this.model.isActive()
+               );
+               this.toggle( this.model.isVisible() );
+       };
+
+       /**
+        * Get the group name
+        *
+        * @return {string} Group name
+        */
+       FilterMenuSectionOptionWidget.prototype.getName = function () {
+               return this.model.getName();
+       };
+
+       module.exports = FilterMenuSectionOptionWidget;
+
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/FilterTagItemWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterTagItemWidget.js
new file mode 100644 (file)
index 0000000..bda898b
--- /dev/null
@@ -0,0 +1,50 @@
+( function () {
+       var TagItemWidget = require( './TagItemWidget.js' ),
+               FilterTagItemWidget;
+
+       /**
+        * Extend OOUI's FilterTagItemWidget to also display a popup on hover.
+        *
+        * @class mw.rcfilters.ui.FilterTagItemWidget
+        * @extends mw.rcfilters.ui.TagItemWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+        * @param {mw.rcfilters.dm.FilterItem} invertModel
+        * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
+        * @param {Object} config Configuration object
+        */
+       FilterTagItemWidget = function MwRcfiltersUiFilterTagItemWidget(
+               controller, filtersViewModel, invertModel, itemModel, config
+       ) {
+               config = config || {};
+
+               FilterTagItemWidget.parent.call( this, controller, filtersViewModel, invertModel, itemModel, config );
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-filterTagItemWidget' );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( FilterTagItemWidget, TagItemWidget );
+
+       /* Methods */
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagItemWidget.prototype.setCurrentMuteState = function () {
+               this.setFlags( {
+                       muted: (
+                               !this.itemModel.isSelected() ||
+                               this.itemModel.isIncluded() ||
+                               this.itemModel.isFullyCovered()
+                       ),
+                       invalid: this.itemModel.isSelected() && this.itemModel.isConflicted()
+               } );
+       };
+
+       module.exports = FilterTagItemWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js
new file mode 100644 (file)
index 0000000..4881542
--- /dev/null
@@ -0,0 +1,778 @@
+( function () {
+       var ViewSwitchWidget = require( './ViewSwitchWidget.js' ),
+               SaveFiltersPopupButtonWidget = require( './SaveFiltersPopupButtonWidget.js' ),
+               MenuSelectWidget = require( './MenuSelectWidget.js' ),
+               FilterTagItemWidget = require( './FilterTagItemWidget.js' ),
+               FilterTagMultiselectWidget;
+
+       /**
+        * List displaying all filter groups
+        *
+        * @class mw.rcfilters.ui.FilterTagMultiselectWidget
+        * @extends OO.ui.MenuTagMultiselectWidget
+        * @mixins OO.ui.mixin.PendingElement
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller Controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+        * @param {Object} config Configuration object
+        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+        * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
+        *  system. If not given, falls back to this widget's $element
+        * @cfg {boolean} [collapsed] Filter area is collapsed
+        */
+       FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( controller, model, savedQueriesModel, config ) {
+               var rcFiltersRow,
+                       title = new OO.ui.LabelWidget( {
+                               label: mw.msg( 'rcfilters-activefilters' ),
+                               classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-title' ]
+                       } ),
+                       $contentWrapper = $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper' );
+
+               config = config || {};
+
+               this.controller = controller;
+               this.model = model;
+               this.queriesModel = savedQueriesModel;
+               this.$overlay = config.$overlay || this.$element;
+               this.$wrapper = config.$wrapper || this.$element;
+               this.matchingQuery = null;
+               this.currentView = this.model.getCurrentView();
+               this.collapsed = false;
+
+               // Parent
+               FilterTagMultiselectWidget.parent.call( this, $.extend( true, {
+                       label: mw.msg( 'rcfilters-filterlist-title' ),
+                       placeholder: mw.msg( 'rcfilters-empty-filter' ),
+                       inputPosition: 'outline',
+                       allowArbitrary: false,
+                       allowDisplayInvalidTags: false,
+                       allowReordering: false,
+                       $overlay: this.$overlay,
+                       menu: {
+                               // Our filtering is done through the model
+                               filterFromInput: false,
+                               hideWhenOutOfView: false,
+                               hideOnChoose: false,
+                               width: 650,
+                               footers: [
+                                       {
+                                               name: 'viewSelect',
+                                               sticky: false,
+                                               // View select menu, appears on default view only
+                                               $element: $( '<div>' )
+                                                       .append( new ViewSwitchWidget( this.controller, this.model ).$element ),
+                                               views: [ 'default' ]
+                                       },
+                                       {
+                                               name: 'feedback',
+                                               // Feedback footer, appears on all views
+                                               $element: $( '<div>' )
+                                                       .append(
+                                                               new OO.ui.ButtonWidget( {
+                                                                       framed: false,
+                                                                       icon: 'feedback',
+                                                                       flags: [ 'progressive' ],
+                                                                       label: mw.msg( 'rcfilters-filterlist-feedbacklink' ),
+                                                                       href: 'https://www.mediawiki.org/wiki/Help_talk:New_filters_for_edit_review'
+                                                               } ).$element
+                                                       )
+                                       }
+                               ]
+                       },
+                       input: {
+                               icon: 'menu',
+                               placeholder: mw.msg( 'rcfilters-search-placeholder' )
+                       }
+               }, config ) );
+
+               this.savedQueryTitle = new OO.ui.LabelWidget( {
+                       label: '',
+                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-savedQueryTitle' ]
+               } );
+
+               this.resetButton = new OO.ui.ButtonWidget( {
+                       framed: false,
+                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-resetButton' ]
+               } );
+
+               this.hideShowButton = new OO.ui.ButtonWidget( {
+                       framed: false,
+                       flags: [ 'progressive' ],
+                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-hideshowButton' ]
+               } );
+               this.toggleCollapsed( !!config.collapsed );
+
+               if ( !mw.user.isAnon() ) {
+                       this.saveQueryButton = new SaveFiltersPopupButtonWidget(
+                               this.controller,
+                               this.queriesModel,
+                               {
+                                       $overlay: this.$overlay
+                               }
+                       );
+
+                       this.saveQueryButton.$element.on( 'mousedown', function ( e ) {
+                               e.stopPropagation();
+                       } );
+
+                       this.saveQueryButton.connect( this, {
+                               click: 'onSaveQueryButtonClick',
+                               saveCurrent: 'setSavedQueryVisibility'
+                       } );
+                       this.queriesModel.connect( this, {
+                               itemUpdate: 'onSavedQueriesItemUpdate',
+                               initialize: 'onSavedQueriesInitialize',
+                               default: 'reevaluateResetRestoreState'
+                       } );
+               }
+
+               this.emptyFilterMessage = new OO.ui.LabelWidget( {
+                       label: mw.msg( 'rcfilters-empty-filter' ),
+                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-emptyFilters' ]
+               } );
+               this.$content.append( this.emptyFilterMessage.$element );
+
+               // Events
+               this.resetButton.connect( this, { click: 'onResetButtonClick' } );
+               this.hideShowButton.connect( this, { click: 'onHideShowButtonClick' } );
+               // Stop propagation for mousedown, so that the widget doesn't
+               // trigger the focus on the input and scrolls up when we click the reset button
+               this.resetButton.$element.on( 'mousedown', function ( e ) {
+                       e.stopPropagation();
+               } );
+               this.hideShowButton.$element.on( 'mousedown', function ( e ) {
+                       e.stopPropagation();
+               } );
+               this.model.connect( this, {
+                       initialize: 'onModelInitialize',
+                       update: 'onModelUpdate',
+                       searchChange: 'onModelSearchChange',
+                       itemUpdate: 'onModelItemUpdate',
+                       highlightChange: 'onModelHighlightChange'
+               } );
+               this.input.connect( this, { change: 'onInputChange' } );
+
+               // The filter list and button should appear side by side regardless of how
+               // wide the button is; the button also changes its width depending
+               // on language and its state, so the safest way to present both side
+               // by side is with a table layout
+               rcFiltersRow = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-row' )
+                       .append(
+                               this.$content
+                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-filters' )
+                       );
+
+               if ( !mw.user.isAnon() ) {
+                       rcFiltersRow.append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-save' )
+                                       .append( this.saveQueryButton.$element )
+                       );
+               }
+
+               // Add a selector at the right of the input
+               this.viewsSelectWidget = new OO.ui.ButtonSelectWidget( {
+                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select-widget' ],
+                       items: [
+                               new OO.ui.ButtonOptionWidget( {
+                                       framed: false,
+                                       data: 'namespaces',
+                                       icon: 'article',
+                                       label: mw.msg( 'namespaces' ),
+                                       title: mw.msg( 'rcfilters-view-namespaces-tooltip' )
+                               } ),
+                               new OO.ui.ButtonOptionWidget( {
+                                       framed: false,
+                                       data: 'tags',
+                                       icon: 'tag',
+                                       label: mw.msg( 'tags-title' ),
+                                       title: mw.msg( 'rcfilters-view-tags-tooltip' )
+                               } )
+                       ]
+               } );
+
+               // Rearrange the UI so the select widget is at the right of the input
+               this.$element.append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-table' )
+                               .append(
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-row' )
+                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' )
+                                               .append(
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' )
+                                                               .append( this.input.$element ),
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select' )
+                                                               .append( this.viewsSelectWidget.$element )
+                                               )
+                               )
+               );
+
+               // Event
+               this.viewsSelectWidget.connect( this, { choose: 'onViewsSelectWidgetChoose' } );
+
+               rcFiltersRow.append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-cell' )
+                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' )
+                               .append( this.resetButton.$element )
+               );
+
+               // Build the content
+               $contentWrapper.append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top' )
+                               .append(
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-title' )
+                                               .append( title.$element ),
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-queryName' )
+                                               .append( this.savedQueryTitle.$element ),
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-hideshow' )
+                                               .append(
+                                                       this.hideShowButton.$element
+                                               )
+                               ),
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-table' )
+                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-filters' )
+                               .append( rcFiltersRow )
+               );
+
+               // Initialize
+               this.$handle.append( $contentWrapper );
+               this.emptyFilterMessage.toggle( this.isEmpty() );
+               this.savedQueryTitle.toggle( false );
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget' );
+
+               this.reevaluateResetRestoreState();
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( FilterTagMultiselectWidget, OO.ui.MenuTagMultiselectWidget );
+
+       /* Methods */
+
+       /**
+        * Override parent method to avoid unnecessary resize events.
+        */
+       FilterTagMultiselectWidget.prototype.updateIfHeightChanged = function () { };
+
+       /**
+        * Respond to view select widget choose event
+        *
+        * @param {OO.ui.ButtonOptionWidget} buttonOptionWidget Chosen widget
+        */
+       FilterTagMultiselectWidget.prototype.onViewsSelectWidgetChoose = function ( buttonOptionWidget ) {
+               this.controller.switchView( buttonOptionWidget.getData() );
+               this.viewsSelectWidget.selectItem( null );
+               this.focus();
+       };
+
+       /**
+        * Respond to model search change event
+        *
+        * @param {string} value Search value
+        */
+       FilterTagMultiselectWidget.prototype.onModelSearchChange = function ( value ) {
+               this.input.setValue( value );
+       };
+
+       /**
+        * Respond to input change event
+        *
+        * @param {string} value Value of the input
+        */
+       FilterTagMultiselectWidget.prototype.onInputChange = function ( value ) {
+               this.controller.setSearch( value );
+       };
+
+       /**
+        * Respond to query button click
+        */
+       FilterTagMultiselectWidget.prototype.onSaveQueryButtonClick = function () {
+               this.getMenu().toggle( false );
+       };
+
+       /**
+        * Respond to save query model initialization
+        */
+       FilterTagMultiselectWidget.prototype.onSavedQueriesInitialize = function () {
+               this.setSavedQueryVisibility();
+       };
+
+       /**
+        * Respond to save query item change. Mainly this is done to update the label in case
+        * a query item has been edited
+        *
+        * @param {mw.rcfilters.dm.SavedQueryItemModel} item Saved query item
+        */
+       FilterTagMultiselectWidget.prototype.onSavedQueriesItemUpdate = function ( item ) {
+               if ( this.matchingQuery === item ) {
+                       // This means we just edited the item that is currently matched
+                       this.savedQueryTitle.setLabel( item.getLabel() );
+               }
+       };
+
+       /**
+        * Respond to menu toggle
+        *
+        * @param {boolean} isVisible Menu is visible
+        */
+       FilterTagMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {
+               // Parent
+               FilterTagMultiselectWidget.parent.prototype.onMenuToggle.call( this );
+
+               if ( isVisible ) {
+                       this.focus();
+
+                       mw.hook( 'RcFilters.popup.open' ).fire();
+
+                       if ( !this.getMenu().findSelectedItem() ) {
+                               // If there are no selected items, scroll menu to top
+                               // This has to be in a setTimeout so the menu has time
+                               // to be positioned and fixed
+                               setTimeout(
+                                       function () {
+                                               this.getMenu().scrollToTop();
+                                       }.bind( this )
+                               );
+                       }
+               } else {
+                       // Clear selection
+                       this.selectTag( null );
+
+                       // Clear the search
+                       this.controller.setSearch( '' );
+
+                       // Log filter grouping
+                       this.controller.trackFilterGroupings( 'filtermenu' );
+
+                       this.blur();
+               }
+
+               this.input.setIcon( isVisible ? 'search' : 'menu' );
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.onInputFocus = function () {
+               // Parent
+               FilterTagMultiselectWidget.parent.prototype.onInputFocus.call( this );
+
+               // Only scroll to top of the viewport if:
+               // - The widget is more than 20px from the top
+               // - The widget is not above the top of the viewport (do not scroll downwards)
+               //   (This isn't represented because >20 is, anyways and always, bigger than 0)
+               this.scrollToTop( this.$element, 0, { min: 20, max: Infinity } );
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.doInputEscape = function () {
+               // Parent
+               FilterTagMultiselectWidget.parent.prototype.doInputEscape.call( this );
+
+               // Blur the input
+               this.input.$input.trigger( 'blur' );
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.onMouseDown = function ( e ) {
+               if ( !this.collapsed && !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
+                       this.menu.toggle();
+
+                       return false;
+               }
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.onChangeTags = function () {
+               // If initialized, call parent method.
+               if ( this.controller.isInitialized() ) {
+                       FilterTagMultiselectWidget.parent.prototype.onChangeTags.call( this );
+               }
+
+               this.emptyFilterMessage.toggle( this.isEmpty() );
+       };
+
+       /**
+        * Respond to model initialize event
+        */
+       FilterTagMultiselectWidget.prototype.onModelInitialize = function () {
+               this.setSavedQueryVisibility();
+       };
+
+       /**
+        * Respond to model update event
+        */
+       FilterTagMultiselectWidget.prototype.onModelUpdate = function () {
+               this.updateElementsForView();
+       };
+
+       /**
+        * Update the elements in the widget to the current view
+        */
+       FilterTagMultiselectWidget.prototype.updateElementsForView = function () {
+               var view = this.model.getCurrentView(),
+                       inputValue = this.input.getValue().trim(),
+                       inputView = this.model.getViewByTrigger( inputValue.substr( 0, 1 ) );
+
+               if ( inputView !== 'default' ) {
+                       // We have a prefix already, remove it
+                       inputValue = inputValue.substr( 1 );
+               }
+
+               if ( inputView !== view ) {
+                       // Add the correct prefix
+                       inputValue = this.model.getViewTrigger( view ) + inputValue;
+               }
+
+               // Update input
+               this.input.setValue( inputValue );
+
+               if ( this.currentView !== view ) {
+                       this.scrollToTop( this.$element );
+                       this.currentView = view;
+               }
+       };
+
+       /**
+        * Set the visibility of the saved query button
+        */
+       FilterTagMultiselectWidget.prototype.setSavedQueryVisibility = function () {
+               if ( mw.user.isAnon() ) {
+                       return;
+               }
+
+               this.matchingQuery = this.controller.findQueryMatchingCurrentState();
+
+               this.savedQueryTitle.setLabel(
+                       this.matchingQuery ? this.matchingQuery.getLabel() : ''
+               );
+               this.savedQueryTitle.toggle( !!this.matchingQuery );
+               this.saveQueryButton.setDisabled( !!this.matchingQuery );
+               this.saveQueryButton.setTitle( !this.matchingQuery ?
+                       mw.msg( 'rcfilters-savedqueries-add-new-title' ) :
+                       mw.msg( 'rcfilters-savedqueries-already-saved' ) );
+
+               if ( this.matchingQuery ) {
+                       this.emphasize();
+               }
+       };
+
+       /**
+        * Respond to model itemUpdate event
+        * fixme: when a new state is applied to the model this function is called 60+ times in a row
+        *
+        * @param {mw.rcfilters.dm.FilterItem} item Filter item model
+        */
+       FilterTagMultiselectWidget.prototype.onModelItemUpdate = function ( item ) {
+               if ( !item.getGroupModel().isHidden() ) {
+                       if (
+                               item.isSelected() ||
+                               (
+                                       this.model.isHighlightEnabled() &&
+                                       item.getHighlightColor()
+                               )
+                       ) {
+                               this.addTag( item.getName(), item.getLabel() );
+                       } else {
+                               // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
+                               if ( this.findItemFromData( item.getName() ) !== null ) {
+                                       this.removeTagByData( item.getName() );
+                               }
+                       }
+               }
+
+               this.setSavedQueryVisibility();
+
+               // Re-evaluate reset state
+               this.reevaluateResetRestoreState();
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.isAllowedData = function ( data ) {
+               return (
+                       this.model.getItemByName( data ) &&
+                       !this.isDuplicateData( data )
+               );
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.onMenuChoose = function ( item ) {
+               this.controller.toggleFilterSelect( item.model.getName() );
+
+               // Select the tag if it exists, or reset selection otherwise
+               this.selectTag( this.findItemFromData( item.model.getName() ) );
+
+               this.focus();
+       };
+
+       /**
+        * Respond to highlightChange event
+        *
+        * @param {boolean} isHighlightEnabled Highlight is enabled
+        */
+       FilterTagMultiselectWidget.prototype.onModelHighlightChange = function ( isHighlightEnabled ) {
+               var highlightedItems = this.model.getHighlightedItems();
+
+               if ( isHighlightEnabled ) {
+                       // Add capsule widgets
+                       highlightedItems.forEach( function ( filterItem ) {
+                               this.addTag( filterItem.getName(), filterItem.getLabel() );
+                       }.bind( this ) );
+               } else {
+                       // Remove capsule widgets if they're not selected
+                       highlightedItems.forEach( function ( filterItem ) {
+                               if ( !filterItem.isSelected() ) {
+                                       // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
+                                       if ( this.findItemFromData( filterItem.getName() ) !== null ) {
+                                               this.removeTagByData( filterItem.getName() );
+                                       }
+                               }
+                       }.bind( this ) );
+               }
+
+               this.setSavedQueryVisibility();
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.onTagSelect = function ( tagItem ) {
+               var menuOption = this.menu.getItemFromModel( tagItem.getModel() );
+
+               this.menu.setUserSelecting( true );
+               // Parent method
+               FilterTagMultiselectWidget.parent.prototype.onTagSelect.call( this, tagItem );
+
+               // Switch view
+               this.controller.resetSearchForView( tagItem.getView() );
+
+               this.selectTag( tagItem );
+               this.scrollToTop( menuOption.$element );
+
+               this.menu.setUserSelecting( false );
+       };
+
+       /**
+        * Select a tag by reference. This is what OO.ui.SelectWidget is doing.
+        * If no items are given, reset selection from all.
+        *
+        * @param {mw.rcfilters.ui.FilterTagItemWidget} [item] Tag to select,
+        *  omit to deselect all
+        */
+       FilterTagMultiselectWidget.prototype.selectTag = function ( item ) {
+               var i, len, selected;
+
+               for ( i = 0, len = this.items.length; i < len; i++ ) {
+                       selected = this.items[ i ] === item;
+                       if ( this.items[ i ].isSelected() !== selected ) {
+                               this.items[ i ].toggleSelected( selected );
+                       }
+               }
+       };
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.onTagRemove = function ( tagItem ) {
+               // Parent method
+               FilterTagMultiselectWidget.parent.prototype.onTagRemove.call( this, tagItem );
+
+               this.controller.clearFilter( tagItem.getName() );
+
+               tagItem.destroy();
+       };
+
+       /**
+        * Respond to click event on the reset button
+        */
+       FilterTagMultiselectWidget.prototype.onResetButtonClick = function () {
+               if ( this.model.areVisibleFiltersEmpty() ) {
+                       // Reset to default filters
+                       this.controller.resetToDefaults();
+               } else {
+                       // Reset to have no filters
+                       this.controller.emptyFilters();
+               }
+       };
+
+       /**
+        * Respond to hide/show button click
+        */
+       FilterTagMultiselectWidget.prototype.onHideShowButtonClick = function () {
+               this.toggleCollapsed();
+       };
+
+       /**
+        * Toggle the collapsed state of the filters widget
+        *
+        * @param {boolean} isCollapsed Widget is collapsed
+        */
+       FilterTagMultiselectWidget.prototype.toggleCollapsed = function ( isCollapsed ) {
+               isCollapsed = isCollapsed === undefined ? !this.collapsed : !!isCollapsed;
+
+               this.collapsed = isCollapsed;
+
+               if ( isCollapsed ) {
+                       // If we are collapsing, close the menu, in case it was open
+                       // We should make sure the menu closes before the rest of the elements
+                       // are hidden, otherwise there is an unknown error in jQuery as ooui
+                       // sets and unsets properties on the input (which is hidden at that point)
+                       this.menu.toggle( false );
+               }
+               this.input.setDisabled( isCollapsed );
+               this.hideShowButton.setLabel( mw.msg(
+                       isCollapsed ? 'rcfilters-activefilters-show' : 'rcfilters-activefilters-hide'
+               ) );
+               this.hideShowButton.setTitle( mw.msg(
+                       isCollapsed ? 'rcfilters-activefilters-show-tooltip' : 'rcfilters-activefilters-hide-tooltip'
+               ) );
+
+               // Toggle the wrapper class, so we have min height values correctly throughout
+               this.$wrapper.toggleClass( 'mw-rcfilters-collapsed', isCollapsed );
+
+               // Save the state
+               this.controller.updateCollapsedState( isCollapsed );
+       };
+
+       /**
+        * Reevaluate the restore state for the widget between setting to defaults and clearing all filters
+        */
+       FilterTagMultiselectWidget.prototype.reevaluateResetRestoreState = function () {
+               var defaultsAreEmpty = this.controller.areDefaultsEmpty(),
+                       currFiltersAreEmpty = this.model.areVisibleFiltersEmpty(),
+                       hideResetButton = currFiltersAreEmpty && defaultsAreEmpty;
+
+               this.resetButton.setIcon(
+                       currFiltersAreEmpty ? 'history' : 'trash'
+               );
+
+               this.resetButton.setLabel(
+                       currFiltersAreEmpty ? mw.msg( 'rcfilters-restore-default-filters' ) : ''
+               );
+               this.resetButton.setTitle(
+                       currFiltersAreEmpty ? null : mw.msg( 'rcfilters-clear-all-filters' )
+               );
+
+               this.resetButton.toggle( !hideResetButton );
+               this.emptyFilterMessage.toggle( currFiltersAreEmpty );
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.createMenuWidget = function ( menuConfig ) {
+               return new MenuSelectWidget(
+                       this.controller,
+                       this.model,
+                       menuConfig
+               );
+       };
+
+       /**
+        * @inheritdoc
+        */
+       FilterTagMultiselectWidget.prototype.createTagItemWidget = function ( data ) {
+               var filterItem = this.model.getItemByName( data );
+
+               if ( filterItem ) {
+                       return new FilterTagItemWidget(
+                               this.controller,
+                               this.model,
+                               this.model.getInvertModel(),
+                               filterItem,
+                               {
+                                       $overlay: this.$overlay
+                               }
+                       );
+               }
+       };
+
+       FilterTagMultiselectWidget.prototype.emphasize = function () {
+               if (
+                       !this.$handle.hasClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' )
+               ) {
+                       this.$handle
+                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' )
+                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
+
+                       setTimeout( function () {
+                               this.$handle
+                                       .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' );
+
+                               setTimeout( function () {
+                                       this.$handle
+                                               .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
+                               }.bind( this ), 1000 );
+                       }.bind( this ), 500 );
+
+               }
+       };
+       /**
+        * Scroll the element to top within its container
+        *
+        * @private
+        * @param {jQuery} $element Element to position
+        * @param {number} [marginFromTop=0] When scrolling the entire widget to the top, leave this
+        *  much space (in pixels) above the widget.
+        * @param {Object} [threshold] Minimum distance from the top of the element to scroll at all
+        * @param {number} [threshold.min] Minimum distance above the element
+        * @param {number} [threshold.max] Minimum distance below the element
+        */
+       FilterTagMultiselectWidget.prototype.scrollToTop = function ( $element, marginFromTop, threshold ) {
+               var container = OO.ui.Element.static.getClosestScrollableContainer( $element[ 0 ], 'y' ),
+                       pos = OO.ui.Element.static.getRelativePosition( $element, $( container ) ),
+                       containerScrollTop = $( container ).scrollTop(),
+                       effectiveScrollTop = $( container ).is( 'body, html' ) ? 0 : containerScrollTop,
+                       newScrollTop = effectiveScrollTop + pos.top - ( marginFromTop || 0 );
+
+               // Scroll to item
+               if (
+                       threshold === undefined ||
+                       (
+                               (
+                                       threshold.min === undefined ||
+                                       newScrollTop - containerScrollTop >= threshold.min
+                               ) &&
+                               (
+                                       threshold.max === undefined ||
+                                       newScrollTop - containerScrollTop <= threshold.max
+                               )
+                       )
+               ) {
+                       // eslint-disable-next-line jquery/no-animate
+                       $( container ).animate( {
+                               scrollTop: newScrollTop
+                       } );
+               }
+       };
+
+       module.exports = FilterTagMultiselectWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js
new file mode 100644 (file)
index 0000000..cb297f6
--- /dev/null
@@ -0,0 +1,139 @@
+( function () {
+       var FilterTagMultiselectWidget = require( './FilterTagMultiselectWidget.js' ),
+               LiveUpdateButtonWidget = require( './LiveUpdateButtonWidget.js' ),
+               ChangesLimitAndDateButtonWidget = require( './ChangesLimitAndDateButtonWidget.js' ),
+               FilterWrapperWidget;
+
+       /**
+        * List displaying all filter groups
+        *
+        * @class mw.rcfilters.ui.FilterWrapperWidget
+        * @extends OO.ui.Widget
+        * @mixins OO.ui.mixin.PendingElement
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller Controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+        * @param {Object} [config] Configuration object
+        * @cfg {Object} [filters] A definition of the filter groups in this list
+        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+        * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
+        *  system. If not given, falls back to this widget's $element
+        * @cfg {boolean} [collapsed] Filter area is collapsed
+        */
+       FilterWrapperWidget = function MwRcfiltersUiFilterWrapperWidget(
+               controller, model, savedQueriesModel, changesListModel, config
+       ) {
+               var $bottom;
+               config = config || {};
+
+               // Parent
+               FilterWrapperWidget.parent.call( this, config );
+               // Mixin constructors
+               OO.ui.mixin.PendingElement.call( this, config );
+
+               this.controller = controller;
+               this.model = model;
+               this.queriesModel = savedQueriesModel;
+               this.changesListModel = changesListModel;
+               this.$overlay = config.$overlay || this.$element;
+               this.$wrapper = config.$wrapper || this.$element;
+
+               this.filterTagWidget = new FilterTagMultiselectWidget(
+                       this.controller,
+                       this.model,
+                       this.queriesModel,
+                       {
+                               $overlay: this.$overlay,
+                               collapsed: config.collapsed,
+                               $wrapper: this.$wrapper
+                       }
+               );
+
+               this.liveUpdateButton = new LiveUpdateButtonWidget(
+                       this.controller,
+                       this.changesListModel
+               );
+
+               this.numChangesAndDateWidget = new ChangesLimitAndDateButtonWidget(
+                       this.controller,
+                       this.model,
+                       {
+                               $overlay: this.$overlay
+                       }
+               );
+
+               this.showNewChangesLink = new OO.ui.ButtonWidget( {
+                       icon: 'reload',
+                       framed: false,
+                       label: mw.msg( 'rcfilters-show-new-changes' ),
+                       flags: [ 'progressive' ],
+                       classes: [ 'mw-rcfilters-ui-filterWrapperWidget-showNewChanges' ]
+               } );
+
+               // Events
+               this.filterTagWidget.menu.connect( this, { toggle: [ 'emit', 'menuToggle' ] } );
+               this.changesListModel.connect( this, { newChangesExist: 'onNewChangesExist' } );
+               this.showNewChangesLink.connect( this, { click: 'onShowNewChangesClick' } );
+               this.showNewChangesLink.toggle( false );
+
+               // Initialize
+               this.$top = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-filterWrapperWidget-top' );
+
+               $bottom = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-filterWrapperWidget-bottom' )
+                       .append(
+                               this.showNewChangesLink.$element,
+                               this.numChangesAndDateWidget.$element
+                       );
+
+               if ( this.controller.pollingRate ) {
+                       $bottom.prepend( this.liveUpdateButton.$element );
+               }
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-filterWrapperWidget' )
+                       .append(
+                               this.$top,
+                               this.filterTagWidget.$element,
+                               $bottom
+                       );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( FilterWrapperWidget, OO.ui.Widget );
+       OO.mixinClass( FilterWrapperWidget, OO.ui.mixin.PendingElement );
+
+       /* Methods */
+
+       /**
+        * Set the content of the top section
+        *
+        * @param {jQuery} $topSectionElement
+        */
+       FilterWrapperWidget.prototype.setTopSection = function ( $topSectionElement ) {
+               this.$top.append( $topSectionElement );
+       };
+
+       /**
+        * Respond to the user clicking the 'show new changes' button
+        */
+       FilterWrapperWidget.prototype.onShowNewChangesClick = function () {
+               this.controller.showNewChanges();
+       };
+
+       /**
+        * Respond to changes list model newChangesExist
+        *
+        * @param {boolean} newChangesExist Whether new changes exist
+        */
+       FilterWrapperWidget.prototype.onNewChangesExist = function ( newChangesExist ) {
+               this.showNewChangesLink.toggle( newChangesExist );
+       };
+
+       module.exports = FilterWrapperWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/FormWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/FormWrapperWidget.js
new file mode 100644 (file)
index 0000000..dbf1776
--- /dev/null
@@ -0,0 +1,176 @@
+( function () {
+       /**
+        * Wrapper for the RC form with hide/show links
+        * Must be constructed after the model is initialized.
+        *
+        * @class mw.rcfilters.ui.FormWrapperWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Changes list view model
+        * @param {mw.rcfilters.dm.ChangesListViewModel} changeListModel Changes list view model
+        * @param {mw.rcfilters.Controller} controller RCfilters controller
+        * @param {jQuery} $formRoot Root element of the form to attach to
+        * @param {Object} config Configuration object
+        */
+       var FormWrapperWidget = function MwRcfiltersUiFormWrapperWidget( filtersModel, changeListModel, controller, $formRoot, config ) {
+               config = config || {};
+
+               // Parent
+               FormWrapperWidget.parent.call( this, $.extend( {}, config, {
+                       $element: $formRoot
+               } ) );
+
+               this.changeListModel = changeListModel;
+               this.filtersModel = filtersModel;
+               this.controller = controller;
+               this.$submitButton = this.$element.find( 'form input[type=submit]' );
+
+               this.$element
+                       .on( 'click', 'a[data-params]', this.onLinkClick.bind( this ) );
+
+               this.$element
+                       .on( 'submit', 'form', this.onFormSubmit.bind( this ) );
+
+               // Events
+               this.changeListModel.connect( this, {
+                       invalidate: 'onChangesModelInvalidate',
+                       update: 'onChangesModelUpdate'
+               } );
+
+               // Initialize
+               this.cleanUpFieldset();
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-FormWrapperWidget' );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( FormWrapperWidget, OO.ui.Widget );
+
+       /**
+        * Respond to link click
+        *
+        * @param {jQuery.Event} e Event
+        * @return {boolean} false
+        */
+       FormWrapperWidget.prototype.onLinkClick = function ( e ) {
+               this.controller.updateChangesList( $( e.target ).data( 'params' ) );
+               return false;
+       };
+
+       /**
+        * Respond to form submit event
+        *
+        * @param {jQuery.Event} e Event
+        * @return {boolean} false
+        */
+       FormWrapperWidget.prototype.onFormSubmit = function ( e ) {
+               var data = {};
+
+               // Collect all data from form
+               $( e.target ).find( 'input:not([type="hidden"],[type="submit"]), select' ).each( function () {
+                       var value = '';
+
+                       if ( !$( this ).is( ':checkbox' ) || $( this ).is( ':checked' ) ) {
+                               value = $( this ).val();
+                       }
+
+                       data[ $( this ).prop( 'name' ) ] = value;
+               } );
+
+               this.controller.updateChangesList( data );
+               return false;
+       };
+
+       /**
+        * Respond to model invalidate
+        */
+       FormWrapperWidget.prototype.onChangesModelInvalidate = function () {
+               this.$submitButton.prop( 'disabled', true );
+       };
+
+       /**
+        * Respond to model update, replace the show/hide links with the ones from the
+        * server so they feature the correct state.
+        *
+        * @param {jQuery|string} $changesList Updated changes list
+        * @param {jQuery} $fieldset Updated fieldset
+        * @param {string} noResultsDetails Type of no result error
+        * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
+        */
+       FormWrapperWidget.prototype.onChangesModelUpdate = function ( $changesList, $fieldset, noResultsDetails, isInitialDOM ) {
+               this.$submitButton.prop( 'disabled', false );
+
+               // Replace the entire fieldset
+               this.$element.empty().append( $fieldset.contents() );
+
+               if ( !isInitialDOM ) {
+                       // Make sure enhanced RC re-initializes correctly
+                       mw.hook( 'wikipage.content' ).fire( this.$element );
+               }
+
+               this.cleanUpFieldset();
+       };
+
+       /**
+        * Clean up the old-style show/hide that we have implemented in the filter list
+        */
+       FormWrapperWidget.prototype.cleanUpFieldset = function () {
+               this.$element.find( '.clshowhideoption[data-feature-in-structured-ui=1]' ).each( function () {
+                       // HACK: Remove the text node after the span.
+                       // If there isn't one, we're at the end, so remove the text node before the span.
+                       // This would be unnecessary if we added separators with CSS.
+                       if ( this.nextSibling && this.nextSibling.nodeType === Node.TEXT_NODE ) {
+                               this.parentNode.removeChild( this.nextSibling );
+                       } else if ( this.previousSibling && this.previousSibling.nodeType === Node.TEXT_NODE ) {
+                               this.parentNode.removeChild( this.previousSibling );
+                       }
+                       // Remove the span itself
+                       this.parentNode.removeChild( this );
+               } );
+
+               // Hide namespaces and tags
+               this.$element.find( '.namespaceForm' ).detach();
+               this.$element.find( '.mw-tagfilter-label' ).closest( 'tr' ).detach();
+
+               // Hide Related Changes page name form
+               this.$element.find( '.targetForm' ).detach();
+
+               // misc: limit, days, watchlist info msg
+               this.$element.find( '.rclinks, .cldays, .wlinfo' ).detach();
+
+               if ( !this.$element.find( '.mw-recentchanges-table tr' ).length ) {
+                       this.$element.find( '.mw-recentchanges-table' ).detach();
+                       this.$element.find( 'hr' ).detach();
+               }
+
+               // Get rid of all <br>s, which are inside rcshowhide
+               // If we still have content in rcshowhide, the <br>s are
+               // gone. Instead, the CSS now has a rule to mark all <span>s
+               // inside .rcshowhide with display:block; to simulate newlines
+               // where they're actually needed.
+               this.$element.find( 'br' ).detach();
+               if ( !this.$element.find( '.rcshowhide' ).contents().length ) {
+                       this.$element.find( '.rcshowhide' ).detach();
+               }
+
+               if ( this.$element.find( '.cloption' ).text().trim() === '' ) {
+                       this.$element.find( '.cloption-submit' ).detach();
+               }
+
+               this.$element.find(
+                       '.rclistfrom, .rcnotefrom, .rcoptions-listfromreset'
+               ).detach();
+
+               // Get rid of the legend
+               this.$element.find( 'legend' ).detach();
+
+               // Check if the element is essentially empty, and detach it if it is
+               if ( !this.$element.text().trim().length ) {
+                       this.$element.detach();
+               }
+       };
+
+       module.exports = FormWrapperWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/GroupWidget.js b/resources/src/mediawiki.rcfilters/ui/GroupWidget.js
new file mode 100644 (file)
index 0000000..17c038e
--- /dev/null
@@ -0,0 +1,45 @@
+( function () {
+       /**
+        * A group widget to allow for aggregation of events
+        *
+        * @class mw.rcfilters.ui.GroupWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {Object} [config] Configuration object
+        * @param {Object} [events] Events to aggregate. The object represent the
+        *  event name to aggregate and the event value to emit on aggregate for items.
+        */
+       var GroupWidget = function MwRcfiltersUiViewSwitchWidget( config ) {
+               var aggregate = {};
+
+               config = config || {};
+
+               // Parent constructor
+               GroupWidget.parent.call( this, config );
+
+               // Mixin constructors
+               OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
+
+               if ( config.events ) {
+                       // Aggregate events
+                       // eslint-disable-next-line jquery/no-each-util
+                       $.each( config.events, function ( eventName, eventEmit ) {
+                               aggregate[ eventName ] = eventEmit;
+                       } );
+
+                       this.aggregate( aggregate );
+               }
+
+               if ( Array.isArray( config.items ) ) {
+                       this.addItems( config.items );
+               }
+       };
+
+       /* Initialize */
+
+       OO.inheritClass( GroupWidget, OO.ui.Widget );
+       OO.mixinClass( GroupWidget, OO.ui.mixin.GroupWidget );
+
+       module.exports = GroupWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/HighlightColorPickerWidget.js b/resources/src/mediawiki.rcfilters/ui/HighlightColorPickerWidget.js
new file mode 100644 (file)
index 0000000..cb5f8eb
--- /dev/null
@@ -0,0 +1,125 @@
+( function () {
+       /**
+        * A widget representing a filter item highlight color picker
+        *
+        * @class mw.rcfilters.ui.HighlightColorPickerWidget
+        * @extends OO.ui.Widget
+        * @mixins OO.ui.mixin.LabelElement
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller RCFilters controller
+        * @param {Object} [config] Configuration object
+        */
+       var HighlightColorPickerWidget = function MwRcfiltersUiHighlightColorPickerWidget( controller, config ) {
+               var colors = [ 'none' ].concat( mw.rcfilters.HighlightColors );
+               config = config || {};
+
+               // Parent
+               HighlightColorPickerWidget.parent.call( this, config );
+               // Mixin constructors
+               OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
+                       label: mw.message( 'rcfilters-highlightmenu-title' ).text()
+               } ) );
+
+               this.controller = controller;
+
+               this.currentSelection = 'none';
+               this.buttonSelect = new OO.ui.ButtonSelectWidget( {
+                       items: colors.map( function ( color ) {
+                               return new OO.ui.ButtonOptionWidget( {
+                                       icon: color === 'none' ? 'check' : null,
+                                       data: color,
+                                       classes: [
+                                               'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color',
+                                               'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color-' + color
+                                       ],
+                                       framed: false
+                               } );
+                       } ),
+                       classes: 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect'
+               } );
+
+               // Event
+               this.buttonSelect.connect( this, { choose: 'onChooseColor' } );
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget' )
+                       .append(
+                               this.$label
+                                       .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget-label' ),
+                               this.buttonSelect.$element
+                       );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( HighlightColorPickerWidget, OO.ui.Widget );
+       OO.mixinClass( HighlightColorPickerWidget, OO.ui.mixin.LabelElement );
+
+       /* Events */
+
+       /**
+        * @event chooseColor
+        * @param {string} The chosen color
+        *
+        * A color has been chosen
+        */
+
+       /* Methods */
+
+       /**
+        * Bind the color picker to an item
+        * @param {mw.rcfilters.dm.FilterItem} filterItem
+        */
+       HighlightColorPickerWidget.prototype.setFilterItem = function ( filterItem ) {
+               if ( this.filterItem ) {
+                       this.filterItem.disconnect( this );
+               }
+
+               this.filterItem = filterItem;
+               this.filterItem.connect( this, { update: 'updateUiBasedOnModel' } );
+               this.updateUiBasedOnModel();
+       };
+
+       /**
+        * Respond to item model update event
+        */
+       HighlightColorPickerWidget.prototype.updateUiBasedOnModel = function () {
+               this.selectColor( this.filterItem.getHighlightColor() || 'none' );
+       };
+
+       /**
+        * Select the color for this widget
+        *
+        * @param {string} color Selected color
+        */
+       HighlightColorPickerWidget.prototype.selectColor = function ( color ) {
+               var previousItem = this.buttonSelect.findItemFromData( this.currentSelection ),
+                       selectedItem = this.buttonSelect.findItemFromData( color );
+
+               if ( this.currentSelection !== color ) {
+                       this.currentSelection = color;
+
+                       this.buttonSelect.selectItem( selectedItem );
+                       if ( previousItem ) {
+                               previousItem.setIcon( null );
+                       }
+
+                       if ( selectedItem ) {
+                               selectedItem.setIcon( 'check' );
+                       }
+               }
+       };
+
+       HighlightColorPickerWidget.prototype.onChooseColor = function ( button ) {
+               var color = button.data;
+               if ( color === 'none' ) {
+                       this.controller.clearHighlightColor( this.filterItem.getName() );
+               } else {
+                       this.controller.setHighlightColor( this.filterItem.getName(), color );
+               }
+               this.emit( 'chooseColor', color );
+       };
+
+       module.exports = HighlightColorPickerWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/HighlightPopupWidget.js b/resources/src/mediawiki.rcfilters/ui/HighlightPopupWidget.js
new file mode 100644 (file)
index 0000000..4c467df
--- /dev/null
@@ -0,0 +1,68 @@
+( function () {
+       var HighlightColorPickerWidget = require( './HighlightColorPickerWidget.js' ),
+               HighlightPopupWidget;
+       /**
+        * A popup containing a color picker, for setting highlight colors.
+        *
+        * @class mw.rcfilters.ui.HighlightPopupWidget
+        * @extends OO.ui.PopupWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller RCFilters controller
+        * @param {Object} [config] Configuration object
+        */
+       HighlightPopupWidget = function MwRcfiltersUiHighlightPopupWidget( controller, config ) {
+               config = config || {};
+
+               // Parent
+               HighlightPopupWidget.parent.call( this, $.extend( {
+                       autoClose: true,
+                       anchor: false,
+                       padded: true,
+                       align: 'backwards',
+                       horizontalPosition: 'end',
+                       width: 290
+               }, config ) );
+
+               this.colorPicker = new HighlightColorPickerWidget( controller );
+
+               this.colorPicker.connect( this, { chooseColor: 'onChooseColor' } );
+
+               this.$body.append( this.colorPicker.$element );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( HighlightPopupWidget, OO.ui.PopupWidget );
+
+       /* Methods */
+
+       /**
+        * Set the button (or other widget) that this popup should hang off.
+        *
+        * @param {OO.ui.Widget} widget Widget the popup should orient itself to
+        */
+       HighlightPopupWidget.prototype.setAssociatedButton = function ( widget ) {
+               this.setFloatableContainer( widget.$element );
+               this.$autoCloseIgnore = widget.$element;
+       };
+
+       /**
+        * Set the filter item that this popup should control the highlight color for.
+        *
+        * @param {mw.rcfilters.dm.FilterItem} item
+        */
+       HighlightPopupWidget.prototype.setFilterItem = function ( item ) {
+               this.colorPicker.setFilterItem( item );
+       };
+
+       /**
+        * When the user chooses a color in the color picker, close the popup.
+        */
+       HighlightPopupWidget.prototype.onChooseColor = function () {
+               this.toggle( false );
+       };
+
+       module.exports = HighlightPopupWidget;
+
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/ItemMenuOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/ItemMenuOptionWidget.js
new file mode 100644 (file)
index 0000000..56ed628
--- /dev/null
@@ -0,0 +1,172 @@
+( function () {
+       var FilterItemHighlightButton = require( './FilterItemHighlightButton.js' ),
+               CheckboxInputWidget = require( './CheckboxInputWidget.js' ),
+               ItemMenuOptionWidget;
+
+       /**
+        * A widget representing a base toggle item
+        *
+        * @class mw.rcfilters.ui.ItemMenuOptionWidget
+        * @extends OO.ui.MenuOptionWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller RCFilters controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+        * @param {mw.rcfilters.dm.ItemModel} invertModel
+        * @param {mw.rcfilters.dm.ItemModel} itemModel Item model
+        * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
+        * @param {Object} config Configuration object
+        */
+       ItemMenuOptionWidget = function MwRcfiltersUiItemMenuOptionWidget(
+               controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
+       ) {
+               var layout,
+                       classes = [],
+                       $label = $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label' );
+
+               config = config || {};
+
+               this.controller = controller;
+               this.filtersViewModel = filtersViewModel;
+               this.invertModel = invertModel;
+               this.itemModel = itemModel;
+
+               // Parent
+               ItemMenuOptionWidget.parent.call( this, $.extend( {
+                       // Override the 'check' icon that OOUI defines
+                       icon: '',
+                       data: this.itemModel.getName(),
+                       label: this.itemModel.getLabel()
+               }, config ) );
+
+               this.checkboxWidget = new CheckboxInputWidget( {
+                       value: this.itemModel.getName(),
+                       selected: this.itemModel.isSelected()
+               } );
+
+               $label.append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-title' )
+                               .append( $( '<bdi>' ).append( this.$label ) )
+               );
+               if ( this.itemModel.getDescription() ) {
+                       $label.append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-desc' )
+                                       .append( $( '<bdi>' ).text( this.itemModel.getDescription() ) )
+                       );
+               }
+
+               this.highlightButton = new FilterItemHighlightButton(
+                       this.controller,
+                       this.itemModel,
+                       highlightPopup,
+                       {
+                               $overlay: config.$overlay || this.$element,
+                               title: mw.msg( 'rcfilters-highlightmenu-help' )
+                       }
+               );
+               this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
+
+               this.excludeLabel = new OO.ui.LabelWidget( {
+                       label: mw.msg( 'rcfilters-filter-excluded' )
+               } );
+               this.excludeLabel.toggle(
+                       this.itemModel.getGroupModel().getView() === 'namespaces' &&
+                       this.itemModel.isSelected() &&
+                       this.invertModel.isSelected()
+               );
+
+               layout = new OO.ui.FieldLayout( this.checkboxWidget, {
+                       label: $label,
+                       align: 'inline'
+               } );
+
+               // Events
+               this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
+               this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
+               this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
+               // HACK: Prevent defaults on 'click' for the label so it
+               // doesn't steal the focus away from the input. This means
+               // we can continue arrow-movement after we click the label
+               // and is consistent with the checkbox *itself* also preventing
+               // defaults on 'click' as well.
+               layout.$label.on( 'click', false );
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget' )
+                       .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-view-' + this.itemModel.getGroupModel().getView() )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-table' )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-row' )
+                                                       .append(
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox' )
+                                                                       .append( layout.$element ),
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-excludeLabel' )
+                                                                       .append( this.excludeLabel.$element ),
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-highlightButton' )
+                                                                       .append( this.highlightButton.$element )
+                                                       )
+                                       )
+                       );
+
+               if ( this.itemModel.getIdentifiers() ) {
+                       this.itemModel.getIdentifiers().forEach( function ( ident ) {
+                               classes.push( 'mw-rcfilters-ui-itemMenuOptionWidget-identifier-' + ident );
+                       } );
+
+                       this.$element.addClass( classes );
+               }
+
+               this.updateUiBasedOnState();
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( ItemMenuOptionWidget, OO.ui.MenuOptionWidget );
+
+       /* Static properties */
+
+       // We do our own scrolling to top
+       ItemMenuOptionWidget.static.scrollIntoViewOnSelect = false;
+
+       /* Methods */
+
+       /**
+        * Respond to item model update event
+        */
+       ItemMenuOptionWidget.prototype.updateUiBasedOnState = function () {
+               this.checkboxWidget.setSelected( this.itemModel.isSelected() );
+
+               this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
+               this.excludeLabel.toggle(
+                       this.itemModel.getGroupModel().getView() === 'namespaces' &&
+                       this.itemModel.isSelected() &&
+                       this.invertModel.isSelected()
+               );
+               this.toggle( this.itemModel.isVisible() );
+       };
+
+       /**
+        * Get the name of this filter
+        *
+        * @return {string} Filter name
+        */
+       ItemMenuOptionWidget.prototype.getName = function () {
+               return this.itemModel.getName();
+       };
+
+       ItemMenuOptionWidget.prototype.getModel = function () {
+               return this.itemModel;
+       };
+
+       module.exports = ItemMenuOptionWidget;
+
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/LiveUpdateButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/LiveUpdateButtonWidget.js
new file mode 100644 (file)
index 0000000..3ccb6e2
--- /dev/null
@@ -0,0 +1,72 @@
+( function () {
+       /**
+        * Widget for toggling live updates
+        *
+        * @class mw.rcfilters.ui.LiveUpdateButtonWidget
+        * @extends OO.ui.ToggleButtonWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+        * @param {Object} [config] Configuration object
+        */
+       var LiveUpdateButtonWidget = function MwRcfiltersUiLiveUpdateButtonWidget( controller, changesListModel, config ) {
+               config = config || {};
+
+               // Parent
+               LiveUpdateButtonWidget.parent.call( this, $.extend( {
+                       label: mw.message( 'rcfilters-liveupdates-button' ).text()
+               }, config ) );
+
+               this.controller = controller;
+               this.model = changesListModel;
+
+               // Events
+               this.connect( this, { click: 'onClick' } );
+               this.model.connect( this, { liveUpdateChange: 'onLiveUpdateChange' } );
+
+               this.$element.addClass( 'mw-rcfilters-ui-liveUpdateButtonWidget' );
+
+               this.setState( false );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( LiveUpdateButtonWidget, OO.ui.ToggleButtonWidget );
+
+       /* Methods */
+
+       /**
+        * Respond to the button being clicked
+        */
+       LiveUpdateButtonWidget.prototype.onClick = function () {
+               this.controller.toggleLiveUpdate();
+       };
+
+       /**
+        * Set the button's state and change its appearance
+        *
+        * @param {boolean} enable Whether the 'live update' feature is now on/off
+        */
+       LiveUpdateButtonWidget.prototype.setState = function ( enable ) {
+               this.setValue( enable );
+               this.setIcon( enable ? 'stop' : 'play' );
+               this.setTitle( mw.message(
+                       enable ?
+                               'rcfilters-liveupdates-button-title-on' :
+                               'rcfilters-liveupdates-button-title-off'
+               ).text() );
+       };
+
+       /**
+        * Respond to the 'live update' feature being turned on/off
+        *
+        * @param {boolean} enable Whether the 'live update' feature is now on/off
+        */
+       LiveUpdateButtonWidget.prototype.onLiveUpdateChange = function ( enable ) {
+               this.setState( enable );
+       };
+
+       module.exports = LiveUpdateButtonWidget;
+
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/MainWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/MainWrapperWidget.js
new file mode 100644 (file)
index 0000000..bc1cac8
--- /dev/null
@@ -0,0 +1,142 @@
+( function () {
+       var SavedLinksListWidget = require( './SavedLinksListWidget.js' ),
+               FilterWrapperWidget = require( './FilterWrapperWidget.js' ),
+               ChangesListWrapperWidget = require( './ChangesListWrapperWidget.js' ),
+               RcTopSectionWidget = require( './RcTopSectionWidget.js' ),
+               RclTopSectionWidget = require( './RclTopSectionWidget.js' ),
+               WatchlistTopSectionWidget = require( './WatchlistTopSectionWidget.js' ),
+               FormWrapperWidget = require( './FormWrapperWidget.js' ),
+               MainWrapperWidget;
+
+       /**
+        * Wrapper for changes list content
+        *
+        * @class mw.rcfilters.ui.MainWrapperWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller Controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
+        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+        * @param {Object} config Configuration object
+        * @cfg {jQuery} $topSection Top section container
+        * @cfg {jQuery} $filtersContainer
+        * @cfg {jQuery} $changesListContainer
+        * @cfg {jQuery} $formContainer
+        * @cfg {boolean} [collapsed] Filter area is collapsed
+        * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
+        *  system. If not given, falls back to this widget's $element
+        */
+       MainWrapperWidget = function MwRcfiltersUiMainWrapperWidget(
+               controller, model, savedQueriesModel, changesListModel, config
+       ) {
+               config = $.extend( {}, config );
+
+               // Parent
+               MainWrapperWidget.parent.call( this, config );
+
+               this.controller = controller;
+               this.model = model;
+               this.changesListModel = changesListModel;
+               this.$topSection = config.$topSection;
+               this.$filtersContainer = config.$filtersContainer;
+               this.$changesListContainer = config.$changesListContainer;
+               this.$formContainer = config.$formContainer;
+               this.$overlay = $( '<div>' ).addClass( 'mw-rcfilters-ui-overlay' );
+               this.$wrapper = config.$wrapper || this.$element;
+
+               this.savedLinksListWidget = new SavedLinksListWidget(
+                       controller, savedQueriesModel, { $overlay: this.$overlay }
+               );
+
+               this.filtersWidget = new FilterWrapperWidget(
+                       controller,
+                       model,
+                       savedQueriesModel,
+                       changesListModel,
+                       {
+                               $overlay: this.$overlay,
+                               $wrapper: this.$wrapper,
+                               collapsed: config.collapsed
+                       }
+               );
+
+               this.changesListWidget = new ChangesListWrapperWidget(
+                       model, changesListModel, controller, this.$changesListContainer );
+
+               /* Events */
+
+               // Toggle changes list overlay when filters menu opens/closes. We use overlay on changes list
+               // to prevent users from accidentally clicking on links in results, while menu is opened.
+               // Overlay on changes list is not the same as this.$overlay
+               this.filtersWidget.connect( this, { menuToggle: this.onFilterMenuToggle.bind( this ) } );
+
+               // Initialize
+               this.$filtersContainer.append( this.filtersWidget.$element );
+               $( 'body' )
+                       .append( this.$overlay )
+                       .addClass( 'mw-rcfilters-ui-initialized' );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( MainWrapperWidget, OO.ui.Widget );
+
+       /* Methods */
+
+       /**
+        * Set the content of the top section, depending on the type of special page.
+        *
+        * @param {string} specialPage
+        */
+       MainWrapperWidget.prototype.setTopSection = function ( specialPage ) {
+               var topSection;
+
+               if ( specialPage === 'Recentchanges' ) {
+                       topSection = new RcTopSectionWidget(
+                               this.savedLinksListWidget, this.$topSection
+                       );
+                       this.filtersWidget.setTopSection( topSection.$element );
+               }
+
+               if ( specialPage === 'Recentchangeslinked' ) {
+                       topSection = new RclTopSectionWidget(
+                               this.savedLinksListWidget, this.controller,
+                               this.model.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' ),
+                               this.model.getGroup( 'page' ).getItemByParamName( 'target' )
+                       );
+
+                       this.filtersWidget.setTopSection( topSection.$element );
+               }
+
+               if ( specialPage === 'Watchlist' ) {
+                       topSection = new WatchlistTopSectionWidget(
+                               this.controller, this.changesListModel, this.savedLinksListWidget, this.$topSection
+                       );
+
+                       this.filtersWidget.setTopSection( topSection.$element );
+               }
+       };
+
+       /**
+        * Filter menu toggle event listener
+        *
+        * @param {boolean} isVisible
+        */
+       MainWrapperWidget.prototype.onFilterMenuToggle = function ( isVisible ) {
+               this.changesListWidget.toggleOverlay( isVisible );
+       };
+
+       /**
+        * Initialize FormWrapperWidget
+        *
+        * @return {mw.rcfilters.ui.FormWrapperWidget} Form wrapper widget
+        */
+       MainWrapperWidget.prototype.initFormWidget = function () {
+               return new FormWrapperWidget(
+                       this.model, this.changesListModel, this.controller, this.$formContainer );
+       };
+
+       module.exports = MainWrapperWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/MarkSeenButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/MarkSeenButtonWidget.js
new file mode 100644 (file)
index 0000000..3914337
--- /dev/null
@@ -0,0 +1,58 @@
+( function () {
+       /**
+        * Button for marking all changes as seen on the Watchlist
+        *
+        * @class mw.rcfilters.ui.MarkSeenButtonWidget
+        * @extends OO.ui.ButtonWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.ChangesListViewModel} model Changes list view model
+        * @param {Object} [config] Configuration object
+        */
+       var MarkSeenButtonWidget = function MwRcfiltersUiMarkSeenButtonWidget( controller, model, config ) {
+               config = config || {};
+
+               // Parent
+               MarkSeenButtonWidget.parent.call( this, $.extend( {
+                       label: mw.message( 'rcfilters-watchlist-markseen-button' ).text(),
+                       icon: 'checkAll'
+               }, config ) );
+
+               this.controller = controller;
+               this.model = model;
+
+               // Events
+               this.connect( this, { click: 'onClick' } );
+               this.model.connect( this, { update: 'onModelUpdate' } );
+
+               this.$element.addClass( 'mw-rcfilters-ui-markSeenButtonWidget' );
+
+               this.onModelUpdate();
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( MarkSeenButtonWidget, OO.ui.ButtonWidget );
+
+       /* Methods */
+
+       /**
+        * Respond to the button being clicked
+        */
+       MarkSeenButtonWidget.prototype.onClick = function () {
+               this.controller.markAllChangesAsSeen();
+               // assume there's no more unseen changes until the next model update
+               this.setDisabled( true );
+       };
+
+       /**
+        * Respond to the model being updated with new changes
+        */
+       MarkSeenButtonWidget.prototype.onModelUpdate = function () {
+               this.setDisabled( !this.model.hasUnseenWatchedChanges() );
+       };
+
+       module.exports = MarkSeenButtonWidget;
+
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js b/resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js
new file mode 100644 (file)
index 0000000..c352f5a
--- /dev/null
@@ -0,0 +1,368 @@
+( function () {
+       var FilterMenuHeaderWidget = require( './FilterMenuHeaderWidget.js' ),
+               HighlightPopupWidget = require( './HighlightPopupWidget.js' ),
+               FilterMenuSectionOptionWidget = require( './FilterMenuSectionOptionWidget.js' ),
+               FilterMenuOptionWidget = require( './FilterMenuOptionWidget.js' ),
+               MenuSelectWidget;
+
+       /**
+        * A floating menu widget for the filter list
+        *
+        * @class mw.rcfilters.ui.MenuSelectWidget
+        * @extends OO.ui.MenuSelectWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller Controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+        * @param {Object} [config] Configuration object
+        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+        * @cfg {Object[]} [footers] An array of objects defining the footers for
+        *  this menu, with a definition whether they appear per specific views.
+        *  The expected structure is:
+        *  [
+        *     {
+        *        name: {string} A unique name for the footer object
+        *        $element: {jQuery} A jQuery object for the content of the footer
+        *        views: {string[]} Optional. An array stating which views this footer is
+        *               active on. Use null or omit to display this on all views.
+        *     }
+        *  ]
+        */
+       MenuSelectWidget = function MwRcfiltersUiMenuSelectWidget( controller, model, config ) {
+               var header;
+
+               config = config || {};
+
+               this.controller = controller;
+               this.model = model;
+               this.currentView = '';
+               this.views = {};
+               this.userSelecting = false;
+
+               this.menuInitialized = false;
+               this.$overlay = config.$overlay || this.$element;
+               this.$body = $( '<div>' ).addClass( 'mw-rcfilters-ui-menuSelectWidget-body' );
+               this.footers = [];
+
+               // Parent
+               MenuSelectWidget.parent.call( this, $.extend( config, {
+                       $autoCloseIgnore: this.$overlay,
+                       width: 650,
+                       // Our filtering is done through the model
+                       filterFromInput: false
+               } ) );
+               this.setGroupElement(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-group' )
+               );
+               this.setClippableElement( this.$body );
+               this.setClippableContainer( this.$element );
+
+               header = new FilterMenuHeaderWidget(
+                       this.controller,
+                       this.model,
+                       {
+                               $overlay: this.$overlay
+                       }
+               );
+
+               this.noResults = new OO.ui.LabelWidget( {
+                       label: mw.msg( 'rcfilters-filterlist-noresults' ),
+                       classes: [ 'mw-rcfilters-ui-menuSelectWidget-noresults' ]
+               } );
+
+               // Events
+               this.model.connect( this, {
+                       initialize: 'onModelInitialize',
+                       searchChange: 'onModelSearchChange'
+               } );
+
+               // Initialization
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-menuSelectWidget' )
+                       .append( header.$element )
+                       .append(
+                               this.$body
+                                       .append( this.$group, this.noResults.$element )
+                       );
+
+               // Append all footers; we will control their visibility
+               // based on view
+               config.footers = config.footers || [];
+               config.footers.forEach( function ( footerData ) {
+                       var isSticky = footerData.sticky === undefined ? true : !!footerData.sticky,
+                               adjustedData = {
+                                       // Wrap the element with our own footer wrapper
+                                       $element: $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer' )
+                                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer-' + footerData.name )
+                                               .append( footerData.$element ),
+                                       views: footerData.views
+                               };
+
+                       if ( !footerData.disabled ) {
+                               this.footers.push( adjustedData );
+
+                               if ( isSticky ) {
+                                       this.$element.append( adjustedData.$element );
+                               } else {
+                                       this.$body.append( adjustedData.$element );
+                               }
+                       }
+               }.bind( this ) );
+
+               // Switch to the correct view
+               this.updateView();
+       };
+
+       /* Initialize */
+
+       OO.inheritClass( MenuSelectWidget, OO.ui.MenuSelectWidget );
+
+       /* Events */
+
+       /* Methods */
+       MenuSelectWidget.prototype.onModelSearchChange = function () {
+               this.updateView();
+       };
+
+       /**
+        * @inheritdoc
+        */
+       MenuSelectWidget.prototype.toggle = function ( show ) {
+               this.lazyMenuCreation();
+               MenuSelectWidget.parent.prototype.toggle.call( this, show );
+               // Always open this menu downwards. FilterTagMultiselectWidget scrolls it into view.
+               this.setVerticalPosition( 'below' );
+       };
+
+       /**
+        * lazy creation of the menu
+        */
+       MenuSelectWidget.prototype.lazyMenuCreation = function () {
+               var widget = this,
+                       items = [],
+                       viewGroupCount = {},
+                       groups = this.model.getFilterGroups();
+
+               if ( this.menuInitialized ) {
+                       return;
+               }
+
+               this.menuInitialized = true;
+
+               // Create shared popup for highlight buttons
+               this.highlightPopup = new HighlightPopupWidget( this.controller );
+               this.$overlay.append( this.highlightPopup.$element );
+
+               // Count groups per view
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( groups, function ( groupName, groupModel ) {
+                       if ( !groupModel.isHidden() ) {
+                               viewGroupCount[ groupModel.getView() ] = viewGroupCount[ groupModel.getView() ] || 0;
+                               viewGroupCount[ groupModel.getView() ]++;
+                       }
+               } );
+
+               // eslint-disable-next-line jquery/no-each-util
+               $.each( groups, function ( groupName, groupModel ) {
+                       var currentItems = [],
+                               view = groupModel.getView();
+
+                       if ( !groupModel.isHidden() ) {
+                               if ( viewGroupCount[ view ] > 1 ) {
+                                       // Only add a section header if there is more than
+                                       // one group
+                                       currentItems.push(
+                                               // Group section
+                                               new FilterMenuSectionOptionWidget(
+                                                       widget.controller,
+                                                       groupModel,
+                                                       {
+                                                               $overlay: widget.$overlay
+                                                       }
+                                               )
+                                       );
+                               }
+
+                               // Add items
+                               widget.model.getGroupFilters( groupName ).forEach( function ( filterItem ) {
+                                       currentItems.push(
+                                               new FilterMenuOptionWidget(
+                                                       widget.controller,
+                                                       widget.model,
+                                                       widget.model.getInvertModel(),
+                                                       filterItem,
+                                                       widget.highlightPopup,
+                                                       {
+                                                               $overlay: widget.$overlay
+                                                       }
+                                               )
+                                       );
+                               } );
+
+                               // Cache the items per view, so we can switch between them
+                               // without rebuilding the widgets each time
+                               widget.views[ view ] = widget.views[ view ] || [];
+                               widget.views[ view ] = widget.views[ view ].concat( currentItems );
+                               items = items.concat( currentItems );
+                       }
+               } );
+
+               this.addItems( items );
+               this.updateView();
+       };
+
+       /**
+        * Respond to model initialize event. Populate the menu from the model
+        */
+       MenuSelectWidget.prototype.onModelInitialize = function () {
+               this.menuInitialized = false;
+               // Set timeout for the menu to lazy build.
+               setTimeout( this.lazyMenuCreation.bind( this ) );
+       };
+
+       /**
+        * Update view
+        */
+       MenuSelectWidget.prototype.updateView = function () {
+               var viewName = this.model.getCurrentView();
+
+               if ( this.views[ viewName ] && this.currentView !== viewName ) {
+                       this.updateFooterVisibility( viewName );
+
+                       this.$element
+                               .data( 'view', viewName )
+                               .removeClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + this.currentView )
+                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + viewName );
+
+                       this.currentView = viewName;
+                       this.scrollToTop();
+               }
+
+               this.postProcessItems();
+               this.clip();
+       };
+
+       /**
+        * Go over the available footers and decide which should be visible
+        * for this view
+        *
+        * @param {string} [currentView] Current view
+        */
+       MenuSelectWidget.prototype.updateFooterVisibility = function ( currentView ) {
+               currentView = currentView || this.model.getCurrentView();
+
+               this.footers.forEach( function ( data ) {
+                       data.$element.toggle(
+                               // This footer should only be shown if it is configured
+                               // for all views or for this specific view
+                               !data.views || data.views.length === 0 || data.views.indexOf( currentView ) > -1
+                       );
+               } );
+       };
+
+       /**
+        * Post-process items after the visibility changed. Make sure
+        * that we always have an item selected, and that the no-results
+        * widget appears if the menu is empty.
+        */
+       MenuSelectWidget.prototype.postProcessItems = function () {
+               var i,
+                       itemWasSelected = false,
+                       items = this.getItems();
+
+               // If we are not already selecting an item, always make sure
+               // that the top item is selected
+               if ( !this.userSelecting ) {
+                       // Select the first item in the list
+                       for ( i = 0; i < items.length; i++ ) {
+                               if (
+                                       !( items[ i ] instanceof OO.ui.MenuSectionOptionWidget ) &&
+                                       items[ i ].isVisible()
+                               ) {
+                                       itemWasSelected = true;
+                                       this.selectItem( items[ i ] );
+                                       break;
+                               }
+                       }
+
+                       if ( !itemWasSelected ) {
+                               this.selectItem( null );
+                       }
+               }
+
+               this.noResults.toggle( !this.getItems().some( function ( item ) {
+                       return item.isVisible();
+               } ) );
+       };
+
+       /**
+        * Get the option widget that matches the model given
+        *
+        * @param {mw.rcfilters.dm.ItemModel} model Item model
+        * @return {mw.rcfilters.ui.ItemMenuOptionWidget} Option widget
+        */
+       MenuSelectWidget.prototype.getItemFromModel = function ( model ) {
+               this.lazyMenuCreation();
+               return this.views[ model.getGroupModel().getView() ].filter( function ( item ) {
+                       return item.getName() === model.getName();
+               } )[ 0 ];
+       };
+
+       /**
+        * @inheritdoc
+        */
+       MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
+               var nextItem,
+                       currentItem = this.findHighlightedItem() || this.findSelectedItem();
+
+               // Call parent
+               MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
+
+               // We want to select the item on arrow movement
+               // rather than just highlight it, like the menu
+               // does by default
+               if ( !this.isDisabled() && this.isVisible() ) {
+                       switch ( e.keyCode ) {
+                               case OO.ui.Keys.UP:
+                               case OO.ui.Keys.LEFT:
+                                       // Get the next item
+                                       nextItem = this.findRelativeSelectableItem( currentItem, -1 );
+                                       break;
+                               case OO.ui.Keys.DOWN:
+                               case OO.ui.Keys.RIGHT:
+                                       // Get the next item
+                                       nextItem = this.findRelativeSelectableItem( currentItem, 1 );
+                                       break;
+                       }
+
+                       nextItem = nextItem && nextItem.constructor.static.selectable ?
+                               nextItem : null;
+
+                       // Select the next item
+                       this.selectItem( nextItem );
+               }
+       };
+
+       /**
+        * Scroll to the top of the menu
+        */
+       MenuSelectWidget.prototype.scrollToTop = function () {
+               this.$body.scrollTop( 0 );
+       };
+
+       /**
+        * Set whether the user is currently selecting an item.
+        * This is important when the user selects an item that is in between
+        * different views, and makes sure we do not re-select a different
+        * item (like the item on top) when this is happening.
+        *
+        * @param {boolean} isSelecting User is selecting
+        */
+       MenuSelectWidget.prototype.setUserSelecting = function ( isSelecting ) {
+               this.userSelecting = !!isSelecting;
+       };
+
+       module.exports = MenuSelectWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/RcTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/RcTopSectionWidget.js
new file mode 100644 (file)
index 0000000..6de9c40
--- /dev/null
@@ -0,0 +1,116 @@
+( function () {
+       /**
+        * Top section (between page title and filters) on Special:Recentchanges
+        *
+        * @class mw.rcfilters.ui.RcTopSectionWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
+        * @param {jQuery} $topLinks Content of the community-defined links
+        * @param {Object} [config] Configuration object
+        */
+       var RcTopSectionWidget = function MwRcfiltersUiRcTopSectionWidget(
+               savedLinksListWidget, $topLinks, config
+       ) {
+               var toplinksTitle,
+                       topLinksCookieName = 'rcfilters-toplinks-collapsed-state',
+                       topLinksCookie = mw.cookie.get( topLinksCookieName ),
+                       topLinksCookieValue = topLinksCookie || 'collapsed',
+                       widget = this;
+
+               config = config || {};
+
+               // Parent
+               RcTopSectionWidget.parent.call( this, config );
+
+               this.$topLinks = $topLinks;
+
+               toplinksTitle = new OO.ui.ButtonWidget( {
+                       framed: false,
+                       indicator: topLinksCookieValue === 'collapsed' ? 'down' : 'up',
+                       flags: [ 'progressive' ],
+                       label: $( '<span>' ).append( mw.message( 'rcfilters-other-review-tools' ).parse() ).contents()
+               } );
+
+               this.$topLinks
+                       .makeCollapsible( {
+                               collapsed: topLinksCookieValue === 'collapsed',
+                               $customTogglers: toplinksTitle.$element
+                       } )
+                       .on( 'beforeExpand.mw-collapsible', function () {
+                               mw.cookie.set( topLinksCookieName, 'expanded' );
+                               toplinksTitle.setIndicator( 'up' );
+                               widget.switchTopLinks( 'expanded' );
+                       } )
+                       .on( 'beforeCollapse.mw-collapsible', function () {
+                               mw.cookie.set( topLinksCookieName, 'collapsed' );
+                               toplinksTitle.setIndicator( 'down' );
+                               widget.switchTopLinks( 'collapsed' );
+                       } );
+
+               this.$topLinks.find( '.mw-recentchanges-toplinks-title' )
+                       .replaceWith( toplinksTitle.$element.removeAttr( 'tabIndex' ) );
+
+               // Create two positions for the toplinks to toggle between
+               // in the table (first cell) or up above it
+               this.$top = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-top' );
+               this.$tableTopLinks = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-cell' )
+                       .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-table' );
+
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-rcTopSectionWidget' )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-table' )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-row' )
+                                                       .append(
+                                                               this.$tableTopLinks,
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-table-placeholder' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' ),
+                                                               !mw.user.isAnon() ?
+                                                                       $( '<div>' )
+                                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                                               .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-savedLinks' )
+                                                                               .append( savedLinksListWidget.$element ) :
+                                                                       null
+                                                       )
+                                       )
+                       );
+
+               // Hack: For jumpiness reasons, this should be a sibling of -head
+               $( '.rcfilters-head' ).before( this.$top );
+
+               // Initialize top links position
+               widget.switchTopLinks( topLinksCookieValue );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( RcTopSectionWidget, OO.ui.Widget );
+
+       /**
+        * Switch the top links widget from inside the table (when collapsed)
+        * to the 'top' (when open)
+        *
+        * @param {string} [state] The state of the top links widget: 'expanded' or 'collapsed'
+        */
+       RcTopSectionWidget.prototype.switchTopLinks = function ( state ) {
+               state = state || 'expanded';
+
+               if ( state === 'expanded' ) {
+                       this.$top.append( this.$topLinks );
+               } else {
+                       this.$tableTopLinks.append( this.$topLinks );
+               }
+               this.$topLinks.toggleClass( 'mw-recentchanges-toplinks-collapsed', state === 'collapsed' );
+       };
+
+       module.exports = RcTopSectionWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/RclTargetPageWidget.js b/resources/src/mediawiki.rcfilters/ui/RclTargetPageWidget.js
new file mode 100644 (file)
index 0000000..6eb0d5b
--- /dev/null
@@ -0,0 +1,82 @@
+( function () {
+       /**
+        * Widget to select and display target page on Special:RecentChangesLinked (AKA Related Changes)
+        *
+        * @class mw.rcfilters.ui.RclTargetPageWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.FilterItem} targetPageModel
+        * @param {Object} [config] Configuration object
+        */
+       var RclTargetPageWidget = function MwRcfiltersUiRclTargetPageWidget(
+               controller, targetPageModel, config
+       ) {
+               config = config || {};
+
+               // Parent
+               RclTargetPageWidget.parent.call( this, config );
+
+               this.controller = controller;
+               this.model = targetPageModel;
+
+               this.titleSearch = new mw.widgets.TitleInputWidget( {
+                       validate: false,
+                       placeholder: mw.msg( 'rcfilters-target-page-placeholder' ),
+                       showImages: true,
+                       showDescriptions: true,
+                       addQueryInput: false
+               } );
+
+               // Events
+               this.model.connect( this, { update: 'updateUiBasedOnModel' } );
+
+               this.titleSearch.$input.on( {
+                       blur: this.onLookupInputBlur.bind( this )
+               } );
+
+               this.titleSearch.lookupMenu.connect( this, {
+                       choose: 'onLookupMenuItemChoose'
+               } );
+
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-rclTargetPageWidget' )
+                       .append( this.titleSearch.$element );
+
+               this.updateUiBasedOnModel();
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( RclTargetPageWidget, OO.ui.Widget );
+
+       /* Methods */
+
+       /**
+        * Respond to the user choosing a title
+        */
+       RclTargetPageWidget.prototype.onLookupMenuItemChoose = function () {
+               this.titleSearch.$input.trigger( 'blur' );
+       };
+
+       /**
+        * Respond to titleSearch $input blur
+        */
+       RclTargetPageWidget.prototype.onLookupInputBlur = function () {
+               this.controller.setTargetPage( this.titleSearch.getQueryValue() );
+       };
+
+       /**
+        * Respond to the model being updated
+        */
+       RclTargetPageWidget.prototype.updateUiBasedOnModel = function () {
+               var title = mw.Title.newFromText( this.model.getValue() ),
+                       text = title ? title.toText() : this.model.getValue();
+               this.titleSearch.setValue( text );
+               this.titleSearch.setTitle( text );
+       };
+
+       module.exports = RclTargetPageWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/RclToOrFromWidget.js b/resources/src/mediawiki.rcfilters/ui/RclToOrFromWidget.js
new file mode 100644 (file)
index 0000000..e2c58d0
--- /dev/null
@@ -0,0 +1,76 @@
+( function () {
+       /**
+        * Widget to select to view changes that link TO or FROM the target page
+        * on Special:RecentChangesLinked (AKA Related Changes)
+        *
+        * @class mw.rcfilters.ui.RclToOrFromWidget
+        * @extends OO.ui.DropdownWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel model this widget is bound to
+        * @param {Object} [config] Configuration object
+        */
+       var RclToOrFromWidget = function MwRcfiltersUiRclToOrFromWidget(
+               controller, showLinkedToModel, config
+       ) {
+               config = config || {};
+
+               this.showLinkedFrom = new OO.ui.MenuOptionWidget( {
+                       data: 'from', // default (showlinkedto=0)
+                       label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedfrom-option-label' ) )
+               } );
+               this.showLinkedTo = new OO.ui.MenuOptionWidget( {
+                       data: 'to', // showlinkedto=1
+                       label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedto-option-label' ) )
+               } );
+
+               // Parent
+               RclToOrFromWidget.parent.call( this, $.extend( {
+                       classes: [ 'mw-rcfilters-ui-rclToOrFromWidget' ],
+                       menu: { items: [ this.showLinkedFrom, this.showLinkedTo ] }
+               }, config ) );
+
+               this.controller = controller;
+               this.model = showLinkedToModel;
+
+               this.getMenu().connect( this, { choose: 'onUserChooseItem' } );
+               this.model.connect( this, { update: 'onModelUpdate' } );
+
+               // force an initial update of the component based on the state
+               this.onModelUpdate();
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( RclToOrFromWidget, OO.ui.DropdownWidget );
+
+       /* Methods */
+
+       /**
+        * Respond to the user choosing an item in the menu
+        *
+        * @param {OO.ui.MenuOptionWidget} chosenItem
+        */
+       RclToOrFromWidget.prototype.onUserChooseItem = function ( chosenItem ) {
+               this.controller.setShowLinkedTo( chosenItem.getData() === 'to' );
+       };
+
+       /**
+        * Respond to model update
+        */
+       RclToOrFromWidget.prototype.onModelUpdate = function () {
+               this.getMenu().selectItem(
+                       this.model.isSelected() ?
+                               this.showLinkedTo :
+                               this.showLinkedFrom
+               );
+               this.setLabel( mw.msg(
+                       this.model.isSelected() ?
+                               'rcfilters-filter-showlinkedto-label' :
+                               'rcfilters-filter-showlinkedfrom-label'
+               ) );
+       };
+
+       module.exports = RclToOrFromWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/RclTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/RclTopSectionWidget.js
new file mode 100644 (file)
index 0000000..d968b9e
--- /dev/null
@@ -0,0 +1,73 @@
+( function () {
+       var RclToOrFromWidget = require( './RclToOrFromWidget.js' ),
+               RclTargetPageWidget = require( './RclTargetPageWidget.js' ),
+               RclTopSectionWidget;
+
+       /**
+        * Top section (between page title and filters) on Special:RecentChangesLinked (AKA RelatedChanges)
+        *
+        * @class mw.rcfilters.ui.RclTopSectionWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel Model for 'showlinkedto' parameter
+        * @param {mw.rcfilters.dm.FilterItem} targetPageModel Model for 'target' parameter
+        * @param {Object} [config] Configuration object
+        */
+       RclTopSectionWidget = function MwRcfiltersUiRclTopSectionWidget(
+               savedLinksListWidget, controller, showLinkedToModel, targetPageModel, config
+       ) {
+               var toOrFromWidget,
+                       targetPage;
+               config = config || {};
+
+               // Parent
+               RclTopSectionWidget.parent.call( this, config );
+
+               this.controller = controller;
+
+               toOrFromWidget = new RclToOrFromWidget( controller, showLinkedToModel );
+               targetPage = new RclTargetPageWidget( controller, targetPageModel );
+
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-rclTopSectionWidget' )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-table' )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-row' )
+                                                       .append(
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .append( toOrFromWidget.$element )
+                                                       ),
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-row' )
+                                                       .append(
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .append( targetPage.$element ),
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-table-placeholder' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' ),
+                                                               !mw.user.isAnon() ?
+                                                                       $( '<div>' )
+                                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                                               .addClass( 'mw-rcfilters-ui-rclTopSectionWidget-savedLinks' )
+                                                                               .append( savedLinksListWidget.$element ) :
+                                                                       null
+                                                       )
+                                       )
+                       );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( RclTopSectionWidget, OO.ui.Widget );
+
+       module.exports = RclTopSectionWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/SaveFiltersPopupButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/SaveFiltersPopupButtonWidget.js
new file mode 100644 (file)
index 0000000..8c3d550
--- /dev/null
@@ -0,0 +1,191 @@
+( function () {
+       /**
+        * Save filters widget. This widget is displayed in the tag area
+        * and allows the user to save the current state of the system
+        * as a new saved filter query they can later load or set as
+        * default.
+        *
+        * @class mw.rcfilters.ui.SaveFiltersPopupButtonWidget
+        * @extends OO.ui.PopupButtonWidget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller Controller
+        * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
+        * @param {Object} [config] Configuration object
+        */
+       var SaveFiltersPopupButtonWidget = function MwRcfiltersUiSaveFiltersPopupButtonWidget( controller, model, config ) {
+               var layout,
+                       checkBoxLayout,
+                       $popupContent = $( '<div>' );
+
+               config = config || {};
+
+               this.controller = controller;
+               this.model = model;
+
+               // Parent
+               SaveFiltersPopupButtonWidget.parent.call( this, $.extend( {
+                       framed: false,
+                       icon: 'bookmark',
+                       title: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
+                       popup: {
+                               classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup' ],
+                               padded: true,
+                               head: true,
+                               label: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
+                               $content: $popupContent
+                       }
+               }, config ) );
+               // // HACK: Add an icon to the popup head label
+               this.popup.$head.prepend( ( new OO.ui.IconWidget( { icon: 'bookmark' } ) ).$element );
+
+               this.input = new OO.ui.TextInputWidget( {
+                       placeholder: mw.msg( 'rcfilters-savedqueries-new-name-placeholder' )
+               } );
+               layout = new OO.ui.FieldLayout( this.input, {
+                       label: mw.msg( 'rcfilters-savedqueries-new-name-label' ),
+                       align: 'top'
+               } );
+
+               this.setAsDefaultCheckbox = new OO.ui.CheckboxInputWidget();
+               checkBoxLayout = new OO.ui.FieldLayout( this.setAsDefaultCheckbox, {
+                       label: mw.msg( 'rcfilters-savedqueries-setdefault' ),
+                       align: 'inline'
+               } );
+
+               this.applyButton = new OO.ui.ButtonWidget( {
+                       label: mw.msg( 'rcfilters-savedqueries-apply-label' ),
+                       classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-apply' ],
+                       flags: [ 'primary', 'progressive' ]
+               } );
+               this.cancelButton = new OO.ui.ButtonWidget( {
+                       label: mw.msg( 'rcfilters-savedqueries-cancel-label' ),
+                       classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-cancel' ]
+               } );
+
+               $popupContent
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-layout' )
+                                       .append( layout.$element ),
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-options' )
+                                       .append( checkBoxLayout.$element ),
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons' )
+                                       .append(
+                                               this.cancelButton.$element,
+                                               this.applyButton.$element
+                                       )
+                       );
+
+               // Events
+               this.popup.connect( this, {
+                       ready: 'onPopupReady'
+               } );
+               this.input.connect( this, {
+                       change: 'onInputChange',
+                       enter: 'onInputEnter'
+               } );
+               this.input.$input.on( {
+                       keyup: this.onInputKeyup.bind( this )
+               } );
+               this.setAsDefaultCheckbox.connect( this, { change: 'onSetAsDefaultChange' } );
+               this.cancelButton.connect( this, { click: 'onCancelButtonClick' } );
+               this.applyButton.connect( this, { click: 'onApplyButtonClick' } );
+
+               // Initialize
+               this.applyButton.setDisabled( !this.input.getValue() );
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget' );
+       };
+
+       /* Initialization */
+       OO.inheritClass( SaveFiltersPopupButtonWidget, OO.ui.PopupButtonWidget );
+
+       /**
+        * Respond to input enter event
+        */
+       SaveFiltersPopupButtonWidget.prototype.onInputEnter = function () {
+               this.apply();
+       };
+
+       /**
+        * Respond to input change event
+        *
+        * @param {string} value Input value
+        */
+       SaveFiltersPopupButtonWidget.prototype.onInputChange = function ( value ) {
+               value = value.trim();
+
+               this.applyButton.setDisabled( !value );
+       };
+
+       /**
+        * Respond to input keyup event, this is the way to intercept 'escape' key
+        *
+        * @param {jQuery.Event} e Event data
+        * @return {boolean} false
+        */
+       SaveFiltersPopupButtonWidget.prototype.onInputKeyup = function ( e ) {
+               if ( e.which === OO.ui.Keys.ESCAPE ) {
+                       this.popup.toggle( false );
+                       return false;
+               }
+       };
+
+       /**
+        * Respond to popup ready event
+        */
+       SaveFiltersPopupButtonWidget.prototype.onPopupReady = function () {
+               this.input.focus();
+       };
+
+       /**
+        * Respond to "set as default" checkbox change
+        * @param {boolean} checked State of the checkbox
+        */
+       SaveFiltersPopupButtonWidget.prototype.onSetAsDefaultChange = function ( checked ) {
+               var messageKey = checked ?
+                       'rcfilters-savedqueries-apply-and-setdefault-label' :
+                       'rcfilters-savedqueries-apply-label';
+
+               this.applyButton
+                       .setIcon( checked ? 'pushPin' : null )
+                       .setLabel( mw.msg( messageKey ) );
+       };
+
+       /**
+        * Respond to cancel button click event
+        */
+       SaveFiltersPopupButtonWidget.prototype.onCancelButtonClick = function () {
+               this.popup.toggle( false );
+       };
+
+       /**
+        * Respond to apply button click event
+        */
+       SaveFiltersPopupButtonWidget.prototype.onApplyButtonClick = function () {
+               this.apply();
+       };
+
+       /**
+        * Apply and add the new quick link
+        */
+       SaveFiltersPopupButtonWidget.prototype.apply = function () {
+               var label = this.input.getValue().trim();
+
+               // This condition is more for sanity-check, since the
+               // apply button should be disabled if the label is empty
+               if ( label ) {
+                       this.controller.saveCurrentQuery( label, this.setAsDefaultCheckbox.isSelected() );
+                       this.input.setValue( '' );
+                       this.setAsDefaultCheckbox.setSelected( false );
+                       this.popup.toggle( false );
+
+                       this.emit( 'saveCurrent' );
+               }
+       };
+
+       module.exports = SaveFiltersPopupButtonWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/SavedLinksListItemWidget.js b/resources/src/mediawiki.rcfilters/ui/SavedLinksListItemWidget.js
new file mode 100644 (file)
index 0000000..ceb5ef8
--- /dev/null
@@ -0,0 +1,333 @@
+( function () {
+       /**
+        * Quick links menu option widget
+        *
+        * @class mw.rcfilters.ui.SavedLinksListItemWidget
+        * @extends OO.ui.Widget
+        * @mixins OO.ui.mixin.LabelElement
+        * @mixins OO.ui.mixin.IconElement
+        * @mixins OO.ui.mixin.TitledElement
+        *
+        * @constructor
+        * @param {mw.rcfilters.dm.SavedQueryItemModel} model View model
+        * @param {Object} [config] Configuration object
+        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+        */
+       var SavedLinksListItemWidget = function MwRcfiltersUiSavedLinksListWidget( model, config ) {
+               config = config || {};
+
+               this.model = model;
+
+               // Parent
+               SavedLinksListItemWidget.parent.call( this, $.extend( {
+                       data: this.model.getID()
+               }, config ) );
+
+               // Mixin constructors
+               OO.ui.mixin.LabelElement.call( this, $.extend( {
+                       label: this.model.getLabel()
+               }, config ) );
+               OO.ui.mixin.IconElement.call( this, $.extend( {
+                       icon: ''
+               }, config ) );
+               OO.ui.mixin.TitledElement.call( this, $.extend( {
+                       title: this.model.getLabel()
+               }, config ) );
+
+               this.edit = false;
+               this.$overlay = config.$overlay || this.$element;
+
+               this.popupButton = new OO.ui.ButtonWidget( {
+                       classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-button' ],
+                       icon: 'ellipsis',
+                       framed: false
+               } );
+               this.menu = new OO.ui.MenuSelectWidget( {
+                       classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-menu' ],
+                       widget: this.popupButton,
+                       width: 200,
+                       horizontalPosition: 'end',
+                       $floatableContainer: this.popupButton.$element,
+                       items: [
+                               new OO.ui.MenuOptionWidget( {
+                                       data: 'edit',
+                                       icon: 'edit',
+                                       label: mw.msg( 'rcfilters-savedqueries-rename' )
+                               } ),
+                               new OO.ui.MenuOptionWidget( {
+                                       data: 'delete',
+                                       icon: 'trash',
+                                       label: mw.msg( 'rcfilters-savedqueries-remove' )
+                               } ),
+                               new OO.ui.MenuOptionWidget( {
+                                       data: 'default',
+                                       icon: 'pushPin',
+                                       label: mw.msg( 'rcfilters-savedqueries-setdefault' )
+                               } )
+                       ]
+               } );
+
+               this.editInput = new OO.ui.TextInputWidget( {
+                       classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-input' ]
+               } );
+               this.saveButton = new OO.ui.ButtonWidget( {
+                       icon: 'check',
+                       flags: [ 'primary', 'progressive' ]
+               } );
+               this.toggleEdit( false );
+
+               // Events
+               this.model.connect( this, { update: 'onModelUpdate' } );
+               this.popupButton.connect( this, { click: 'onPopupButtonClick' } );
+               this.menu.connect( this, {
+                       choose: 'onMenuChoose'
+               } );
+               this.saveButton.connect( this, { click: 'save' } );
+               this.editInput.connect( this, {
+                       change: 'onInputChange',
+                       enter: 'save'
+               } );
+               this.editInput.$input.on( {
+                       blur: this.onInputBlur.bind( this ),
+                       keyup: this.onInputKeyup.bind( this )
+               } );
+               this.$element.on( { click: this.onClick.bind( this ) } );
+               this.$label.on( { click: this.onClick.bind( this ) } );
+               this.$icon.on( { click: this.onDefaultIconClick.bind( this ) } );
+               // Prevent propagation on mousedown for the save button
+               // so the menu doesn't close
+               this.saveButton.$element.on( { mousedown: function () {
+                       return false;
+               } } );
+
+               // Initialize
+               this.toggleDefault( !!this.model.isDefault() );
+               this.$overlay.append( this.menu.$element );
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget' )
+                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-query-' + this.model.getID() )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-table' )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-row' )
+                                                       .append(
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-content' )
+                                                                       .append(
+                                                                               this.$label
+                                                                                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-label' ),
+                                                                               this.editInput.$element,
+                                                                               this.saveButton.$element
+                                                                       ),
+                                                               $( '<div>' )
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-icon' )
+                                                                       .append( this.$icon ),
+                                                               this.popupButton.$element
+                                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                       )
+                                       )
+                       );
+       };
+
+       /* Initialization */
+       OO.inheritClass( SavedLinksListItemWidget, OO.ui.Widget );
+       OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.LabelElement );
+       OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.IconElement );
+       OO.mixinClass( SavedLinksListItemWidget, OO.ui.mixin.TitledElement );
+
+       /* Events */
+
+       /**
+        * @event delete
+        *
+        * The delete option was selected for this item
+        */
+
+       /**
+        * @event default
+        * @param {boolean} default Item is default
+        *
+        * The 'make default' option was selected for this item
+        */
+
+       /**
+        * @event edit
+        * @param {string} newLabel New label for the query
+        *
+        * The label has been edited
+        */
+
+       /* Methods */
+
+       /**
+        * Respond to model update event
+        */
+       SavedLinksListItemWidget.prototype.onModelUpdate = function () {
+               this.setLabel( this.model.getLabel() );
+               this.toggleDefault( this.model.isDefault() );
+       };
+
+       /**
+        * Respond to click on the element or label
+        *
+        * @fires click
+        */
+       SavedLinksListItemWidget.prototype.onClick = function () {
+               if ( !this.editing ) {
+                       this.emit( 'click' );
+               }
+       };
+
+       /**
+        * Respond to click on the 'default' icon. Open the submenu where the
+        * default state can be changed.
+        *
+        * @return {boolean} false
+        */
+       SavedLinksListItemWidget.prototype.onDefaultIconClick = function () {
+               this.menu.toggle();
+               return false;
+       };
+
+       /**
+        * Respond to popup button click event
+        */
+       SavedLinksListItemWidget.prototype.onPopupButtonClick = function () {
+               this.menu.toggle();
+       };
+
+       /**
+        * Respond to menu choose event
+        *
+        * @param {OO.ui.MenuOptionWidget} item Chosen item
+        * @fires delete
+        * @fires default
+        */
+       SavedLinksListItemWidget.prototype.onMenuChoose = function ( item ) {
+               var action = item.getData();
+
+               if ( action === 'edit' ) {
+                       this.toggleEdit( true );
+               } else if ( action === 'delete' ) {
+                       this.emit( 'delete' );
+               } else if ( action === 'default' ) {
+                       this.emit( 'default', !this.default );
+               }
+               // Reset selected
+               this.menu.selectItem( null );
+               // Close the menu
+               this.menu.toggle( false );
+       };
+
+       /**
+        * Respond to input keyup event, this is the way to intercept 'escape' key
+        *
+        * @param {jQuery.Event} e Event data
+        * @return {boolean} false
+        */
+       SavedLinksListItemWidget.prototype.onInputKeyup = function ( e ) {
+               if ( e.which === OO.ui.Keys.ESCAPE ) {
+                       // Return the input to the original label
+                       this.editInput.setValue( this.getLabel() );
+                       this.toggleEdit( false );
+                       return false;
+               }
+       };
+
+       /**
+        * Respond to blur event on the input
+        */
+       SavedLinksListItemWidget.prototype.onInputBlur = function () {
+               this.save();
+
+               // Whether the save succeeded or not, the input-blur event
+               // means we need to cancel editing mode
+               this.toggleEdit( false );
+       };
+
+       /**
+        * Respond to input change event
+        *
+        * @param {string} value Input value
+        */
+       SavedLinksListItemWidget.prototype.onInputChange = function ( value ) {
+               value = value.trim();
+
+               this.saveButton.setDisabled( !value );
+       };
+
+       /**
+        * Save the name of the query
+        *
+        * @param {string} [value] The value to save
+        * @fires edit
+        */
+       SavedLinksListItemWidget.prototype.save = function () {
+               var value = this.editInput.getValue().trim();
+
+               if ( value ) {
+                       this.emit( 'edit', value );
+                       this.toggleEdit( false );
+               }
+       };
+
+       /**
+        * Toggle edit mode on this widget
+        *
+        * @param {boolean} isEdit Widget is in edit mode
+        */
+       SavedLinksListItemWidget.prototype.toggleEdit = function ( isEdit ) {
+               isEdit = isEdit === undefined ? !this.editing : isEdit;
+
+               if ( this.editing !== isEdit ) {
+                       this.$element.toggleClass( 'mw-rcfilters-ui-savedLinksListItemWidget-edit', isEdit );
+                       this.editInput.setValue( this.getLabel() );
+
+                       this.editInput.toggle( isEdit );
+                       this.$label.toggleClass( 'oo-ui-element-hidden', isEdit );
+                       this.$icon.toggleClass( 'oo-ui-element-hidden', isEdit );
+                       this.popupButton.toggle( !isEdit );
+                       this.saveButton.toggle( isEdit );
+
+                       if ( isEdit ) {
+                               this.editInput.$input.trigger( 'focus' );
+                       }
+                       this.editing = isEdit;
+               }
+       };
+
+       /**
+        * Toggle default this widget
+        *
+        * @param {boolean} isDefault This item is default
+        */
+       SavedLinksListItemWidget.prototype.toggleDefault = function ( isDefault ) {
+               isDefault = isDefault === undefined ? !this.default : isDefault;
+
+               if ( this.default !== isDefault ) {
+                       this.default = isDefault;
+                       this.setIcon( this.default ? 'pushPin' : '' );
+                       this.menu.findItemFromData( 'default' ).setLabel(
+                               this.default ?
+                                       mw.msg( 'rcfilters-savedqueries-unsetdefault' ) :
+                                       mw.msg( 'rcfilters-savedqueries-setdefault' )
+                       );
+               }
+       };
+
+       /**
+        * Get item ID
+        *
+        * @return {string} Query identifier
+        */
+       SavedLinksListItemWidget.prototype.getID = function () {
+               return this.model.getID();
+       };
+
+       module.exports = SavedLinksListItemWidget;
+
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/SavedLinksListWidget.js b/resources/src/mediawiki.rcfilters/ui/SavedLinksListWidget.js
new file mode 100644 (file)
index 0000000..5422daf
--- /dev/null
@@ -0,0 +1,159 @@
+( function () {
+       var GroupWidget = require( './GroupWidget.js' ),
+               SavedLinksListItemWidget = require( './SavedLinksListItemWidget.js' ),
+               SavedLinksListWidget;
+
+       /**
+        * Quick links widget
+        *
+        * @class mw.rcfilters.ui.SavedLinksListWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller Controller
+        * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
+        * @param {Object} [config] Configuration object
+        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+        */
+       SavedLinksListWidget = function MwRcfiltersUiSavedLinksListWidget( controller, model, config ) {
+               var $labelNoEntries = $( '<div>' )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-title' )
+                                       .text( mw.msg( 'rcfilters-quickfilters-placeholder-title' ) ),
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-description' )
+                                       .text( mw.msg( 'rcfilters-quickfilters-placeholder-description' ) )
+                       );
+
+               config = config || {};
+
+               // Parent
+               SavedLinksListWidget.parent.call( this, config );
+
+               this.controller = controller;
+               this.model = model;
+               this.$overlay = config.$overlay || this.$element;
+
+               this.placeholderItem = new OO.ui.DecoratedOptionWidget( {
+                       classes: [ 'mw-rcfilters-ui-savedLinksListWidget-placeholder' ],
+                       label: $labelNoEntries,
+                       icon: 'bookmark'
+               } );
+
+               this.menu = new GroupWidget( {
+                       events: {
+                               click: 'menuItemClick',
+                               delete: 'menuItemDelete',
+                               default: 'menuItemDefault',
+                               edit: 'menuItemEdit'
+                       },
+                       classes: [ 'mw-rcfilters-ui-savedLinksListWidget-menu' ],
+                       items: [ this.placeholderItem ]
+               } );
+               this.button = new OO.ui.PopupButtonWidget( {
+                       classes: [ 'mw-rcfilters-ui-savedLinksListWidget-button' ],
+                       label: mw.msg( 'rcfilters-quickfilters' ),
+                       icon: 'bookmark',
+                       indicator: 'down',
+                       $overlay: this.$overlay,
+                       popup: {
+                               width: 300,
+                               anchor: false,
+                               align: 'backwards',
+                               $autoCloseIgnore: this.$overlay,
+                               $content: this.menu.$element
+                       }
+               } );
+
+               // Events
+               this.model.connect( this, {
+                       add: 'onModelAddItem',
+                       remove: 'onModelRemoveItem'
+               } );
+               this.menu.connect( this, {
+                       menuItemClick: 'onMenuItemClick',
+                       menuItemDelete: 'onMenuItemRemove',
+                       menuItemDefault: 'onMenuItemDefault',
+                       menuItemEdit: 'onMenuItemEdit'
+               } );
+
+               this.placeholderItem.toggle( this.model.isEmpty() );
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-savedLinksListWidget' )
+                       .append( this.button.$element );
+       };
+
+       /* Initialization */
+       OO.inheritClass( SavedLinksListWidget, OO.ui.Widget );
+
+       /* Methods */
+
+       /**
+        * Respond to menu item click event
+        *
+        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+        */
+       SavedLinksListWidget.prototype.onMenuItemClick = function ( item ) {
+               this.controller.applySavedQuery( item.getID() );
+               this.button.popup.toggle( false );
+       };
+
+       /**
+        * Respond to menu item remove event
+        *
+        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+        */
+       SavedLinksListWidget.prototype.onMenuItemRemove = function ( item ) {
+               this.controller.removeSavedQuery( item.getID() );
+       };
+
+       /**
+        * Respond to menu item default event
+        *
+        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+        * @param {boolean} isDefault Item is default
+        */
+       SavedLinksListWidget.prototype.onMenuItemDefault = function ( item, isDefault ) {
+               this.controller.setDefaultSavedQuery( isDefault ? item.getID() : null );
+       };
+
+       /**
+        * Respond to menu item edit event
+        *
+        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+        * @param {string} newLabel New label
+        */
+       SavedLinksListWidget.prototype.onMenuItemEdit = function ( item, newLabel ) {
+               this.controller.renameSavedQuery( item.getID(), newLabel );
+       };
+
+       /**
+        * Respond to menu add item event
+        *
+        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+        */
+       SavedLinksListWidget.prototype.onModelAddItem = function ( item ) {
+               if ( this.menu.findItemFromData( item.getID() ) ) {
+                       return;
+               }
+
+               this.menu.addItems( [
+                       new SavedLinksListItemWidget( item, { $overlay: this.$overlay } )
+               ] );
+               this.placeholderItem.toggle( this.model.isEmpty() );
+       };
+
+       /**
+        * Respond to menu remove item event
+        *
+        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
+        */
+       SavedLinksListWidget.prototype.onModelRemoveItem = function ( item ) {
+               this.menu.removeItems( [ this.menu.findItemFromData( item.getID() ) ] );
+               this.placeholderItem.toggle( this.model.isEmpty() );
+       };
+
+       module.exports = SavedLinksListWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/TagItemWidget.js b/resources/src/mediawiki.rcfilters/ui/TagItemWidget.js
new file mode 100644 (file)
index 0000000..d66c5b5
--- /dev/null
@@ -0,0 +1,225 @@
+( function () {
+       /**
+        * Extend OOUI's TagItemWidget to also display a popup on hover.
+        *
+        * @class mw.rcfilters.ui.TagItemWidget
+        * @extends OO.ui.TagItemWidget
+        * @mixins OO.ui.mixin.PopupElement
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
+        * @param {mw.rcfilters.dm.FilterItem} invertModel
+        * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
+        * @param {Object} config Configuration object
+        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
+        */
+       var TagItemWidget = function MwRcfiltersUiTagItemWidget(
+               controller, filtersViewModel, invertModel, itemModel, config
+       ) {
+               // Configuration initialization
+               config = config || {};
+
+               this.controller = controller;
+               this.invertModel = invertModel;
+               this.filtersViewModel = filtersViewModel;
+               this.itemModel = itemModel;
+               this.selected = false;
+
+               TagItemWidget.parent.call( this, $.extend( {
+                       data: this.itemModel.getName()
+               }, config ) );
+
+               this.$overlay = config.$overlay || this.$element;
+               this.popupLabel = new OO.ui.LabelWidget();
+
+               // Mixin constructors
+               OO.ui.mixin.PopupElement.call( this, $.extend( {
+                       popup: {
+                               padded: false,
+                               align: 'center',
+                               position: 'above',
+                               $content: $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-tagItemWidget-popup-content' )
+                                       .append( this.popupLabel.$element ),
+                               $floatableContainer: this.$element,
+                               classes: [ 'mw-rcfilters-ui-tagItemWidget-popup' ]
+                       }
+               }, config ) );
+
+               this.popupTimeoutShow = null;
+               this.popupTimeoutHide = null;
+
+               this.$highlight = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-tagItemWidget-highlight' );
+
+               // Add title attribute with the item label to 'x' button
+               this.closeButton.setTitle( mw.msg( 'rcfilters-tag-remove', this.itemModel.getLabel() ) );
+
+               // Events
+               this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
+               this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
+               this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
+
+               // Initialization
+               this.$overlay.append( this.popup.$element );
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-tagItemWidget' )
+                       .prepend( this.$highlight )
+                       .attr( 'aria-haspopup', 'true' )
+                       .on( 'mouseenter', this.onMouseEnter.bind( this ) )
+                       .on( 'mouseleave', this.onMouseLeave.bind( this ) );
+
+               this.updateUiBasedOnState();
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( TagItemWidget, OO.ui.TagItemWidget );
+       OO.mixinClass( TagItemWidget, OO.ui.mixin.PopupElement );
+
+       /* Methods */
+
+       /**
+        * Respond to model update event
+        */
+       TagItemWidget.prototype.updateUiBasedOnState = function () {
+               // Update label if needed
+               var labelMsg = this.itemModel.getLabelMessageKey( this.invertModel.isSelected() );
+               if ( labelMsg ) {
+                       this.setLabel( $( '<div>' ).append(
+                               $( '<bdi>' ).html(
+                                       mw.message( labelMsg, mw.html.escape( this.itemModel.getLabel() ) ).parse()
+                               )
+                       ).contents() );
+               } else {
+                       this.setLabel(
+                               $( '<bdi>' ).append(
+                                       this.itemModel.getLabel()
+                               )
+                       );
+               }
+
+               this.setCurrentMuteState();
+               this.setHighlightColor();
+       };
+
+       /**
+        * Set the current highlight color for this item
+        */
+       TagItemWidget.prototype.setHighlightColor = function () {
+               var selectedColor = this.filtersViewModel.isHighlightEnabled() && this.itemModel.isHighlighted ?
+                       this.itemModel.getHighlightColor() :
+                       null;
+
+               this.$highlight
+                       .attr( 'data-color', selectedColor )
+                       .toggleClass(
+                               'mw-rcfilters-ui-tagItemWidget-highlight-highlighted',
+                               !!selectedColor
+                       );
+       };
+
+       /**
+        * Set the current mute state for this item
+        */
+       TagItemWidget.prototype.setCurrentMuteState = function () {};
+
+       /**
+        * Respond to mouse enter event
+        */
+       TagItemWidget.prototype.onMouseEnter = function () {
+               var labelText = this.itemModel.getStateMessage();
+
+               if ( labelText ) {
+                       this.popupLabel.setLabel( labelText );
+
+                       // Set timeout for the popup to show
+                       this.popupTimeoutShow = setTimeout( function () {
+                               this.popup.toggle( true );
+                       }.bind( this ), 500 );
+
+                       // Cancel the hide timeout
+                       clearTimeout( this.popupTimeoutHide );
+                       this.popupTimeoutHide = null;
+               }
+       };
+
+       /**
+        * Respond to mouse leave event
+        */
+       TagItemWidget.prototype.onMouseLeave = function () {
+               this.popupTimeoutHide = setTimeout( function () {
+                       this.popup.toggle( false );
+               }.bind( this ), 250 );
+
+               // Clear the show timeout
+               clearTimeout( this.popupTimeoutShow );
+               this.popupTimeoutShow = null;
+       };
+
+       /**
+        * Set selected state on this widget
+        *
+        * @param {boolean} [isSelected] Widget is selected
+        */
+       TagItemWidget.prototype.toggleSelected = function ( isSelected ) {
+               isSelected = isSelected !== undefined ? isSelected : !this.selected;
+
+               if ( this.selected !== isSelected ) {
+                       this.selected = isSelected;
+
+                       this.$element.toggleClass( 'mw-rcfilters-ui-tagItemWidget-selected', this.selected );
+               }
+       };
+
+       /**
+        * Get the selected state of this widget
+        *
+        * @return {boolean} Tag is selected
+        */
+       TagItemWidget.prototype.isSelected = function () {
+               return this.selected;
+       };
+
+       /**
+        * Get item name
+        *
+        * @return {string} Filter name
+        */
+       TagItemWidget.prototype.getName = function () {
+               return this.itemModel.getName();
+       };
+
+       /**
+        * Get item model
+        *
+        * @return {string} Filter model
+        */
+       TagItemWidget.prototype.getModel = function () {
+               return this.itemModel;
+       };
+
+       /**
+        * Get item view
+        *
+        * @return {string} Filter view
+        */
+       TagItemWidget.prototype.getView = function () {
+               return this.itemModel.getGroupModel().getView();
+       };
+
+       /**
+        * Remove and destroy external elements of this widget
+        */
+       TagItemWidget.prototype.destroy = function () {
+               // Destroy the popup
+               this.popup.$element.detach();
+
+               // Disconnect events
+               this.itemModel.disconnect( this );
+               this.closeButton.disconnect( this );
+       };
+
+       module.exports = TagItemWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/ValuePickerWidget.js b/resources/src/mediawiki.rcfilters/ui/ValuePickerWidget.js
new file mode 100644 (file)
index 0000000..ebd81c8
--- /dev/null
@@ -0,0 +1,114 @@
+( function () {
+       /**
+        * Widget defining the behavior used to choose from a set of values
+        * in a single_value group
+        *
+        * @class mw.rcfilters.ui.ValuePickerWidget
+        * @extends OO.ui.Widget
+        * @mixins OO.ui.mixin.LabelElement
+        *
+        * @constructor
+        * @param {mw.rcfilters.dm.FilterGroup} model Group model
+        * @param {Object} [config] Configuration object
+        * @cfg {Function} [itemFilter] A filter function for the items from the
+        *  model. If not given, all items will be included. The function must
+        *  handle item models and return a boolean whether the item is included
+        *  or not. Example: function ( itemModel ) { return itemModel.isSelected(); }
+        */
+       var ValuePickerWidget = function MwRcfiltersUiValuePickerWidget( model, config ) {
+               config = config || {};
+
+               // Parent
+               ValuePickerWidget.parent.call( this, config );
+               // Mixin constructors
+               OO.ui.mixin.LabelElement.call( this, config );
+
+               this.model = model;
+               this.itemFilter = config.itemFilter || function () {
+                       return true;
+               };
+
+               // Build the selection from the item models
+               this.selectWidget = new OO.ui.ButtonSelectWidget();
+               this.initializeSelectWidget();
+
+               // Events
+               this.model.connect( this, { update: 'onModelUpdate' } );
+               this.selectWidget.connect( this, { choose: 'onSelectWidgetChoose' } );
+
+               // Initialize
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-valuePickerWidget' )
+                       .append(
+                               this.$label
+                                       .addClass( 'mw-rcfilters-ui-valuePickerWidget-title' ),
+                               this.selectWidget.$element
+                       );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( ValuePickerWidget, OO.ui.Widget );
+       OO.mixinClass( ValuePickerWidget, OO.ui.mixin.LabelElement );
+
+       /* Events */
+
+       /**
+        * @event choose
+        * @param {string} name Item name
+        *
+        * An item has been chosen
+        */
+
+       /* Methods */
+
+       /**
+        * Respond to model update event
+        */
+       ValuePickerWidget.prototype.onModelUpdate = function () {
+               this.selectCurrentModelItem();
+       };
+
+       /**
+        * Respond to select widget choose event
+        *
+        * @param {OO.ui.ButtonOptionWidget} chosenItem Chosen item
+        * @fires choose
+        */
+       ValuePickerWidget.prototype.onSelectWidgetChoose = function ( chosenItem ) {
+               this.emit( 'choose', chosenItem.getData() );
+       };
+
+       /**
+        * Initialize the select widget
+        */
+       ValuePickerWidget.prototype.initializeSelectWidget = function () {
+               var items = this.model.getItems()
+                       .filter( this.itemFilter )
+                       .map( function ( filterItem ) {
+                               return new OO.ui.ButtonOptionWidget( {
+                                       data: filterItem.getName(),
+                                       label: filterItem.getLabel()
+                               } );
+                       } );
+
+               this.selectWidget.clearItems();
+               this.selectWidget.addItems( items );
+
+               this.selectCurrentModelItem();
+       };
+
+       /**
+        * Select the current item that corresponds with the model item
+        * that is currently selected
+        */
+       ValuePickerWidget.prototype.selectCurrentModelItem = function () {
+               var selectedItem = this.model.findSelectedItems()[ 0 ];
+
+               if ( selectedItem ) {
+                       this.selectWidget.selectItemByData( selectedItem.getName() );
+               }
+       };
+
+       module.exports = ValuePickerWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/ViewSwitchWidget.js b/resources/src/mediawiki.rcfilters/ui/ViewSwitchWidget.js
new file mode 100644 (file)
index 0000000..c00d414
--- /dev/null
@@ -0,0 +1,84 @@
+( function () {
+       var GroupWidget = require( './GroupWidget.js' ),
+               ViewSwitchWidget;
+
+       /**
+        * A widget for the footer for the default view, allowing to switch views
+        *
+        * @class mw.rcfilters.ui.ViewSwitchWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller Controller
+        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
+        * @param {Object} [config] Configuration object
+        */
+       ViewSwitchWidget = function MwRcfiltersUiViewSwitchWidget( controller, model, config ) {
+               config = config || {};
+
+               // Parent
+               ViewSwitchWidget.parent.call( this, config );
+
+               this.controller = controller;
+               this.model = model;
+
+               this.buttons = new GroupWidget( {
+                       events: {
+                               click: 'buttonClick'
+                       },
+                       items: [
+                               new OO.ui.ButtonWidget( {
+                                       data: 'namespaces',
+                                       icon: 'article',
+                                       label: mw.msg( 'namespaces' )
+                               } ),
+                               new OO.ui.ButtonWidget( {
+                                       data: 'tags',
+                                       icon: 'tag',
+                                       label: mw.msg( 'rcfilters-view-tags' )
+                               } )
+                       ]
+               } );
+
+               // Events
+               this.model.connect( this, { update: 'onModelUpdate' } );
+               this.buttons.connect( this, { buttonClick: 'onButtonClick' } );
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-viewSwitchWidget' )
+                       .append(
+                               new OO.ui.LabelWidget( {
+                                       label: mw.msg( 'rcfilters-advancedfilters' )
+                               } ).$element,
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-viewSwitchWidget-buttons' )
+                                       .append( this.buttons.$element )
+                       );
+       };
+
+       /* Initialize */
+
+       OO.inheritClass( ViewSwitchWidget, OO.ui.Widget );
+
+       /**
+        * Respond to model update event
+        */
+       ViewSwitchWidget.prototype.onModelUpdate = function () {
+               var currentView = this.model.getCurrentView();
+
+               this.buttons.getItems().forEach( function ( buttonWidget ) {
+                       buttonWidget.setActive( buttonWidget.getData() === currentView );
+               } );
+       };
+
+       /**
+        * Respond to button switch click
+        *
+        * @param {OO.ui.ButtonWidget} buttonWidget Clicked button
+        */
+       ViewSwitchWidget.prototype.onButtonClick = function ( buttonWidget ) {
+               this.controller.switchView( buttonWidget.getData() );
+       };
+
+       module.exports = ViewSwitchWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/WatchlistTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/WatchlistTopSectionWidget.js
new file mode 100644 (file)
index 0000000..16c0533
--- /dev/null
@@ -0,0 +1,88 @@
+( function () {
+       var MarkSeenButtonWidget = require( './MarkSeenButtonWidget.js' ),
+               WatchlistTopSectionWidget;
+       /**
+        * Top section (between page title and filters) on Special:Watchlist
+        *
+        * @class mw.rcfilters.ui.WatchlistTopSectionWidget
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {mw.rcfilters.Controller} controller
+        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
+        * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
+        * @param {jQuery} $watchlistDetails Content of the 'details' section that includes watched pages count
+        * @param {Object} [config] Configuration object
+        */
+       WatchlistTopSectionWidget = function MwRcfiltersUiWatchlistTopSectionWidget(
+               controller, changesListModel, savedLinksListWidget, $watchlistDetails, config
+       ) {
+               var editWatchlistButton,
+                       markSeenButton,
+                       $topTable,
+                       $bottomTable,
+                       $separator;
+               config = config || {};
+
+               // Parent
+               WatchlistTopSectionWidget.parent.call( this, config );
+
+               editWatchlistButton = new OO.ui.ButtonWidget( {
+                       label: mw.msg( 'rcfilters-watchlist-edit-watchlist-button' ),
+                       icon: 'edit',
+                       href: require( '../config.json' ).StructuredChangeFiltersEditWatchlistUrl
+               } );
+               markSeenButton = new MarkSeenButtonWidget( controller, changesListModel );
+
+               $topTable = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-table' )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-row' )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-watchlistDetails' )
+                                                       .append( $watchlistDetails )
+                                       )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-editWatchlistButton' )
+                                                       .append( editWatchlistButton.$element )
+                                       )
+                       );
+
+               $bottomTable = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-table' )
+                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinksTable' )
+                       .append(
+                               $( '<div>' )
+                                       .addClass( 'mw-rcfilters-ui-row' )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                       .append( markSeenButton.$element )
+                                       )
+                                       .append(
+                                               $( '<div>' )
+                                                       .addClass( 'mw-rcfilters-ui-cell' )
+                                                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinks' )
+                                                       .append( savedLinksListWidget.$element )
+                                       )
+                       );
+
+               $separator = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-separator' );
+
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget' )
+                       .append( $topTable, $separator, $bottomTable );
+       };
+
+       /* Initialization */
+
+       OO.inheritClass( WatchlistTopSectionWidget, OO.ui.Widget );
+
+       module.exports = WatchlistTopSectionWidget;
+}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.js
deleted file mode 100644 (file)
index e907a15..0000000
+++ /dev/null
@@ -1,168 +0,0 @@
-( function () {
-       /**
-        * Widget defining the button controlling the popup for the number of results
-        *
-        * @class
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {Object} [config] Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        */
-       mw.rcfilters.ui.ChangesLimitAndDateButtonWidget = function MwRcfiltersUiChangesLimitWidget( controller, model, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.parent.call( this, config );
-
-               this.controller = controller;
-               this.model = model;
-
-               this.$overlay = config.$overlay || this.$element;
-
-               this.button = null;
-               this.limitGroupModel = null;
-               this.groupByPageItemModel = null;
-               this.daysGroupModel = null;
-
-               this.model.connect( this, {
-                       initialize: 'onModelInitialize'
-               } );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-changesLimitAndDateButtonWidget' );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.ChangesLimitAndDateButtonWidget, OO.ui.Widget );
-
-       /**
-        * Respond to model initialize event
-        */
-       mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.prototype.onModelInitialize = function () {
-               var changesLimitPopupWidget, selectedItem, currentValue, datePopupWidget,
-                       displayGroupModel = this.model.getGroup( 'display' );
-
-               this.limitGroupModel = this.model.getGroup( 'limit' );
-               this.groupByPageItemModel = displayGroupModel.getItemByParamName( 'enhanced' );
-               this.daysGroupModel = this.model.getGroup( 'days' );
-
-               // HACK: We need the model to be ready before we populate the button
-               // and the widget, because we require the filter items for the
-               // limit and their events. This addition is only done after the
-               // model is initialized.
-               // Note: This will be fixed soon!
-               if ( this.limitGroupModel && this.daysGroupModel ) {
-                       changesLimitPopupWidget = new mw.rcfilters.ui.ChangesLimitPopupWidget(
-                               this.limitGroupModel,
-                               this.groupByPageItemModel
-                       );
-
-                       datePopupWidget = new mw.rcfilters.ui.DatePopupWidget(
-                               this.daysGroupModel,
-                               {
-                                       label: mw.msg( 'rcfilters-date-popup-title' )
-                               }
-                       );
-
-                       selectedItem = this.limitGroupModel.findSelectedItems()[ 0 ];
-                       currentValue = ( selectedItem && selectedItem.getLabel() ) ||
-                               mw.language.convertNumber( this.limitGroupModel.getDefaultParamValue() );
-
-                       this.button = new OO.ui.PopupButtonWidget( {
-                               icon: 'settings',
-                               indicator: 'down',
-                               label: mw.msg( 'rcfilters-limit-and-date-label', currentValue ),
-                               $overlay: this.$overlay,
-                               popup: {
-                                       width: 300,
-                                       padded: false,
-                                       anchor: false,
-                                       align: 'backwards',
-                                       $autoCloseIgnore: this.$overlay,
-                                       $content: $( '<div>' ).append(
-                                               // TODO: Merge ChangesLimitPopupWidget with DatePopupWidget into one common widget
-                                               changesLimitPopupWidget.$element,
-                                               datePopupWidget.$element
-                                       )
-                               }
-                       } );
-                       this.updateButtonLabel();
-
-                       // Events
-                       this.limitGroupModel.connect( this, { update: 'updateButtonLabel' } );
-                       this.daysGroupModel.connect( this, { update: 'updateButtonLabel' } );
-                       changesLimitPopupWidget.connect( this, {
-                               limit: 'onPopupLimit',
-                               groupByPage: 'onPopupGroupByPage'
-                       } );
-                       datePopupWidget.connect( this, { days: 'onPopupDays' } );
-
-                       this.$element.append( this.button.$element );
-               }
-       };
-
-       /**
-        * Respond to popup limit change event
-        *
-        * @param {string} filterName Chosen filter name
-        */
-       mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.prototype.onPopupLimit = function ( filterName ) {
-               var item = this.limitGroupModel.getItemByName( filterName );
-
-               this.controller.toggleFilterSelect( filterName, true );
-               this.controller.updateLimitDefault( item.getParamName() );
-               this.button.popup.toggle( false );
-       };
-
-       /**
-        * Respond to popup limit change event
-        *
-        * @param {boolean} isGrouped The result set is grouped by page
-        */
-       mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.prototype.onPopupGroupByPage = function ( isGrouped ) {
-               this.controller.toggleFilterSelect( this.groupByPageItemModel.getName(), isGrouped );
-               this.controller.updateGroupByPageDefault( isGrouped );
-               this.button.popup.toggle( false );
-       };
-
-       /**
-        * Respond to popup limit change event
-        *
-        * @param {string} filterName Chosen filter name
-        */
-       mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.prototype.onPopupDays = function ( filterName ) {
-               var item = this.daysGroupModel.getItemByName( filterName );
-
-               this.controller.toggleFilterSelect( filterName, true );
-               this.controller.updateDaysDefault( item.getParamName() );
-               this.button.popup.toggle( false );
-       };
-
-       /**
-        * Respond to limit choose event
-        *
-        * @param {string} filterName Filter name
-        */
-       mw.rcfilters.ui.ChangesLimitAndDateButtonWidget.prototype.updateButtonLabel = function () {
-               var message,
-                       limit = this.limitGroupModel.findSelectedItems()[ 0 ],
-                       label = limit && limit.getLabel(),
-                       days = this.daysGroupModel.findSelectedItems()[ 0 ],
-                       daysParamName = Number( days.getParamName() ) < 1 ?
-                               'rcfilters-days-show-hours' :
-                               'rcfilters-days-show-days';
-
-               // Update the label
-               if ( label && days ) {
-                       message = mw.msg( 'rcfilters-limit-and-date-label', label,
-                               mw.msg( daysParamName, days.getLabel() )
-                       );
-                       this.button.setLabel( message );
-               }
-       };
-
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitPopupWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesLimitPopupWidget.js
deleted file mode 100644 (file)
index 8cf9657..0000000
+++ /dev/null
@@ -1,78 +0,0 @@
-( function () {
-       /**
-        * Widget defining the popup to choose number of results
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FilterGroup} limitModel Group model for 'limit'
-        * @param {mw.rcfilters.dm.FilterItem} groupByPageItemModel Group model for 'limit'
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.ChangesLimitPopupWidget = function MwRcfiltersUiChangesLimitPopupWidget( limitModel, groupByPageItemModel, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.ChangesLimitPopupWidget.parent.call( this, config );
-
-               this.limitModel = limitModel;
-               this.groupByPageItemModel = groupByPageItemModel;
-
-               this.valuePicker = new mw.rcfilters.ui.ValuePickerWidget(
-                       this.limitModel,
-                       {
-                               label: mw.msg( 'rcfilters-limit-title' )
-                       }
-               );
-
-               this.groupByPageCheckbox = new OO.ui.CheckboxInputWidget( {
-                       selected: this.groupByPageItemModel.isSelected()
-               } );
-
-               // Events
-               this.valuePicker.connect( this, { choose: [ 'emit', 'limit' ] } );
-               this.groupByPageCheckbox.connect( this, { change: [ 'emit', 'groupByPage' ] } );
-               this.groupByPageItemModel.connect( this, { update: 'onGroupByPageModelUpdate' } );
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-changesLimitPopupWidget' )
-                       .append(
-                               this.valuePicker.$element,
-                               new OO.ui.FieldLayout(
-                                       this.groupByPageCheckbox,
-                                       {
-                                               align: 'inline',
-                                               label: mw.msg( 'rcfilters-group-results-by-page' )
-                                       }
-                               ).$element
-                       );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.ChangesLimitPopupWidget, OO.ui.Widget );
-
-       /* Events */
-
-       /**
-        * @event limit
-        * @param {string} name Item name
-        *
-        * A limit item was chosen
-        */
-
-       /**
-        * @event groupByPage
-        * @param {boolean} isGrouped The results are grouped by page
-        *
-        * Results are grouped by page
-        */
-
-       /**
-        * Respond to group by page model update
-        */
-       mw.rcfilters.ui.ChangesLimitPopupWidget.prototype.onGroupByPageModelUpdate = function () {
-               this.groupByPageCheckbox.setSelected( this.groupByPageItemModel.isSelected() );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ChangesListWrapperWidget.js
deleted file mode 100644 (file)
index b76078e..0000000
+++ /dev/null
@@ -1,385 +0,0 @@
-( function () {
-       /**
-        * List of changes
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel View model
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListViewModel View model
-        * @param {mw.rcfilters.Controller} controller
-        * @param {jQuery} $changesListRoot Root element of the changes list to attach to
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget = function MwRcfiltersUiChangesListWrapperWidget(
-               filtersViewModel,
-               changesListViewModel,
-               controller,
-               $changesListRoot,
-               config
-       ) {
-               config = $.extend( {}, config, {
-                       $element: $changesListRoot
-               } );
-
-               // Parent
-               mw.rcfilters.ui.ChangesListWrapperWidget.parent.call( this, config );
-
-               this.filtersViewModel = filtersViewModel;
-               this.changesListViewModel = changesListViewModel;
-               this.controller = controller;
-               this.highlightClasses = null;
-
-               // Events
-               this.filtersViewModel.connect( this, {
-                       itemUpdate: 'onItemUpdate',
-                       highlightChange: 'onHighlightChange'
-               } );
-               this.changesListViewModel.connect( this, {
-                       invalidate: 'onModelInvalidate',
-                       update: 'onModelUpdate'
-               } );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget' )
-                       // We handle our own display/hide of the empty results message
-                       // We keep the timeout class here and remove it later, since at this
-                       // stage it is still needed to identify that the timeout occurred.
-                       .removeClass( 'mw-changeslist-empty' );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.ChangesListWrapperWidget, OO.ui.Widget );
-
-       /**
-        * Get all available highlight classes
-        *
-        * @return {string[]} An array of available highlight class names
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.getHighlightClasses = function () {
-               if ( !this.highlightClasses || !this.highlightClasses.length ) {
-                       this.highlightClasses = this.filtersViewModel.getItemsSupportingHighlights()
-                               .map( function ( filterItem ) {
-                                       return filterItem.getCssClass();
-                               } );
-               }
-
-               return this.highlightClasses;
-       };
-
-       /**
-        * Respond to the highlight feature being toggled on and off
-        *
-        * @param {boolean} highlightEnabled
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.onHighlightChange = function ( highlightEnabled ) {
-               if ( highlightEnabled ) {
-                       this.applyHighlight();
-               } else {
-                       this.clearHighlight();
-               }
-       };
-
-       /**
-        * Respond to a filter item model update
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.onItemUpdate = function () {
-               if ( this.controller.isInitialized() && this.filtersViewModel.isHighlightEnabled() ) {
-                       // this.controller.isInitialized() is still false during page load,
-                       // we don't want to clear/apply highlights at this stage.
-                       this.clearHighlight();
-                       this.applyHighlight();
-               }
-       };
-
-       /**
-        * Respond to changes list model invalidate
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.onModelInvalidate = function () {
-               $( 'body' ).addClass( 'mw-rcfilters-ui-loading' );
-       };
-
-       /**
-        * Respond to changes list model update
-        *
-        * @param {jQuery|string} $changesListContent The content of the updated changes list
-        * @param {jQuery} $fieldset The content of the updated fieldset
-        * @param {string} noResultsDetails Type of no result error
-        * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
-        * @param {boolean} from Timestamp of the new changes
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.onModelUpdate = function (
-               $changesListContent, $fieldset, noResultsDetails, isInitialDOM, from
-       ) {
-               var conflictItem,
-                       $message = $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results' ),
-                       isEmpty = $changesListContent === 'NO_RESULTS',
-                       // For enhanced mode, we have to load these modules, which are
-                       // not loaded for the 'regular' mode in the backend
-                       loaderPromise = mw.user.options.get( 'usenewrc' ) ?
-                               mw.loader.using( [ 'mediawiki.special.changeslist.enhanced', 'mediawiki.icon' ] ) :
-                               $.Deferred().resolve(),
-                       widget = this;
-
-               this.$element.toggleClass( 'mw-changeslist', !isEmpty );
-               if ( isEmpty ) {
-                       this.$element.empty();
-
-                       if ( this.filtersViewModel.hasConflict() ) {
-                               conflictItem = this.filtersViewModel.getFirstConflictedItem();
-
-                               $message
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-conflict' )
-                                                       .text( mw.message( 'rcfilters-noresults-conflict' ).text() ),
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-message' )
-                                                       .text( mw.message( conflictItem.getCurrentConflictResultMessage() ).text() )
-                                       );
-                       } else {
-                               $message
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-noresult' )
-                                                       .text( mw.msg( this.getMsgKeyForNoResults( noResultsDetails ) ) )
-                                       );
-
-                               // remove all classes matching mw-changeslist-*
-                               this.$element.removeClass( function ( elementIndex, allClasses ) {
-                                       return allClasses
-                                               .split( ' ' )
-                                               .filter( function ( className ) {
-                                                       return className.indexOf( 'mw-changeslist-' ) === 0;
-                                               } )
-                                               .join( ' ' );
-                               } );
-                       }
-
-                       this.$element.append( $message );
-               } else {
-                       if ( !isInitialDOM ) {
-                               this.$element.empty().append( $changesListContent );
-
-                               if ( from ) {
-                                       this.emphasizeNewChanges( from );
-                               }
-                       }
-
-                       // Apply highlight
-                       this.applyHighlight();
-
-               }
-
-               this.$element.prepend( $( '<div>' ).addClass( 'mw-changeslist-overlay' ) );
-
-               loaderPromise.done( function () {
-                       if ( !isInitialDOM && !isEmpty ) {
-                               // Make sure enhanced RC re-initializes correctly
-                               mw.hook( 'wikipage.content' ).fire( widget.$element );
-                       }
-
-                       $( 'body' ).removeClass( 'mw-rcfilters-ui-loading' );
-               } );
-       };
-
-       /** Toggles overlay class on changes list
-        *
-        * @param {boolean} isVisible True if overlay should be visible
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.toggleOverlay = function ( isVisible ) {
-               this.$element.toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget--overlaid', isVisible );
-       };
-
-       /**
-        * Map a reason for having no results to its message key
-        *
-        * @param {string} reason One of the NO_RESULTS_* "constant" that represent
-        *   a reason for having no results
-        * @return {string} Key for the message that explains why there is no results in this case
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.getMsgKeyForNoResults = function ( reason ) {
-               var reasonMsgKeyMap = {
-                       NO_RESULTS_NORMAL: 'recentchanges-noresult',
-                       NO_RESULTS_TIMEOUT: 'recentchanges-timeout',
-                       NO_RESULTS_NETWORK_ERROR: 'recentchanges-network',
-                       NO_RESULTS_NO_TARGET_PAGE: 'recentchanges-notargetpage',
-                       NO_RESULTS_INVALID_TARGET_PAGE: 'allpagesbadtitle'
-               };
-               return reasonMsgKeyMap[ reason ];
-       };
-
-       /**
-        * Emphasize the elements (or groups) newer than the 'from' parameter
-        * @param {string} from Anything newer than this is considered 'new'
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.emphasizeNewChanges = function ( from ) {
-               var $firstNew,
-                       $indicator,
-                       $newChanges = $( [] ),
-                       selector = this.inEnhancedMode() ?
-                               'table.mw-enhanced-rc[data-mw-ts]' :
-                               'li[data-mw-ts]',
-                       set = this.$element.find( selector ),
-                       length = set.length;
-
-               set.each( function ( index ) {
-                       var $this = $( this ),
-                               ts = $this.data( 'mw-ts' );
-
-                       if ( ts >= from ) {
-                               $newChanges = $newChanges.add( $this );
-                               $firstNew = $this;
-
-                               // guards against putting the marker after the last element
-                               if ( index === ( length - 1 ) ) {
-                                       $firstNew = null;
-                               }
-                       }
-               } );
-
-               if ( $firstNew ) {
-                       $indicator = $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-previousChangesIndicator' );
-
-                       $firstNew.after( $indicator );
-               }
-
-               // FIXME: Use CSS transition
-               // eslint-disable-next-line jquery/no-fade
-               $newChanges
-                       .hide()
-                       .fadeIn( 1000 );
-       };
-
-       /**
-        * In enhanced mode, we need to check whether the grouped results all have the
-        * same active highlights in order to see whether the "parent" of the group should
-        * be grey or highlighted normally.
-        *
-        * This is called every time highlights are applied.
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.updateEnhancedParentHighlight = function () {
-               var activeHighlightClasses,
-                       $enhancedTopPageCell = this.$element.find( 'table.mw-enhanced-rc.mw-collapsible' );
-
-               activeHighlightClasses = this.filtersViewModel.getCurrentlyUsedHighlightColors().map( function ( color ) {
-                       return 'mw-rcfilters-highlight-color-' + color;
-               } );
-
-               // Go over top pages and their children, and figure out if all sub-pages have the
-               // same highlights between themselves. If they do, the parent should be highlighted
-               // with all colors. If classes are different, the parent should receive a grey
-               // background
-               $enhancedTopPageCell.each( function () {
-                       var firstChildClasses, $rowsWithDifferentHighlights,
-                               $table = $( this );
-
-                       // Collect the relevant classes from the first nested child
-                       firstChildClasses = activeHighlightClasses.filter( function ( className ) {
-                               return $table.find( 'tr:nth-child(2)' ).hasClass( className );
-                       } );
-                       // Filter the non-head rows and see if they all have the same classes
-                       // to the first row
-                       $rowsWithDifferentHighlights = $table.find( 'tr:not(:first-child)' ).filter( function () {
-                               var classesInThisRow,
-                                       $this = $( this );
-
-                               classesInThisRow = activeHighlightClasses.filter( function ( className ) {
-                                       return $this.hasClass( className );
-                               } );
-
-                               return !OO.compare( firstChildClasses, classesInThisRow );
-                       } );
-
-                       // If classes are different, tag the row for using grey color
-                       $table.find( 'tr:first-child' )
-                               .toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey', $rowsWithDifferentHighlights.length > 0 );
-               } );
-       };
-
-       /**
-        * @return {boolean} Whether the changes are grouped by page
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.inEnhancedMode = function () {
-               var uri = new mw.Uri();
-               return ( uri.query.enhanced !== undefined && Number( uri.query.enhanced ) ) ||
-                       ( uri.query.enhanced === undefined && Number( mw.user.options.get( 'usenewrc' ) ) );
-       };
-
-       /**
-        * Apply color classes based on filters highlight configuration
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.applyHighlight = function () {
-               if ( !this.filtersViewModel.isHighlightEnabled() ) {
-                       return;
-               }
-
-               this.filtersViewModel.getHighlightedItems().forEach( function ( filterItem ) {
-                       var $elements = this.$element.find( '.' + filterItem.getCssClass() );
-
-                       // Add highlight class to all highlighted list items
-                       $elements
-                               .addClass(
-                                       'mw-rcfilters-highlighted ' +
-                                       'mw-rcfilters-highlight-color-' + filterItem.getHighlightColor()
-                               );
-
-                       // Track the filters for each item in .data( 'highlightedFilters' )
-                       $elements.each( function () {
-                               var filters = $( this ).data( 'highlightedFilters' );
-                               if ( !filters ) {
-                                       filters = [];
-                                       $( this ).data( 'highlightedFilters', filters );
-                               }
-                               if ( filters.indexOf( filterItem.getLabel() ) === -1 ) {
-                                       filters.push( filterItem.getLabel() );
-                               }
-                       } );
-               }.bind( this ) );
-               // Apply a title to each highlighted item, with a list of filters
-               this.$element.find( '.mw-rcfilters-highlighted' ).each( function () {
-                       var filters = $( this ).data( 'highlightedFilters' );
-
-                       if ( filters && filters.length ) {
-                               $( this ).attr( 'title', mw.msg(
-                                       'rcfilters-highlighted-filters-list',
-                                       filters.join( mw.msg( 'comma-separator' ) )
-                               ) );
-                       }
-
-               } );
-               if ( this.inEnhancedMode() ) {
-                       this.updateEnhancedParentHighlight();
-               }
-
-               // Turn on highlights
-               this.$element.addClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
-       };
-
-       /**
-        * Remove all color classes
-        */
-       mw.rcfilters.ui.ChangesListWrapperWidget.prototype.clearHighlight = function () {
-               // Remove highlight classes
-               mw.rcfilters.HighlightColors.forEach( function ( color ) {
-                       this.$element
-                               .find( '.mw-rcfilters-highlight-color-' + color )
-                               .removeClass( 'mw-rcfilters-highlight-color-' + color );
-               }.bind( this ) );
-
-               this.$element.find( '.mw-rcfilters-highlighted' )
-                       .removeAttr( 'title' )
-                       .removeData( 'highlightedFilters' )
-                       .removeClass( 'mw-rcfilters-highlighted' );
-
-               // Remove grey from enhanced rows
-               this.$element.find( '.mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' )
-                       .removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' );
-
-               // Turn off highlights
-               this.$element.removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CheckboxInputWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.CheckboxInputWidget.js
deleted file mode 100644 (file)
index b273a01..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-( function () {
-       /**
-        * A widget representing a single toggle filter
-        *
-        * @extends OO.ui.CheckboxInputWidget
-        *
-        * @constructor
-        * @param {Object} config Configuration object
-        */
-       mw.rcfilters.ui.CheckboxInputWidget = function MwRcfiltersUiCheckboxInputWidget( config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.CheckboxInputWidget.parent.call( this, config );
-
-               // Event
-               this.$input
-                       // HACK: This widget just pretends to be a checkbox for visual purposes.
-                       // In reality, all actions - setting to true or false, etc - are
-                       // decided by the model, and executed by the controller. This means
-                       // that we want to let the controller and model make the decision
-                       // of whether to check/uncheck this checkboxInputWidget, and for that,
-                       // we have to bypass the browser action that checks/unchecks it during
-                       // click.
-                       .on( 'click', false )
-                       .on( 'change', this.onUserChange.bind( this ) );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.CheckboxInputWidget, OO.ui.CheckboxInputWidget );
-
-       /* Events */
-
-       /**
-        * @event userChange
-        * @param {boolean} Current state of the checkbox
-        *
-        * The user has checked or unchecked this checkbox
-        */
-
-       /* Methods */
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.CheckboxInputWidget.prototype.onEdit = function () {
-               // Similarly to preventing defaults in 'click' event, we want
-               // to prevent this widget from deciding anything about its own
-               // state; it emits a change event and the model and controller
-               // make a decision about what its select state is.
-               // onEdit has a widget.$input.prop( 'checked' ) inside a setTimeout()
-               // so we really want to prevent that from messing with what
-               // the model decides the state of the widget is.
-       };
-
-       /**
-        * Respond to checkbox change by a user and emit 'userChange'.
-        */
-       mw.rcfilters.ui.CheckboxInputWidget.prototype.onUserChange = function () {
-               this.emit( 'userChange', this.$input.prop( 'checked' ) );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DatePopupWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.DatePopupWidget.js
deleted file mode 100644 (file)
index 792ea4b..0000000
+++ /dev/null
@@ -1,66 +0,0 @@
-( function () {
-       /**
-        * Widget defining the popup to choose date for the results
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FilterGroup} model Group model for 'days'
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.DatePopupWidget = function MwRcfiltersUiDatePopupWidget( model, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.ChangesLimitPopupWidget.parent.call( this, config );
-               // Mixin constructors
-               OO.ui.mixin.LabelElement.call( this, config );
-
-               this.model = model;
-
-               this.hoursValuePicker = new mw.rcfilters.ui.ValuePickerWidget(
-                       this.model,
-                       {
-                               classes: [ 'mw-rcfilters-ui-datePopupWidget-hours' ],
-                               label: mw.msg( 'rcfilters-hours-title' ),
-                               itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) < 1; }
-                       }
-               );
-               this.daysValuePicker = new mw.rcfilters.ui.ValuePickerWidget(
-                       this.model,
-                       {
-                               classes: [ 'mw-rcfilters-ui-datePopupWidget-days' ],
-                               label: mw.msg( 'rcfilters-days-title' ),
-                               itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) >= 1; }
-                       }
-               );
-
-               // Events
-               this.hoursValuePicker.connect( this, { choose: [ 'emit', 'days' ] } );
-               this.daysValuePicker.connect( this, { choose: [ 'emit', 'days' ] } );
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-datePopupWidget' )
-                       .append(
-                               this.$label
-                                       .addClass( 'mw-rcfilters-ui-datePopupWidget-title' ),
-                               this.hoursValuePicker.$element,
-                               this.daysValuePicker.$element
-                       );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.DatePopupWidget, OO.ui.Widget );
-       OO.mixinClass( mw.rcfilters.ui.DatePopupWidget, OO.ui.mixin.LabelElement );
-
-       /* Events */
-
-       /**
-        * @event days
-        * @param {string} name Item name
-        *
-        * A days item was chosen
-        */
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemHighlightButton.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterItemHighlightButton.js
deleted file mode 100644 (file)
index 289f1ee..0000000
+++ /dev/null
@@ -1,82 +0,0 @@
-( function () {
-       /**
-        * A button to configure highlight for a filter item
-        *
-        * @extends OO.ui.PopupButtonWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller RCFilters controller
-        * @param {mw.rcfilters.dm.FilterItem} model Filter item model
-        * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.FilterItemHighlightButton = function MwRcfiltersUiFilterItemHighlightButton( controller, model, highlightPopup, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.FilterItemHighlightButton.parent.call( this, $.extend( true, {}, config, {
-                       icon: 'highlight',
-                       indicator: 'down'
-               } ) );
-
-               this.controller = controller;
-               this.model = model;
-               this.popup = highlightPopup;
-
-               // Event
-               this.model.connect( this, { update: 'updateUiBasedOnModel' } );
-               // This lives inside a MenuOptionWidget, which intercepts mousedown
-               // to select the item. We want to prevent that when we click the highlight
-               // button
-               this.$element.on( 'mousedown', function ( e ) {
-                       e.stopPropagation();
-               } );
-
-               this.updateUiBasedOnModel();
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterItemHighlightButton' );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.FilterItemHighlightButton, OO.ui.PopupButtonWidget );
-
-       /* Static Properties */
-
-       /**
-        * @static
-        */
-       mw.rcfilters.ui.FilterItemHighlightButton.static.cancelButtonMouseDownEvents = true;
-
-       /* Methods */
-
-       mw.rcfilters.ui.FilterItemHighlightButton.prototype.onAction = function () {
-               this.popup.setAssociatedButton( this );
-               this.popup.setFilterItem( this.model );
-
-               // Parent method
-               mw.rcfilters.ui.FilterItemHighlightButton.parent.prototype.onAction.call( this );
-       };
-
-       /**
-        * Respond to item model update event
-        */
-       mw.rcfilters.ui.FilterItemHighlightButton.prototype.updateUiBasedOnModel = function () {
-               var currentColor = this.model.getHighlightColor(),
-                       widget = this;
-
-               this.$icon.toggleClass(
-                       'mw-rcfilters-ui-filterItemHighlightButton-circle',
-                       currentColor !== null
-               );
-
-               mw.rcfilters.HighlightColors.forEach( function ( c ) {
-                       widget.$icon
-                               .toggleClass(
-                                       'mw-rcfilters-ui-filterItemHighlightButton-circle-color-' + c,
-                                       c === currentColor
-                               );
-               } );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuHeaderWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuHeaderWidget.js
deleted file mode 100644 (file)
index 1fef7a0..0000000
+++ /dev/null
@@ -1,182 +0,0 @@
-( function () {
-       /**
-        * Menu header for the RCFilters filters menu
-        *
-        * @class
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {Object} config Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        */
-       mw.rcfilters.ui.FilterMenuHeaderWidget = function MwRcfiltersUiFilterMenuHeaderWidget( controller, model, config ) {
-               config = config || {};
-
-               this.controller = controller;
-               this.model = model;
-               this.$overlay = config.$overlay || this.$element;
-
-               // Parent
-               mw.rcfilters.ui.FilterMenuHeaderWidget.parent.call( this, config );
-               OO.ui.mixin.LabelElement.call( this, $.extend( {
-                       label: mw.msg( 'rcfilters-filterlist-title' ),
-                       $label: $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-title' )
-               }, config ) );
-
-               // "Back" to default view button
-               this.backButton = new OO.ui.ButtonWidget( {
-                       icon: 'previous',
-                       framed: false,
-                       title: mw.msg( 'rcfilters-view-return-to-default-tooltip' ),
-                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-backButton' ]
-               } );
-               this.backButton.toggle( this.model.getCurrentView() !== 'default' );
-
-               // Help icon for Tagged edits
-               this.helpIcon = new OO.ui.ButtonWidget( {
-                       icon: 'helpNotice',
-                       framed: false,
-                       title: mw.msg( 'rcfilters-view-tags-help-icon-tooltip' ),
-                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-helpIcon' ],
-                       href: mw.util.getUrl( 'Special:Tags' ),
-                       target: '_blank'
-               } );
-               this.helpIcon.toggle( this.model.getCurrentView() === 'tags' );
-
-               // Highlight button
-               this.highlightButton = new OO.ui.ToggleButtonWidget( {
-                       icon: 'highlight',
-                       label: mw.message( 'rcfilters-highlightbutton-title' ).text(),
-                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-hightlightButton' ]
-               } );
-
-               // Invert namespaces button
-               this.invertNamespacesButton = new OO.ui.ToggleButtonWidget( {
-                       icon: '',
-                       classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-invertNamespacesButton' ]
-               } );
-               this.invertNamespacesButton.toggle( this.model.getCurrentView() === 'namespaces' );
-
-               // Events
-               this.backButton.connect( this, { click: 'onBackButtonClick' } );
-               this.highlightButton
-                       .connect( this, { click: 'onHighlightButtonClick' } );
-               this.invertNamespacesButton
-                       .connect( this, { click: 'onInvertNamespacesButtonClick' } );
-               this.model.connect( this, {
-                       highlightChange: 'onModelHighlightChange',
-                       searchChange: 'onModelSearchChange',
-                       initialize: 'onModelInitialize'
-               } );
-               this.view = this.model.getCurrentView();
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-table' )
-                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-row' )
-                                                       .append(
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-back' )
-                                                                       .append( this.backButton.$element ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-title' )
-                                                                       .append( this.$label, this.helpIcon.$element ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-invert' )
-                                                                       .append( this.invertNamespacesButton.$element ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-highlight' )
-                                                                       .append( this.highlightButton.$element )
-                                                       )
-                                       )
-                       );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.FilterMenuHeaderWidget, OO.ui.Widget );
-       OO.mixinClass( mw.rcfilters.ui.FilterMenuHeaderWidget, OO.ui.mixin.LabelElement );
-
-       /* Methods */
-
-       /**
-        * Respond to model initialization event
-        *
-        * Note: need to wait for initialization before getting the invertModel
-        * and registering its update event. Creating all the models before the UI
-        * would help with that.
-        */
-       mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onModelInitialize = function () {
-               this.invertModel = this.model.getInvertModel();
-               this.updateInvertButton();
-               this.invertModel.connect( this, { update: 'updateInvertButton' } );
-       };
-
-       /**
-        * Respond to model update event
-        */
-       mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onModelSearchChange = function () {
-               var currentView = this.model.getCurrentView();
-
-               if ( this.view !== currentView ) {
-                       this.setLabel( this.model.getViewTitle( currentView ) );
-
-                       this.invertNamespacesButton.toggle( currentView === 'namespaces' );
-                       this.backButton.toggle( currentView !== 'default' );
-                       this.helpIcon.toggle( currentView === 'tags' );
-                       this.view = currentView;
-               }
-       };
-
-       /**
-        * Respond to model highlight change event
-        *
-        * @param {boolean} highlightEnabled Highlight is enabled
-        */
-       mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onModelHighlightChange = function ( highlightEnabled ) {
-               this.highlightButton.setActive( highlightEnabled );
-       };
-
-       /**
-        * Update the state of the invert button
-        */
-       mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.updateInvertButton = function () {
-               this.invertNamespacesButton.setActive( this.invertModel.isSelected() );
-               this.invertNamespacesButton.setLabel(
-                       this.invertModel.isSelected() ?
-                               mw.msg( 'rcfilters-exclude-button-on' ) :
-                               mw.msg( 'rcfilters-exclude-button-off' )
-               );
-       };
-
-       mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onBackButtonClick = function () {
-               this.controller.switchView( 'default' );
-       };
-
-       /**
-        * Respond to highlight button click
-        */
-       mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onHighlightButtonClick = function () {
-               this.controller.toggleHighlight();
-       };
-
-       /**
-        * Respond to highlight button click
-        */
-       mw.rcfilters.ui.FilterMenuHeaderWidget.prototype.onInvertNamespacesButtonClick = function () {
-               this.controller.toggleInvertedNamespaces();
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuOptionWidget.js
deleted file mode 100644 (file)
index 8840155..0000000
+++ /dev/null
@@ -1,90 +0,0 @@
-( function () {
-       /**
-        * A widget representing a single toggle filter
-        *
-        * @extends mw.rcfilters.ui.ItemMenuOptionWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller RCFilters controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
-        * @param {mw.rcfilters.dm.FilterItem} invertModel
-        * @param {mw.rcfilters.dm.FilterItem} itemModel Filter item model
-        * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker popup
-        * @param {Object} config Configuration object
-        */
-       mw.rcfilters.ui.FilterMenuOptionWidget = function MwRcfiltersUiFilterMenuOptionWidget(
-               controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
-       ) {
-               config = config || {};
-
-               this.controller = controller;
-               this.invertModel = invertModel;
-               this.model = itemModel;
-
-               // Parent
-               mw.rcfilters.ui.FilterMenuOptionWidget.parent.call( this, controller, filtersViewModel, this.invertModel, itemModel, highlightPopup, config );
-
-               // Event
-               this.model.getGroupModel().connect( this, { update: 'onGroupModelUpdate' } );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterMenuOptionWidget' );
-       };
-
-       /* Initialization */
-       OO.inheritClass( mw.rcfilters.ui.FilterMenuOptionWidget, mw.rcfilters.ui.ItemMenuOptionWidget );
-
-       /* Static properties */
-
-       // We do our own scrolling to top
-       mw.rcfilters.ui.FilterMenuOptionWidget.static.scrollIntoViewOnSelect = false;
-
-       /* Methods */
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterMenuOptionWidget.prototype.updateUiBasedOnState = function () {
-               // Parent
-               mw.rcfilters.ui.FilterMenuOptionWidget.parent.prototype.updateUiBasedOnState.call( this );
-
-               this.setCurrentMuteState();
-       };
-
-       /**
-        * Respond to item group model update event
-        */
-       mw.rcfilters.ui.FilterMenuOptionWidget.prototype.onGroupModelUpdate = function () {
-               this.setCurrentMuteState();
-       };
-
-       /**
-        * Set the current muted view of the widget based on its state
-        */
-       mw.rcfilters.ui.FilterMenuOptionWidget.prototype.setCurrentMuteState = function () {
-               if (
-                       this.model.getGroupModel().getView() === 'namespaces' &&
-                       this.invertModel.isSelected()
-               ) {
-                       // This is an inverted behavior than the other rules, specifically
-                       // for inverted namespaces
-                       this.setFlags( {
-                               muted: this.model.isSelected()
-                       } );
-               } else {
-                       this.setFlags( {
-                               muted: (
-                                       this.model.isConflicted() ||
-                                       (
-                                               // Item is also muted when any of the items in its group is active
-                                               this.model.getGroupModel().isActive() &&
-                                               // But it isn't selected
-                                               !this.model.isSelected() &&
-                                               // And also not included
-                                               !this.model.isIncluded()
-                                       )
-                               )
-                       } );
-               }
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuSectionOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterMenuSectionOptionWidget.js
deleted file mode 100644 (file)
index 3d598c9..0000000
+++ /dev/null
@@ -1,125 +0,0 @@
-( function () {
-       /**
-        * A widget representing a menu section for filter groups
-        *
-        * @class
-        * @extends OO.ui.MenuSectionOptionWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller RCFilters controller
-        * @param {mw.rcfilters.dm.FilterGroup} model Filter group model
-        * @param {Object} config Configuration object
-        * @cfg {jQuery} [$overlay] Overlay
-        */
-       mw.rcfilters.ui.FilterMenuSectionOptionWidget = function MwRcfiltersUiFilterMenuSectionOptionWidget( controller, model, config ) {
-               var whatsThisMessages,
-                       $header = $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header' ),
-                       $popupContent = $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content' );
-
-               config = config || {};
-
-               this.controller = controller;
-               this.model = model;
-               this.$overlay = config.$overlay || this.$element;
-
-               // Parent
-               mw.rcfilters.ui.FilterMenuSectionOptionWidget.parent.call( this, $.extend( {
-                       label: this.model.getTitle(),
-                       $label: $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header-title' )
-               }, config ) );
-
-               $header.append( this.$label );
-
-               if ( this.model.hasWhatsThis() ) {
-                       whatsThisMessages = this.model.getWhatsThis();
-
-                       // Create popup
-                       if ( whatsThisMessages.header ) {
-                               $popupContent.append(
-                                       ( new OO.ui.LabelWidget( {
-                                               label: mw.msg( whatsThisMessages.header ),
-                                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-header' ]
-                                       } ) ).$element
-                               );
-                       }
-                       if ( whatsThisMessages.body ) {
-                               $popupContent.append(
-                                       ( new OO.ui.LabelWidget( {
-                                               label: mw.msg( whatsThisMessages.body ),
-                                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-body' ]
-                                       } ) ).$element
-                               );
-                       }
-                       if ( whatsThisMessages.linkText && whatsThisMessages.url ) {
-                               $popupContent.append(
-                                       ( new OO.ui.ButtonWidget( {
-                                               framed: false,
-                                               flags: [ 'progressive' ],
-                                               href: whatsThisMessages.url,
-                                               label: mw.msg( whatsThisMessages.linkText ),
-                                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-link' ]
-                                       } ) ).$element
-                               );
-                       }
-
-                       // Add button
-                       this.whatsThisButton = new OO.ui.PopupButtonWidget( {
-                               framed: false,
-                               label: mw.msg( 'rcfilters-filterlist-whatsthis' ),
-                               $overlay: this.$overlay,
-                               classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton' ],
-                               flags: [ 'progressive' ],
-                               popup: {
-                                       padded: false,
-                                       align: 'center',
-                                       position: 'above',
-                                       $content: $popupContent,
-                                       classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup' ]
-                               }
-                       } );
-
-                       $header
-                               .append( this.whatsThisButton.$element );
-               }
-
-               // Events
-               this.model.connect( this, { update: 'updateUiBasedOnState' } );
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget' )
-                       .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-name-' + this.model.getName() )
-                       .append( $header );
-               this.updateUiBasedOnState();
-       };
-
-       /* Initialize */
-
-       OO.inheritClass( mw.rcfilters.ui.FilterMenuSectionOptionWidget, OO.ui.MenuSectionOptionWidget );
-
-       /* Methods */
-
-       /**
-        * Respond to model update event
-        */
-       mw.rcfilters.ui.FilterMenuSectionOptionWidget.prototype.updateUiBasedOnState = function () {
-               this.$element.toggleClass(
-                       'mw-rcfilters-ui-filterMenuSectionOptionWidget-active',
-                       this.model.isActive()
-               );
-               this.toggle( this.model.isVisible() );
-       };
-
-       /**
-        * Get the group name
-        *
-        * @return {string} Group name
-        */
-       mw.rcfilters.ui.FilterMenuSectionOptionWidget.prototype.getName = function () {
-               return this.model.getName();
-       };
-
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagItemWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagItemWidget.js
deleted file mode 100644 (file)
index 411ada9..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-( function () {
-       /**
-        * Extend OOUI's FilterTagItemWidget to also display a popup on hover.
-        *
-        * @class
-        * @extends mw.rcfilters.ui.TagItemWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
-        * @param {mw.rcfilters.dm.FilterItem} invertModel
-        * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
-        * @param {Object} config Configuration object
-        */
-       mw.rcfilters.ui.FilterTagItemWidget = function MwRcfiltersUiFilterTagItemWidget(
-               controller, filtersViewModel, invertModel, itemModel, config
-       ) {
-               config = config || {};
-
-               mw.rcfilters.ui.FilterTagItemWidget.parent.call( this, controller, filtersViewModel, invertModel, itemModel, config );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterTagItemWidget' );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.FilterTagItemWidget, mw.rcfilters.ui.TagItemWidget );
-
-       /* Methods */
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagItemWidget.prototype.setCurrentMuteState = function () {
-               this.setFlags( {
-                       muted: (
-                               !this.itemModel.isSelected() ||
-                               this.itemModel.isIncluded() ||
-                               this.itemModel.isFullyCovered()
-                       ),
-                       invalid: this.itemModel.isSelected() && this.itemModel.isConflicted()
-               } );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterTagMultiselectWidget.js
deleted file mode 100644 (file)
index 6d45144..0000000
+++ /dev/null
@@ -1,770 +0,0 @@
-( function () {
-       /**
-        * List displaying all filter groups
-        *
-        * @class
-        * @extends OO.ui.MenuTagMultiselectWidget
-        * @mixins OO.ui.mixin.PendingElement
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
-        * @param {Object} config Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
-        *  system. If not given, falls back to this widget's $element
-        * @cfg {boolean} [collapsed] Filter area is collapsed
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( controller, model, savedQueriesModel, config ) {
-               var rcFiltersRow,
-                       title = new OO.ui.LabelWidget( {
-                               label: mw.msg( 'rcfilters-activefilters' ),
-                               classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-title' ]
-                       } ),
-                       $contentWrapper = $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper' );
-
-               config = config || {};
-
-               this.controller = controller;
-               this.model = model;
-               this.queriesModel = savedQueriesModel;
-               this.$overlay = config.$overlay || this.$element;
-               this.$wrapper = config.$wrapper || this.$element;
-               this.matchingQuery = null;
-               this.currentView = this.model.getCurrentView();
-               this.collapsed = false;
-
-               // Parent
-               mw.rcfilters.ui.FilterTagMultiselectWidget.parent.call( this, $.extend( true, {
-                       label: mw.msg( 'rcfilters-filterlist-title' ),
-                       placeholder: mw.msg( 'rcfilters-empty-filter' ),
-                       inputPosition: 'outline',
-                       allowArbitrary: false,
-                       allowDisplayInvalidTags: false,
-                       allowReordering: false,
-                       $overlay: this.$overlay,
-                       menu: {
-                               // Our filtering is done through the model
-                               filterFromInput: false,
-                               hideWhenOutOfView: false,
-                               hideOnChoose: false,
-                               width: 650,
-                               footers: [
-                                       {
-                                               name: 'viewSelect',
-                                               sticky: false,
-                                               // View select menu, appears on default view only
-                                               $element: $( '<div>' )
-                                                       .append( new mw.rcfilters.ui.ViewSwitchWidget( this.controller, this.model ).$element ),
-                                               views: [ 'default' ]
-                                       },
-                                       {
-                                               name: 'feedback',
-                                               // Feedback footer, appears on all views
-                                               $element: $( '<div>' )
-                                                       .append(
-                                                               new OO.ui.ButtonWidget( {
-                                                                       framed: false,
-                                                                       icon: 'feedback',
-                                                                       flags: [ 'progressive' ],
-                                                                       label: mw.msg( 'rcfilters-filterlist-feedbacklink' ),
-                                                                       href: 'https://www.mediawiki.org/wiki/Help_talk:New_filters_for_edit_review'
-                                                               } ).$element
-                                                       )
-                                       }
-                               ]
-                       },
-                       input: {
-                               icon: 'menu',
-                               placeholder: mw.msg( 'rcfilters-search-placeholder' )
-                       }
-               }, config ) );
-
-               this.savedQueryTitle = new OO.ui.LabelWidget( {
-                       label: '',
-                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-savedQueryTitle' ]
-               } );
-
-               this.resetButton = new OO.ui.ButtonWidget( {
-                       framed: false,
-                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-resetButton' ]
-               } );
-
-               this.hideShowButton = new OO.ui.ButtonWidget( {
-                       framed: false,
-                       flags: [ 'progressive' ],
-                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-hideshowButton' ]
-               } );
-               this.toggleCollapsed( !!config.collapsed );
-
-               if ( !mw.user.isAnon() ) {
-                       this.saveQueryButton = new mw.rcfilters.ui.SaveFiltersPopupButtonWidget(
-                               this.controller,
-                               this.queriesModel,
-                               {
-                                       $overlay: this.$overlay
-                               }
-                       );
-
-                       this.saveQueryButton.$element.on( 'mousedown', function ( e ) {
-                               e.stopPropagation();
-                       } );
-
-                       this.saveQueryButton.connect( this, {
-                               click: 'onSaveQueryButtonClick',
-                               saveCurrent: 'setSavedQueryVisibility'
-                       } );
-                       this.queriesModel.connect( this, {
-                               itemUpdate: 'onSavedQueriesItemUpdate',
-                               initialize: 'onSavedQueriesInitialize',
-                               default: 'reevaluateResetRestoreState'
-                       } );
-               }
-
-               this.emptyFilterMessage = new OO.ui.LabelWidget( {
-                       label: mw.msg( 'rcfilters-empty-filter' ),
-                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-emptyFilters' ]
-               } );
-               this.$content.append( this.emptyFilterMessage.$element );
-
-               // Events
-               this.resetButton.connect( this, { click: 'onResetButtonClick' } );
-               this.hideShowButton.connect( this, { click: 'onHideShowButtonClick' } );
-               // Stop propagation for mousedown, so that the widget doesn't
-               // trigger the focus on the input and scrolls up when we click the reset button
-               this.resetButton.$element.on( 'mousedown', function ( e ) {
-                       e.stopPropagation();
-               } );
-               this.hideShowButton.$element.on( 'mousedown', function ( e ) {
-                       e.stopPropagation();
-               } );
-               this.model.connect( this, {
-                       initialize: 'onModelInitialize',
-                       update: 'onModelUpdate',
-                       searchChange: 'onModelSearchChange',
-                       itemUpdate: 'onModelItemUpdate',
-                       highlightChange: 'onModelHighlightChange'
-               } );
-               this.input.connect( this, { change: 'onInputChange' } );
-
-               // The filter list and button should appear side by side regardless of how
-               // wide the button is; the button also changes its width depending
-               // on language and its state, so the safest way to present both side
-               // by side is with a table layout
-               rcFiltersRow = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-row' )
-                       .append(
-                               this.$content
-                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-filters' )
-                       );
-
-               if ( !mw.user.isAnon() ) {
-                       rcFiltersRow.append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-save' )
-                                       .append( this.saveQueryButton.$element )
-                       );
-               }
-
-               // Add a selector at the right of the input
-               this.viewsSelectWidget = new OO.ui.ButtonSelectWidget( {
-                       classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select-widget' ],
-                       items: [
-                               new OO.ui.ButtonOptionWidget( {
-                                       framed: false,
-                                       data: 'namespaces',
-                                       icon: 'article',
-                                       label: mw.msg( 'namespaces' ),
-                                       title: mw.msg( 'rcfilters-view-namespaces-tooltip' )
-                               } ),
-                               new OO.ui.ButtonOptionWidget( {
-                                       framed: false,
-                                       data: 'tags',
-                                       icon: 'tag',
-                                       label: mw.msg( 'tags-title' ),
-                                       title: mw.msg( 'rcfilters-view-tags-tooltip' )
-                               } )
-                       ]
-               } );
-
-               // Rearrange the UI so the select widget is at the right of the input
-               this.$element.append(
-                       $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-table' )
-                               .append(
-                                       $( '<div>' )
-                                               .addClass( 'mw-rcfilters-ui-row' )
-                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' )
-                                               .append(
-                                                       $( '<div>' )
-                                                               .addClass( 'mw-rcfilters-ui-cell' )
-                                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' )
-                                                               .append( this.input.$element ),
-                                                       $( '<div>' )
-                                                               .addClass( 'mw-rcfilters-ui-cell' )
-                                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select' )
-                                                               .append( this.viewsSelectWidget.$element )
-                                               )
-                               )
-               );
-
-               // Event
-               this.viewsSelectWidget.connect( this, { choose: 'onViewsSelectWidgetChoose' } );
-
-               rcFiltersRow.append(
-                       $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-cell' )
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' )
-                               .append( this.resetButton.$element )
-               );
-
-               // Build the content
-               $contentWrapper.append(
-                       $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top' )
-                               .append(
-                                       $( '<div>' )
-                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-title' )
-                                               .append( title.$element ),
-                                       $( '<div>' )
-                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-queryName' )
-                                               .append( this.savedQueryTitle.$element ),
-                                       $( '<div>' )
-                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-hideshow' )
-                                               .append(
-                                                       this.hideShowButton.$element
-                                               )
-                               ),
-                       $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-table' )
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-filters' )
-                               .append( rcFiltersRow )
-               );
-
-               // Initialize
-               this.$handle.append( $contentWrapper );
-               this.emptyFilterMessage.toggle( this.isEmpty() );
-               this.savedQueryTitle.toggle( false );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget' );
-
-               this.reevaluateResetRestoreState();
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.FilterTagMultiselectWidget, OO.ui.MenuTagMultiselectWidget );
-
-       /* Methods */
-
-       /**
-        * Override parent method to avoid unnecessary resize events.
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.updateIfHeightChanged = function () { };
-
-       /**
-        * Respond to view select widget choose event
-        *
-        * @param {OO.ui.ButtonOptionWidget} buttonOptionWidget Chosen widget
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onViewsSelectWidgetChoose = function ( buttonOptionWidget ) {
-               this.controller.switchView( buttonOptionWidget.getData() );
-               this.viewsSelectWidget.selectItem( null );
-               this.focus();
-       };
-
-       /**
-        * Respond to model search change event
-        *
-        * @param {string} value Search value
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelSearchChange = function ( value ) {
-               this.input.setValue( value );
-       };
-
-       /**
-        * Respond to input change event
-        *
-        * @param {string} value Value of the input
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onInputChange = function ( value ) {
-               this.controller.setSearch( value );
-       };
-
-       /**
-        * Respond to query button click
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onSaveQueryButtonClick = function () {
-               this.getMenu().toggle( false );
-       };
-
-       /**
-        * Respond to save query model initialization
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onSavedQueriesInitialize = function () {
-               this.setSavedQueryVisibility();
-       };
-
-       /**
-        * Respond to save query item change. Mainly this is done to update the label in case
-        * a query item has been edited
-        *
-        * @param {mw.rcfilters.dm.SavedQueryItemModel} item Saved query item
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onSavedQueriesItemUpdate = function ( item ) {
-               if ( this.matchingQuery === item ) {
-                       // This means we just edited the item that is currently matched
-                       this.savedQueryTitle.setLabel( item.getLabel() );
-               }
-       };
-
-       /**
-        * Respond to menu toggle
-        *
-        * @param {boolean} isVisible Menu is visible
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {
-               // Parent
-               mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onMenuToggle.call( this );
-
-               if ( isVisible ) {
-                       this.focus();
-
-                       mw.hook( 'RcFilters.popup.open' ).fire();
-
-                       if ( !this.getMenu().findSelectedItem() ) {
-                               // If there are no selected items, scroll menu to top
-                               // This has to be in a setTimeout so the menu has time
-                               // to be positioned and fixed
-                               setTimeout(
-                                       function () {
-                                               this.getMenu().scrollToTop();
-                                       }.bind( this )
-                               );
-                       }
-               } else {
-                       // Clear selection
-                       this.selectTag( null );
-
-                       // Clear the search
-                       this.controller.setSearch( '' );
-
-                       // Log filter grouping
-                       this.controller.trackFilterGroupings( 'filtermenu' );
-
-                       this.blur();
-               }
-
-               this.input.setIcon( isVisible ? 'search' : 'menu' );
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onInputFocus = function () {
-               // Parent
-               mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onInputFocus.call( this );
-
-               // Only scroll to top of the viewport if:
-               // - The widget is more than 20px from the top
-               // - The widget is not above the top of the viewport (do not scroll downwards)
-               //   (This isn't represented because >20 is, anyways and always, bigger than 0)
-               this.scrollToTop( this.$element, 0, { min: 20, max: Infinity } );
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.doInputEscape = function () {
-               // Parent
-               mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.doInputEscape.call( this );
-
-               // Blur the input
-               this.input.$input.trigger( 'blur' );
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onMouseDown = function ( e ) {
-               if ( !this.collapsed && !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
-                       this.menu.toggle();
-
-                       return false;
-               }
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onChangeTags = function () {
-               // If initialized, call parent method.
-               if ( this.controller.isInitialized() ) {
-                       mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onChangeTags.call( this );
-               }
-
-               this.emptyFilterMessage.toggle( this.isEmpty() );
-       };
-
-       /**
-        * Respond to model initialize event
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelInitialize = function () {
-               this.setSavedQueryVisibility();
-       };
-
-       /**
-        * Respond to model update event
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelUpdate = function () {
-               this.updateElementsForView();
-       };
-
-       /**
-        * Update the elements in the widget to the current view
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.updateElementsForView = function () {
-               var view = this.model.getCurrentView(),
-                       inputValue = this.input.getValue().trim(),
-                       inputView = this.model.getViewByTrigger( inputValue.substr( 0, 1 ) );
-
-               if ( inputView !== 'default' ) {
-                       // We have a prefix already, remove it
-                       inputValue = inputValue.substr( 1 );
-               }
-
-               if ( inputView !== view ) {
-                       // Add the correct prefix
-                       inputValue = this.model.getViewTrigger( view ) + inputValue;
-               }
-
-               // Update input
-               this.input.setValue( inputValue );
-
-               if ( this.currentView !== view ) {
-                       this.scrollToTop( this.$element );
-                       this.currentView = view;
-               }
-       };
-
-       /**
-        * Set the visibility of the saved query button
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.setSavedQueryVisibility = function () {
-               if ( mw.user.isAnon() ) {
-                       return;
-               }
-
-               this.matchingQuery = this.controller.findQueryMatchingCurrentState();
-
-               this.savedQueryTitle.setLabel(
-                       this.matchingQuery ? this.matchingQuery.getLabel() : ''
-               );
-               this.savedQueryTitle.toggle( !!this.matchingQuery );
-               this.saveQueryButton.setDisabled( !!this.matchingQuery );
-               this.saveQueryButton.setTitle( !this.matchingQuery ?
-                       mw.msg( 'rcfilters-savedqueries-add-new-title' ) :
-                       mw.msg( 'rcfilters-savedqueries-already-saved' ) );
-
-               if ( this.matchingQuery ) {
-                       this.emphasize();
-               }
-       };
-
-       /**
-        * Respond to model itemUpdate event
-        * fixme: when a new state is applied to the model this function is called 60+ times in a row
-        *
-        * @param {mw.rcfilters.dm.FilterItem} item Filter item model
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelItemUpdate = function ( item ) {
-               if ( !item.getGroupModel().isHidden() ) {
-                       if (
-                               item.isSelected() ||
-                               (
-                                       this.model.isHighlightEnabled() &&
-                                       item.getHighlightColor()
-                               )
-                       ) {
-                               this.addTag( item.getName(), item.getLabel() );
-                       } else {
-                               // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
-                               if ( this.findItemFromData( item.getName() ) !== null ) {
-                                       this.removeTagByData( item.getName() );
-                               }
-                       }
-               }
-
-               this.setSavedQueryVisibility();
-
-               // Re-evaluate reset state
-               this.reevaluateResetRestoreState();
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.isAllowedData = function ( data ) {
-               return (
-                       this.model.getItemByName( data ) &&
-                       !this.isDuplicateData( data )
-               );
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onMenuChoose = function ( item ) {
-               this.controller.toggleFilterSelect( item.model.getName() );
-
-               // Select the tag if it exists, or reset selection otherwise
-               this.selectTag( this.findItemFromData( item.model.getName() ) );
-
-               this.focus();
-       };
-
-       /**
-        * Respond to highlightChange event
-        *
-        * @param {boolean} isHighlightEnabled Highlight is enabled
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onModelHighlightChange = function ( isHighlightEnabled ) {
-               var highlightedItems = this.model.getHighlightedItems();
-
-               if ( isHighlightEnabled ) {
-                       // Add capsule widgets
-                       highlightedItems.forEach( function ( filterItem ) {
-                               this.addTag( filterItem.getName(), filterItem.getLabel() );
-                       }.bind( this ) );
-               } else {
-                       // Remove capsule widgets if they're not selected
-                       highlightedItems.forEach( function ( filterItem ) {
-                               if ( !filterItem.isSelected() ) {
-                                       // Only attempt to remove the tag if we can find an item for it (T198140, T198231)
-                                       if ( this.findItemFromData( filterItem.getName() ) !== null ) {
-                                               this.removeTagByData( filterItem.getName() );
-                                       }
-                               }
-                       }.bind( this ) );
-               }
-
-               this.setSavedQueryVisibility();
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onTagSelect = function ( tagItem ) {
-               var menuOption = this.menu.getItemFromModel( tagItem.getModel() );
-
-               this.menu.setUserSelecting( true );
-               // Parent method
-               mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onTagSelect.call( this, tagItem );
-
-               // Switch view
-               this.controller.resetSearchForView( tagItem.getView() );
-
-               this.selectTag( tagItem );
-               this.scrollToTop( menuOption.$element );
-
-               this.menu.setUserSelecting( false );
-       };
-
-       /**
-        * Select a tag by reference. This is what OO.ui.SelectWidget is doing.
-        * If no items are given, reset selection from all.
-        *
-        * @param {mw.rcfilters.ui.FilterTagItemWidget} [item] Tag to select,
-        *  omit to deselect all
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.selectTag = function ( item ) {
-               var i, len, selected;
-
-               for ( i = 0, len = this.items.length; i < len; i++ ) {
-                       selected = this.items[ i ] === item;
-                       if ( this.items[ i ].isSelected() !== selected ) {
-                               this.items[ i ].toggleSelected( selected );
-                       }
-               }
-       };
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onTagRemove = function ( tagItem ) {
-               // Parent method
-               mw.rcfilters.ui.FilterTagMultiselectWidget.parent.prototype.onTagRemove.call( this, tagItem );
-
-               this.controller.clearFilter( tagItem.getName() );
-
-               tagItem.destroy();
-       };
-
-       /**
-        * Respond to click event on the reset button
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onResetButtonClick = function () {
-               if ( this.model.areVisibleFiltersEmpty() ) {
-                       // Reset to default filters
-                       this.controller.resetToDefaults();
-               } else {
-                       // Reset to have no filters
-                       this.controller.emptyFilters();
-               }
-       };
-
-       /**
-        * Respond to hide/show button click
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onHideShowButtonClick = function () {
-               this.toggleCollapsed();
-       };
-
-       /**
-        * Toggle the collapsed state of the filters widget
-        *
-        * @param {boolean} isCollapsed Widget is collapsed
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.toggleCollapsed = function ( isCollapsed ) {
-               isCollapsed = isCollapsed === undefined ? !this.collapsed : !!isCollapsed;
-
-               this.collapsed = isCollapsed;
-
-               if ( isCollapsed ) {
-                       // If we are collapsing, close the menu, in case it was open
-                       // We should make sure the menu closes before the rest of the elements
-                       // are hidden, otherwise there is an unknown error in jQuery as ooui
-                       // sets and unsets properties on the input (which is hidden at that point)
-                       this.menu.toggle( false );
-               }
-               this.input.setDisabled( isCollapsed );
-               this.hideShowButton.setLabel( mw.msg(
-                       isCollapsed ? 'rcfilters-activefilters-show' : 'rcfilters-activefilters-hide'
-               ) );
-               this.hideShowButton.setTitle( mw.msg(
-                       isCollapsed ? 'rcfilters-activefilters-show-tooltip' : 'rcfilters-activefilters-hide-tooltip'
-               ) );
-
-               // Toggle the wrapper class, so we have min height values correctly throughout
-               this.$wrapper.toggleClass( 'mw-rcfilters-collapsed', isCollapsed );
-
-               // Save the state
-               this.controller.updateCollapsedState( isCollapsed );
-       };
-
-       /**
-        * Reevaluate the restore state for the widget between setting to defaults and clearing all filters
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.reevaluateResetRestoreState = function () {
-               var defaultsAreEmpty = this.controller.areDefaultsEmpty(),
-                       currFiltersAreEmpty = this.model.areVisibleFiltersEmpty(),
-                       hideResetButton = currFiltersAreEmpty && defaultsAreEmpty;
-
-               this.resetButton.setIcon(
-                       currFiltersAreEmpty ? 'history' : 'trash'
-               );
-
-               this.resetButton.setLabel(
-                       currFiltersAreEmpty ? mw.msg( 'rcfilters-restore-default-filters' ) : ''
-               );
-               this.resetButton.setTitle(
-                       currFiltersAreEmpty ? null : mw.msg( 'rcfilters-clear-all-filters' )
-               );
-
-               this.resetButton.toggle( !hideResetButton );
-               this.emptyFilterMessage.toggle( currFiltersAreEmpty );
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.createMenuWidget = function ( menuConfig ) {
-               return new mw.rcfilters.ui.MenuSelectWidget(
-                       this.controller,
-                       this.model,
-                       menuConfig
-               );
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.createTagItemWidget = function ( data ) {
-               var filterItem = this.model.getItemByName( data );
-
-               if ( filterItem ) {
-                       return new mw.rcfilters.ui.FilterTagItemWidget(
-                               this.controller,
-                               this.model,
-                               this.model.getInvertModel(),
-                               filterItem,
-                               {
-                                       $overlay: this.$overlay
-                               }
-                       );
-               }
-       };
-
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.emphasize = function () {
-               if (
-                       !this.$handle.hasClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' )
-               ) {
-                       this.$handle
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' )
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
-
-                       setTimeout( function () {
-                               this.$handle
-                                       .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' );
-
-                               setTimeout( function () {
-                                       this.$handle
-                                               .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
-                               }.bind( this ), 1000 );
-                       }.bind( this ), 500 );
-
-               }
-       };
-       /**
-        * Scroll the element to top within its container
-        *
-        * @private
-        * @param {jQuery} $element Element to position
-        * @param {number} [marginFromTop=0] When scrolling the entire widget to the top, leave this
-        *  much space (in pixels) above the widget.
-        * @param {Object} [threshold] Minimum distance from the top of the element to scroll at all
-        * @param {number} [threshold.min] Minimum distance above the element
-        * @param {number} [threshold.max] Minimum distance below the element
-        */
-       mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.scrollToTop = function ( $element, marginFromTop, threshold ) {
-               var container = OO.ui.Element.static.getClosestScrollableContainer( $element[ 0 ], 'y' ),
-                       pos = OO.ui.Element.static.getRelativePosition( $element, $( container ) ),
-                       containerScrollTop = $( container ).scrollTop(),
-                       effectiveScrollTop = $( container ).is( 'body, html' ) ? 0 : containerScrollTop,
-                       newScrollTop = effectiveScrollTop + pos.top - ( marginFromTop || 0 );
-
-               // Scroll to item
-               if (
-                       threshold === undefined ||
-                       (
-                               (
-                                       threshold.min === undefined ||
-                                       newScrollTop - containerScrollTop >= threshold.min
-                               ) &&
-                               (
-                                       threshold.max === undefined ||
-                                       newScrollTop - containerScrollTop <= threshold.max
-                               )
-                       )
-               ) {
-                       // eslint-disable-next-line jquery/no-animate
-                       $( container ).animate( {
-                               scrollTop: newScrollTop
-                       } );
-               }
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FilterWrapperWidget.js
deleted file mode 100644 (file)
index 567d86d..0000000
+++ /dev/null
@@ -1,131 +0,0 @@
-( function () {
-       /**
-        * List displaying all filter groups
-        *
-        * @extends OO.ui.Widget
-        * @mixins OO.ui.mixin.PendingElement
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
-        * @param {Object} [config] Configuration object
-        * @cfg {Object} [filters] A definition of the filter groups in this list
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
-        *  system. If not given, falls back to this widget's $element
-        * @cfg {boolean} [collapsed] Filter area is collapsed
-        */
-       mw.rcfilters.ui.FilterWrapperWidget = function MwRcfiltersUiFilterWrapperWidget(
-               controller, model, savedQueriesModel, changesListModel, config
-       ) {
-               var $bottom;
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.FilterWrapperWidget.parent.call( this, config );
-               // Mixin constructors
-               OO.ui.mixin.PendingElement.call( this, config );
-
-               this.controller = controller;
-               this.model = model;
-               this.queriesModel = savedQueriesModel;
-               this.changesListModel = changesListModel;
-               this.$overlay = config.$overlay || this.$element;
-               this.$wrapper = config.$wrapper || this.$element;
-
-               this.filterTagWidget = new mw.rcfilters.ui.FilterTagMultiselectWidget(
-                       this.controller,
-                       this.model,
-                       this.queriesModel,
-                       {
-                               $overlay: this.$overlay,
-                               collapsed: config.collapsed,
-                               $wrapper: this.$wrapper
-                       }
-               );
-
-               this.liveUpdateButton = new mw.rcfilters.ui.LiveUpdateButtonWidget(
-                       this.controller,
-                       this.changesListModel
-               );
-
-               this.numChangesAndDateWidget = new mw.rcfilters.ui.ChangesLimitAndDateButtonWidget(
-                       this.controller,
-                       this.model,
-                       {
-                               $overlay: this.$overlay
-                       }
-               );
-
-               this.showNewChangesLink = new OO.ui.ButtonWidget( {
-                       icon: 'reload',
-                       framed: false,
-                       label: mw.msg( 'rcfilters-show-new-changes' ),
-                       flags: [ 'progressive' ],
-                       classes: [ 'mw-rcfilters-ui-filterWrapperWidget-showNewChanges' ]
-               } );
-
-               // Events
-               this.filterTagWidget.menu.connect( this, { toggle: [ 'emit', 'menuToggle' ] } );
-               this.changesListModel.connect( this, { newChangesExist: 'onNewChangesExist' } );
-               this.showNewChangesLink.connect( this, { click: 'onShowNewChangesClick' } );
-               this.showNewChangesLink.toggle( false );
-
-               // Initialize
-               this.$top = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-filterWrapperWidget-top' );
-
-               $bottom = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-filterWrapperWidget-bottom' )
-                       .append(
-                               this.showNewChangesLink.$element,
-                               this.numChangesAndDateWidget.$element
-                       );
-
-               if ( mw.config.get( 'StructuredChangeFiltersLiveUpdatePollingRate' ) ) {
-                       $bottom.prepend( this.liveUpdateButton.$element );
-               }
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterWrapperWidget' )
-                       .append(
-                               this.$top,
-                               this.filterTagWidget.$element,
-                               $bottom
-                       );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.FilterWrapperWidget, OO.ui.Widget );
-       OO.mixinClass( mw.rcfilters.ui.FilterWrapperWidget, OO.ui.mixin.PendingElement );
-
-       /* Methods */
-
-       /**
-        * Set the content of the top section
-        *
-        * @param {jQuery} $topSectionElement
-        */
-       mw.rcfilters.ui.FilterWrapperWidget.prototype.setTopSection = function ( $topSectionElement ) {
-               this.$top.append( $topSectionElement );
-       };
-
-       /**
-        * Respond to the user clicking the 'show new changes' button
-        */
-       mw.rcfilters.ui.FilterWrapperWidget.prototype.onShowNewChangesClick = function () {
-               this.controller.showNewChanges();
-       };
-
-       /**
-        * Respond to changes list model newChangesExist
-        *
-        * @param {boolean} newChangesExist Whether new changes exist
-        */
-       mw.rcfilters.ui.FilterWrapperWidget.prototype.onNewChangesExist = function ( newChangesExist ) {
-               this.showNewChangesLink.toggle( newChangesExist );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.FormWrapperWidget.js
deleted file mode 100644 (file)
index a28cde0..0000000
+++ /dev/null
@@ -1,173 +0,0 @@
-( function () {
-       /**
-        * Wrapper for the RC form with hide/show links
-        * Must be constructed after the model is initialized.
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Changes list view model
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changeListModel Changes list view model
-        * @param {mw.rcfilters.Controller} controller RCfilters controller
-        * @param {jQuery} $formRoot Root element of the form to attach to
-        * @param {Object} config Configuration object
-        */
-       mw.rcfilters.ui.FormWrapperWidget = function MwRcfiltersUiFormWrapperWidget( filtersModel, changeListModel, controller, $formRoot, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.FormWrapperWidget.parent.call( this, $.extend( {}, config, {
-                       $element: $formRoot
-               } ) );
-
-               this.changeListModel = changeListModel;
-               this.filtersModel = filtersModel;
-               this.controller = controller;
-               this.$submitButton = this.$element.find( 'form input[type=submit]' );
-
-               this.$element
-                       .on( 'click', 'a[data-params]', this.onLinkClick.bind( this ) );
-
-               this.$element
-                       .on( 'submit', 'form', this.onFormSubmit.bind( this ) );
-
-               // Events
-               this.changeListModel.connect( this, {
-                       invalidate: 'onChangesModelInvalidate',
-                       update: 'onChangesModelUpdate'
-               } );
-
-               // Initialize
-               this.cleanUpFieldset();
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-FormWrapperWidget' );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.FormWrapperWidget, OO.ui.Widget );
-
-       /**
-        * Respond to link click
-        *
-        * @param {jQuery.Event} e Event
-        * @return {boolean} false
-        */
-       mw.rcfilters.ui.FormWrapperWidget.prototype.onLinkClick = function ( e ) {
-               this.controller.updateChangesList( $( e.target ).data( 'params' ) );
-               return false;
-       };
-
-       /**
-        * Respond to form submit event
-        *
-        * @param {jQuery.Event} e Event
-        * @return {boolean} false
-        */
-       mw.rcfilters.ui.FormWrapperWidget.prototype.onFormSubmit = function ( e ) {
-               var data = {};
-
-               // Collect all data from form
-               $( e.target ).find( 'input:not([type="hidden"],[type="submit"]), select' ).each( function () {
-                       var value = '';
-
-                       if ( !$( this ).is( ':checkbox' ) || $( this ).is( ':checked' ) ) {
-                               value = $( this ).val();
-                       }
-
-                       data[ $( this ).prop( 'name' ) ] = value;
-               } );
-
-               this.controller.updateChangesList( data );
-               return false;
-       };
-
-       /**
-        * Respond to model invalidate
-        */
-       mw.rcfilters.ui.FormWrapperWidget.prototype.onChangesModelInvalidate = function () {
-               this.$submitButton.prop( 'disabled', true );
-       };
-
-       /**
-        * Respond to model update, replace the show/hide links with the ones from the
-        * server so they feature the correct state.
-        *
-        * @param {jQuery|string} $changesList Updated changes list
-        * @param {jQuery} $fieldset Updated fieldset
-        * @param {string} noResultsDetails Type of no result error
-        * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
-        */
-       mw.rcfilters.ui.FormWrapperWidget.prototype.onChangesModelUpdate = function ( $changesList, $fieldset, noResultsDetails, isInitialDOM ) {
-               this.$submitButton.prop( 'disabled', false );
-
-               // Replace the entire fieldset
-               this.$element.empty().append( $fieldset.contents() );
-
-               if ( !isInitialDOM ) {
-                       // Make sure enhanced RC re-initializes correctly
-                       mw.hook( 'wikipage.content' ).fire( this.$element );
-               }
-
-               this.cleanUpFieldset();
-       };
-
-       /**
-        * Clean up the old-style show/hide that we have implemented in the filter list
-        */
-       mw.rcfilters.ui.FormWrapperWidget.prototype.cleanUpFieldset = function () {
-               this.$element.find( '.clshowhideoption[data-feature-in-structured-ui=1]' ).each( function () {
-                       // HACK: Remove the text node after the span.
-                       // If there isn't one, we're at the end, so remove the text node before the span.
-                       // This would be unnecessary if we added separators with CSS.
-                       if ( this.nextSibling && this.nextSibling.nodeType === Node.TEXT_NODE ) {
-                               this.parentNode.removeChild( this.nextSibling );
-                       } else if ( this.previousSibling && this.previousSibling.nodeType === Node.TEXT_NODE ) {
-                               this.parentNode.removeChild( this.previousSibling );
-                       }
-                       // Remove the span itself
-                       this.parentNode.removeChild( this );
-               } );
-
-               // Hide namespaces and tags
-               this.$element.find( '.namespaceForm' ).detach();
-               this.$element.find( '.mw-tagfilter-label' ).closest( 'tr' ).detach();
-
-               // Hide Related Changes page name form
-               this.$element.find( '.targetForm' ).detach();
-
-               // misc: limit, days, watchlist info msg
-               this.$element.find( '.rclinks, .cldays, .wlinfo' ).detach();
-
-               if ( !this.$element.find( '.mw-recentchanges-table tr' ).length ) {
-                       this.$element.find( '.mw-recentchanges-table' ).detach();
-                       this.$element.find( 'hr' ).detach();
-               }
-
-               // Get rid of all <br>s, which are inside rcshowhide
-               // If we still have content in rcshowhide, the <br>s are
-               // gone. Instead, the CSS now has a rule to mark all <span>s
-               // inside .rcshowhide with display:block; to simulate newlines
-               // where they're actually needed.
-               this.$element.find( 'br' ).detach();
-               if ( !this.$element.find( '.rcshowhide' ).contents().length ) {
-                       this.$element.find( '.rcshowhide' ).detach();
-               }
-
-               if ( this.$element.find( '.cloption' ).text().trim() === '' ) {
-                       this.$element.find( '.cloption-submit' ).detach();
-               }
-
-               this.$element.find(
-                       '.rclistfrom, .rcnotefrom, .rcoptions-listfromreset'
-               ).detach();
-
-               // Get rid of the legend
-               this.$element.find( 'legend' ).detach();
-
-               // Check if the element is essentially empty, and detach it if it is
-               if ( !this.$element.text().trim().length ) {
-                       this.$element.detach();
-               }
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.GroupWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.GroupWidget.js
deleted file mode 100644 (file)
index ab49414..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-( function () {
-       /**
-        * A group widget to allow for aggregation of events
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {Object} [config] Configuration object
-        * @param {Object} [events] Events to aggregate. The object represent the
-        *  event name to aggregate and the event value to emit on aggregate for items.
-        */
-       mw.rcfilters.ui.GroupWidget = function MwRcfiltersUiViewSwitchWidget( config ) {
-               var aggregate = {};
-
-               config = config || {};
-
-               // Parent constructor
-               mw.rcfilters.ui.GroupWidget.parent.call( this, config );
-
-               // Mixin constructors
-               OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
-
-               if ( config.events ) {
-                       // Aggregate events
-                       // eslint-disable-next-line jquery/no-each-util
-                       $.each( config.events, function ( eventName, eventEmit ) {
-                               aggregate[ eventName ] = eventEmit;
-                       } );
-
-                       this.aggregate( aggregate );
-               }
-
-               if ( Array.isArray( config.items ) ) {
-                       this.addItems( config.items );
-               }
-       };
-
-       /* Initialize */
-
-       OO.inheritClass( mw.rcfilters.ui.GroupWidget, OO.ui.Widget );
-       OO.mixinClass( mw.rcfilters.ui.GroupWidget, OO.ui.mixin.GroupWidget );
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightColorPickerWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightColorPickerWidget.js
deleted file mode 100644 (file)
index a55246f..0000000
+++ /dev/null
@@ -1,122 +0,0 @@
-( function () {
-       /**
-        * A widget representing a filter item highlight color picker
-        *
-        * @extends OO.ui.Widget
-        * @mixins OO.ui.mixin.LabelElement
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller RCFilters controller
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.HighlightColorPickerWidget = function MwRcfiltersUiHighlightColorPickerWidget( controller, config ) {
-               var colors = [ 'none' ].concat( mw.rcfilters.HighlightColors );
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.HighlightColorPickerWidget.parent.call( this, config );
-               // Mixin constructors
-               OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
-                       label: mw.message( 'rcfilters-highlightmenu-title' ).text()
-               } ) );
-
-               this.controller = controller;
-
-               this.currentSelection = 'none';
-               this.buttonSelect = new OO.ui.ButtonSelectWidget( {
-                       items: colors.map( function ( color ) {
-                               return new OO.ui.ButtonOptionWidget( {
-                                       icon: color === 'none' ? 'check' : null,
-                                       data: color,
-                                       classes: [
-                                               'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color',
-                                               'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color-' + color
-                                       ],
-                                       framed: false
-                               } );
-                       } ),
-                       classes: 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect'
-               } );
-
-               // Event
-               this.buttonSelect.connect( this, { choose: 'onChooseColor' } );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget' )
-                       .append(
-                               this.$label
-                                       .addClass( 'mw-rcfilters-ui-highlightColorPickerWidget-label' ),
-                               this.buttonSelect.$element
-                       );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.HighlightColorPickerWidget, OO.ui.Widget );
-       OO.mixinClass( mw.rcfilters.ui.HighlightColorPickerWidget, OO.ui.mixin.LabelElement );
-
-       /* Events */
-
-       /**
-        * @event chooseColor
-        * @param {string} The chosen color
-        *
-        * A color has been chosen
-        */
-
-       /* Methods */
-
-       /**
-        * Bind the color picker to an item
-        * @param {mw.rcfilters.dm.FilterItem} filterItem
-        */
-       mw.rcfilters.ui.HighlightColorPickerWidget.prototype.setFilterItem = function ( filterItem ) {
-               if ( this.filterItem ) {
-                       this.filterItem.disconnect( this );
-               }
-
-               this.filterItem = filterItem;
-               this.filterItem.connect( this, { update: 'updateUiBasedOnModel' } );
-               this.updateUiBasedOnModel();
-       };
-
-       /**
-        * Respond to item model update event
-        */
-       mw.rcfilters.ui.HighlightColorPickerWidget.prototype.updateUiBasedOnModel = function () {
-               this.selectColor( this.filterItem.getHighlightColor() || 'none' );
-       };
-
-       /**
-        * Select the color for this widget
-        *
-        * @param {string} color Selected color
-        */
-       mw.rcfilters.ui.HighlightColorPickerWidget.prototype.selectColor = function ( color ) {
-               var previousItem = this.buttonSelect.findItemFromData( this.currentSelection ),
-                       selectedItem = this.buttonSelect.findItemFromData( color );
-
-               if ( this.currentSelection !== color ) {
-                       this.currentSelection = color;
-
-                       this.buttonSelect.selectItem( selectedItem );
-                       if ( previousItem ) {
-                               previousItem.setIcon( null );
-                       }
-
-                       if ( selectedItem ) {
-                               selectedItem.setIcon( 'check' );
-                       }
-               }
-       };
-
-       mw.rcfilters.ui.HighlightColorPickerWidget.prototype.onChooseColor = function ( button ) {
-               var color = button.data;
-               if ( color === 'none' ) {
-                       this.controller.clearHighlightColor( this.filterItem.getName() );
-               } else {
-                       this.controller.setHighlightColor( this.filterItem.getName(), color );
-               }
-               this.emit( 'chooseColor', color );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightPopupWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.HighlightPopupWidget.js
deleted file mode 100644 (file)
index 2dd0379..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-( function () {
-       /**
-        * A popup containing a color picker, for setting highlight colors.
-        *
-        * @extends OO.ui.PopupWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller RCFilters controller
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.HighlightPopupWidget = function MwRcfiltersUiHighlightPopupWidget( controller, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.HighlightPopupWidget.parent.call( this, $.extend( {
-                       autoClose: true,
-                       anchor: false,
-                       padded: true,
-                       align: 'backwards',
-                       horizontalPosition: 'end',
-                       width: 290
-               }, config ) );
-
-               this.colorPicker = new mw.rcfilters.ui.HighlightColorPickerWidget( controller );
-
-               this.colorPicker.connect( this, { chooseColor: 'onChooseColor' } );
-
-               this.$body.append( this.colorPicker.$element );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.HighlightPopupWidget, OO.ui.PopupWidget );
-
-       /* Methods */
-
-       /**
-        * Set the button (or other widget) that this popup should hang off.
-        *
-        * @param {OO.ui.Widget} widget Widget the popup should orient itself to
-        */
-       mw.rcfilters.ui.HighlightPopupWidget.prototype.setAssociatedButton = function ( widget ) {
-               this.setFloatableContainer( widget.$element );
-               this.$autoCloseIgnore = widget.$element;
-       };
-
-       /**
-        * Set the filter item that this popup should control the highlight color for.
-        *
-        * @param {mw.rcfilters.dm.FilterItem} item
-        */
-       mw.rcfilters.ui.HighlightPopupWidget.prototype.setFilterItem = function ( item ) {
-               this.colorPicker.setFilterItem( item );
-       };
-
-       /**
-        * When the user chooses a color in the color picker, close the popup.
-        */
-       mw.rcfilters.ui.HighlightPopupWidget.prototype.onChooseColor = function () {
-               this.toggle( false );
-       };
-
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ItemMenuOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ItemMenuOptionWidget.js
deleted file mode 100644 (file)
index cda13eb..0000000
+++ /dev/null
@@ -1,165 +0,0 @@
-( function () {
-       /**
-        * A widget representing a base toggle item
-        *
-        * @extends OO.ui.MenuOptionWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller RCFilters controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
-        * @param {mw.rcfilters.dm.ItemModel} invertModel
-        * @param {mw.rcfilters.dm.ItemModel} itemModel Item model
-        * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
-        * @param {Object} config Configuration object
-        */
-       mw.rcfilters.ui.ItemMenuOptionWidget = function MwRcfiltersUiItemMenuOptionWidget(
-               controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
-       ) {
-               var layout,
-                       classes = [],
-                       $label = $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label' );
-
-               config = config || {};
-
-               this.controller = controller;
-               this.filtersViewModel = filtersViewModel;
-               this.invertModel = invertModel;
-               this.itemModel = itemModel;
-
-               // Parent
-               mw.rcfilters.ui.ItemMenuOptionWidget.parent.call( this, $.extend( {
-                       // Override the 'check' icon that OOUI defines
-                       icon: '',
-                       data: this.itemModel.getName(),
-                       label: this.itemModel.getLabel()
-               }, config ) );
-
-               this.checkboxWidget = new mw.rcfilters.ui.CheckboxInputWidget( {
-                       value: this.itemModel.getName(),
-                       selected: this.itemModel.isSelected()
-               } );
-
-               $label.append(
-                       $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-title' )
-                               .append( $( '<bdi>' ).append( this.$label ) )
-               );
-               if ( this.itemModel.getDescription() ) {
-                       $label.append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-desc' )
-                                       .append( $( '<bdi>' ).text( this.itemModel.getDescription() ) )
-                       );
-               }
-
-               this.highlightButton = new mw.rcfilters.ui.FilterItemHighlightButton(
-                       this.controller,
-                       this.itemModel,
-                       highlightPopup,
-                       {
-                               $overlay: config.$overlay || this.$element,
-                               title: mw.msg( 'rcfilters-highlightmenu-help' )
-                       }
-               );
-               this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
-
-               this.excludeLabel = new OO.ui.LabelWidget( {
-                       label: mw.msg( 'rcfilters-filter-excluded' )
-               } );
-               this.excludeLabel.toggle(
-                       this.itemModel.getGroupModel().getView() === 'namespaces' &&
-                       this.itemModel.isSelected() &&
-                       this.invertModel.isSelected()
-               );
-
-               layout = new OO.ui.FieldLayout( this.checkboxWidget, {
-                       label: $label,
-                       align: 'inline'
-               } );
-
-               // Events
-               this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
-               this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
-               this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
-               // HACK: Prevent defaults on 'click' for the label so it
-               // doesn't steal the focus away from the input. This means
-               // we can continue arrow-movement after we click the label
-               // and is consistent with the checkbox *itself* also preventing
-               // defaults on 'click' as well.
-               layout.$label.on( 'click', false );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget' )
-                       .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-view-' + this.itemModel.getGroupModel().getView() )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-table' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-row' )
-                                                       .append(
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox' )
-                                                                       .append( layout.$element ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-excludeLabel' )
-                                                                       .append( this.excludeLabel.$element ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-highlightButton' )
-                                                                       .append( this.highlightButton.$element )
-                                                       )
-                                       )
-                       );
-
-               if ( this.itemModel.getIdentifiers() ) {
-                       this.itemModel.getIdentifiers().forEach( function ( ident ) {
-                               classes.push( 'mw-rcfilters-ui-itemMenuOptionWidget-identifier-' + ident );
-                       } );
-
-                       this.$element.addClass( classes );
-               }
-
-               this.updateUiBasedOnState();
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.ItemMenuOptionWidget, OO.ui.MenuOptionWidget );
-
-       /* Static properties */
-
-       // We do our own scrolling to top
-       mw.rcfilters.ui.ItemMenuOptionWidget.static.scrollIntoViewOnSelect = false;
-
-       /* Methods */
-
-       /**
-        * Respond to item model update event
-        */
-       mw.rcfilters.ui.ItemMenuOptionWidget.prototype.updateUiBasedOnState = function () {
-               this.checkboxWidget.setSelected( this.itemModel.isSelected() );
-
-               this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
-               this.excludeLabel.toggle(
-                       this.itemModel.getGroupModel().getView() === 'namespaces' &&
-                       this.itemModel.isSelected() &&
-                       this.invertModel.isSelected()
-               );
-               this.toggle( this.itemModel.isVisible() );
-       };
-
-       /**
-        * Get the name of this filter
-        *
-        * @return {string} Filter name
-        */
-       mw.rcfilters.ui.ItemMenuOptionWidget.prototype.getName = function () {
-               return this.itemModel.getName();
-       };
-
-       mw.rcfilters.ui.ItemMenuOptionWidget.prototype.getModel = function () {
-               return this.itemModel;
-       };
-
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.LiveUpdateButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.LiveUpdateButtonWidget.js
deleted file mode 100644 (file)
index 926ff4a..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-( function () {
-       /**
-        * Widget for toggling live updates
-        *
-        * @extends OO.ui.ToggleButtonWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.LiveUpdateButtonWidget = function MwRcfiltersUiLiveUpdateButtonWidget( controller, changesListModel, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.LiveUpdateButtonWidget.parent.call( this, $.extend( {
-                       label: mw.message( 'rcfilters-liveupdates-button' ).text()
-               }, config ) );
-
-               this.controller = controller;
-               this.model = changesListModel;
-
-               // Events
-               this.connect( this, { click: 'onClick' } );
-               this.model.connect( this, { liveUpdateChange: 'onLiveUpdateChange' } );
-
-               this.$element.addClass( 'mw-rcfilters-ui-liveUpdateButtonWidget' );
-
-               this.setState( false );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.LiveUpdateButtonWidget, OO.ui.ToggleButtonWidget );
-
-       /* Methods */
-
-       /**
-        * Respond to the button being clicked
-        */
-       mw.rcfilters.ui.LiveUpdateButtonWidget.prototype.onClick = function () {
-               this.controller.toggleLiveUpdate();
-       };
-
-       /**
-        * Set the button's state and change its appearance
-        *
-        * @param {boolean} enable Whether the 'live update' feature is now on/off
-        */
-       mw.rcfilters.ui.LiveUpdateButtonWidget.prototype.setState = function ( enable ) {
-               this.setValue( enable );
-               this.setIcon( enable ? 'stop' : 'play' );
-               this.setTitle( mw.message(
-                       enable ?
-                               'rcfilters-liveupdates-button-title-on' :
-                               'rcfilters-liveupdates-button-title-off'
-               ).text() );
-       };
-
-       /**
-        * Respond to the 'live update' feature being turned on/off
-        *
-        * @param {boolean} enable Whether the 'live update' feature is now on/off
-        */
-       mw.rcfilters.ui.LiveUpdateButtonWidget.prototype.onLiveUpdateChange = function ( enable ) {
-               this.setState( enable );
-       };
-
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MainWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MainWrapperWidget.js
deleted file mode 100644 (file)
index b402627..0000000
+++ /dev/null
@@ -1,130 +0,0 @@
-( function () {
-       /**
-        * Wrapper for changes list content
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
-        * @param {Object} config Configuration object
-        * @cfg {jQuery} $topSection Top section container
-        * @cfg {jQuery} $filtersContainer
-        * @cfg {jQuery} $changesListContainer
-        * @cfg {jQuery} $formContainer
-        * @cfg {boolean} [collapsed] Filter area is collapsed
-        * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
-        *  system. If not given, falls back to this widget's $element
-        */
-       mw.rcfilters.ui.MainWrapperWidget = function MwRcfiltersUiMainWrapperWidget(
-               controller, model, savedQueriesModel, changesListModel, config
-       ) {
-               config = $.extend( {}, config );
-
-               // Parent
-               mw.rcfilters.ui.MainWrapperWidget.parent.call( this, config );
-
-               this.controller = controller;
-               this.model = model;
-               this.changesListModel = changesListModel;
-               this.$topSection = config.$topSection;
-               this.$filtersContainer = config.$filtersContainer;
-               this.$changesListContainer = config.$changesListContainer;
-               this.$formContainer = config.$formContainer;
-               this.$overlay = $( '<div>' ).addClass( 'mw-rcfilters-ui-overlay' );
-               this.$wrapper = config.$wrapper || this.$element;
-
-               this.savedLinksListWidget = new mw.rcfilters.ui.SavedLinksListWidget(
-                       controller, savedQueriesModel, { $overlay: this.$overlay }
-               );
-
-               this.filtersWidget = new mw.rcfilters.ui.FilterWrapperWidget(
-                       controller,
-                       model,
-                       savedQueriesModel,
-                       changesListModel,
-                       {
-                               $overlay: this.$overlay,
-                               $wrapper: this.$wrapper,
-                               collapsed: config.collapsed
-                       }
-               );
-
-               this.changesListWidget = new mw.rcfilters.ui.ChangesListWrapperWidget(
-                       model, changesListModel, controller, this.$changesListContainer );
-
-               /* Events */
-
-               // Toggle changes list overlay when filters menu opens/closes. We use overlay on changes list
-               // to prevent users from accidentally clicking on links in results, while menu is opened.
-               // Overlay on changes list is not the same as this.$overlay
-               this.filtersWidget.connect( this, { menuToggle: this.onFilterMenuToggle.bind( this ) } );
-
-               // Initialize
-               this.$filtersContainer.append( this.filtersWidget.$element );
-               $( 'body' )
-                       .append( this.$overlay )
-                       .addClass( 'mw-rcfilters-ui-initialized' );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.MainWrapperWidget, OO.ui.Widget );
-
-       /* Methods */
-
-       /**
-        * Set the content of the top section, depending on the type of special page.
-        *
-        * @param {string} specialPage
-        */
-       mw.rcfilters.ui.MainWrapperWidget.prototype.setTopSection = function ( specialPage ) {
-               var topSection;
-
-               if ( specialPage === 'Recentchanges' ) {
-                       topSection = new mw.rcfilters.ui.RcTopSectionWidget(
-                               this.savedLinksListWidget, this.$topSection
-                       );
-                       this.filtersWidget.setTopSection( topSection.$element );
-               }
-
-               if ( specialPage === 'Recentchangeslinked' ) {
-                       topSection = new mw.rcfilters.ui.RclTopSectionWidget(
-                               this.savedLinksListWidget, this.controller,
-                               this.model.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' ),
-                               this.model.getGroup( 'page' ).getItemByParamName( 'target' )
-                       );
-
-                       this.filtersWidget.setTopSection( topSection.$element );
-               }
-
-               if ( specialPage === 'Watchlist' ) {
-                       topSection = new mw.rcfilters.ui.WatchlistTopSectionWidget(
-                               this.controller, this.changesListModel, this.savedLinksListWidget, this.$topSection
-                       );
-
-                       this.filtersWidget.setTopSection( topSection.$element );
-               }
-       };
-
-       /**
-        * Filter menu toggle event listener
-        *
-        * @param {boolean} isVisible
-        */
-       mw.rcfilters.ui.MainWrapperWidget.prototype.onFilterMenuToggle = function ( isVisible ) {
-               this.changesListWidget.toggleOverlay( isVisible );
-       };
-
-       /**
-        * Initialize FormWrapperWidget
-        *
-        * @return {mw.rcfilters.ui.FormWrapperWidget} Form wrapper widget
-        */
-       mw.rcfilters.ui.MainWrapperWidget.prototype.initFormWidget = function () {
-               return new mw.rcfilters.ui.FormWrapperWidget(
-                       this.model, this.changesListModel, this.controller, this.$formContainer );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MarkSeenButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MarkSeenButtonWidget.js
deleted file mode 100644 (file)
index 328be8c..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-( function () {
-       /**
-        * Button for marking all changes as seen on the Watchlist
-        *
-        * @extends OO.ui.ButtonWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.ChangesListViewModel} model Changes list view model
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.MarkSeenButtonWidget = function MwRcfiltersUiMarkSeenButtonWidget( controller, model, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.MarkSeenButtonWidget.parent.call( this, $.extend( {
-                       label: mw.message( 'rcfilters-watchlist-markseen-button' ).text(),
-                       icon: 'checkAll'
-               }, config ) );
-
-               this.controller = controller;
-               this.model = model;
-
-               // Events
-               this.connect( this, { click: 'onClick' } );
-               this.model.connect( this, { update: 'onModelUpdate' } );
-
-               this.$element.addClass( 'mw-rcfilters-ui-markSeenButtonWidget' );
-
-               this.onModelUpdate();
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.MarkSeenButtonWidget, OO.ui.ButtonWidget );
-
-       /* Methods */
-
-       /**
-        * Respond to the button being clicked
-        */
-       mw.rcfilters.ui.MarkSeenButtonWidget.prototype.onClick = function () {
-               this.controller.markAllChangesAsSeen();
-               // assume there's no more unseen changes until the next model update
-               this.setDisabled( true );
-       };
-
-       /**
-        * Respond to the model being updated with new changes
-        */
-       mw.rcfilters.ui.MarkSeenButtonWidget.prototype.onModelUpdate = function () {
-               this.setDisabled( !this.model.hasUnseenWatchedChanges() );
-       };
-
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js
deleted file mode 100644 (file)
index 49f980c..0000000
+++ /dev/null
@@ -1,359 +0,0 @@
-( function () {
-       /**
-        * A floating menu widget for the filter list
-        *
-        * @extends OO.ui.MenuSelectWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {Object} [config] Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        * @cfg {Object[]} [footers] An array of objects defining the footers for
-        *  this menu, with a definition whether they appear per specific views.
-        *  The expected structure is:
-        *  [
-        *     {
-        *        name: {string} A unique name for the footer object
-        *        $element: {jQuery} A jQuery object for the content of the footer
-        *        views: {string[]} Optional. An array stating which views this footer is
-        *               active on. Use null or omit to display this on all views.
-        *     }
-        *  ]
-        */
-       mw.rcfilters.ui.MenuSelectWidget = function MwRcfiltersUiMenuSelectWidget( controller, model, config ) {
-               var header;
-
-               config = config || {};
-
-               this.controller = controller;
-               this.model = model;
-               this.currentView = '';
-               this.views = {};
-               this.userSelecting = false;
-
-               this.menuInitialized = false;
-               this.$overlay = config.$overlay || this.$element;
-               this.$body = $( '<div>' ).addClass( 'mw-rcfilters-ui-menuSelectWidget-body' );
-               this.footers = [];
-
-               // Parent
-               mw.rcfilters.ui.MenuSelectWidget.parent.call( this, $.extend( config, {
-                       $autoCloseIgnore: this.$overlay,
-                       width: 650,
-                       // Our filtering is done through the model
-                       filterFromInput: false
-               } ) );
-               this.setGroupElement(
-                       $( '<div>' )
-                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-group' )
-               );
-               this.setClippableElement( this.$body );
-               this.setClippableContainer( this.$element );
-
-               header = new mw.rcfilters.ui.FilterMenuHeaderWidget(
-                       this.controller,
-                       this.model,
-                       {
-                               $overlay: this.$overlay
-                       }
-               );
-
-               this.noResults = new OO.ui.LabelWidget( {
-                       label: mw.msg( 'rcfilters-filterlist-noresults' ),
-                       classes: [ 'mw-rcfilters-ui-menuSelectWidget-noresults' ]
-               } );
-
-               // Events
-               this.model.connect( this, {
-                       initialize: 'onModelInitialize',
-                       searchChange: 'onModelSearchChange'
-               } );
-
-               // Initialization
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-menuSelectWidget' )
-                       .append( header.$element )
-                       .append(
-                               this.$body
-                                       .append( this.$group, this.noResults.$element )
-                       );
-
-               // Append all footers; we will control their visibility
-               // based on view
-               config.footers = config.footers || [];
-               config.footers.forEach( function ( footerData ) {
-                       var isSticky = footerData.sticky === undefined ? true : !!footerData.sticky,
-                               adjustedData = {
-                                       // Wrap the element with our own footer wrapper
-                                       $element: $( '<div>' )
-                                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer' )
-                                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer-' + footerData.name )
-                                               .append( footerData.$element ),
-                                       views: footerData.views
-                               };
-
-                       if ( !footerData.disabled ) {
-                               this.footers.push( adjustedData );
-
-                               if ( isSticky ) {
-                                       this.$element.append( adjustedData.$element );
-                               } else {
-                                       this.$body.append( adjustedData.$element );
-                               }
-                       }
-               }.bind( this ) );
-
-               // Switch to the correct view
-               this.updateView();
-       };
-
-       /* Initialize */
-
-       OO.inheritClass( mw.rcfilters.ui.MenuSelectWidget, OO.ui.MenuSelectWidget );
-
-       /* Events */
-
-       /* Methods */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.onModelSearchChange = function () {
-               this.updateView();
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.toggle = function ( show ) {
-               this.lazyMenuCreation();
-               mw.rcfilters.ui.MenuSelectWidget.parent.prototype.toggle.call( this, show );
-               // Always open this menu downwards. FilterTagMultiselectWidget scrolls it into view.
-               this.setVerticalPosition( 'below' );
-       };
-
-       /**
-        * lazy creation of the menu
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.lazyMenuCreation = function () {
-               var widget = this,
-                       items = [],
-                       viewGroupCount = {},
-                       groups = this.model.getFilterGroups();
-
-               if ( this.menuInitialized ) {
-                       return;
-               }
-
-               this.menuInitialized = true;
-
-               // Create shared popup for highlight buttons
-               this.highlightPopup = new mw.rcfilters.ui.HighlightPopupWidget( this.controller );
-               this.$overlay.append( this.highlightPopup.$element );
-
-               // Count groups per view
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( groups, function ( groupName, groupModel ) {
-                       if ( !groupModel.isHidden() ) {
-                               viewGroupCount[ groupModel.getView() ] = viewGroupCount[ groupModel.getView() ] || 0;
-                               viewGroupCount[ groupModel.getView() ]++;
-                       }
-               } );
-
-               // eslint-disable-next-line jquery/no-each-util
-               $.each( groups, function ( groupName, groupModel ) {
-                       var currentItems = [],
-                               view = groupModel.getView();
-
-                       if ( !groupModel.isHidden() ) {
-                               if ( viewGroupCount[ view ] > 1 ) {
-                                       // Only add a section header if there is more than
-                                       // one group
-                                       currentItems.push(
-                                               // Group section
-                                               new mw.rcfilters.ui.FilterMenuSectionOptionWidget(
-                                                       widget.controller,
-                                                       groupModel,
-                                                       {
-                                                               $overlay: widget.$overlay
-                                                       }
-                                               )
-                                       );
-                               }
-
-                               // Add items
-                               widget.model.getGroupFilters( groupName ).forEach( function ( filterItem ) {
-                                       currentItems.push(
-                                               new mw.rcfilters.ui.FilterMenuOptionWidget(
-                                                       widget.controller,
-                                                       widget.model,
-                                                       widget.model.getInvertModel(),
-                                                       filterItem,
-                                                       widget.highlightPopup,
-                                                       {
-                                                               $overlay: widget.$overlay
-                                                       }
-                                               )
-                                       );
-                               } );
-
-                               // Cache the items per view, so we can switch between them
-                               // without rebuilding the widgets each time
-                               widget.views[ view ] = widget.views[ view ] || [];
-                               widget.views[ view ] = widget.views[ view ].concat( currentItems );
-                               items = items.concat( currentItems );
-                       }
-               } );
-
-               this.addItems( items );
-               this.updateView();
-       };
-
-       /**
-        * Respond to model initialize event. Populate the menu from the model
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.onModelInitialize = function () {
-               this.menuInitialized = false;
-               // Set timeout for the menu to lazy build.
-               setTimeout( this.lazyMenuCreation.bind( this ) );
-       };
-
-       /**
-        * Update view
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.updateView = function () {
-               var viewName = this.model.getCurrentView();
-
-               if ( this.views[ viewName ] && this.currentView !== viewName ) {
-                       this.updateFooterVisibility( viewName );
-
-                       this.$element
-                               .data( 'view', viewName )
-                               .removeClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + this.currentView )
-                               .addClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + viewName );
-
-                       this.currentView = viewName;
-                       this.scrollToTop();
-               }
-
-               this.postProcessItems();
-               this.clip();
-       };
-
-       /**
-        * Go over the available footers and decide which should be visible
-        * for this view
-        *
-        * @param {string} [currentView] Current view
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.updateFooterVisibility = function ( currentView ) {
-               currentView = currentView || this.model.getCurrentView();
-
-               this.footers.forEach( function ( data ) {
-                       data.$element.toggle(
-                               // This footer should only be shown if it is configured
-                               // for all views or for this specific view
-                               !data.views || data.views.length === 0 || data.views.indexOf( currentView ) > -1
-                       );
-               } );
-       };
-
-       /**
-        * Post-process items after the visibility changed. Make sure
-        * that we always have an item selected, and that the no-results
-        * widget appears if the menu is empty.
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.postProcessItems = function () {
-               var i,
-                       itemWasSelected = false,
-                       items = this.getItems();
-
-               // If we are not already selecting an item, always make sure
-               // that the top item is selected
-               if ( !this.userSelecting ) {
-                       // Select the first item in the list
-                       for ( i = 0; i < items.length; i++ ) {
-                               if (
-                                       !( items[ i ] instanceof OO.ui.MenuSectionOptionWidget ) &&
-                                       items[ i ].isVisible()
-                               ) {
-                                       itemWasSelected = true;
-                                       this.selectItem( items[ i ] );
-                                       break;
-                               }
-                       }
-
-                       if ( !itemWasSelected ) {
-                               this.selectItem( null );
-                       }
-               }
-
-               this.noResults.toggle( !this.getItems().some( function ( item ) {
-                       return item.isVisible();
-               } ) );
-       };
-
-       /**
-        * Get the option widget that matches the model given
-        *
-        * @param {mw.rcfilters.dm.ItemModel} model Item model
-        * @return {mw.rcfilters.ui.ItemMenuOptionWidget} Option widget
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.getItemFromModel = function ( model ) {
-               this.lazyMenuCreation();
-               return this.views[ model.getGroupModel().getView() ].filter( function ( item ) {
-                       return item.getName() === model.getName();
-               } )[ 0 ];
-       };
-
-       /**
-        * @inheritdoc
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
-               var nextItem,
-                       currentItem = this.findHighlightedItem() || this.findSelectedItem();
-
-               // Call parent
-               mw.rcfilters.ui.MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
-
-               // We want to select the item on arrow movement
-               // rather than just highlight it, like the menu
-               // does by default
-               if ( !this.isDisabled() && this.isVisible() ) {
-                       switch ( e.keyCode ) {
-                               case OO.ui.Keys.UP:
-                               case OO.ui.Keys.LEFT:
-                                       // Get the next item
-                                       nextItem = this.findRelativeSelectableItem( currentItem, -1 );
-                                       break;
-                               case OO.ui.Keys.DOWN:
-                               case OO.ui.Keys.RIGHT:
-                                       // Get the next item
-                                       nextItem = this.findRelativeSelectableItem( currentItem, 1 );
-                                       break;
-                       }
-
-                       nextItem = nextItem && nextItem.constructor.static.selectable ?
-                               nextItem : null;
-
-                       // Select the next item
-                       this.selectItem( nextItem );
-               }
-       };
-
-       /**
-        * Scroll to the top of the menu
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.scrollToTop = function () {
-               this.$body.scrollTop( 0 );
-       };
-
-       /**
-        * Set whether the user is currently selecting an item.
-        * This is important when the user selects an item that is in between
-        * different views, and makes sure we do not re-select a different
-        * item (like the item on top) when this is happening.
-        *
-        * @param {boolean} isSelecting User is selecting
-        */
-       mw.rcfilters.ui.MenuSelectWidget.prototype.setUserSelecting = function ( isSelecting ) {
-               this.userSelecting = !!isSelecting;
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RcTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RcTopSectionWidget.js
deleted file mode 100644 (file)
index e3d5575..0000000
+++ /dev/null
@@ -1,113 +0,0 @@
-( function () {
-       /**
-        * Top section (between page title and filters) on Special:Recentchanges
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
-        * @param {jQuery} $topLinks Content of the community-defined links
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.RcTopSectionWidget = function MwRcfiltersUiRcTopSectionWidget(
-               savedLinksListWidget, $topLinks, config
-       ) {
-               var toplinksTitle,
-                       topLinksCookieName = 'rcfilters-toplinks-collapsed-state',
-                       topLinksCookie = mw.cookie.get( topLinksCookieName ),
-                       topLinksCookieValue = topLinksCookie || 'collapsed',
-                       widget = this;
-
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.RcTopSectionWidget.parent.call( this, config );
-
-               this.$topLinks = $topLinks;
-
-               toplinksTitle = new OO.ui.ButtonWidget( {
-                       framed: false,
-                       indicator: topLinksCookieValue === 'collapsed' ? 'down' : 'up',
-                       flags: [ 'progressive' ],
-                       label: $( '<span>' ).append( mw.message( 'rcfilters-other-review-tools' ).parse() ).contents()
-               } );
-
-               this.$topLinks
-                       .makeCollapsible( {
-                               collapsed: topLinksCookieValue === 'collapsed',
-                               $customTogglers: toplinksTitle.$element
-                       } )
-                       .on( 'beforeExpand.mw-collapsible', function () {
-                               mw.cookie.set( topLinksCookieName, 'expanded' );
-                               toplinksTitle.setIndicator( 'up' );
-                               widget.switchTopLinks( 'expanded' );
-                       } )
-                       .on( 'beforeCollapse.mw-collapsible', function () {
-                               mw.cookie.set( topLinksCookieName, 'collapsed' );
-                               toplinksTitle.setIndicator( 'down' );
-                               widget.switchTopLinks( 'collapsed' );
-                       } );
-
-               this.$topLinks.find( '.mw-recentchanges-toplinks-title' )
-                       .replaceWith( toplinksTitle.$element.removeAttr( 'tabIndex' ) );
-
-               // Create two positions for the toplinks to toggle between
-               // in the table (first cell) or up above it
-               this.$top = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-top' );
-               this.$tableTopLinks = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-cell' )
-                       .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-table' );
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-rcTopSectionWidget' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-table' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-row' )
-                                                       .append(
-                                                               this.$tableTopLinks,
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-table-placeholder' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' ),
-                                                               !mw.user.isAnon() ?
-                                                                       $( '<div>' )
-                                                                               .addClass( 'mw-rcfilters-ui-cell' )
-                                                                               .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-savedLinks' )
-                                                                               .append( savedLinksListWidget.$element ) :
-                                                                       null
-                                                       )
-                                       )
-                       );
-
-               // Hack: For jumpiness reasons, this should be a sibling of -head
-               $( '.rcfilters-head' ).before( this.$top );
-
-               // Initialize top links position
-               widget.switchTopLinks( topLinksCookieValue );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.RcTopSectionWidget, OO.ui.Widget );
-
-       /**
-        * Switch the top links widget from inside the table (when collapsed)
-        * to the 'top' (when open)
-        *
-        * @param {string} [state] The state of the top links widget: 'expanded' or 'collapsed'
-        */
-       mw.rcfilters.ui.RcTopSectionWidget.prototype.switchTopLinks = function ( state ) {
-               state = state || 'expanded';
-
-               if ( state === 'expanded' ) {
-                       this.$top.append( this.$topLinks );
-               } else {
-                       this.$tableTopLinks.append( this.$topLinks );
-               }
-               this.$topLinks.toggleClass( 'mw-recentchanges-toplinks-collapsed', state === 'collapsed' );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js
deleted file mode 100644 (file)
index dc76085..0000000
+++ /dev/null
@@ -1,79 +0,0 @@
-( function () {
-       /**
-        * Widget to select and display target page on Special:RecentChangesLinked (AKA Related Changes)
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.FilterItem} targetPageModel
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.RclTargetPageWidget = function MwRcfiltersUiRclTargetPageWidget(
-               controller, targetPageModel, config
-       ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.RclTargetPageWidget.parent.call( this, config );
-
-               this.controller = controller;
-               this.model = targetPageModel;
-
-               this.titleSearch = new mw.widgets.TitleInputWidget( {
-                       validate: false,
-                       placeholder: mw.msg( 'rcfilters-target-page-placeholder' ),
-                       showImages: true,
-                       showDescriptions: true,
-                       addQueryInput: false
-               } );
-
-               // Events
-               this.model.connect( this, { update: 'updateUiBasedOnModel' } );
-
-               this.titleSearch.$input.on( {
-                       blur: this.onLookupInputBlur.bind( this )
-               } );
-
-               this.titleSearch.lookupMenu.connect( this, {
-                       choose: 'onLookupMenuItemChoose'
-               } );
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-rclTargetPageWidget' )
-                       .append( this.titleSearch.$element );
-
-               this.updateUiBasedOnModel();
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.RclTargetPageWidget, OO.ui.Widget );
-
-       /* Methods */
-
-       /**
-        * Respond to the user choosing a title
-        */
-       mw.rcfilters.ui.RclTargetPageWidget.prototype.onLookupMenuItemChoose = function () {
-               this.titleSearch.$input.trigger( 'blur' );
-       };
-
-       /**
-        * Respond to titleSearch $input blur
-        */
-       mw.rcfilters.ui.RclTargetPageWidget.prototype.onLookupInputBlur = function () {
-               this.controller.setTargetPage( this.titleSearch.getQueryValue() );
-       };
-
-       /**
-        * Respond to the model being updated
-        */
-       mw.rcfilters.ui.RclTargetPageWidget.prototype.updateUiBasedOnModel = function () {
-               var title = mw.Title.newFromText( this.model.getValue() ),
-                       text = title ? title.toText() : this.model.getValue();
-               this.titleSearch.setValue( text );
-               this.titleSearch.setTitle( text );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclToOrFromWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclToOrFromWidget.js
deleted file mode 100644 (file)
index 8925dcf..0000000
+++ /dev/null
@@ -1,73 +0,0 @@
-( function () {
-       /**
-        * Widget to select to view changes that link TO or FROM the target page
-        * on Special:RecentChangesLinked (AKA Related Changes)
-        *
-        * @extends OO.ui.DropdownWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel model this widget is bound to
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.RclToOrFromWidget = function MwRcfiltersUiRclToOrFromWidget(
-               controller, showLinkedToModel, config
-       ) {
-               config = config || {};
-
-               this.showLinkedFrom = new OO.ui.MenuOptionWidget( {
-                       data: 'from', // default (showlinkedto=0)
-                       label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedfrom-option-label' ) )
-               } );
-               this.showLinkedTo = new OO.ui.MenuOptionWidget( {
-                       data: 'to', // showlinkedto=1
-                       label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedto-option-label' ) )
-               } );
-
-               // Parent
-               mw.rcfilters.ui.RclToOrFromWidget.parent.call( this, $.extend( {
-                       classes: [ 'mw-rcfilters-ui-rclToOrFromWidget' ],
-                       menu: { items: [ this.showLinkedFrom, this.showLinkedTo ] }
-               }, config ) );
-
-               this.controller = controller;
-               this.model = showLinkedToModel;
-
-               this.getMenu().connect( this, { choose: 'onUserChooseItem' } );
-               this.model.connect( this, { update: 'onModelUpdate' } );
-
-               // force an initial update of the component based on the state
-               this.onModelUpdate();
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.RclToOrFromWidget, OO.ui.DropdownWidget );
-
-       /* Methods */
-
-       /**
-        * Respond to the user choosing an item in the menu
-        *
-        * @param {OO.ui.MenuOptionWidget} chosenItem
-        */
-       mw.rcfilters.ui.RclToOrFromWidget.prototype.onUserChooseItem = function ( chosenItem ) {
-               this.controller.setShowLinkedTo( chosenItem.getData() === 'to' );
-       };
-
-       /**
-        * Respond to model update
-        */
-       mw.rcfilters.ui.RclToOrFromWidget.prototype.onModelUpdate = function () {
-               this.getMenu().selectItem(
-                       this.model.isSelected() ?
-                               this.showLinkedTo :
-                               this.showLinkedFrom
-               );
-               this.setLabel( mw.msg(
-                       this.model.isSelected() ?
-                               'rcfilters-filter-showlinkedto-label' :
-                               'rcfilters-filter-showlinkedfrom-label'
-               ) );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTopSectionWidget.js
deleted file mode 100644 (file)
index 7488254..0000000
+++ /dev/null
@@ -1,66 +0,0 @@
-( function () {
-       /**
-        * Top section (between page title and filters) on Special:RecentChangesLinked (AKA RelatedChanges)
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel Model for 'showlinkedto' parameter
-        * @param {mw.rcfilters.dm.FilterItem} targetPageModel Model for 'target' parameter
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.RclTopSectionWidget = function MwRcfiltersUiRclTopSectionWidget(
-               savedLinksListWidget, controller, showLinkedToModel, targetPageModel, config
-       ) {
-               var toOrFromWidget,
-                       targetPage;
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.RclTopSectionWidget.parent.call( this, config );
-
-               this.controller = controller;
-
-               toOrFromWidget = new mw.rcfilters.ui.RclToOrFromWidget( controller, showLinkedToModel );
-               targetPage = new mw.rcfilters.ui.RclTargetPageWidget( controller, targetPageModel );
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-rclTopSectionWidget' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-table' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-row' )
-                                                       .append(
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .append( toOrFromWidget.$element )
-                                                       ),
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-row' )
-                                                       .append(
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .append( targetPage.$element ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-table-placeholder' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' ),
-                                                               !mw.user.isAnon() ?
-                                                                       $( '<div>' )
-                                                                               .addClass( 'mw-rcfilters-ui-cell' )
-                                                                               .addClass( 'mw-rcfilters-ui-rclTopSectionWidget-savedLinks' )
-                                                                               .append( savedLinksListWidget.$element ) :
-                                                                       null
-                                                       )
-                                       )
-                       );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.RclTopSectionWidget, OO.ui.Widget );
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.js
deleted file mode 100644 (file)
index ae1ec90..0000000
+++ /dev/null
@@ -1,188 +0,0 @@
-( function () {
-       /**
-        * Save filters widget. This widget is displayed in the tag area
-        * and allows the user to save the current state of the system
-        * as a new saved filter query they can later load or set as
-        * default.
-        *
-        * @extends OO.ui.PopupButtonWidget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.SaveFiltersPopupButtonWidget = function MwRcfiltersUiSaveFiltersPopupButtonWidget( controller, model, config ) {
-               var layout,
-                       checkBoxLayout,
-                       $popupContent = $( '<div>' );
-
-               config = config || {};
-
-               this.controller = controller;
-               this.model = model;
-
-               // Parent
-               mw.rcfilters.ui.SaveFiltersPopupButtonWidget.parent.call( this, $.extend( {
-                       framed: false,
-                       icon: 'bookmark',
-                       title: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
-                       popup: {
-                               classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup' ],
-                               padded: true,
-                               head: true,
-                               label: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
-                               $content: $popupContent
-                       }
-               }, config ) );
-               // // HACK: Add an icon to the popup head label
-               this.popup.$head.prepend( ( new OO.ui.IconWidget( { icon: 'bookmark' } ) ).$element );
-
-               this.input = new OO.ui.TextInputWidget( {
-                       placeholder: mw.msg( 'rcfilters-savedqueries-new-name-placeholder' )
-               } );
-               layout = new OO.ui.FieldLayout( this.input, {
-                       label: mw.msg( 'rcfilters-savedqueries-new-name-label' ),
-                       align: 'top'
-               } );
-
-               this.setAsDefaultCheckbox = new OO.ui.CheckboxInputWidget();
-               checkBoxLayout = new OO.ui.FieldLayout( this.setAsDefaultCheckbox, {
-                       label: mw.msg( 'rcfilters-savedqueries-setdefault' ),
-                       align: 'inline'
-               } );
-
-               this.applyButton = new OO.ui.ButtonWidget( {
-                       label: mw.msg( 'rcfilters-savedqueries-apply-label' ),
-                       classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-apply' ],
-                       flags: [ 'primary', 'progressive' ]
-               } );
-               this.cancelButton = new OO.ui.ButtonWidget( {
-                       label: mw.msg( 'rcfilters-savedqueries-cancel-label' ),
-                       classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-cancel' ]
-               } );
-
-               $popupContent
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-layout' )
-                                       .append( layout.$element ),
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-options' )
-                                       .append( checkBoxLayout.$element ),
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons' )
-                                       .append(
-                                               this.cancelButton.$element,
-                                               this.applyButton.$element
-                                       )
-                       );
-
-               // Events
-               this.popup.connect( this, {
-                       ready: 'onPopupReady'
-               } );
-               this.input.connect( this, {
-                       change: 'onInputChange',
-                       enter: 'onInputEnter'
-               } );
-               this.input.$input.on( {
-                       keyup: this.onInputKeyup.bind( this )
-               } );
-               this.setAsDefaultCheckbox.connect( this, { change: 'onSetAsDefaultChange' } );
-               this.cancelButton.connect( this, { click: 'onCancelButtonClick' } );
-               this.applyButton.connect( this, { click: 'onApplyButtonClick' } );
-
-               // Initialize
-               this.applyButton.setDisabled( !this.input.getValue() );
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget' );
-       };
-
-       /* Initialization */
-       OO.inheritClass( mw.rcfilters.ui.SaveFiltersPopupButtonWidget, OO.ui.PopupButtonWidget );
-
-       /**
-        * Respond to input enter event
-        */
-       mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onInputEnter = function () {
-               this.apply();
-       };
-
-       /**
-        * Respond to input change event
-        *
-        * @param {string} value Input value
-        */
-       mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onInputChange = function ( value ) {
-               value = value.trim();
-
-               this.applyButton.setDisabled( !value );
-       };
-
-       /**
-        * Respond to input keyup event, this is the way to intercept 'escape' key
-        *
-        * @param {jQuery.Event} e Event data
-        * @return {boolean} false
-        */
-       mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onInputKeyup = function ( e ) {
-               if ( e.which === OO.ui.Keys.ESCAPE ) {
-                       this.popup.toggle( false );
-                       return false;
-               }
-       };
-
-       /**
-        * Respond to popup ready event
-        */
-       mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onPopupReady = function () {
-               this.input.focus();
-       };
-
-       /**
-        * Respond to "set as default" checkbox change
-        * @param {boolean} checked State of the checkbox
-        */
-       mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onSetAsDefaultChange = function ( checked ) {
-               var messageKey = checked ?
-                       'rcfilters-savedqueries-apply-and-setdefault-label' :
-                       'rcfilters-savedqueries-apply-label';
-
-               this.applyButton
-                       .setIcon( checked ? 'pushPin' : null )
-                       .setLabel( mw.msg( messageKey ) );
-       };
-
-       /**
-        * Respond to cancel button click event
-        */
-       mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onCancelButtonClick = function () {
-               this.popup.toggle( false );
-       };
-
-       /**
-        * Respond to apply button click event
-        */
-       mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.onApplyButtonClick = function () {
-               this.apply();
-       };
-
-       /**
-        * Apply and add the new quick link
-        */
-       mw.rcfilters.ui.SaveFiltersPopupButtonWidget.prototype.apply = function () {
-               var label = this.input.getValue().trim();
-
-               // This condition is more for sanity-check, since the
-               // apply button should be disabled if the label is empty
-               if ( label ) {
-                       this.controller.saveCurrentQuery( label, this.setAsDefaultCheckbox.isSelected() );
-                       this.input.setValue( '' );
-                       this.setAsDefaultCheckbox.setSelected( false );
-                       this.popup.toggle( false );
-
-                       this.emit( 'saveCurrent' );
-               }
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListItemWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListItemWidget.js
deleted file mode 100644 (file)
index f1364d1..0000000
+++ /dev/null
@@ -1,331 +0,0 @@
-( function () {
-       /**
-        * Quick links menu option widget
-        *
-        * @class
-        * @extends OO.ui.Widget
-        * @mixins OO.ui.mixin.LabelElement
-        * @mixins OO.ui.mixin.IconElement
-        * @mixins OO.ui.mixin.TitledElement
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.SavedQueryItemModel} model View model
-        * @param {Object} [config] Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget = function MwRcfiltersUiSavedLinksListWidget( model, config ) {
-               config = config || {};
-
-               this.model = model;
-
-               // Parent
-               mw.rcfilters.ui.SavedLinksListItemWidget.parent.call( this, $.extend( {
-                       data: this.model.getID()
-               }, config ) );
-
-               // Mixin constructors
-               OO.ui.mixin.LabelElement.call( this, $.extend( {
-                       label: this.model.getLabel()
-               }, config ) );
-               OO.ui.mixin.IconElement.call( this, $.extend( {
-                       icon: ''
-               }, config ) );
-               OO.ui.mixin.TitledElement.call( this, $.extend( {
-                       title: this.model.getLabel()
-               }, config ) );
-
-               this.edit = false;
-               this.$overlay = config.$overlay || this.$element;
-
-               this.popupButton = new OO.ui.ButtonWidget( {
-                       classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-button' ],
-                       icon: 'ellipsis',
-                       framed: false
-               } );
-               this.menu = new OO.ui.MenuSelectWidget( {
-                       classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-menu' ],
-                       widget: this.popupButton,
-                       width: 200,
-                       horizontalPosition: 'end',
-                       $floatableContainer: this.popupButton.$element,
-                       items: [
-                               new OO.ui.MenuOptionWidget( {
-                                       data: 'edit',
-                                       icon: 'edit',
-                                       label: mw.msg( 'rcfilters-savedqueries-rename' )
-                               } ),
-                               new OO.ui.MenuOptionWidget( {
-                                       data: 'delete',
-                                       icon: 'trash',
-                                       label: mw.msg( 'rcfilters-savedqueries-remove' )
-                               } ),
-                               new OO.ui.MenuOptionWidget( {
-                                       data: 'default',
-                                       icon: 'pushPin',
-                                       label: mw.msg( 'rcfilters-savedqueries-setdefault' )
-                               } )
-                       ]
-               } );
-
-               this.editInput = new OO.ui.TextInputWidget( {
-                       classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-input' ]
-               } );
-               this.saveButton = new OO.ui.ButtonWidget( {
-                       icon: 'check',
-                       flags: [ 'primary', 'progressive' ]
-               } );
-               this.toggleEdit( false );
-
-               // Events
-               this.model.connect( this, { update: 'onModelUpdate' } );
-               this.popupButton.connect( this, { click: 'onPopupButtonClick' } );
-               this.menu.connect( this, {
-                       choose: 'onMenuChoose'
-               } );
-               this.saveButton.connect( this, { click: 'save' } );
-               this.editInput.connect( this, {
-                       change: 'onInputChange',
-                       enter: 'save'
-               } );
-               this.editInput.$input.on( {
-                       blur: this.onInputBlur.bind( this ),
-                       keyup: this.onInputKeyup.bind( this )
-               } );
-               this.$element.on( { click: this.onClick.bind( this ) } );
-               this.$label.on( { click: this.onClick.bind( this ) } );
-               this.$icon.on( { click: this.onDefaultIconClick.bind( this ) } );
-               // Prevent propagation on mousedown for the save button
-               // so the menu doesn't close
-               this.saveButton.$element.on( { mousedown: function () {
-                       return false;
-               } } );
-
-               // Initialize
-               this.toggleDefault( !!this.model.isDefault() );
-               this.$overlay.append( this.menu.$element );
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget' )
-                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-query-' + this.model.getID() )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-table' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-row' )
-                                                       .append(
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-content' )
-                                                                       .append(
-                                                                               this.$label
-                                                                                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-label' ),
-                                                                               this.editInput.$element,
-                                                                               this.saveButton.$element
-                                                                       ),
-                                                               $( '<div>' )
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                                       .addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-icon' )
-                                                                       .append( this.$icon ),
-                                                               this.popupButton.$element
-                                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                       )
-                                       )
-                       );
-       };
-
-       /* Initialization */
-       OO.inheritClass( mw.rcfilters.ui.SavedLinksListItemWidget, OO.ui.Widget );
-       OO.mixinClass( mw.rcfilters.ui.SavedLinksListItemWidget, OO.ui.mixin.LabelElement );
-       OO.mixinClass( mw.rcfilters.ui.SavedLinksListItemWidget, OO.ui.mixin.IconElement );
-       OO.mixinClass( mw.rcfilters.ui.SavedLinksListItemWidget, OO.ui.mixin.TitledElement );
-
-       /* Events */
-
-       /**
-        * @event delete
-        *
-        * The delete option was selected for this item
-        */
-
-       /**
-        * @event default
-        * @param {boolean} default Item is default
-        *
-        * The 'make default' option was selected for this item
-        */
-
-       /**
-        * @event edit
-        * @param {string} newLabel New label for the query
-        *
-        * The label has been edited
-        */
-
-       /* Methods */
-
-       /**
-        * Respond to model update event
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onModelUpdate = function () {
-               this.setLabel( this.model.getLabel() );
-               this.toggleDefault( this.model.isDefault() );
-       };
-
-       /**
-        * Respond to click on the element or label
-        *
-        * @fires click
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onClick = function () {
-               if ( !this.editing ) {
-                       this.emit( 'click' );
-               }
-       };
-
-       /**
-        * Respond to click on the 'default' icon. Open the submenu where the
-        * default state can be changed.
-        *
-        * @return {boolean} false
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onDefaultIconClick = function () {
-               this.menu.toggle();
-               return false;
-       };
-
-       /**
-        * Respond to popup button click event
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onPopupButtonClick = function () {
-               this.menu.toggle();
-       };
-
-       /**
-        * Respond to menu choose event
-        *
-        * @param {OO.ui.MenuOptionWidget} item Chosen item
-        * @fires delete
-        * @fires default
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onMenuChoose = function ( item ) {
-               var action = item.getData();
-
-               if ( action === 'edit' ) {
-                       this.toggleEdit( true );
-               } else if ( action === 'delete' ) {
-                       this.emit( 'delete' );
-               } else if ( action === 'default' ) {
-                       this.emit( 'default', !this.default );
-               }
-               // Reset selected
-               this.menu.selectItem( null );
-               // Close the menu
-               this.menu.toggle( false );
-       };
-
-       /**
-        * Respond to input keyup event, this is the way to intercept 'escape' key
-        *
-        * @param {jQuery.Event} e Event data
-        * @return {boolean} false
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onInputKeyup = function ( e ) {
-               if ( e.which === OO.ui.Keys.ESCAPE ) {
-                       // Return the input to the original label
-                       this.editInput.setValue( this.getLabel() );
-                       this.toggleEdit( false );
-                       return false;
-               }
-       };
-
-       /**
-        * Respond to blur event on the input
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onInputBlur = function () {
-               this.save();
-
-               // Whether the save succeeded or not, the input-blur event
-               // means we need to cancel editing mode
-               this.toggleEdit( false );
-       };
-
-       /**
-        * Respond to input change event
-        *
-        * @param {string} value Input value
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.onInputChange = function ( value ) {
-               value = value.trim();
-
-               this.saveButton.setDisabled( !value );
-       };
-
-       /**
-        * Save the name of the query
-        *
-        * @param {string} [value] The value to save
-        * @fires edit
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.save = function () {
-               var value = this.editInput.getValue().trim();
-
-               if ( value ) {
-                       this.emit( 'edit', value );
-                       this.toggleEdit( false );
-               }
-       };
-
-       /**
-        * Toggle edit mode on this widget
-        *
-        * @param {boolean} isEdit Widget is in edit mode
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.toggleEdit = function ( isEdit ) {
-               isEdit = isEdit === undefined ? !this.editing : isEdit;
-
-               if ( this.editing !== isEdit ) {
-                       this.$element.toggleClass( 'mw-rcfilters-ui-savedLinksListItemWidget-edit', isEdit );
-                       this.editInput.setValue( this.getLabel() );
-
-                       this.editInput.toggle( isEdit );
-                       this.$label.toggleClass( 'oo-ui-element-hidden', isEdit );
-                       this.$icon.toggleClass( 'oo-ui-element-hidden', isEdit );
-                       this.popupButton.toggle( !isEdit );
-                       this.saveButton.toggle( isEdit );
-
-                       if ( isEdit ) {
-                               this.editInput.$input.trigger( 'focus' );
-                       }
-                       this.editing = isEdit;
-               }
-       };
-
-       /**
-        * Toggle default this widget
-        *
-        * @param {boolean} isDefault This item is default
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.toggleDefault = function ( isDefault ) {
-               isDefault = isDefault === undefined ? !this.default : isDefault;
-
-               if ( this.default !== isDefault ) {
-                       this.default = isDefault;
-                       this.setIcon( this.default ? 'pushPin' : '' );
-                       this.menu.findItemFromData( 'default' ).setLabel(
-                               this.default ?
-                                       mw.msg( 'rcfilters-savedqueries-unsetdefault' ) :
-                                       mw.msg( 'rcfilters-savedqueries-setdefault' )
-                       );
-               }
-       };
-
-       /**
-        * Get item ID
-        *
-        * @return {string} Query identifier
-        */
-       mw.rcfilters.ui.SavedLinksListItemWidget.prototype.getID = function () {
-               return this.model.getID();
-       };
-
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.SavedLinksListWidget.js
deleted file mode 100644 (file)
index b4ec781..0000000
+++ /dev/null
@@ -1,153 +0,0 @@
-( function () {
-       /**
-        * Quick links widget
-        *
-        * @class
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
-        * @param {Object} [config] Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        */
-       mw.rcfilters.ui.SavedLinksListWidget = function MwRcfiltersUiSavedLinksListWidget( controller, model, config ) {
-               var $labelNoEntries = $( '<div>' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-title' )
-                                       .text( mw.msg( 'rcfilters-quickfilters-placeholder-title' ) ),
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-description' )
-                                       .text( mw.msg( 'rcfilters-quickfilters-placeholder-description' ) )
-                       );
-
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.SavedLinksListWidget.parent.call( this, config );
-
-               this.controller = controller;
-               this.model = model;
-               this.$overlay = config.$overlay || this.$element;
-
-               this.placeholderItem = new OO.ui.DecoratedOptionWidget( {
-                       classes: [ 'mw-rcfilters-ui-savedLinksListWidget-placeholder' ],
-                       label: $labelNoEntries,
-                       icon: 'bookmark'
-               } );
-
-               this.menu = new mw.rcfilters.ui.GroupWidget( {
-                       events: {
-                               click: 'menuItemClick',
-                               delete: 'menuItemDelete',
-                               default: 'menuItemDefault',
-                               edit: 'menuItemEdit'
-                       },
-                       classes: [ 'mw-rcfilters-ui-savedLinksListWidget-menu' ],
-                       items: [ this.placeholderItem ]
-               } );
-               this.button = new OO.ui.PopupButtonWidget( {
-                       classes: [ 'mw-rcfilters-ui-savedLinksListWidget-button' ],
-                       label: mw.msg( 'rcfilters-quickfilters' ),
-                       icon: 'bookmark',
-                       indicator: 'down',
-                       $overlay: this.$overlay,
-                       popup: {
-                               width: 300,
-                               anchor: false,
-                               align: 'backwards',
-                               $autoCloseIgnore: this.$overlay,
-                               $content: this.menu.$element
-                       }
-               } );
-
-               // Events
-               this.model.connect( this, {
-                       add: 'onModelAddItem',
-                       remove: 'onModelRemoveItem'
-               } );
-               this.menu.connect( this, {
-                       menuItemClick: 'onMenuItemClick',
-                       menuItemDelete: 'onMenuItemRemove',
-                       menuItemDefault: 'onMenuItemDefault',
-                       menuItemEdit: 'onMenuItemEdit'
-               } );
-
-               this.placeholderItem.toggle( this.model.isEmpty() );
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-savedLinksListWidget' )
-                       .append( this.button.$element );
-       };
-
-       /* Initialization */
-       OO.inheritClass( mw.rcfilters.ui.SavedLinksListWidget, OO.ui.Widget );
-
-       /* Methods */
-
-       /**
-        * Respond to menu item click event
-        *
-        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
-        */
-       mw.rcfilters.ui.SavedLinksListWidget.prototype.onMenuItemClick = function ( item ) {
-               this.controller.applySavedQuery( item.getID() );
-               this.button.popup.toggle( false );
-       };
-
-       /**
-        * Respond to menu item remove event
-        *
-        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
-        */
-       mw.rcfilters.ui.SavedLinksListWidget.prototype.onMenuItemRemove = function ( item ) {
-               this.controller.removeSavedQuery( item.getID() );
-       };
-
-       /**
-        * Respond to menu item default event
-        *
-        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
-        * @param {boolean} isDefault Item is default
-        */
-       mw.rcfilters.ui.SavedLinksListWidget.prototype.onMenuItemDefault = function ( item, isDefault ) {
-               this.controller.setDefaultSavedQuery( isDefault ? item.getID() : null );
-       };
-
-       /**
-        * Respond to menu item edit event
-        *
-        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
-        * @param {string} newLabel New label
-        */
-       mw.rcfilters.ui.SavedLinksListWidget.prototype.onMenuItemEdit = function ( item, newLabel ) {
-               this.controller.renameSavedQuery( item.getID(), newLabel );
-       };
-
-       /**
-        * Respond to menu add item event
-        *
-        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
-        */
-       mw.rcfilters.ui.SavedLinksListWidget.prototype.onModelAddItem = function ( item ) {
-               if ( this.menu.findItemFromData( item.getID() ) ) {
-                       return;
-               }
-
-               this.menu.addItems( [
-                       new mw.rcfilters.ui.SavedLinksListItemWidget( item, { $overlay: this.$overlay } )
-               ] );
-               this.placeholderItem.toggle( this.model.isEmpty() );
-       };
-
-       /**
-        * Respond to menu remove item event
-        *
-        * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
-        */
-       mw.rcfilters.ui.SavedLinksListWidget.prototype.onModelRemoveItem = function ( item ) {
-               this.menu.removeItems( [ this.menu.findItemFromData( item.getID() ) ] );
-               this.placeholderItem.toggle( this.model.isEmpty() );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.TagItemWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.TagItemWidget.js
deleted file mode 100644 (file)
index 88117e7..0000000
+++ /dev/null
@@ -1,223 +0,0 @@
-( function () {
-       /**
-        * Extend OOUI's TagItemWidget to also display a popup on hover.
-        *
-        * @class
-        * @extends OO.ui.TagItemWidget
-        * @mixins OO.ui.mixin.PopupElement
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
-        * @param {mw.rcfilters.dm.FilterItem} invertModel
-        * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
-        * @param {Object} config Configuration object
-        * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
-        */
-       mw.rcfilters.ui.TagItemWidget = function MwRcfiltersUiTagItemWidget(
-               controller, filtersViewModel, invertModel, itemModel, config
-       ) {
-               // Configuration initialization
-               config = config || {};
-
-               this.controller = controller;
-               this.invertModel = invertModel;
-               this.filtersViewModel = filtersViewModel;
-               this.itemModel = itemModel;
-               this.selected = false;
-
-               mw.rcfilters.ui.TagItemWidget.parent.call( this, $.extend( {
-                       data: this.itemModel.getName()
-               }, config ) );
-
-               this.$overlay = config.$overlay || this.$element;
-               this.popupLabel = new OO.ui.LabelWidget();
-
-               // Mixin constructors
-               OO.ui.mixin.PopupElement.call( this, $.extend( {
-                       popup: {
-                               padded: false,
-                               align: 'center',
-                               position: 'above',
-                               $content: $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-tagItemWidget-popup-content' )
-                                       .append( this.popupLabel.$element ),
-                               $floatableContainer: this.$element,
-                               classes: [ 'mw-rcfilters-ui-tagItemWidget-popup' ]
-                       }
-               }, config ) );
-
-               this.popupTimeoutShow = null;
-               this.popupTimeoutHide = null;
-
-               this.$highlight = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-tagItemWidget-highlight' );
-
-               // Add title attribute with the item label to 'x' button
-               this.closeButton.setTitle( mw.msg( 'rcfilters-tag-remove', this.itemModel.getLabel() ) );
-
-               // Events
-               this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
-               this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
-               this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
-
-               // Initialization
-               this.$overlay.append( this.popup.$element );
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-tagItemWidget' )
-                       .prepend( this.$highlight )
-                       .attr( 'aria-haspopup', 'true' )
-                       .on( 'mouseenter', this.onMouseEnter.bind( this ) )
-                       .on( 'mouseleave', this.onMouseLeave.bind( this ) );
-
-               this.updateUiBasedOnState();
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.TagItemWidget, OO.ui.TagItemWidget );
-       OO.mixinClass( mw.rcfilters.ui.TagItemWidget, OO.ui.mixin.PopupElement );
-
-       /* Methods */
-
-       /**
-        * Respond to model update event
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.updateUiBasedOnState = function () {
-               // Update label if needed
-               var labelMsg = this.itemModel.getLabelMessageKey( this.invertModel.isSelected() );
-               if ( labelMsg ) {
-                       this.setLabel( $( '<div>' ).append(
-                               $( '<bdi>' ).html(
-                                       mw.message( labelMsg, mw.html.escape( this.itemModel.getLabel() ) ).parse()
-                               )
-                       ).contents() );
-               } else {
-                       this.setLabel(
-                               $( '<bdi>' ).append(
-                                       this.itemModel.getLabel()
-                               )
-                       );
-               }
-
-               this.setCurrentMuteState();
-               this.setHighlightColor();
-       };
-
-       /**
-        * Set the current highlight color for this item
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.setHighlightColor = function () {
-               var selectedColor = this.filtersViewModel.isHighlightEnabled() && this.itemModel.isHighlighted ?
-                       this.itemModel.getHighlightColor() :
-                       null;
-
-               this.$highlight
-                       .attr( 'data-color', selectedColor )
-                       .toggleClass(
-                               'mw-rcfilters-ui-tagItemWidget-highlight-highlighted',
-                               !!selectedColor
-                       );
-       };
-
-       /**
-        * Set the current mute state for this item
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.setCurrentMuteState = function () {};
-
-       /**
-        * Respond to mouse enter event
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.onMouseEnter = function () {
-               var labelText = this.itemModel.getStateMessage();
-
-               if ( labelText ) {
-                       this.popupLabel.setLabel( labelText );
-
-                       // Set timeout for the popup to show
-                       this.popupTimeoutShow = setTimeout( function () {
-                               this.popup.toggle( true );
-                       }.bind( this ), 500 );
-
-                       // Cancel the hide timeout
-                       clearTimeout( this.popupTimeoutHide );
-                       this.popupTimeoutHide = null;
-               }
-       };
-
-       /**
-        * Respond to mouse leave event
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.onMouseLeave = function () {
-               this.popupTimeoutHide = setTimeout( function () {
-                       this.popup.toggle( false );
-               }.bind( this ), 250 );
-
-               // Clear the show timeout
-               clearTimeout( this.popupTimeoutShow );
-               this.popupTimeoutShow = null;
-       };
-
-       /**
-        * Set selected state on this widget
-        *
-        * @param {boolean} [isSelected] Widget is selected
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.toggleSelected = function ( isSelected ) {
-               isSelected = isSelected !== undefined ? isSelected : !this.selected;
-
-               if ( this.selected !== isSelected ) {
-                       this.selected = isSelected;
-
-                       this.$element.toggleClass( 'mw-rcfilters-ui-tagItemWidget-selected', this.selected );
-               }
-       };
-
-       /**
-        * Get the selected state of this widget
-        *
-        * @return {boolean} Tag is selected
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.isSelected = function () {
-               return this.selected;
-       };
-
-       /**
-        * Get item name
-        *
-        * @return {string} Filter name
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.getName = function () {
-               return this.itemModel.getName();
-       };
-
-       /**
-        * Get item model
-        *
-        * @return {string} Filter model
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.getModel = function () {
-               return this.itemModel;
-       };
-
-       /**
-        * Get item view
-        *
-        * @return {string} Filter view
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.getView = function () {
-               return this.itemModel.getGroupModel().getView();
-       };
-
-       /**
-        * Remove and destroy external elements of this widget
-        */
-       mw.rcfilters.ui.TagItemWidget.prototype.destroy = function () {
-               // Destroy the popup
-               this.popup.$element.detach();
-
-               // Disconnect events
-               this.itemModel.disconnect( this );
-               this.closeButton.disconnect( this );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ValuePickerWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ValuePickerWidget.js
deleted file mode 100644 (file)
index e65abf2..0000000
+++ /dev/null
@@ -1,112 +0,0 @@
-( function () {
-       /**
-        * Widget defining the behavior used to choose from a set of values
-        * in a single_value group
-        *
-        * @class
-        * @extends OO.ui.Widget
-        * @mixins OO.ui.mixin.LabelElement
-        *
-        * @constructor
-        * @param {mw.rcfilters.dm.FilterGroup} model Group model
-        * @param {Object} [config] Configuration object
-        * @cfg {Function} [itemFilter] A filter function for the items from the
-        *  model. If not given, all items will be included. The function must
-        *  handle item models and return a boolean whether the item is included
-        *  or not. Example: function ( itemModel ) { return itemModel.isSelected(); }
-        */
-       mw.rcfilters.ui.ValuePickerWidget = function MwRcfiltersUiValuePickerWidget( model, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.ValuePickerWidget.parent.call( this, config );
-               // Mixin constructors
-               OO.ui.mixin.LabelElement.call( this, config );
-
-               this.model = model;
-               this.itemFilter = config.itemFilter || function () {
-                       return true;
-               };
-
-               // Build the selection from the item models
-               this.selectWidget = new OO.ui.ButtonSelectWidget();
-               this.initializeSelectWidget();
-
-               // Events
-               this.model.connect( this, { update: 'onModelUpdate' } );
-               this.selectWidget.connect( this, { choose: 'onSelectWidgetChoose' } );
-
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-valuePickerWidget' )
-                       .append(
-                               this.$label
-                                       .addClass( 'mw-rcfilters-ui-valuePickerWidget-title' ),
-                               this.selectWidget.$element
-                       );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.ValuePickerWidget, OO.ui.Widget );
-       OO.mixinClass( mw.rcfilters.ui.ValuePickerWidget, OO.ui.mixin.LabelElement );
-
-       /* Events */
-
-       /**
-        * @event choose
-        * @param {string} name Item name
-        *
-        * An item has been chosen
-        */
-
-       /* Methods */
-
-       /**
-        * Respond to model update event
-        */
-       mw.rcfilters.ui.ValuePickerWidget.prototype.onModelUpdate = function () {
-               this.selectCurrentModelItem();
-       };
-
-       /**
-        * Respond to select widget choose event
-        *
-        * @param {OO.ui.ButtonOptionWidget} chosenItem Chosen item
-        * @fires choose
-        */
-       mw.rcfilters.ui.ValuePickerWidget.prototype.onSelectWidgetChoose = function ( chosenItem ) {
-               this.emit( 'choose', chosenItem.getData() );
-       };
-
-       /**
-        * Initialize the select widget
-        */
-       mw.rcfilters.ui.ValuePickerWidget.prototype.initializeSelectWidget = function () {
-               var items = this.model.getItems()
-                       .filter( this.itemFilter )
-                       .map( function ( filterItem ) {
-                               return new OO.ui.ButtonOptionWidget( {
-                                       data: filterItem.getName(),
-                                       label: filterItem.getLabel()
-                               } );
-                       } );
-
-               this.selectWidget.clearItems();
-               this.selectWidget.addItems( items );
-
-               this.selectCurrentModelItem();
-       };
-
-       /**
-        * Select the current item that corresponds with the model item
-        * that is currently selected
-        */
-       mw.rcfilters.ui.ValuePickerWidget.prototype.selectCurrentModelItem = function () {
-               var selectedItem = this.model.findSelectedItems()[ 0 ];
-
-               if ( selectedItem ) {
-                       this.selectWidget.selectItemByData( selectedItem.getName() );
-               }
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ViewSwitchWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.ViewSwitchWidget.js
deleted file mode 100644 (file)
index 72d2203..0000000
+++ /dev/null
@@ -1,78 +0,0 @@
-( function () {
-       /**
-        * A widget for the footer for the default view, allowing to switch views
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller Controller
-        * @param {mw.rcfilters.dm.FiltersViewModel} model View model
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.ViewSwitchWidget = function MwRcfiltersUiViewSwitchWidget( controller, model, config ) {
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.ViewSwitchWidget.parent.call( this, config );
-
-               this.controller = controller;
-               this.model = model;
-
-               this.buttons = new mw.rcfilters.ui.GroupWidget( {
-                       events: {
-                               click: 'buttonClick'
-                       },
-                       items: [
-                               new OO.ui.ButtonWidget( {
-                                       data: 'namespaces',
-                                       icon: 'article',
-                                       label: mw.msg( 'namespaces' )
-                               } ),
-                               new OO.ui.ButtonWidget( {
-                                       data: 'tags',
-                                       icon: 'tag',
-                                       label: mw.msg( 'rcfilters-view-tags' )
-                               } )
-                       ]
-               } );
-
-               // Events
-               this.model.connect( this, { update: 'onModelUpdate' } );
-               this.buttons.connect( this, { buttonClick: 'onButtonClick' } );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-viewSwitchWidget' )
-                       .append(
-                               new OO.ui.LabelWidget( {
-                                       label: mw.msg( 'rcfilters-advancedfilters' )
-                               } ).$element,
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-viewSwitchWidget-buttons' )
-                                       .append( this.buttons.$element )
-                       );
-       };
-
-       /* Initialize */
-
-       OO.inheritClass( mw.rcfilters.ui.ViewSwitchWidget, OO.ui.Widget );
-
-       /**
-        * Respond to model update event
-        */
-       mw.rcfilters.ui.ViewSwitchWidget.prototype.onModelUpdate = function () {
-               var currentView = this.model.getCurrentView();
-
-               this.buttons.getItems().forEach( function ( buttonWidget ) {
-                       buttonWidget.setActive( buttonWidget.getData() === currentView );
-               } );
-       };
-
-       /**
-        * Respond to button switch click
-        *
-        * @param {OO.ui.ButtonWidget} buttonWidget Clicked button
-        */
-       mw.rcfilters.ui.ViewSwitchWidget.prototype.onButtonClick = function ( buttonWidget ) {
-               this.controller.switchView( buttonWidget.getData() );
-       };
-}() );
diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.WatchlistTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.WatchlistTopSectionWidget.js
deleted file mode 100644 (file)
index 423c105..0000000
+++ /dev/null
@@ -1,83 +0,0 @@
-( function () {
-       /**
-        * Top section (between page title and filters) on Special:Watchlist
-        *
-        * @extends OO.ui.Widget
-        *
-        * @constructor
-        * @param {mw.rcfilters.Controller} controller
-        * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
-        * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
-        * @param {jQuery} $watchlistDetails Content of the 'details' section that includes watched pages count
-        * @param {Object} [config] Configuration object
-        */
-       mw.rcfilters.ui.WatchlistTopSectionWidget = function MwRcfiltersUiWatchlistTopSectionWidget(
-               controller, changesListModel, savedLinksListWidget, $watchlistDetails, config
-       ) {
-               var editWatchlistButton,
-                       markSeenButton,
-                       $topTable,
-                       $bottomTable,
-                       $separator;
-               config = config || {};
-
-               // Parent
-               mw.rcfilters.ui.WatchlistTopSectionWidget.parent.call( this, config );
-
-               editWatchlistButton = new OO.ui.ButtonWidget( {
-                       label: mw.msg( 'rcfilters-watchlist-edit-watchlist-button' ),
-                       icon: 'edit',
-                       href: mw.config.get( 'wgStructuredChangeFiltersEditWatchlistUrl' )
-               } );
-               markSeenButton = new mw.rcfilters.ui.MarkSeenButtonWidget( controller, changesListModel );
-
-               $topTable = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-table' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-row' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-watchlistDetails' )
-                                                       .append( $watchlistDetails )
-                                       )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-editWatchlistButton' )
-                                                       .append( editWatchlistButton.$element )
-                                       )
-                       );
-
-               $bottomTable = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-table' )
-                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinksTable' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-row' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                       .append( markSeenButton.$element )
-                                       )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinks' )
-                                                       .append( savedLinksListWidget.$element )
-                                       )
-                       );
-
-               $separator = $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-separator' );
-
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget' )
-                       .append( $topTable, $separator, $bottomTable );
-       };
-
-       /* Initialization */
-
-       OO.inheritClass( mw.rcfilters.ui.WatchlistTopSectionWidget, OO.ui.Widget );
-}() );
index 8abb8f2..77ca848 100644 (file)
                                };
                                img.src = dataURL;
                        }, mw.config.get( 'wgFileCanRotate' ) ? function ( data ) {
-                               var jpegmeta = mw.loader.require( 'mediawiki.libs.jpegmeta' );
+                               var jpegmeta = require( 'mediawiki.libs.jpegmeta' );
                                try {
                                        meta = jpegmeta( data, file.fileName );
                                        // eslint-disable-next-line no-underscore-dangle, camelcase
index e41ed58..d9b1227 100644 (file)
                }
        } );
 
-       /**
-        * @method stickyRandomId
-        * @deprecated since 1.32 use getPageviewToken instead
-        */
-       mw.log.deprecate( mw.user, 'stickyRandomId', mw.user.getPageviewToken, 'Please use getPageviewToken instead' );
-
 }() );
index 44e48e5..b5ba6a6 100644 (file)
@@ -13,8 +13,7 @@
        'use strict';
 
        var mw, StringSet, log,
-               hasOwn = Object.prototype.hasOwnProperty,
-               trackQueue = [];
+               hasOwn = Object.prototype.hasOwnProperty;
 
        /**
         * FNV132 hash function
         * @return {string} hash as an seven-character base 36 string
         */
        function fnv132( str ) {
-               /* eslint-disable no-bitwise */
                var hash = 0x811C9DC5,
                        i;
 
+               /* eslint-disable no-bitwise */
                for ( i = 0; i < str.length; i++ ) {
                        hash += ( hash << 1 ) + ( hash << 4 ) + ( hash << 7 ) + ( hash << 8 ) + ( hash << 24 );
                        hash ^= str.charCodeAt( i );
@@ -42,9 +41,9 @@
                while ( hash.length < 7 ) {
                        hash = '0' + hash;
                }
+               /* eslint-enable no-bitwise */
 
                return hash;
-               /* eslint-enable no-bitwise */
        }
 
        function defineFallbacks() {
        function logError( topic, data ) {
                var msg,
                        e = data.exception,
-                       source = data.source,
-                       module = data.module,
                        console = window.console;
 
                if ( console && console.log ) {
-                       msg = ( e ? 'Exception' : 'Error' ) + ' in ' + source;
-                       if ( module ) {
-                               msg += ' in module ' + module;
-                       }
-                       msg += ( e ? ':' : '.' );
+                       msg = ( e ? 'Exception' : 'Error' ) +
+                               ' in ' + data.source +
+                               ( data.module ? ' in module ' + data.module : '' ) +
+                               ( e ? ':' : '.' );
+
                        console.log( msg );
 
                        // If we have an exception object, log it to the warning channel to trigger
                        this.set = function ( selection, value ) {
                                var s;
                                if ( arguments.length > 1 ) {
-                                       if ( typeof selection !== 'string' ) {
-                                               return false;
+                                       if ( typeof selection === 'string' ) {
+                                               setGlobalMapValue( this, selection, value );
+                                               return true;
                                        }
-                                       setGlobalMapValue( this, selection, value );
-                                       return true;
-                               }
-                               if ( typeof selection === 'object' ) {
+                               } else if ( typeof selection === 'object' ) {
                                        for ( s in selection ) {
                                                setGlobalMapValue( this, s, selection[ s ] );
                                        }
                        var s;
                        // Use `arguments.length` because `undefined` is also a valid value.
                        if ( arguments.length > 1 ) {
-                               if ( typeof selection !== 'string' ) {
-                                       return false;
+                               // Set one key
+                               if ( typeof selection === 'string' ) {
+                                       this.values[ selection ] = value;
+                                       return true;
                                }
-                               this.values[ selection ] = value;
-                               return true;
-                       }
-                       if ( typeof selection === 'object' ) {
+                       } else if ( typeof selection === 'object' ) {
+                               // Set multiple keys
                                for ( s in selection ) {
                                        this.values[ s ] = selection[ s ];
                                }
                                                mw.track( 'mw.deprecate', name );
                                        }
                                        mw.log.warn(
-                                               'Use of "' + name + '" is deprecated.' + ( msg ? ( ' ' + msg ) : '' )
+                                               'Use of "' + name + '" is deprecated.' + ( msg ? ' ' + msg : '' )
                                        );
                                }
                        }
        mw = {
                redefineFallbacksForTest: function () {
                        if ( !window.QUnit ) {
-                               throw new Error( 'Reset not allowed outside unit tests' );
+                               throw new Error( 'Not allowed' );
                        }
                        defineFallbacks();
                },
                 * @return {number} Current time
                 */
                now: function () {
-                       // Optimisation: Define the shortcut on first call, not at module definition.
+                       // Optimisation: Make startup initialisation faster by defining the
+                       // shortcut on first call, not at module definition.
                        var perf = window.performance,
                                navStart = perf && perf.timing && perf.timing.navigationStart;
 
                        // Define the relevant shortcut
-                       mw.now = navStart && typeof perf.now === 'function' ?
+                       mw.now = navStart && perf.now ?
                                function () { return navStart + perf.now(); } :
                                Date.now;
 
                /**
                 * List of all analytic events emitted so far.
                 *
+                * Exposed only for use by mediawiki.base.
+                *
                 * @private
                 * @property {Array}
                 */
-               trackQueue: trackQueue,
+               trackQueue: [],
 
                track: function ( topic, data ) {
-                       trackQueue.push( { topic: topic, timeStamp: mw.now(), data: data } );
-                       // The base module extends this method to fire events here
+                       mw.trackQueue.push( { topic: topic, timeStamp: mw.now(), data: data } );
+                       // This method is extended by mediawiki.base to also fire events.
                },
 
                /**
                                                        i -= 1;
                                                        try {
                                                                if ( failed && job.error ) {
-                                                                       job.error( new Error( 'Module has failed dependencies' ), job.dependencies );
+                                                                       job.error( new Error( 'Failed dependencies' ), job.dependencies );
                                                                } else if ( !failed && job.ready ) {
                                                                        job.ready();
                                                                }
 
                                // Add base modules
                                if ( baseModules.indexOf( module ) === -1 ) {
-                                       baseModules.forEach( function ( baseModule ) {
-                                               if ( resolved.indexOf( baseModule ) === -1 ) {
-                                                       resolved.push( baseModule );
+                                       for ( i = 0; i < baseModules.length; i++ ) {
+                                               if ( resolved.indexOf( baseModules[ i ] ) === -1 ) {
+                                                       resolved.push( baseModules[ i ] );
                                                }
-                                       } );
+                                       }
                                }
 
                                // Tracks down dependencies
                                                // these as the server will deny them anyway (T101806).
                                                if ( registry[ module ].group === 'private' ) {
                                                        setAndPropagate( module, 'error' );
-                                                       return;
+                                               } else {
+                                                       queue.push( module );
                                                }
-                                               queue.push( module );
                                        }
                                } );
 
                                                        } else {
                                                                mainScript = script.files[ script.main ];
                                                                if ( typeof mainScript !== 'function' ) {
-                                                                       throw new Error( 'Main script file ' + script.main + ' in module ' + module +
-                                                                               'must be of type function, is of type ' + typeof mainScript );
+                                                                       throw new Error( 'Main file ' + script.main + ' in module ' + module +
+                                                                               ' must be of type function, found ' + typeof mainScript );
                                                                }
                                                                // jQuery parameters are not passed for multi-file modules
                                                                mainScript(
                                                                return;
                                                        }
                                                        // Unknown type
-                                                       throw new Error( 'invalid type for external url, must be text/css or text/javascript. not ' + type );
+                                                       throw new Error( 'type must be text/css or text/javascript, found ' + type );
                                                }
                                                // Called with single module
                                                modules = [ modules ];
                                        // Only ready modules can be required
                                        if ( state !== 'ready' ) {
                                                // Module may've forgotten to declare a dependency
-                                               throw new Error( 'Module "' + moduleName + '" is not loaded.' );
+                                               throw new Error( 'Module "' + moduleName + '" is not loaded' );
                                        }
 
                                        return registry[ moduleName ].module.exports;
                                                        this.stats.hits++;
                                                        return this.items[ key ];
                                                }
+
                                                this.stats.misses++;
                                                return false;
                                        },
 
                                                try {
                                                        if ( typeof descriptor.script === 'function' ) {
+                                                               // Function literal: cast to string
                                                                encodedScript = String( descriptor.script );
                                                        } else if (
-                                                               // Plain object: an object that is not null and is not an array
+                                                               // Plain object: serialise as object literal (not JSON),
+                                                               // making sure to preserve the functions.
                                                                typeof descriptor.script === 'object' &&
                                                                descriptor.script &&
                                                                !Array.isArray( descriptor.script )
                                                        ) {
                                                                encodedScript = '{' +
-                                                                       '"main":' + JSON.stringify( descriptor.script.main ) + ',' +
-                                                                       '"files":{' +
+                                                                       'main:' + JSON.stringify( descriptor.script.main ) + ',' +
+                                                                       'files:{' +
                                                                        Object.keys( descriptor.script.files ).map( function ( key ) {
                                                                                var value = descriptor.script.files[ key ];
                                                                                return JSON.stringify( key ) + ':' +
                                                                        } ).join( ',' ) +
                                                                        '}}';
                                                        } else {
+                                                               // Array of urls, or null.
                                                                encodedScript = JSON.stringify( descriptor.script );
                                                        }
                                                        args = [
index dbc757f..70056ba 100644 (file)
@@ -225,7 +225,7 @@ Deprecation message.' ]
                        . '<script>(window.RLQ=window.RLQ||[]).push(function(){'
                        . 'mw.config.set({"key":"value"});'
                        . 'mw.loader.state({"test.exempt":"ready","test.private":"loading","test.styles.pure":"ready","test.styles.private":"ready","test.styles.deprecated":"ready","test.scripts":"loading"});'
-                       . 'mw.loader.implement("test.private@{blankVer}",function($,jQuery,require,module){},{"css":[]});'
+                       . 'mw.loader.implement("test.private@{blankVer}",null,{"css":[]});'
                        . 'RLPAGEMODULES=["test"];mw.loader.load(RLPAGEMODULES);'
                        . 'mw.loader.load("/w/load.php?debug=false\u0026lang=nl\u0026modules=test.scripts\u0026only=scripts\u0026skin=fallback");'
                        . '});</script>' . "\n"
@@ -343,7 +343,7 @@ Deprecation message.' ]
                                'context' => [],
                                'modules' => [ 'test.private' ],
                                'only' => ResourceLoaderModule::TYPE_COMBINED,
-                               'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.implement("test.private@{blankVer}",function($,jQuery,require,module){},{"css":[]});});</script>',
+                               'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.implement("test.private@{blankVer}",null,{"css":[]});});</script>',
                        ],
                        [
                                'context' => [],
@@ -393,7 +393,7 @@ Deprecation message.' ]
                                'context' => [],
                                'modules' => [ 'test.shouldembed' ],
                                'only' => ResourceLoaderModule::TYPE_COMBINED,
-                               'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.implement("test.shouldembed@09p30q0",function($,jQuery,require,module){},{"css":[]});});</script>',
+                               'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.implement("test.shouldembed@09p30q0",null,{"css":[]});});</script>',
                        ],
                        [
                                'context' => [],
@@ -411,7 +411,7 @@ Deprecation message.' ]
                                'context' => [],
                                'modules' => [ 'test', 'test.shouldembed' ],
                                'only' => ResourceLoaderModule::TYPE_COMBINED,
-                               'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.load("/w/load.php?debug=false\u0026lang=nl\u0026modules=test\u0026skin=fallback");mw.loader.implement("test.shouldembed@09p30q0",function($,jQuery,require,module){},{"css":[]});});</script>',
+                               'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.load("/w/load.php?debug=false\u0026lang=nl\u0026modules=test\u0026skin=fallback");mw.loader.implement("test.shouldembed@09p30q0",null,{"css":[]});});</script>',
                        ],
                        [
                                'context' => [],
index 50ac601..070ad56 100644 (file)
@@ -1,6 +1,43 @@
 const MWBot = require( 'mwbot' ),
        Page = require( './Page' ),
-       FRONTPAGE_REQUESTS_MAX_RUNS = 10; // (arbitrary) safe-guard against endless execution
+       MAINPAGE_REQUESTS_MAX_RUNS = 10; // (arbitrary) safe-guard against endless execution
+
+function getJobCount() {
+       let bot = new MWBot( {
+               apiUrl: `${browser.options.baseUrl}/api.php`
+       } );
+       return bot.request( {
+               action: 'query',
+               meta: 'siteinfo',
+               siprop: 'statistics'
+       } ).then( ( response ) => {
+               return response.query.statistics.jobs;
+       } );
+}
+
+function log( message ) {
+       process.stdout.write( `RunJobs ${message}\n` );
+}
+
+function runThroughMainPageRequests( runCount = 1 ) {
+       let page = new Page();
+       log( `through requests to the main page (run ${runCount}).` );
+
+       page.openTitle( '' );
+
+       return getJobCount().then( ( jobCount ) => {
+               if ( jobCount === 0 ) {
+                       log( 'found no more queued jobs.' );
+                       return;
+               }
+               log( `detected ${jobCount} more queued job(s).` );
+               if ( runCount >= MAINPAGE_REQUESTS_MAX_RUNS ) {
+                       log( 'stopping requests to the main page due to reached limit.' );
+                       return;
+               }
+               return runThroughMainPageRequests( ++runCount );
+       } );
+}
 
 /**
  * Trigger the execution of jobs
@@ -26,48 +63,9 @@ class RunJobs {
 
        static run() {
                browser.call( () => {
-                       return this.runThroughFrontPageRequests();
+                       return runThroughMainPageRequests();
                } );
        }
-
-       static getJobCount() {
-               let bot = new MWBot( {
-                       apiUrl: `${browser.options.baseUrl}/api.php`
-               } );
-               return new Promise( ( resolve ) => {
-                       return bot.request( {
-                               action: 'query',
-                               meta: 'siteinfo',
-                               siprop: 'statistics'
-                       } ).then( ( response ) => {
-                               resolve( response.query.statistics.jobs );
-                       } );
-               } );
-       }
-
-       static runThroughFrontPageRequests( runCount = 1 ) {
-               let page = new Page();
-               this.log( `through requests to the front page (run ${runCount}).` );
-
-               page.openTitle( '' );
-
-               return this.getJobCount().then( ( jobCount ) => {
-                       if ( jobCount === 0 ) {
-                               this.log( 'found no more queued jobs.' );
-                               return;
-                       }
-                       this.log( `detected ${jobCount} more queued job(s).` );
-                       if ( runCount >= FRONTPAGE_REQUESTS_MAX_RUNS ) {
-                               this.log( 'stopping requests to the front page due to reached limit.' );
-                               return;
-                       }
-                       return this.runThroughFrontPageRequests( ++runCount );
-               } );
-       }
-
-       static log( message ) {
-               process.stdout.write( `RunJobs ${message}\n` );
-       }
 }
 
 module.exports = RunJobs;