Merge "jobqueue: add GenericParameterJob and RunnableJob interface"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Wed, 10 Apr 2019 16:07:09 +0000 (16:07 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Wed, 10 Apr 2019 16:07:09 +0000 (16:07 +0000)
183 files changed:
.fresnel.yml
.phan/config.php
RELEASE-NOTES-1.33
autoload.php
docs/hooks.txt
includes/CommentStore.php
includes/DefaultSettings.php
includes/GlobalFunctions.php
includes/MWNamespace.php
includes/MagicWord.php
includes/MediaWikiServices.php
includes/MovePage.php
includes/OutputPage.php
includes/ServiceWiring.php
includes/Title.php
includes/actions/HistoryAction.php
includes/api/i18n/pt-br.json
includes/api/i18n/pt.json
includes/api/i18n/sv.json
includes/api/i18n/zh-hant.json
includes/debug/DeprecationHelper.php
includes/deferred/LinksUpdate.php
includes/export/Dump7ZipOutput.php
includes/export/DumpPipeOutput.php
includes/export/XmlDumpWriter.php
includes/htmlform/HTMLForm.php
includes/htmlform/OOUIHTMLForm.php
includes/installer/DatabaseUpdater.php
includes/installer/i18n/fa.json
includes/installer/i18n/ia.json
includes/installer/i18n/nl.json
includes/installer/i18n/sv.json
includes/libs/objectcache/WinCacheBagOStuff.php
includes/libs/rdbms/database/Database.php
includes/libs/rdbms/database/DatabaseMssql.php
includes/libs/rdbms/encasing/MssqlBlob.php
includes/logging/LogEntry.php
includes/media/BitmapHandler.php
includes/media/DjVuHandler.php
includes/media/DjVuImage.php
includes/media/SvgHandler.php
includes/media/TransformationalImageHandler.php
includes/page/WikiPage.php
includes/parser/DateFormatter.php
includes/parser/Parser.php
includes/parser/ParserFactory.php
includes/parser/Preprocessor_DOM.php
includes/resourceloader/ResourceLoader.php
includes/resourceloader/ResourceLoaderContext.php
includes/resourceloader/ResourceLoaderImage.php
includes/search/SearchPostgres.php
includes/specialpage/SpecialPageFactory.php
includes/specials/SpecialEmailuser.php
includes/specials/SpecialUploadStash.php
includes/specials/SpecialVersion.php
includes/title/NamespaceInfo.php [new file with mode: 0644]
includes/upload/UploadBase.php
includes/user/User.php
languages/Language.php
languages/i18n/ar.json
languages/i18n/ast.json
languages/i18n/az.json
languages/i18n/be-tarask.json
languages/i18n/be.json
languages/i18n/bn.json
languages/i18n/ca.json
languages/i18n/ckb.json
languages/i18n/cs.json
languages/i18n/da.json
languages/i18n/de.json
languages/i18n/diq.json
languages/i18n/el.json
languages/i18n/en.json
languages/i18n/es.json
languages/i18n/eu.json
languages/i18n/fi.json
languages/i18n/fr.json
languages/i18n/fy.json
languages/i18n/hy.json
languages/i18n/hyw.json
languages/i18n/io.json
languages/i18n/it.json
languages/i18n/ja.json
languages/i18n/lb.json
languages/i18n/lrc.json
languages/i18n/lt.json
languages/i18n/mk.json
languages/i18n/ml.json
languages/i18n/my.json
languages/i18n/nl.json
languages/i18n/pl.json
languages/i18n/qqq.json
languages/i18n/ru.json
languages/i18n/rue.json
languages/i18n/sh.json
languages/i18n/sl.json
languages/i18n/sv.json
languages/i18n/uk.json
languages/i18n/ur.json
maintenance/7zip.inc
maintenance/Maintenance.php
maintenance/dumpTextPass.php
maintenance/hhvm/makeRepo.php
maintenance/hhvm/run-server
maintenance/mwdocgen.php
maintenance/populateImageSha1.php
maintenance/storage/checkStorage.php
maintenance/storage/recompressTracked.php
maintenance/updateCollation.php
resources/Resources.php
resources/src/jquery/jquery.makeCollapsible.styles.less
resources/src/mediawiki.action/mediawiki.action.history.styles.css [deleted file]
resources/src/mediawiki.action/mediawiki.action.history.styles.less [new file with mode: 0644]
resources/src/mediawiki.cookie/.eslintrc.json [new file with mode: 0644]
resources/src/mediawiki.cookie/index.js
resources/src/mediawiki.jqueryMsg/.eslintrc.json [new file with mode: 0644]
resources/src/mediawiki.jqueryMsg/mediawiki.jqueryMsg.js
resources/src/mediawiki.legacy/shared.css
resources/src/mediawiki.less/mediawiki.mixins.less
resources/src/mediawiki.rcfilters/.eslintrc.json [new file with mode: 0644]
resources/src/mediawiki.rcfilters/Controller.js
resources/src/mediawiki.rcfilters/HighlightColors.js
resources/src/mediawiki.rcfilters/UriProcessor.js
resources/src/mediawiki.rcfilters/dm/ChangesListViewModel.js
resources/src/mediawiki.rcfilters/dm/FilterGroup.js
resources/src/mediawiki.rcfilters/dm/FilterItem.js
resources/src/mediawiki.rcfilters/dm/FiltersViewModel.js
resources/src/mediawiki.rcfilters/dm/ItemModel.js
resources/src/mediawiki.rcfilters/dm/SavedQueriesModel.js
resources/src/mediawiki.rcfilters/dm/SavedQueryItemModel.js
resources/src/mediawiki.rcfilters/mw.rcfilters.init.js
resources/src/mediawiki.rcfilters/mw.rcfilters.js
resources/src/mediawiki.rcfilters/ui/ChangesLimitAndDateButtonWidget.js
resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js
resources/src/mediawiki.rcfilters/ui/ChangesListWrapperWidget.js
resources/src/mediawiki.rcfilters/ui/CheckboxInputWidget.js
resources/src/mediawiki.rcfilters/ui/DatePopupWidget.js
resources/src/mediawiki.rcfilters/ui/FilterItemHighlightButton.js
resources/src/mediawiki.rcfilters/ui/FilterMenuHeaderWidget.js
resources/src/mediawiki.rcfilters/ui/FilterMenuOptionWidget.js
resources/src/mediawiki.rcfilters/ui/FilterMenuSectionOptionWidget.js
resources/src/mediawiki.rcfilters/ui/FilterTagItemWidget.js
resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js
resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js
resources/src/mediawiki.rcfilters/ui/FormWrapperWidget.js
resources/src/mediawiki.rcfilters/ui/GroupWidget.js
resources/src/mediawiki.rcfilters/ui/HighlightColorPickerWidget.js
resources/src/mediawiki.rcfilters/ui/HighlightPopupWidget.js
resources/src/mediawiki.rcfilters/ui/ItemMenuOptionWidget.js
resources/src/mediawiki.rcfilters/ui/LiveUpdateButtonWidget.js
resources/src/mediawiki.rcfilters/ui/MainWrapperWidget.js
resources/src/mediawiki.rcfilters/ui/MarkSeenButtonWidget.js
resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js
resources/src/mediawiki.rcfilters/ui/RcTopSectionWidget.js
resources/src/mediawiki.rcfilters/ui/RclTargetPageWidget.js
resources/src/mediawiki.rcfilters/ui/RclToOrFromWidget.js
resources/src/mediawiki.rcfilters/ui/RclTopSectionWidget.js
resources/src/mediawiki.rcfilters/ui/SaveFiltersPopupButtonWidget.js
resources/src/mediawiki.rcfilters/ui/SavedLinksListItemWidget.js
resources/src/mediawiki.rcfilters/ui/SavedLinksListWidget.js
resources/src/mediawiki.rcfilters/ui/TagItemWidget.js
resources/src/mediawiki.rcfilters/ui/ValuePickerWidget.js
resources/src/mediawiki.rcfilters/ui/ViewSwitchWidget.js
resources/src/mediawiki.rcfilters/ui/WatchlistTopSectionWidget.js
resources/src/mediawiki.user.js
resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.styles.less
resources/src/startup/mediawiki.js
resources/src/startup/startup.js
tests/parser/ParserTestRunner.php
tests/phpunit/MediaWikiTestCase.php
tests/phpunit/includes/MWNamespaceTest.php [deleted file]
tests/phpunit/includes/MediaWikiServicesTest.php
tests/phpunit/includes/ServiceWiringTest.php
tests/phpunit/includes/api/ApiQuerySiteinfoTest.php
tests/phpunit/includes/cache/MessageCacheTest.php
tests/phpunit/includes/db/LBFactoryTest.php
tests/phpunit/includes/page/WikiPageDbTestBase.php
tests/phpunit/includes/title/NamespaceInfoTest.php [new file with mode: 0644]
tests/phpunit/maintenance/DumpAsserter.php
tests/phpunit/maintenance/DumpTestCase.php
tests/phpunit/maintenance/backupTextPassTest.php
tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js
tests/selenium/specs/rollback.js

index 2f71e4b..e85de79 100644 (file)
@@ -1,7 +1,7 @@
 warmup: true
 runs: 5
 scenarios:
-  Load a page:
+  Read a page:
     # The only page that exists by default is the main page.
     # But its actual name is configurable/unknown (T216791).
     # Omit 'title' to let MediaWiki show the default (which is the main page),
@@ -29,6 +29,18 @@ scenarios:
     probes:
       - screenshot
       - trace
+  View history of a page:
+    url: "{MW_SERVER}{MW_SCRIPT_PATH}/index.php?action=history"
+    viewport:
+      width: 1100
+      height: 700
+    reports:
+      - navtiming
+      - paint
+      - transfer
+    probes:
+      - screenshot
+      - trace
   View recent changes:
     url: "{MW_SERVER}{MW_SCRIPT_PATH}/index.php?title=Special:RecentChanges"
     viewport:
index 37b2153..12e723d 100644 (file)
@@ -90,8 +90,6 @@ $cfg['suppress_issue_types'] = array_merge( $cfg['suppress_issue_types'], [
        // approximate error count: 127
        "PhanParamTooMany",
        // approximate error count: 2
-       "PhanPluginDuplicateExpressionBinaryOp",
-       // approximate error count: 2
        "PhanTraitParentReference",
        // approximate error count: 30
        "PhanTypeArraySuspicious",
@@ -99,26 +97,16 @@ $cfg['suppress_issue_types'] = array_merge( $cfg['suppress_issue_types'], [
        "PhanTypeArraySuspiciousNullable",
        // approximate error count: 26
        "PhanTypeComparisonFromArray",
-       // approximate error count: 2
-       "PhanTypeComparisonToArray",
-       // approximate error count: 7
-       "PhanTypeExpectedObjectPropAccess",
        // approximate error count: 63
        "PhanTypeInvalidDimOffset",
-       // approximate error count: 6
-       "PhanTypeInvalidExpressionArrayDestructuring",
        // approximate error count: 7
        "PhanTypeInvalidLeftOperandOfIntegerOp",
        // approximate error count: 2
-       "PhanTypeInvalidRightOperand",
-       // approximate error count: 2
        "PhanTypeInvalidRightOperandOfIntegerOp",
        // approximate error count: 154
        "PhanTypeMismatchArgument",
        // approximate error count: 27
        "PhanTypeMismatchArgumentInternal",
-       // approximate error count: 1
-       "PhanTypeMismatchBitwiseBinaryOperands",
        // approximate error count: 2
        "PhanTypeMismatchDimEmpty",
        // approximate error count: 27
index 9c5081c..fd316c4 100644 (file)
@@ -37,6 +37,7 @@ Some specific notes for MediaWiki 1.33 upgrades are below:
 For notes on 1.32.x and older releases, see HISTORY.
 
 === Configuration changes for system administrators in 1.33 ===
+
 ==== New configuration ====
 * $wgEnablePartialBlocks – This enables the Partial Blocks feature, which gives
   accounts with block permissions the ability to block users, IPs, and IP ranges
@@ -57,6 +58,10 @@ For notes on 1.32.x and older releases, see HISTORY.
   argon2 to be used, by default, it will automatically choose the best available
   algorithm depending on which version of PHP you have available. To use this,
   you can set `$wgPasswordDefault = 'argon2';`.
+* $wgActorTableSchemaMigrationStage now defaults to reading the new schema.
+  update.php will back-populate the new database fields due to the changed
+  setting, which may take some time on large wikis. You can avoid downtime by
+  following a process like that described in T188327.
 
 ==== Removed configuration ====
 * $wgTagStatisticsNewTable (T199334) — This temporary setting, added in
@@ -114,6 +119,7 @@ For notes on 1.32.x and older releases, see HISTORY.
   associated with this entry in the patrol log.
 
 === External library changes in 1.33 ===
+
 ==== New external libraries ====
 * Added wikimedia/password-blacklist 0.1.4.
 * Added guzzlehttp/guzzle 6.3.3.
@@ -353,6 +359,13 @@ because of Phabricator reports.
 * MessageBlobStore::getBlob(), deprecated in 1.27, has been removed.
   Use ::getBlobs() instead.
 * The .background-size() LESS mixin, deprecated in 1.27, has been removed.
+* MWNamespace::clearCaches() has been removed.  So has the $rebuild parameter
+  to MWNamespace::getCanonicalNamespaces(), which was deprecated since 1.31.
+  Instead, create a new NamespaceInfo, such as by calling
+  resetServiceForTesting( 'NamespaceInfo' ) on a MediaWikiServices.
+  For classes that inherit from MediaWikiTestCase and used setMwGlobals() to
+  modify a variable that affects namespaces, caches will automatically be
+  reset and any calls to MWNamespace::clearCaches() can be removed entirely.
 
 === Deprecations in 1.33 ===
 * The configuration option $wgUseESI has been deprecated, and is expected
@@ -421,6 +434,7 @@ because of Phabricator reports.
 * Block::isValid is deprecated, since it is no longer needed in core.
 * Calling Maintenance::hasArg() as well as Maintenance::getArg() with no
   parameter has been deprecated. Please pass the argument number 0.
+* The MWNamespace class is deprecated.  Use MediaWikiServices::getNamespaceInfo.
 
 === Other changes in 1.33 ===
 * (T201747) Html::openElement() warns if given an element name with a space
@@ -436,6 +450,8 @@ because of Phabricator reports.
   insertions into links tables.
 * Category::newFromID( $id )->getID() will now return $id without any
   validation, to avoid a mostly unnecessary DB query.
+* On Special:Version, the name for an extension can no longer be arbitrary
+  html when no link is specified.
 
 == Compatibility ==
 MediaWiki 1.33 requires PHP 7.0.13 or later. Although HHVM 3.18.5 or later is
index 0ce423f..20fc489 100644 (file)
@@ -1016,6 +1016,7 @@ $wgAutoloadLocalClasses = [
        'NamespaceAwareForeignTitleFactory' => __DIR__ . '/includes/title/NamespaceAwareForeignTitleFactory.php',
        'NamespaceDupes' => __DIR__ . '/maintenance/namespaceDupes.php',
        'NamespaceImportTitleFactory' => __DIR__ . '/includes/title/NamespaceImportTitleFactory.php',
+       'NamespaceInfo' => __DIR__ . '/includes/title/NamespaceInfo.php',
        'NewFilesPager' => __DIR__ . '/includes/specials/pagers/NewFilesPager.php',
        'NewPagesPager' => __DIR__ . '/includes/specials/pagers/NewPagesPager.php',
        'NewUsersLogFormatter' => __DIR__ . '/includes/logging/NewUsersLogFormatter.php',
index 5f2c129..d3d04ba 100644 (file)
@@ -1612,7 +1612,7 @@ $out: OutputPage object
 notifications.
 &$title: Title object of page
 &$url: string value as output (out parameter, can modify)
-$query: query options passed to Title::getCanonicalURL()
+$query: query options as string passed to Title::getCanonicalURL()
 
 'GetContentModels': Add content models to the list of available models.
 &$models: array containing current model list, as strings. Extensions should add to this list.
@@ -1650,7 +1650,7 @@ $single: Only extract the current language; if false, the prop value should
 'GetFullURL': Modify fully-qualified URLs used in redirects/export/offsite data.
 &$title: Title object of page
 &$url: string value as output (out parameter, can modify)
-$query: query options passed to Title::getFullURL()
+$query: query options as string passed to Title::getFullURL()
 
 'GetHumanTimestamp': Pre-emptively override the human-readable timestamp
 generated by MWTimestamp::getHumanTimestamp(). Return false in this hook to use
@@ -1664,7 +1664,7 @@ $lang: Language that will be used to render the timestamp
 'GetInternalURL': Modify fully-qualified URLs used for squid cache purging.
 &$title: Title object of page
 &$url: string value as output (out parameter, can modify)
-$query: query options passed to Title::getInternalURL()
+$query: query options as string passed to Title::getInternalURL()
 
 'GetIP': modify the ip of the current user (called only once).
 &$ip: string holding the ip as determined so far
@@ -1689,7 +1689,7 @@ be buggy for internal urls on render if you do not re-implement the horrible
 hack that Title::getLocalURL uses in your own extension.
 &$title: Title object of page
 &$url: string value as output (out parameter, can modify)
-$query: query options passed to Title::getLocalURL()
+$query: query options as string passed to Title::getLocalURL()
 
 'GetLocalURL::Article': Modify local URLs specifically pointing to article paths
 without any fancy queries or variants.
@@ -1699,7 +1699,7 @@ without any fancy queries or variants.
 'GetLocalURL::Internal': Modify local URLs to internal pages.
 &$title: Title object of page
 &$url: string value as output (out parameter, can modify)
-$query: query options passed to Title::getLocalURL()
+$query: query options as string passed to Title::getLocalURL()
 
 'GetLogTypesOnUser': Add log types where the target is a userpage
 &$types: Array of log types
@@ -2333,7 +2333,7 @@ namespace.
 $index: Integer; the index of the namespace being checked.
 &$result: Boolean; whether MediaWiki currently thinks that pages in this
   namespace are movable. Hooks may change this value to override the return
-  value of MWNamespace::isMovable().
+  value of NamespaceInfo::isMovable().
 
 'NewDifferenceEngine': Called when a new DifferenceEngine object is made
 $title: the diff page title (nullable)
index 1a60bb7..4a673c4 100644 (file)
@@ -202,6 +202,7 @@ class CommentStore {
         *   - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
         *   - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
         *  All tables, fields, and joins are aliased, so `+` is safe to use.
+        * @phan-return array{tables:string[],fields:string[],joins:array}
         */
        public function getJoin( $key = null ) {
                $key = $this->getKey( $key );
index cedba70..44e0310 100644 (file)
@@ -3992,13 +3992,12 @@ $wgRedirectSources = false;
 $wgCapitalLinks = true;
 
 /**
- * @since 1.16 - This can now be set per-namespace. Some special namespaces (such
- * as Special, see MWNamespace::$alwaysCapitalizedNamespaces for the full list) must be
- * true by default (and setting them has no effect), due to various things that
- * require them to be so. Also, since Talk namespaces need to directly mirror their
- * associated content namespaces, the values for those are ignored in favor of the
- * subject namespace's setting. Setting for NS_MEDIA is taken automatically from
- * NS_FILE.
+ * @since 1.16 - This can now be set per-namespace. Some special namespaces (such as Special, see
+ * NamespaceInfo::$alwaysCapitalizedNamespaces for the full list) must be true by default (and
+ * setting them has no effect), due to various things that require them to be so. Also, since Talk
+ * namespaces need to directly mirror their associated content namespaces, the values for those are
+ * ignored in favor of the subject namespace's setting. Setting for NS_MEDIA is taken automatically
+ * from NS_FILE.
  *
  * @par Example:
  * @code
@@ -8972,7 +8971,7 @@ $wgXmlDumpSchemaVersion = XML_DUMP_SCHEMA_VERSION_10;
  * @since 1.32 changed allowed flags
  * @var int An appropriate combination of SCHEMA_COMPAT_XXX flags.
  */
-$wgActorTableSchemaMigrationStage = SCHEMA_COMPAT_OLD;
+$wgActorTableSchemaMigrationStage = SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW;
 
 /**
  * Flag to enable Partial Blocks. This allows an admin to prevent a user from editing specific pages
index b0a79e8..cdbc27a 100644 (file)
@@ -2136,7 +2136,7 @@ function wfStringToBool( $val ) {
  * @param string|string[] ...$args strings to escape and glue together,
  *  or a single array of strings parameter
  * @return string
- * @deprecated since 1.30 use MediaWiki\Shell::escape()
+ * @deprecated since 1.30 use MediaWiki\Shell\Shell::escape()
  */
 function wfEscapeShellArg( ...$args ) {
        return Shell::escape( ...$args );
index b40da00..a36a12f 100644 (file)
 use MediaWiki\MediaWikiServices;
 
 /**
- * This is a utility class with only static functions
- * for dealing with namespaces that encodes all the
- * "magic" behaviors of them based on index.  The textual
- * names of the namespaces are handled by Language.php.
- *
- * These are synonyms for the names given in the language file
- * Users and translators should not change them
+ * @deprecated since 1.33, use NamespaceInfo instead
  */
 class MWNamespace {
-
-       /**
-        * These namespaces should always be first-letter capitalized, now and
-        * forevermore. Historically, they could've probably been lowercased too,
-        * but some things are just too ingrained now. :)
-        */
-       private static $alwaysCapitalizedNamespaces = [ NS_SPECIAL, NS_USER, NS_MEDIAWIKI ];
-
-       /** @var string[]|null Canonical namespaces cache */
-       private static $canonicalNamespaces = null;
-
-       /** @var array|false Canonical namespaces index cache */
-       private static $namespaceIndexes = false;
-
-       /** @var int[]|null Valid namespaces cache */
-       private static $validNamespaces = null;
-
-       /**
-        * Throw an exception when trying to get the subject or talk page
-        * for a given namespace where it does not make sense.
-        * Special namespaces are defined in includes/Defines.php and have
-        * a value below 0 (ex: NS_SPECIAL = -1 , NS_MEDIA = -2)
-        *
-        * @param int $index
-        * @param string $method
-        *
-        * @throws MWException
-        * @return bool
-        */
-       private static function isMethodValidFor( $index, $method ) {
-               if ( $index < NS_MAIN ) {
-                       throw new MWException( "$method does not make any sense for given namespace $index" );
-               }
-               return true;
-       }
-
-       /**
-        * Clear internal caches
-        *
-        * For use in unit testing when namespace configuration is changed.
-        *
-        * @since 1.31
-        */
-       public static function clearCaches() {
-               self::$canonicalNamespaces = null;
-               self::$namespaceIndexes = false;
-               self::$validNamespaces = null;
-       }
-
        /**
         * Can pages in the given namespace be moved?
         *
@@ -87,16 +32,7 @@ class MWNamespace {
         * @return bool
         */
        public static function isMovable( $index ) {
-               global $wgAllowImageMoving;
-
-               $result = !( $index < NS_MAIN || ( $index == NS_FILE && !$wgAllowImageMoving ) );
-
-               /**
-                * @since 1.20
-                */
-               Hooks::run( 'NamespaceIsMovable', [ $index, &$result ] );
-
-               return $result;
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->isMovable( $index );
        }
 
        /**
@@ -107,7 +43,7 @@ class MWNamespace {
         * @since 1.19
         */
        public static function isSubject( $index ) {
-               return !self::isTalk( $index );
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->isSubject( $index );
        }
 
        /**
@@ -117,8 +53,7 @@ class MWNamespace {
         * @return bool
         */
        public static function isTalk( $index ) {
-               return $index > NS_MAIN
-                       && $index % 2;
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->isTalk( $index );
        }
 
        /**
@@ -128,10 +63,7 @@ class MWNamespace {
         * @return int
         */
        public static function getTalk( $index ) {
-               self::isMethodValidFor( $index, __METHOD__ );
-               return self::isTalk( $index )
-                       ? $index
-                       : $index + 1;
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->getTalk( $index );
        }
 
        /**
@@ -142,14 +74,7 @@ class MWNamespace {
         * @return int
         */
        public static function getSubject( $index ) {
-               # Handle special namespaces
-               if ( $index < NS_MAIN ) {
-                       return $index;
-               }
-
-               return self::isTalk( $index )
-                       ? $index - 1
-                       : $index;
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->getSubject( $index );
        }
 
        /**
@@ -161,15 +86,7 @@ class MWNamespace {
         * @return int|null If no associated namespace could be found
         */
        public static function getAssociated( $index ) {
-               self::isMethodValidFor( $index, __METHOD__ );
-
-               if ( self::isSubject( $index ) ) {
-                       return self::getTalk( $index );
-               } elseif ( self::isTalk( $index ) ) {
-                       return self::getSubject( $index );
-               } else {
-                       return null;
-               }
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->getAssociated( $index );
        }
 
        /**
@@ -181,8 +98,7 @@ class MWNamespace {
         * @since 1.19
         */
        public static function exists( $index ) {
-               $nslist = self::getCanonicalNamespaces();
-               return isset( $nslist[$index] );
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->exists( $index );
        }
 
        /**
@@ -200,7 +116,7 @@ class MWNamespace {
         * @since 1.19
         */
        public static function equals( $ns1, $ns2 ) {
-               return $ns1 == $ns2;
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->equals( $ns1, $ns2 );
        }
 
        /**
@@ -215,36 +131,19 @@ class MWNamespace {
         * @since 1.19
         */
        public static function subjectEquals( $ns1, $ns2 ) {
-               return self::getSubject( $ns1 ) == self::getSubject( $ns2 );
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->
+                       subjectEquals( $ns1, $ns2 );
        }
 
        /**
         * Returns array of all defined namespaces with their canonical
         * (English) names.
         *
-        * @param bool $rebuild Rebuild namespace list (default = false). Used for testing.
-        *  Deprecated since 1.31, use self::clearCaches() instead.
-        *
         * @return array
         * @since 1.17
         */
-       public static function getCanonicalNamespaces( $rebuild = false ) {
-               if ( $rebuild ) {
-                       self::clearCaches();
-               }
-
-               if ( self::$canonicalNamespaces === null ) {
-                       global $wgExtraNamespaces, $wgCanonicalNamespaceNames;
-                       self::$canonicalNamespaces = [ NS_MAIN => '' ] + $wgCanonicalNamespaceNames;
-                       // Add extension namespaces
-                       self::$canonicalNamespaces +=
-                               ExtensionRegistry::getInstance()->getAttribute( 'ExtensionNamespaces' );
-                       if ( is_array( $wgExtraNamespaces ) ) {
-                               self::$canonicalNamespaces += $wgExtraNamespaces;
-                       }
-                       Hooks::run( 'CanonicalNamespaces', [ &self::$canonicalNamespaces ] );
-               }
-               return self::$canonicalNamespaces;
+       public static function getCanonicalNamespaces() {
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->getCanonicalNamespaces();
        }
 
        /**
@@ -254,8 +153,7 @@ class MWNamespace {
         * @return string|bool If no canonical definition.
         */
        public static function getCanonicalName( $index ) {
-               $nslist = self::getCanonicalNamespaces();
-               return $nslist[$index] ?? false;
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->getCanonicalName( $index );
        }
 
        /**
@@ -266,17 +164,7 @@ class MWNamespace {
         * @return int
         */
        public static function getCanonicalIndex( $name ) {
-               if ( self::$namespaceIndexes === false ) {
-                       self::$namespaceIndexes = [];
-                       foreach ( self::getCanonicalNamespaces() as $i => $text ) {
-                               self::$namespaceIndexes[strtolower( $text )] = $i;
-                       }
-               }
-               if ( array_key_exists( $name, self::$namespaceIndexes ) ) {
-                       return self::$namespaceIndexes[$name];
-               } else {
-                       return null;
-               }
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->getCanonicalIndex( $name );
        }
 
        /**
@@ -285,17 +173,7 @@ class MWNamespace {
         * @return array
         */
        public static function getValidNamespaces() {
-               if ( is_null( self::$validNamespaces ) ) {
-                       foreach ( array_keys( self::getCanonicalNamespaces() ) as $ns ) {
-                               if ( $ns >= 0 ) {
-                                       self::$validNamespaces[] = $ns;
-                               }
-                       }
-                       // T109137: sort numerically
-                       sort( self::$validNamespaces, SORT_NUMERIC );
-               }
-
-               return self::$validNamespaces;
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->getValidNamespaces();
        }
 
        /**
@@ -320,7 +198,7 @@ class MWNamespace {
         * @return bool True if this namespace either is or has a corresponding talk namespace.
         */
        public static function hasTalkNamespace( $index ) {
-               return $index >= NS_MAIN;
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->hasTalkNamespace( $index );
        }
 
        /**
@@ -331,8 +209,7 @@ class MWNamespace {
         * @return bool
         */
        public static function isContent( $index ) {
-               global $wgContentNamespaces;
-               return $index == NS_MAIN || in_array( $index, $wgContentNamespaces );
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->isContent( $index );
        }
 
        /**
@@ -343,8 +220,7 @@ class MWNamespace {
         * @return bool
         */
        public static function wantSignatures( $index ) {
-               global $wgExtraSignatureNamespaces;
-               return self::isTalk( $index ) || in_array( $index, $wgExtraSignatureNamespaces );
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->wantSignatures( $index );
        }
 
        /**
@@ -354,7 +230,7 @@ class MWNamespace {
         * @return bool
         */
        public static function isWatchable( $index ) {
-               return $index >= NS_MAIN;
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->isWatchable( $index );
        }
 
        /**
@@ -364,8 +240,7 @@ class MWNamespace {
         * @return bool
         */
        public static function hasSubpages( $index ) {
-               global $wgNamespacesWithSubpages;
-               return !empty( $wgNamespacesWithSubpages[$index] );
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->hasSubpages( $index );
        }
 
        /**
@@ -373,15 +248,7 @@ class MWNamespace {
         * @return array Array of namespace indices
         */
        public static function getContentNamespaces() {
-               global $wgContentNamespaces;
-               if ( !is_array( $wgContentNamespaces ) || $wgContentNamespaces === [] ) {
-                       return [ NS_MAIN ];
-               } elseif ( !in_array( NS_MAIN, $wgContentNamespaces ) ) {
-                       // always force NS_MAIN to be part of array (to match the algorithm used by isContent)
-                       return array_merge( [ NS_MAIN ], $wgContentNamespaces );
-               } else {
-                       return $wgContentNamespaces;
-               }
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->getContentNamespaces();
        }
 
        /**
@@ -391,10 +258,7 @@ class MWNamespace {
         * @return array Array of namespace indices
         */
        public static function getSubjectNamespaces() {
-               return array_filter(
-                       self::getValidNamespaces(),
-                       'MWNamespace::isSubject'
-               );
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->getSubjectNamespaces();
        }
 
        /**
@@ -404,10 +268,7 @@ class MWNamespace {
         * @return array Array of namespace indices
         */
        public static function getTalkNamespaces() {
-               return array_filter(
-                       self::getValidNamespaces(),
-                       'MWNamespace::isTalk'
-               );
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->getTalkNamespaces();
        }
 
        /**
@@ -417,23 +278,7 @@ class MWNamespace {
         * @return bool
         */
        public static function isCapitalized( $index ) {
-               global $wgCapitalLinks, $wgCapitalLinkOverrides;
-               // Turn NS_MEDIA into NS_FILE
-               $index = $index === NS_MEDIA ? NS_FILE : $index;
-
-               // Make sure to get the subject of our namespace
-               $index = self::getSubject( $index );
-
-               // Some namespaces are special and should always be upper case
-               if ( in_array( $index, self::$alwaysCapitalizedNamespaces ) ) {
-                       return true;
-               }
-               if ( isset( $wgCapitalLinkOverrides[$index] ) ) {
-                       // $wgCapitalLinkOverrides is explicitly set
-                       return $wgCapitalLinkOverrides[$index];
-               }
-               // Default to the global setting
-               return $wgCapitalLinks;
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->isCapitalized( $index );
        }
 
        /**
@@ -445,7 +290,8 @@ class MWNamespace {
         * @return bool
         */
        public static function hasGenderDistinction( $index ) {
-               return $index == NS_USER || $index == NS_USER_TALK;
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->
+                       hasGenderDistinction( $index );
        }
 
        /**
@@ -456,8 +302,7 @@ class MWNamespace {
         * @return bool
         */
        public static function isNonincludable( $index ) {
-               global $wgNonincludableNamespaces;
-               return $wgNonincludableNamespaces && in_array( $index, $wgNonincludableNamespaces );
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->isNonincludable( $index );
        }
 
        /**
@@ -472,9 +317,8 @@ class MWNamespace {
         * @return null|string Default model name for the given namespace, if set
         */
        public static function getNamespaceContentModel( $index ) {
-               $config = MediaWikiServices::getInstance()->getMainConfig();
-               $models = $config->get( 'NamespaceContentModels' );
-               return $models[$index] ?? null;
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->
+                       getNamespaceContentModel( $index );
        }
 
        /**
@@ -487,64 +331,8 @@ class MWNamespace {
         * @return array
         */
        public static function getRestrictionLevels( $index, User $user = null ) {
-               global $wgNamespaceProtection, $wgRestrictionLevels;
-
-               if ( !isset( $wgNamespaceProtection[$index] ) ) {
-                       // All levels are valid if there's no namespace restriction.
-                       // But still filter by user, if necessary
-                       $levels = $wgRestrictionLevels;
-                       if ( $user ) {
-                               $levels = array_values( array_filter( $levels, function ( $level ) use ( $user ) {
-                                       $right = $level;
-                                       if ( $right == 'sysop' ) {
-                                               $right = 'editprotected'; // BC
-                                       }
-                                       if ( $right == 'autoconfirmed' ) {
-                                               $right = 'editsemiprotected'; // BC
-                                       }
-                                       return ( $right == '' || $user->isAllowed( $right ) );
-                               } ) );
-                       }
-                       return $levels;
-               }
-
-               // First, get the list of groups that can edit this namespace.
-               $namespaceGroups = [];
-               $combine = 'array_merge';
-               foreach ( (array)$wgNamespaceProtection[$index] as $right ) {
-                       if ( $right == 'sysop' ) {
-                               $right = 'editprotected'; // BC
-                       }
-                       if ( $right == 'autoconfirmed' ) {
-                               $right = 'editsemiprotected'; // BC
-                       }
-                       if ( $right != '' ) {
-                               $namespaceGroups = call_user_func( $combine, $namespaceGroups,
-                                       User::getGroupsWithPermission( $right ) );
-                               $combine = 'array_intersect';
-                       }
-               }
-
-               // Now, keep only those restriction levels where there is at least one
-               // group that can edit the namespace but would be blocked by the
-               // restriction.
-               $usableLevels = [ '' ];
-               foreach ( $wgRestrictionLevels as $level ) {
-                       $right = $level;
-                       if ( $right == 'sysop' ) {
-                               $right = 'editprotected'; // BC
-                       }
-                       if ( $right == 'autoconfirmed' ) {
-                               $right = 'editsemiprotected'; // BC
-                       }
-                       if ( $right != '' && ( !$user || $user->isAllowed( $right ) ) &&
-                               array_diff( $namespaceGroups, User::getGroupsWithPermission( $right ) )
-                       ) {
-                               $usableLevels[] = $level;
-                       }
-               }
-
-               return $usableLevels;
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->
+                       getRestrictionLevels( $index, $user );
        }
 
        /**
@@ -558,14 +346,7 @@ class MWNamespace {
         * @return string One of 'subcat', 'file', 'page'
         */
        public static function getCategoryLinkType( $index ) {
-               self::isMethodValidFor( $index, __METHOD__ );
-
-               if ( $index == NS_CATEGORY ) {
-                       return 'subcat';
-               } elseif ( $index == NS_FILE ) {
-                       return 'file';
-               } else {
-                       return 'page';
-               }
+               return MediaWikiServices::getInstance()->getNamespaceInfo()->
+                       getCategoryLinkType( $index );
        }
 }
index 4420d1d..3c77234 100644 (file)
@@ -125,6 +125,7 @@ class MagicWord {
         * @deprecated since 1.32, use MagicWordFactory::get
         */
        public static function get( $id ) {
+               wfDeprecated( __METHOD__, '1.32' );
                return MediaWikiServices::getInstance()->getMagicWordFactory()->get( $id );
        }
 
@@ -135,6 +136,7 @@ class MagicWord {
         * @deprecated since 1.32, use MagicWordFactory::getVariableIDs
         */
        public static function getVariableIDs() {
+               wfDeprecated( __METHOD__, '1.32' );
                return MediaWikiServices::getInstance()->getMagicWordFactory()->getVariableIDs();
        }
 
@@ -144,6 +146,7 @@ class MagicWord {
         * @deprecated since 1.32, use MagicWordFactory::getSubstIDs
         */
        public static function getSubstIDs() {
+               wfDeprecated( __METHOD__, '1.32' );
                return MediaWikiServices::getInstance()->getMagicWordFactory()->getSubstIDs();
        }
 
@@ -155,6 +158,7 @@ class MagicWord {
         * @deprecated since 1.32, use MagicWordFactory::getCacheTTL
         */
        public static function getCacheTTL( $id ) {
+               wfDeprecated( __METHOD__, '1.32' );
                return MediaWikiServices::getInstance()->getMagicWordFactory()->getCacheTTL( $id );
        }
 
@@ -165,6 +169,7 @@ class MagicWord {
         * @deprecated since 1.32, use MagicWordFactory::getDoubleUnderscoreArray
         */
        public static function getDoubleUnderscoreArray() {
+               wfDeprecated( __METHOD__, '1.32' );
                return MediaWikiServices::getInstance()->getMagicWordFactory()->getDoubleUnderscoreArray();
        }
 
index 6bf5d1d..8c60dc7 100644 (file)
@@ -39,6 +39,7 @@ use MediaWiki\Linker\LinkRenderer;
 use MediaWiki\Linker\LinkRendererFactory;
 use MWException;
 use MimeAnalyzer;
+use NamespaceInfo;
 use ObjectCache;
 use Parser;
 use ParserCache;
@@ -667,6 +668,14 @@ class MediaWikiServices extends ServiceContainer {
                return $this->getService( 'MimeAnalyzer' );
        }
 
+       /**
+        * @since 1.33
+        * @return NamespaceInfo
+        */
+       public function getNamespaceInfo() : NamespaceInfo {
+               return $this->getService( 'NamespaceInfo' );
+       }
+
        /**
         * @since 1.32
         * @return NameTableStoreFactory
@@ -676,6 +685,7 @@ class MediaWikiServices extends ServiceContainer {
        }
 
        /**
+        * @since 1.32
         * @return OldRevisionImporter
         */
        public function getOldRevisionImporter() {
index 2edd669..24178ac 100644 (file)
@@ -272,7 +272,8 @@ class MovePage {
                        [ 'cl_from' => $pageid ],
                        __METHOD__
                );
-               $type = MWNamespace::getCategoryLinkType( $this->newTitle->getNamespace() );
+               $type = MediaWikiServices::getInstance()->getNamespaceInfo()->
+                       getCategoryLinkType( $this->newTitle->getNamespace() );
                foreach ( $prefixes as $prefixRow ) {
                        $prefix = $prefixRow->cl_sortkey_prefix;
                        $catTo = $prefixRow->cl_to;
index cb3f1ad..b0000ab 100644 (file)
@@ -3223,7 +3223,10 @@ class OutputPage extends ContextSource {
                // Use an IE conditional comment to serve the script only to old IE
                $pieces[] = '<!--[if lt IE 9]>' .
                        ResourceLoaderClientHtml::makeLoad(
-                               ResourceLoaderContext::newDummyContext(),
+                               new ResourceLoaderContext(
+                                       $this->getResourceLoader(),
+                                       new FauxRequest( [] )
+                               ),
                                [ 'html5shiv' ],
                                ResourceLoaderModule::TYPE_SCRIPTS,
                                [ 'sync' => true ],
index cccb5e7..a82feaa 100644 (file)
@@ -330,6 +330,10 @@ return [
                return new MimeAnalyzer( $params );
        },
 
+       'NamespaceInfo' => function ( MediaWikiServices $services ) : NamespaceInfo {
+               return new NamespaceInfo( $services->getMainConfig() );
+       },
+
        'NameTableStoreFactory' => function ( MediaWikiServices $services ) : NameTableStoreFactory {
                return new NameTableStoreFactory(
                        $services->getDBLoadBalancerFactory(),
@@ -368,7 +372,8 @@ return [
                        $services->getContentLanguage(),
                        wfUrlProtocols(),
                        $services->getSpecialPageFactory(),
-                       $services->getMainConfig()
+                       $services->getMainConfig(),
+                       $services->getLinkRendererFactory()
                );
        },
 
@@ -428,6 +433,7 @@ return [
        },
 
        'ResourceLoader' => function ( MediaWikiServices $services ) : ResourceLoader {
+               global $IP;
                $config = $services->getMainConfig();
 
                $rl = new ResourceLoader(
@@ -435,6 +441,7 @@ return [
                        LoggerFactory::getInstance( 'resourceloader' )
                );
                $rl->addSource( $config->get( 'ResourceLoaderSources' ) );
+               $rl->register( include "$IP/resources/Resources.php" );
 
                return $rl;
        },
@@ -564,8 +571,13 @@ return [
        },
 
        'SpecialPageFactory' => function ( MediaWikiServices $services ) : SpecialPageFactory {
+               $config = $services->getMainConfig();
+               $options = [];
+               foreach ( SpecialPageFactory::$constructorOptions as $key ) {
+                       $options[$key] = $config->get( $key );
+               }
                return new SpecialPageFactory(
-                       $services->getMainConfig(),
+                       $options,
                        $services->getContentLanguage()
                );
        },
index 3d54750..3891c82 100644 (file)
@@ -58,6 +58,8 @@ class Title implements LinkTarget, IDBAccessObject {
         * Flag for use with factory methods like newFromLinkTarget() that have
         * a $forceClone parameter. If set, the method must return a new instance.
         * Without this flag, some factory methods may return existing instances.
+        *
+        * @since 1.33
         */
        const NEW_CLONE = 'clone';
 
@@ -2063,8 +2065,8 @@ class Title implements LinkTarget, IDBAccessObject {
         * protocol-relative, the URL will be expanded to http://
         *
         * @see self::getLocalURL for the arguments.
-        * @param string $query
-        * @param string|bool $query2
+        * @param string|string[] $query
+        * @param string|bool $query2 Deprecated
         * @return string The URL
         */
        public function getInternalURL( $query = '', $query2 = false ) {
@@ -2086,8 +2088,8 @@ class Title implements LinkTarget, IDBAccessObject {
         * NOTE: Unlike getInternalURL(), the canonical URL includes the fragment
         *
         * @see self::getLocalURL for the arguments.
-        * @param string $query
-        * @param string|bool $query2
+        * @param string|string[] $query
+        * @param string|bool $query2 Deprecated
         * @return string The URL
         * @since 1.18
         */
@@ -2730,7 +2732,7 @@ class Title implements LinkTarget, IDBAccessObject {
                $id = $this->getArticleID();
                if ( $id ) {
                        $fname = __METHOD__;
-                       $loadRestrictionsFromDb = function ( Database $dbr ) use ( $fname, $id ) {
+                       $loadRestrictionsFromDb = function ( IDatabase $dbr ) use ( $fname, $id ) {
                                return iterator_to_array(
                                        $dbr->select(
                                                'page_restrictions',
index 06e214f..bc1d351 100644 (file)
@@ -268,6 +268,7 @@ class HistoryAction extends FormlessAction {
                $htmlForm
                        ->setMethod( 'get' )
                        ->setAction( wfScript() )
+                       ->setCollapsible( true )
                        ->setId( 'mw-history-searchform' )
                        ->setSubmitText( $this->msg( 'historyaction-submit' )->text() )
                        ->setWrapperAttributes( [ 'id' => 'mw-history-search' ] )
index eec3902..27b4d79 100644 (file)
        "apihelp-query+userinfo-paramvalue-prop-registrationdate": "Adiciona a data de registro do usuário.",
        "apihelp-query+userinfo-paramvalue-prop-unreadcount": "Adiciona a contagem de páginas não lidas na lista de páginas vigiadas do usuário (máximo $1; retorna <samp>$2</samp> se mais).",
        "apihelp-query+userinfo-paramvalue-prop-centralids": "Adiciona os IDs centrais e o status do anexo do usuário.",
+       "apihelp-query+userinfo-paramvalue-prop-latestcontrib": "Adiciona a data da última contribuição do usuário.",
        "apihelp-query+userinfo-param-attachedwiki": "Com <kbd>$1prop=centralids</kbd>, indique se o usuário está conectado com a wiki identificada por este ID.",
        "apihelp-query+userinfo-example-simple": "Ober informações sobre o usuário atual.",
        "apihelp-query+userinfo-example-data": "Obter informações adicionais sobre o usuário atual.",
index df5fd69..67465e2 100644 (file)
        "apihelp-query+userinfo-paramvalue-prop-registrationdate": "Adiciona a data de registo do utilizador.",
        "apihelp-query+userinfo-paramvalue-prop-unreadcount": "Adiciona a contagem de páginas não lidas da lista de páginas vigiadas do utilizador (máximo $1; devolve <samp>$2</samp> se forem mais).",
        "apihelp-query+userinfo-paramvalue-prop-centralids": "Adiciona os identificadores centrais e o estado de ligação central (''attachment'') do utilizador.",
+       "apihelp-query+userinfo-paramvalue-prop-latestcontrib": "Adiciona a data da última contribuição do utilizador.",
        "apihelp-query+userinfo-param-attachedwiki": "Com <kbd>$1prop=centralids</kbd>, indicar se o utilizador tem ligação com a wiki designada por este identificador.",
        "apihelp-query+userinfo-example-simple": "Obter informações sobre o utilizador atual.",
        "apihelp-query+userinfo-example-data": "Obter informações adicionais sobre o utilizador atual.",
index df94ce2..710133e 100644 (file)
        "apihelp-query+usercontribs-summary": "Hämta alla redigeringar av en användare.",
        "apihelp-query+userinfo-summary": "Få information om den aktuella användaren.",
        "apihelp-query+userinfo-paramvalue-prop-preferencestoken": "Hämta en nyckel för att ändra aktuell användares inställningar.",
+       "apihelp-query+userinfo-paramvalue-prop-latestcontrib": "Lägg till datumet för användarens senaste bidrag.",
        "apihelp-query+userinfo-example-simple": "Få information om den aktuella användaren.",
        "apihelp-query+userinfo-example-data": "Få ytterligare information om den aktuella användaren.",
        "apihelp-query+users-summary": "Hämta information om en lista över användare.",
index a0a920a..225db87 100644 (file)
@@ -24,8 +24,8 @@
        "apihelp-main-param-action": "要執行的動作。",
        "apihelp-main-param-format": "輸出的格式。",
        "apihelp-main-param-maxlag": "最大延遲可在當 MediaWiki 安裝於資料庫複寫叢集時使用。為了保存引起更多站台複寫延遲的操作,此參數可讓客戶端等待至複寫延遲小於指定值為止。在過渡延遲的情況下,錯誤碼 <samp>maxlag</samp> 會帶有著像是 <samp>Waiting for $host: $lag seconds lagged</samp> 的訊息內容回傳。<br />請查看[[mw:Special:MyLanguage/Manual:Maxlag_parameter|手冊:Maxlag 參數]]來獲取更多資訊。",
-       "apihelp-main-param-smaxage": "將HTTP緩存控制頭欄位設為<code>s-maxage</code>秒。錯誤不會做緩存。",
-       "apihelp-main-param-maxage": "將HTTP緩存控制頭欄位設為<code>max-age</code>秒。錯誤不會做緩存。",
+       "apihelp-main-param-smaxage": "將HTTP暫存控制頭欄位設為<code>s-maxage</code>秒。錯誤不會做暫存。",
+       "apihelp-main-param-maxage": "將HTTP暫存控制頭欄位設為<code>max-age</code>秒。錯誤不會做暫存。",
        "apihelp-main-param-assert": "若設為<kbd>user</kbd>,會確認使用者是否已登入;若設為<kbd>bot</kbd>,會確認是否擁有機械人權限。",
        "apihelp-main-param-assertuser": "確認目前使用者就是指定的使用者。",
        "apihelp-main-param-requestid": "在此處提供的任何值都將包括在響應之中。可用於區分請求。",
        "apihelp-format-example-generic": "以 $1 格式傳回查詢結果。",
        "apihelp-format-param-wrappedhtml": "回傳作為 JSON 物件的美觀列印 HTML 內容以及關聯 ResourceLoader 模組。",
        "apihelp-json-summary": "使用 JSON 格式輸出資料。",
-       "apihelp-json-param-callback": "若有指定,將輸出包在指定的函式調用。出於安全考量,會限制所有使用者特定資料。",
+       "apihelp-json-param-callback": "若有指定,將輸出包在指定的函式呼叫。出於安全考量,會限制所有使用者特定資料。",
        "apihelp-json-param-utf8": "若有指定的話,將多數(並非全部)非 ASCII 字元編碼成 UTF-8,而不是以十六進位轉義序列來取代掉。預設是當 <var>formatversion</var> 不是 <kbd>1</kbd> 時。",
-       "apihelp-json-param-ascii": "è\8b¥æ\9c\89æ\8c\87å®\9aï¼\8c編碼æ\89\80æ\9c\89使ç\94¨å\8d\81å\85­é\80²ä½\8dè½\89義序列的非 ASCII。預設當 <var>formatversion</var> 為 <kbd>1</kbd> 時。",
-       "apihelp-json-param-formatversion": "輸出格式:\n;1:向下兼容格式(XML 風格布林,用於內容節點的 <samp>*</samp> 鍵、其它)。\n;2:現代格式。\n;latest:使用最新格式(目前為 <kbd>2</kbd>)可能會不帶警告作更改。",
+       "apihelp-json-param-ascii": "è\8b¥æ\9c\89æ\8c\87å®\9aï¼\8c編碼æ\89\80æ\9c\89使ç\94¨å\8d\81å\85­é\80²ä½\8dè·³è\84«序列的非 ASCII。預設當 <var>formatversion</var> 為 <kbd>1</kbd> 時。",
+       "apihelp-json-param-formatversion": "輸出格式:\n;1:向下相容格式(XML 式布林值,用於內容節點的 <samp>*</samp> 鍵、其它)。\n;2:現代格式。\n;latest:使用最新格式(目前為 <kbd>2</kbd>)可能會不帶警告作更改。",
        "apihelp-jsonfm-summary": "使用 JSON 格式輸出資料 (使用 HTML 格式顯示)。",
        "apihelp-none-summary": "不輸出。",
        "apihelp-php-summary": "使用序列化 PHP 格式輸出資料。",
        "api-help-examples": "{{PLURAL:$1|範例}}:",
        "api-help-permissions": "{{PLURAL:$1|權限}}:",
        "api-help-permissions-granted-to": "{{PLURAL:$1|已授權給}}: $2",
-       "api-help-right-apihighlimits": "在 API 查詢使用較高的限制(慢查詢:$1;快查詢:$2)。慢查詢的限制也適用於多值參數。",
+       "api-help-right-apihighlimits": "在 API 查詢使用較高的限制(慢速查詢:$1;快速查詢:$2)。慢速查詢的限制也適用於多值參數。",
        "api-help-open-in-apisandbox": "<small>[在沙盒中開啟]</small>",
-       "api-help-authmanager-general-usage": "使用此模組的一般程式是:\n# 通過 <kbd>amirequestsfor=$4</kbd> 取得來自 <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> 的可用欄位,和來自 <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> 的<kbd>$5</kbd>權杖。\n# 向用戶顯示欄位,並獲得其提交的內容。\n# 提交(POST)至此模組,提供 <var>$1returnurl</var> 及任何相關欄位。\n# 在回應中檢查 <samp>status</samp>。\n#* 如果您收到了 <samp>PASS</samp>(成功)或<samp>FAIL</samp>(失敗),則認為操作結束。成功與否如上句所示。\n#* 如果您收到了 <samp>UI</samp>,向用戶顯示新欄位,並再次獲取其提交的內容。然後再次使用 <var>$1continue</var>,向本模組提交相關欄位,並重復第四步。\n#* 如果您收到了 <samp>REDIRECT</samp>,將使用者指向<samp>redirecttarget</samp> 中的目標,等待其返回<var>$1returnurl</var>。然後再次使用 <var>$1continue</var>,向本模組提交返回 URL 中提供的一切欄位,並重復第四步。\n#* 如果您收到了 <samp>RESTART</samp>,這意味著身份驗證正常運作,但我們沒有連結的使用者帳戶。您可以將此視為 <samp>UI</samp>或<samp>FAIL</samp>。",
+       "api-help-authmanager-general-usage": "使用此模組的一般步驟是:\n# 透過 <kbd>amirequestsfor=$4</kbd> 取得來自 <kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd> 的可用欄位,和來自 <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> 的<kbd>$5</kbd>權杖。\n# 向使用者顯示欄位,並獲得其提交的內容。\n# 提交(POST)至此模組,提供 <var>$1returnurl</var> 及任何相關欄位。\n# 在回應中檢查 <samp>status</samp>。\n#* 如果您收到了 <samp>PASS</samp>(成功)或<samp>FAIL</samp>(失敗),則認為操作結束。成功與否如上句所示。\n#* 如果您收到了 <samp>UI</samp>,向使用者顯示新欄位,並再次獲取其提交的內容。然後再次使用 <var>$1continue</var>,向本模組提交相關欄位,並重復第四步。\n#* 如果您收到了 <samp>REDIRECT</samp>,將使用者指向<samp>redirecttarget</samp> 中的目標,等待其返回<var>$1returnurl</var>。然後再次使用 <var>$1continue</var>,向本模組提交返回 URL 中提供的一切欄位,並重復第四步。\n#* 如果您收到了 <samp>RESTART</samp>,表示身份驗證正常運作,但我們沒有連結的使用者帳戶。您可以將此視為 <samp>UI</samp>或<samp>FAIL</samp>。",
        "api-help-authmanagerhelper-requests": "只使用這些身份驗證請求,透過自<kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd>回傳的<samp>id</samp>與<kbd>amirequestsfor=$1</kbd>,或來自此模組之前的回應。",
        "api-help-authmanagerhelper-request": "使用此身份驗證請求,透過自<kbd>[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]</kbd>回傳的<samp>id</samp>與<kbd>amirequestsfor=$1</kbd>。",
        "api-help-authmanagerhelper-messageformat": "用於回傳訊息的格式。",
index 91ad67e..dc73ac9 100644 (file)
@@ -50,7 +50,7 @@ trait DeprecationHelper {
         * the name of the class defining the property, <component> is the MediaWiki component
         * (extension, skin etc.) for use in the deprecation warning) or null if it is MediaWiki.
         * E.g. [ 'mNewRev' => [ '1.32', 'DifferenceEngine', null ]
-        * @var string[]
+        * @var string[][]
         */
        protected $deprecatedPublicProperties = [];
 
index 14f86b7..9d3309b 100644 (file)
@@ -615,7 +615,8 @@ class LinksUpdate extends DataUpdate implements EnqueueableDataUpdate {
                        $nt = Title::makeTitleSafe( NS_CATEGORY, $name );
                        $contLang->findVariantLink( $name, $nt, true );
 
-                       $type = MWNamespace::getCategoryLinkType( $this->mTitle->getNamespace() );
+                       $type = MediaWikiServices::getInstance()->getNamespaceInfo()->
+                               getCategoryLinkType( $this->mTitle->getNamespace() );
 
                        # Treat custom sortkeys as a prefix, so that if multiple
                        # things are forced to sort as '*' or something, they'll
index 31c945c..a50150e 100644 (file)
@@ -23,6 +23,8 @@
  * @file
  */
 
+use MediaWiki\Shell\Shell;
+
 /**
  * @ingroup Dump
  */
@@ -49,8 +51,8 @@ class Dump7ZipOutput extends DumpPipeOutput {
         */
        function setup7zCommand( $file ) {
                $command = "7za a -bd -si -mx=";
-               $command .= wfEscapeShellArg( $this->compressionLevel ) . ' ';
-               $command .= wfEscapeShellArg( $file );
+               $command .= Shell::escape( $this->compressionLevel ) . ' ';
+               $command .= Shell::escape( $file );
                // Suppress annoying useless crap from p7zip
                // Unfortunately this could suppress real error messages too
                $command .= ' >' . wfGetNull() . ' 2>&1';
index 26010da..a353c44 100644 (file)
@@ -25,6 +25,8 @@
  * @file
  */
 
+use MediaWiki\Shell\Shell;
+
 /**
  * @ingroup Dump
  */
@@ -38,7 +40,7 @@ class DumpPipeOutput extends DumpFileOutput {
         */
        function __construct( $command, $file = null ) {
                if ( !is_null( $file ) ) {
-                       $command .= " > " . wfEscapeShellArg( $file );
+                       $command .= " > " . Shell::escape( $file );
                }
 
                $this->startCommand( $command );
@@ -94,7 +96,7 @@ class DumpPipeOutput extends DumpFileOutput {
                        $this->renameOrException( $newname );
                        if ( $open ) {
                                $command = $this->command;
-                               $command .= " > " . wfEscapeShellArg( $this->filename );
+                               $command .= " > " . Shell::escape( $this->filename );
                                $this->startCommand( $command );
                        }
                }
index 6ca8853..54249a8 100644 (file)
@@ -207,6 +207,12 @@ class XmlDumpWriter {
         * @return string
         */
        function closePage() {
+               if ( $this->currentTitle !== null ) {
+                       $linkCache = MediaWikiServices::getInstance()->getLinkCache();
+                       // In rare cases, link cache has the same key for some pages which
+                       // might be read as part of the same batch. T220424 and T220316
+                       $linkCache->clearLink( $this->currentTitle );
+               }
                return "  </page>\n";
        }
 
@@ -289,8 +295,13 @@ class XmlDumpWriter {
                        try {
                                $text = $content_handler->exportTransform( $text, $content_format );
                        }
-                       catch ( MWException $ex ) {
-                               // leave text as is; that's the way it goes
+                       catch ( Exception $ex ) {
+                               if ( $ex instanceof MWException || $ex instanceof RuntimeException ) {
+                                       // leave text as is; that's the way it goes
+                                       wfLogWarning( 'exportTransform failed on text for revid ' . $row->rev_id . "\n" );
+                               } else {
+                                       throw $ex;
+                               }
                        }
                        $out .= "      " . Xml::elementClean( 'text',
                                [ 'xml:space' => 'preserve', 'bytes' => intval( $row->rev_len ) ],
@@ -299,21 +310,33 @@ class XmlDumpWriter {
                        // TODO: make this fully MCR aware, see T174031
                        $rev = $this->getRevisionStore()->newRevisionFromRow( $row, 0, $this->currentTitle );
                        $slot = $rev->getSlot( 'main' );
-                       $content = $slot->getContent();
-
-                       if ( $content instanceof TextContent ) {
-                               // HACK: For text based models, bypass the serialization step.
-                               // This allows extensions (like Flow)that use incompatible combinations
-                               // of serialization format and content model.
-                               $text = $content->getNativeData();
-                       } else {
-                               $text = $content->serialize( $content_format );
+                       try {
+                               $content = $slot->getContent();
+
+                               if ( $content instanceof TextContent ) {
+                                       // HACK: For text based models, bypass the serialization step.
+                                       // This allows extensions (like Flow)that use incompatible combinations
+                                       // of serialization format and content model.
+                                       $text = $content->getNativeData();
+                               } else {
+                                       $text = $content->serialize( $content_format );
+                               }
+                               $text = $content_handler->exportTransform( $text, $content_format );
+                               $out .= "      " . Xml::elementClean( 'text',
+                                       [ 'xml:space' => 'preserve', 'bytes' => intval( $slot->getSize() ) ],
+                                       strval( $text ) ) . "\n";
+                       }
+                       catch ( Exception $ex ) {
+                               if ( $ex instanceof MWException || $ex instanceof RuntimeException ) {
+                                       // there's no provsion in the schema for an attribute that will let
+                                       // the user know this element was unavailable due to error; an empty
+                                       // tag is the best we can do
+                                       $out .= "      " . Xml::element( 'text' ) . "\n";
+                                       wfLogWarning( 'failed to load content for revid ' . $row->rev_id . "\n" );
+                               } else {
+                                       throw $ex;
+                               }
                        }
-
-                       $text = $content_handler->exportTransform( $text, $content_format );
-                       $out .= "      " . Xml::elementClean( 'text',
-                               [ 'xml:space' => 'preserve', 'bytes' => intval( $slot->getSize() ) ],
-                               strval( $text ) ) . "\n";
                } elseif ( isset( $row->rev_text_id ) ) {
                        // Stub output for pre-MCR schema
                        // TODO: MCR: rev_text_id only exists in the pre-MCR schema. Remove this when
index e5330b6..aeeb934 100644 (file)
@@ -221,6 +221,20 @@ class HTMLForm extends ContextSource {
         */
        protected $mAction = false;
 
+       /**
+        * Whether the HTML form can be collapsed
+        * @since 1.33
+        * @var bool
+        */
+       protected $mCollapsible = false;
+
+       /**
+        * Whether the HTML form IS collapsed by default
+        * @since 1.33
+        * @var bool
+        */
+       protected $mCollapsed = false;
+
        /**
         * Form attribute autocomplete. A typical value is "off". null does not set the attribute
         * @since 1.27
@@ -1047,6 +1061,18 @@ class HTMLForm extends ContextSource {
                return '' . $this->mPre . $html . $this->mPost;
        }
 
+       /**
+        * Make the form collapsible
+        * @since 1.33
+        * @param bool $collapsed whether it should be by default
+        * @return HTMLForm $this for chaining calls (since 1.20)
+        */
+       public function setCollapsible( $collapsed = false ) {
+               $this->mCollapsible = true;
+               $this->mCollapsed = $collapsed;
+               return $this;
+       }
+
        /**
         * Get HTML attributes for the `<form>` tag.
         * @return array
index e21d783..22ece4c 100644 (file)
@@ -281,20 +281,30 @@ class OOUIHTMLForm extends HTMLForm {
 
        public function wrapForm( $html ) {
                if ( is_string( $this->mWrapperLegend ) ) {
+                       $classes = $this->mCollapsible ? [ 'mw-collapsible' ] : [];
+                       if ( $this->mCollapsed ) {
+                               $classes[] = 'mw-collapsed';
+                       }
                        $content = new OOUI\FieldsetLayout( [
                                'label' => $this->mWrapperLegend,
-                               'items' => [
-                                       new OOUI\Widget( [
-                                               'content' => new OOUI\HtmlSnippet( $html )
-                                       ] ),
-                               ],
+                               'classes' => $classes,
+                               'group' => new OOUI\StackLayout( [
+                                       'expanded' => false,
+                                       'classes' => [ 'oo-ui-fieldsetLayout-group mw-collapsible-content' ],
+                                       'items' => [
+                                               new OOUI\Widget( [
+                                                       'content' => new OOUI\HtmlSnippet( $html )
+                                               ] ),
+                                       ],
+                               ] ),
                        ] + OOUI\Element::configFromHtmlAttributes( $this->mWrapperAttributes ) );
                } else {
                        $content = new OOUI\HtmlSnippet( $html );
                }
 
+               $classes = [ 'mw-htmlform', 'mw-htmlform-ooui' ];
                $form = new OOUI\FormLayout( $this->getFormAttributes() + [
-                       'classes' => [ 'mw-htmlform', 'mw-htmlform-ooui' ],
+                       'classes' => $classes,
                        'content' => $content,
                ] );
 
index 750f108..54ffa5a 100644 (file)
@@ -20,8 +20,8 @@
  * @file
  * @ingroup Deployment
  */
-use Wikimedia\Rdbms\Database;
 use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\IMaintainableDatabase;
 use MediaWiki\MediaWikiServices;
 
 require_once __DIR__ . '/../../maintenance/Maintenance.php';
@@ -59,7 +59,7 @@ abstract class DatabaseUpdater {
        /**
         * Handle to the database subclass
         *
-        * @var Database
+        * @var IMaintainableDatabase
         */
        protected $db;
 
@@ -110,11 +110,15 @@ abstract class DatabaseUpdater {
        protected $holdContentHandlerUseDB = true;
 
        /**
-        * @param Database &$db To perform updates on
+        * @param IMaintainableDatabase &$db To perform updates on
         * @param bool $shared Whether to perform updates on shared tables
         * @param Maintenance|null $maintenance Maintenance object which created us
         */
-       protected function __construct( Database &$db, $shared, Maintenance $maintenance = null ) {
+       protected function __construct(
+               IMaintainableDatabase &$db,
+               $shared,
+               Maintenance $maintenance = null
+       ) {
                $this->db = $db;
                $this->db->setFlag( DBO_DDLMODE ); // For Oracle's handling of schema files
                $this->shared = $shared;
@@ -177,14 +181,18 @@ abstract class DatabaseUpdater {
        }
 
        /**
-        * @param Database $db
+        * @param IMaintainableDatabase $db
         * @param bool $shared
         * @param Maintenance|null $maintenance
         *
         * @throws MWException
         * @return DatabaseUpdater
         */
-       public static function newForDB( Database $db, $shared = false, Maintenance $maintenance = null ) {
+       public static function newForDB(
+               IMaintainableDatabase $db,
+               $shared = false,
+               Maintenance $maintenance = null
+       ) {
                $type = $db->getType();
                if ( in_array( $type, Installer::getDBTypes() ) ) {
                        $class = ucfirst( $type ) . 'Updater';
@@ -198,7 +206,7 @@ abstract class DatabaseUpdater {
        /**
         * Get a database connection to run updates
         *
-        * @return Database
+        * @return IMaintainableDatabase
         */
        public function getDB() {
                return $this->db;
index c7fe405..bef363d 100644 (file)
@@ -16,7 +16,8 @@
                        "Hamisun",
                        "Alifakoor",
                        "Seb35",
-                       "Ahmad252"
+                       "Ahmad252",
+                       "FarsiNevis"
                ]
        },
        "config-desc": "نصب‌کنندهٔ مدیاویکی",
        "config-install-done": "'''تبریک!'''\nبا موفقیت مدیاویکی را نصب کردید.\nبرنامه نصب‌کننده پرونده <code>LocalSettings.php</code> را درست کرد.\nکه شامل تمام تنظیمات می‌باشد.\n\nشما نیاز به دریافت آن دارید و آن را در پایهٔ نصب ویکی قرار دهید (همان پوشهٔ index.php). دریافت باید به صورت خودکار شروع شده‌باشد.\n\nاگر دریافت شروع نشد یا اگر آن را لغو کردید با کلیک روی پیوند زیر می‌توانید آن را دریافت کنید:\n\n$3\n\n'''توجه داشته باشید:''' اگر این را الآن انجام ندهید، این پرونده تولیدشده در صورتی که نصب را بدون دریافت آن تمام کردید بعداً در اختیار شما قرار نخواهد گرفت.\n\nوقتی انجام شد شما می‌توانید '''[$2 وارد ویکی شوید]'''.",
        "config-install-done-path": "<strong>تبریک!</strong>\nمدیاویکی با موفقیت نصب گردید.\nبرنامه نصب‌کننده یک پرونده <code>LocalSettings.php</code> ایجاد کرده است که شامل تمام تنظیمات می‌باشد.\n\nلازم است شما آن را دریافت کرده و در <code>$4</code> قرار دهید. اِن دریافت می باِست به صورت خودکار شروع شده‌باشد.\n\nاگر دریافت شروع نشده بود و یا آن را لغو کرده اید با کلیک روی پیوند زیر می‌توانید آن را دریافت کنید:\n\n$3\n\n<strong>توجه:</strong> اگر این کار را هم اکنون انجام ندهید و بدون دریافت آن از برنامه نصب خارج شويد، این پرونده تنظیمات تولیدشده در آینده در اختیار شما قرار نخواهد داشت.\n\nوقتی که آن کار را انجام داديد، می‌توانید <strong>[$2 وارد ويکی خودتان شويد]</strong>.",
        "config-install-success": "نصب مدیاویکی موفق بود. شما می‌توانید برای دیدن ویکی‌تان از <$1$2> بازدید کنید.\nاگر پرسشی داشتید، فهرست پرسش‌های متداول ما را مطالعه کنید:\n<https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ> یا از یکی از انجمن‌های پشیبانی که در آن صفحه فهرست شده‌اند استفاده کنید.",
+       "config-install-db-success": "دادگان با موفقیت نصب شد",
        "config-download-localsettings": "دریافت <code>LocalSettings.php</code>",
        "config-help": "راهنما",
        "config-help-tooltip": "برای گسترش کلیک کنید",
index 01dc661..86cddd6 100644 (file)
        "config-sqlite-cant-create-db": "Non poteva crear le file de base de datos <code>$1</code>.",
        "config-sqlite-fts3-downgrade": "PHP non ha supporto pro FTS3. Le tabellas es retrogradate.",
        "config-can-upgrade": "Il ha tabellas MediaWiki in iste base de datos.\nPro actualisar los a MediaWiki $1, clicca super '''Continuar'''.",
+       "config-upgrade-error": "Un error ha occurrite durante le actualisation del tabellas MediaWiki in tu base de datos.\n\nPro plus information, examina le registro supra. Pro tentar lo de novo, clicca sur <strong>Continuar</strong>.",
        "config-upgrade-done": "Actualisation complete.\n\nTu pote ora [$1 comenciar a usar tu wiki].\n\nSi tu vole regenerar tu file <code>LocalSettings.php</code>, clicca super le button hic infra.\nIsto '''non es recommendate''' si tu non ha problemas con tu wiki.",
        "config-upgrade-done-no-regenerate": "Actualisation complete.\n\nTu pote ora [$1 comenciar a usar tu wiki].",
        "config-regenerate": "Regenerar LocalSettings.php →",
        "config-install-done": "<strong>Felicitationes!</strong>\nTu ha installate MediaWiki.\n\nLe installator ha generate un file <code>LocalSettings.php</code>.\nIste contine tote le configuration.\n\nEs necessari discargar lo e poner lo in le base del installation wiki (le mesme directorio que index.php).\nLe discargamento debe haber comenciate automaticamente.\n\nSi le discargamento non ha comenciate, o si illo esseva cancellate, recomencia le discargamento con un clic sur le ligamine sequente:\n\n$3\n\n<strong>Nota:</strong> Si tu non discarga iste file de configuration ora, illo non essera disponibile plus tarde.\n\nPost facer isto, tu pote <strong>[$2 entrar in tu wiki]</strong>.",
        "config-install-done-path": "<strong>Felicitationes!</strong>\nTu ha installate MediaWiki.\n\nLe installator ha generate un file <code>LocalSettings.php</code>.\nIste contine tote le configuration.\n\nEs necessari discargar lo e poner lo in <code>$4</code>.\nLe discargamento debe haber comenciate automaticamente.\n\nSi le discargamento non ha comenciate, o si illo esseva cancellate, recomencia le discargamento con un clic sur le ligamine sequente:\n\n$3\n\n<strong>Nota:</strong> Si tu non discarga iste file de configuration ora, illo non essera disponibile plus tarde.\n\nPost facer isto, tu pote <strong>[$2 entrar in tu wiki]</strong>.",
        "config-install-success": "MediaWiki ha essite installate con successo. Tu pote ora\nvisitar <$1$2> pro vider tu wiki.\nSi tu ha questiones, consulta nostre lista de questiones frequentemente ponite:\n<https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ> o usa un del\nforos de supporto indicate sur ille pagina.",
+       "config-install-db-success": "Le installation del base de datos ha succedite",
        "config-download-localsettings": "Discargar <code>LocalSettings.php</code>",
        "config-help": "adjuta",
        "config-help-tooltip": "clicca pro displicar",
        "config-skins-screenshots": "$1 (capturas de schermo: $2)",
        "config-extensions-requires": "$1 (require $2)",
        "config-screenshot": "captura de schermo",
+       "config-extension-not-found": "Impossibile trovar le file de registration pro le extension \"$1\"",
+       "config-extension-dependency": "Un error de dependentia se ha producite durante le installation del extension \"$1\": $2",
        "mainpagetext": "<strong>MediaWiki ha essite installate.</strong>",
        "mainpagedocfooter": "Consulta le [https://meta.wikimedia.org/wiki/Help:Contents Guida del usator] pro information sur le uso del software wiki.\n\n== Pro initiar ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista de configurationes]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ a proposito de MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista de diffusion pro annuncios de nove versiones de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Traducer MediaWiki in tu lingua]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Como combatter le spam in tu wiki]"
 }
index 54f743d..c79aa63 100644 (file)
        "config-install-done": "<strong>Gefeliciteerd!</strong>\nU hebt MediaWiki geïnstalleerd.\n\nHet installatieprogramma heeft het bestand <code>LocalSettings.php</code> aangemaakt.\nDit bevat al uw instellingen.\n\nU moet het bestand downloaden en in de hoofdmap van uw wiki-installatie plaatsen, in dezelfde map als index.php.\nDe download zou automatisch moeten zijn gestart.\n\nAls de download niet is gestart of als u de download hebt geannuleerd, dan kunt u de download opnieuw starten door op de onderstaande koppeling te klikken:\n\n$3\n\n<strong>Let op:</strong> als u dit niet nu doet, dan is het bestand als u later de installatieprocedure afsluit zonder het bestand te downloaden niet meer beschikbaar.\n\nNa het plaatsen van het bestand met instellingen kunt u <strong>[$2 uw wiki gebruiken]</strong>.",
        "config-install-done-path": "<strong>Gefeliciteerd!</strong>\nU hebt MediaWiki geïnstalleerd.\n\nHet installatieprogramma heeft het bestand <code>LocalSettings.php</code> aangemaakt.\nDit bevat al uw instellingen.\n\nU moet het bestand downloaden en in <code>$4</code> plaatsen. De download zou automatisch moeten zijn gestart.\n\nAls de download niet is gestart of als u de download hebt geannuleerd, dan kunt u de download opnieuw starten door op de onderstaande koppeling te klikken:\n\n$3\n\n<strong>Let op:</strong> Als u dit niet nu doet, dan is het bestand als u later de installatieprocedure afsluit zonder het bestand te downloaden niet meer beschikbaar.\n\nNa het plaatsen van het bestand met instellingen kunt u <strong>[$2 uw wiki gebruiken]</strong>.",
        "config-install-success": "MediaWiki is geïnstalleerd. U kunt nu\n<$1$2> bezoeken om uw wiki te bekijken.\nAls u vragen hebt, bekijk dan onze lijst met veelgestelde vragen:\n<https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ>, of gebruik een van de hulpforums vermeld op die pagina.",
+       "config-install-db-success": "Het installeren van de database is geslaagd",
        "config-download-localsettings": "<code>LocalSettings.php</code> downloaden",
        "config-help": "hulp",
        "config-help-tooltip": "klik om uit te vouwen",
index a313371..d0b5e14 100644 (file)
@@ -87,7 +87,7 @@
        "config-using-32bit": "<strong>Varning:</strong> ditt system verkar vara en 32-bitarsversion. Detta [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:32-bit rekommenderas inte].",
        "config-db-type": "Databastyp:",
        "config-db-host": "Databasvärd:",
-       "config-db-host-help": "Om din databasserver är på en annan server, ange då värdnamnet eller IP-adressen här.\n\nOm du använder ett delat webbhotell, bör din leverantör ge dig rätt värdnamn i deras dokumentation.\n\nOm du installerar på en Windowsserver och använder MySQL, kanske \"localhost\" inte fungerar för servernamnet. Om det inte gör det försök med \"127.0.0.1\" som den lokala IP-adressen.\n\nOm du använder PostgreSQL, lämna detta fält blankt för att ansluta via en Unix-socket.",
+       "config-db-host-help": "Om din databasserver är på en annan server, ange då värdnamnet eller IP-adressen här.\n\nOm du använder ett delat webbhotell, bör din leverantör ge dig rätt värdnamn i deras dokumentation.\n\nOm du använder MySQL, kanske \"localhost\" inte fungerar för servernamnet. Om det inte gör det försök med \"127.0.0.1\" som den lokala IP-adressen.\n\nOm du använder PostgreSQL, lämna detta fält blankt för att ansluta via en Unix-socket.",
        "config-db-host-oracle": "Databas TNS:",
        "config-db-host-oracle-help": "Ange ett giltigt [http://download.oracle.com/docs/cd/B28359_01/network.111/b28317/tnsnames.htm Local Connect Name]; en tnsnames.ora-fil måste vara synlig för denna installation.<br />Om du använder klientbibliotek 10g eller nyare kan du också använda [http://download.oracle.com/docs/cd/E11882_01/network.112/e10836/naming.htm Easy Connect] namngivningsmetoden.",
        "config-db-wiki-settings": "Identifiera denna wiki",
        "config-invalid-db-server-oracle": "Ogiltig databas-TNS \"$1\".\nAnvända antingen \"TNS Name\" eller en \"Easy Connect\"-sträng ([http://docs.oracle.com/cd/E11882_01/network.112/e10836/naming.htm Oracles namngivningsmetoder]).",
        "config-invalid-db-name": "\"$1\" är ett ogiltigt databasnamn.\nAnvänd bara ASCII-bokstäver (a-z, A-Z), siffror (0-9), understreck (_) och bindestreck (-).",
        "config-invalid-db-prefix": "\"$1\" är ett ogiltigt databasprefix.\nAnvänd bara ASCII-bokstäver (a-z, A-Z), siffror (0-9), understreck (_) och bindestreck (-).",
-       "config-connection-error": "$1.\n\nKontrollera värd, användarnamn och lösenord och försök igen.",
+       "config-connection-error": "$1.\n\nKontrollera värd, användarnamn och lösenord och försök igen. Om du använder \"localhost\" som databasvärden, försök använda \"127.0.0.1\" istället (eller tvärtom).",
        "config-invalid-schema": "\"$1\" är ett ogiltigt schema för MediaWiki.\nAnvänd bara ASCII-bokstäver (a-z, A-Z), siffror (0-9), understreck (_) och bindestreck (-).",
        "config-db-sys-create-oracle": "Installationsprogrammet stöder endast användningen av ett SYSDBA-konto för att skapa ett nytt konto.",
        "config-db-sys-user-exists-oracle": "Användarkontot \"$1\" finns redan. SYSDBA kan endast användas för att skapa ett nytt konto!",
        "config-install-done": "<strong>Grattis!</strong>\nDu har installerat MediaWiki.\n\nInstallationsprogrammet har genererat filen <code>LocalSettings.php</code>.\nDet innehåller alla dina konfigurationer.\n\nDu kommer att behöva ladda ner den och placera den i roten för din wiki-installation (samma katalog som index.php). Nedladdningen borde ha startats automatiskt.\n\nOm ingen nedladdning erbjöds, eller om du har avbrutit det kan du starta om nedladdningen genom att klicka på länken nedan:\n\n$3\n\n<strong>OBS</strong>: Om du inte gör detta nu, kommer denna genererade konfigurationsfil inte vara tillgänglig för dig senare om du avslutar installationen utan att ladda ned den.\n\nNär det är klart, kan du <strong>[$2 gå in på din wiki]</strong>",
        "config-install-done-path": "<strong>Grattis!</strong>\nDu har installerat MediaWiki.\n\nInstallationsprogrammet har genererat filen <code>LocalSettings.php</code>.\nDet innehåller alla dina konfigurationer.\n\nDu kommer att behöva ladda ner den och placera den i <code>$4</code>. Nedladdningen borde ha startats automatiskt.\n\nOm ingen nedladdning erbjöds, eller om du har avbrutit det kan du starta om nedladdningen genom att klicka på länken nedan:\n\n$3\n\n<strong>OBS</strong>: Om du inte gör detta nu, kommer denna genererade konfigurationsfil inte vara tillgänglig för dig senare om du avslutar installationen utan att ladda ned den.\n\nNär det är klart, kan du <strong>[$2 gå in på din wiki]</strong>",
        "config-install-success": "MediaWiki har installerats. Du kan nu besöka <$1$2> för att se din wiki.\nOm du undrar någonting, kolla in vår lista över vanliga ställda frågor:\n<https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ> eller använda något supportforum som länkas på sidan.",
+       "config-install-db-success": "Databasen har konfigurerats",
        "config-download-localsettings": "Ladda ner <code>LocalSettings.php</code>",
        "config-help": "hjälp",
        "config-help-tooltip": "klicka för att expandera",
index 818f6f1..8c419b2 100644 (file)
@@ -70,12 +70,18 @@ class WinCacheBagOStuff extends BagOStuff {
        public function set( $key, $value, $expire = 0, $flags = 0 ) {
                $result = wincache_ucache_set( $key, serialize( $value ), $expire );
 
+               // false positive, wincache_ucache_set returns an empty array
+               // in some circumstances.
+               // @phan-suppress-next-line PhanTypeComparisonToArray
                return ( $result === [] || $result === true );
        }
 
        public function add( $key, $value, $exptime = 0, $flags = 0 ) {
                $result = wincache_ucache_add( $key, serialize( $value ), $exptime );
 
+               // false positive, wincache_ucache_add returns an empty array
+               // in some circumstances.
+               // @phan-suppress-next-line PhanTypeComparisonToArray
                return ( $result === [] || $result === true );
        }
 
index c5ef758..ba97887 100644 (file)
@@ -104,7 +104,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        /** @var callable Deprecation logging callback */
        protected $deprecationLogger;
 
-       /** @var resource|null Database connection */
+       /** @var object|resource|null Database connection */
        protected $conn = null;
        /** @var bool */
        protected $opened = false;
index 2aefd5f..6d266ae 100644 (file)
@@ -702,12 +702,6 @@ class DatabaseMssql extends Database {
                                }
                                if ( is_null( $value ) ) {
                                        $sql .= 'null';
-                               } elseif ( is_array( $value ) || is_object( $value ) ) {
-                                       if ( is_object( $value ) && $value instanceof Blob ) {
-                                               $sql .= $this->addQuotes( $value );
-                                       } else {
-                                               $sql .= $this->addQuotes( serialize( $value ) );
-                                       }
                                } else {
                                        $sql .= $this->addQuotes( $value );
                                }
index 97d5072..1819a9a 100644 (file)
@@ -6,15 +6,13 @@ class MssqlBlob extends Blob {
        /** @noinspection PhpMissingParentConstructorInspection */
 
        /**
-        * @param Blob|array|string $data
+        * @param Blob|string $data
         */
        public function __construct( $data ) {
                if ( $data instanceof MssqlBlob ) {
                        $this->data = $data->data;
                } elseif ( $data instanceof Blob ) {
                        $this->data = $data->fetch();
-               } elseif ( is_array( $data ) && is_object( $data ) ) {
-                       $this->data = serialize( $data );
                } else {
                        $this->data = $data;
                }
index 5cad31f..c5e4a92 100644 (file)
@@ -100,7 +100,7 @@ interface LogEntry {
        /**
         * Get the access restriction.
         *
-        * @return string
+        * @return int
         */
        public function getDeleted();
 
index f003554..2d1c3b2 100644 (file)
@@ -21,6 +21,8 @@
  * @ingroup Media
  */
 
+use MediaWiki\Shell\Shell;
+
 /**
  * Generic handler for bitmap images
  *
@@ -228,7 +230,7 @@ class BitmapHandler extends TransformationalImageHandler {
                $rotation = isset( $params['disableRotation'] ) ? 0 : $this->getRotation( $image );
                list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation );
 
-               $cmd = wfEscapeShellArg( ...array_merge(
+               $cmd = Shell::escape( ...array_merge(
                        [ $wgImageMagickConvertCommand ],
                        $quality,
                        // Specify white background color, will be used for transparent images
@@ -373,12 +375,12 @@ class BitmapHandler extends TransformationalImageHandler {
                global $wgCustomConvertCommand;
 
                # Variables: %s %d %w %h
-               $src = wfEscapeShellArg( $params['srcPath'] );
-               $dst = wfEscapeShellArg( $params['dstPath'] );
+               $src = Shell::escape( $params['srcPath'] );
+               $dst = Shell::escape( $params['dstPath'] );
                $cmd = $wgCustomConvertCommand;
                $cmd = str_replace( '%s', $src, str_replace( '%d', $dst, $cmd ) ); # Filenames
-               $cmd = str_replace( '%h', wfEscapeShellArg( $params['physicalHeight'] ),
-                       str_replace( '%w', wfEscapeShellArg( $params['physicalWidth'] ), $cmd ) ); # Size
+               $cmd = str_replace( '%h', Shell::escape( $params['physicalHeight'] ),
+                       str_replace( '%w', Shell::escape( $params['physicalWidth'] ), $cmd ) ); # Size
                wfDebug( __METHOD__ . ": Running custom convert command $cmd\n" );
                $retval = 0;
                $err = wfShellExecWithStderr( $cmd, $retval );
@@ -569,10 +571,10 @@ class BitmapHandler extends TransformationalImageHandler {
                $scaler = $this->getScalerType( null, false );
                switch ( $scaler ) {
                        case 'im':
-                               $cmd = wfEscapeShellArg( $wgImageMagickConvertCommand ) . " " .
-                                       wfEscapeShellArg( $this->escapeMagickInput( $params['srcPath'], $scene ) ) .
-                                       " -rotate " . wfEscapeShellArg( "-$rotation" ) . " " .
-                                       wfEscapeShellArg( $this->escapeMagickOutput( $params['dstPath'] ) );
+                               $cmd = Shell::escape( $wgImageMagickConvertCommand ) . " " .
+                                       Shell::escape( $this->escapeMagickInput( $params['srcPath'], $scene ) ) .
+                                       " -rotate " . Shell::escape( "-$rotation" ) . " " .
+                                       Shell::escape( $this->escapeMagickOutput( $params['dstPath'] ) );
                                wfDebug( __METHOD__ . ": running ImageMagick: $cmd\n" );
                                $retval = 0;
                                $err = wfShellExecWithStderr( $cmd, $retval );
index a0e7f2c..3b904e8 100644 (file)
@@ -21,6 +21,7 @@
  * @ingroup Media
  */
 use MediaWiki\MediaWikiServices;
+use MediaWiki\Shell\Shell;
 
 /**
  * Handler for DjVu images
@@ -204,7 +205,7 @@ class DjVuHandler extends ImageHandler {
 
                # Use a subshell (brackets) to aggregate stderr from both pipeline commands
                # before redirecting it to the overall stdout. This works in both Linux and Windows XP.
-               $cmd = '(' . wfEscapeShellArg(
+               $cmd = '(' . Shell::escape(
                        $wgDjvuRenderer,
                        "-format=ppm",
                        "-page={$page}",
@@ -213,7 +214,7 @@ class DjVuHandler extends ImageHandler {
                if ( $wgDjvuPostProcessor ) {
                        $cmd .= " | {$wgDjvuPostProcessor}";
                }
-               $cmd .= ' > ' . wfEscapeShellArg( $dstPath ) . ') 2>&1';
+               $cmd .= ' > ' . Shell::escape( $dstPath ) . ') 2>&1';
                wfDebug( __METHOD__ . ": $cmd\n" );
                $retval = '';
                $err = wfShellExec( $cmd, $retval );
index d059cd8..7189179 100644 (file)
@@ -24,6 +24,8 @@
  * @ingroup Media
  */
 
+use MediaWiki\Shell\Shell;
+
 /**
  * Support for detecting/validating DjVu image files and getting
  * some basic file metadata (resolution etc)
@@ -108,7 +110,7 @@ class DjVuImage {
                                $this->dumpForm( $file, $chunkLength, $indent + 1 );
                        } else {
                                fseek( $file, $chunkLength, SEEK_CUR );
-                               if ( $chunkLength & 1 == 1 ) {
+                               if ( ( $chunkLength & 1 ) == 1 ) {
                                        // Padding byte between chunks
                                        fseek( $file, 1, SEEK_CUR );
                                }
@@ -166,7 +168,7 @@ class DjVuImage {
        private function skipChunk( $file, $chunkLength ) {
                fseek( $file, $chunkLength, SEEK_CUR );
 
-               if ( $chunkLength & 0x01 == 1 && !feof( $file ) ) {
+               if ( ( $chunkLength & 0x01 ) == 1 && !feof( $file ) ) {
                        // padding byte
                        fseek( $file, 1, SEEK_CUR );
                }
@@ -253,19 +255,19 @@ class DjVuImage {
                if ( isset( $wgDjvuDump ) ) {
                        # djvudump is faster as of version 3.5
                        # https://sourceforge.net/p/djvu/bugs/71/
-                       $cmd = wfEscapeShellArg( $wgDjvuDump ) . ' ' . wfEscapeShellArg( $this->mFilename );
+                       $cmd = Shell::escape( $wgDjvuDump ) . ' ' . Shell::escape( $this->mFilename );
                        $dump = wfShellExec( $cmd );
                        $xml = $this->convertDumpToXML( $dump );
                } elseif ( isset( $wgDjvuToXML ) ) {
-                       $cmd = wfEscapeShellArg( $wgDjvuToXML ) . ' --without-anno --without-text ' .
-                               wfEscapeShellArg( $this->mFilename );
+                       $cmd = Shell::escape( $wgDjvuToXML ) . ' --without-anno --without-text ' .
+                               Shell::escape( $this->mFilename );
                        $xml = wfShellExec( $cmd );
                } else {
                        $xml = null;
                }
                # Text layer
                if ( isset( $wgDjvuTxt ) ) {
-                       $cmd = wfEscapeShellArg( $wgDjvuTxt ) . ' --detail=page ' . wfEscapeShellArg( $this->mFilename );
+                       $cmd = Shell::escape( $wgDjvuTxt ) . ' --detail=page ' . Shell::escape( $this->mFilename );
                        wfDebug( __METHOD__ . ": $cmd\n" );
                        $retval = '';
                        $txt = wfShellExec( $cmd, $retval, [], [ 'memory' => self::DJVUTXT_MEMORY_LIMIT ] );
index 4d75d04..bdda674 100644 (file)
@@ -20,6 +20,8 @@
  * @file
  * @ingroup Media
  */
+
+use MediaWiki\Shell\Shell;
 use Wikimedia\ScopedCallback;
 
 /**
@@ -335,11 +337,11 @@ class SvgHandler extends ImageHandler {
                                // External command
                                $cmd = str_replace(
                                        [ '$path/', '$width', '$height', '$input', '$output' ],
-                                       [ $wgSVGConverterPath ? wfEscapeShellArg( "$wgSVGConverterPath/" ) : "",
+                                       [ $wgSVGConverterPath ? Shell::escape( "$wgSVGConverterPath/" ) : "",
                                                intval( $width ),
                                                intval( $height ),
-                                               wfEscapeShellArg( $srcPath ),
-                                               wfEscapeShellArg( $dstPath ) ],
+                                               Shell::escape( $srcPath ),
+                                               Shell::escape( $dstPath ) ],
                                        $wgSVGConverters[$wgSVGConverter]
                                );
 
index 38dc390..dbeca0b 100644 (file)
@@ -26,6 +26,7 @@
  * @ingroup Media
  */
 use MediaWiki\MediaWikiServices;
+use MediaWiki\Shell\Shell;
 
 /**
  * Handler for images that need to be transformed
@@ -517,7 +518,7 @@ abstract class TransformationalImageHandler extends ImageHandler {
                        function () use ( $method ) {
                                global $wgImageMagickConvertCommand;
 
-                               $cmd = wfEscapeShellArg( $wgImageMagickConvertCommand ) . ' -version';
+                               $cmd = Shell::escape( $wgImageMagickConvertCommand ) . ' -version';
                                wfDebug( $method . ": Running convert -version\n" );
                                $retval = '';
                                $return = wfShellExecWithStderr( $cmd, $retval );
index f33ce35..931740c 100644 (file)
@@ -3604,7 +3604,8 @@ class WikiPage implements Page, IDBAccessObject {
         */
        public function updateCategoryCounts( array $added, array $deleted, $id = 0 ) {
                $id = $id ?: $this->getId();
-               $type = MWNamespace::getCategoryLinkType( $this->getTitle()->getNamespace() );
+               $type = MediaWikiServices::getInstance()->getNamespaceInfo()->
+                       getCategoryLinkType( $this->getTitle()->getNamespace() );
 
                $addFields = [ 'cat_pages = cat_pages + 1' ];
                $removeFields = [ 'cat_pages = cat_pages - 1' ];
index fb79442..c9bbc43 100644 (file)
@@ -258,7 +258,7 @@ class DateFormatter {
 
                if ( !isset( $bits['m'] ) ) {
                        $m = $this->makeIsoMonth( $bits['F'] );
-                       if ( !$m || $m == '00' ) {
+                       if ( $m === false ) {
                                $fail = true;
                        } else {
                                $bits['m'] = $m;
@@ -311,7 +311,7 @@ class DateFormatter {
                if ( $fail ) {
                        // This occurs when parsing a date with day or month outside the bounds
                        // of possibilities.
-                       $text = $orig;
+                       return $orig;
                }
 
                $isoBits = [];
@@ -336,8 +336,8 @@ class DateFormatter {
        private function getMonthRegex() {
                $names = [];
                for ( $i = 1; $i <= 12; $i++ ) {
-                       $names[] = $this->lang->getMonthName( $i );
-                       $names[] = $this->lang->getMonthAbbreviation( $i );
+                       $names[] = preg_quote( $this->lang->getMonthName( $i ), '/' );
+                       $names[] = preg_quote( $this->lang->getMonthAbbreviation( $i ), '/' );
                }
                return implode( '|', $names );
        }
@@ -345,11 +345,14 @@ class DateFormatter {
        /**
         * Makes an ISO month, e.g. 02, from a month name
         * @param string $monthName Month name
-        * @return string ISO month name
+        * @return string|false ISO month name, or false if the input was invalid
         */
        private function makeIsoMonth( $monthName ) {
-               $n = $this->xMonths[$this->lang->lc( $monthName )];
-               return sprintf( '%02d', $n );
+               $isoMonth = $this->xMonths[$this->lang->lc( $monthName )] ?? false;
+               if ( $isoMonth === false ) {
+                       return false;
+               }
+               return sprintf( '%02d', $isoMonth );
        }
 
        /**
index 0ced8a1..47e5b40 100644 (file)
@@ -21,6 +21,7 @@
  * @ingroup Parser
  */
 use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\Linker\LinkRendererFactory;
 use MediaWiki\MediaWikiServices;
 use MediaWiki\Special\SpecialPageFactory;
 use Wikimedia\ScopedCallback;
@@ -276,6 +277,9 @@ class Parser {
        /** @var Config */
        private $siteConfig;
 
+       /** @var LinkRendererFactory */
+       private $linkRendererFactory;
+
        /**
         * @param array $parserConf See $wgParserConf documentation
         * @param MagicWordFactory|null $magicWordFactory
@@ -284,11 +288,13 @@ class Parser {
         * @param string|null $urlProtocols As returned from wfUrlProtocols()
         * @param SpecialPageFactory|null $spFactory
         * @param Config|null $siteConfig
+        * @param LinkRendererFactory|null $linkRendererFactory
         */
        public function __construct(
                array $parserConf = [], MagicWordFactory $magicWordFactory = null,
                Language $contLang = null, ParserFactory $factory = null, $urlProtocols = null,
-               SpecialPageFactory $spFactory = null, Config $siteConfig = null
+               SpecialPageFactory $spFactory = null, Config $siteConfig = null,
+               LinkRendererFactory $linkRendererFactory = null
        ) {
                $this->mConf = $parserConf;
                $this->mUrlProtocols = $urlProtocols ?? wfUrlProtocols();
@@ -320,6 +326,9 @@ class Parser {
                $this->factory = $factory ?? $services->getParserFactory();
                $this->specialPageFactory = $spFactory ?? $services->getSpecialPageFactory();
                $this->siteConfig = $siteConfig ?? MediaWikiServices::getInstance()->getMainConfig();
+
+               $this->linkRendererFactory =
+                       $linkRendererFactory ?? MediaWikiServices::getInstance()->getLinkRendererFactory();
        }
 
        /**
@@ -973,9 +982,9 @@ class Parser {
         * @return LinkRenderer
         */
        public function getLinkRenderer() {
+               // XXX We make the LinkRenderer with current options and then cache it forever
                if ( !$this->mLinkRenderer ) {
-                       $this->mLinkRenderer = MediaWikiServices::getInstance()
-                               ->getLinkRendererFactory()->create();
+                       $this->mLinkRenderer = $this->linkRendererFactory->create();
                        $this->mLinkRenderer->setStubThreshold(
                                $this->getOptions()->getStubThreshold()
                        );
index eb05ace..05c0622 100644 (file)
@@ -18,6 +18,7 @@
  * @file
  * @ingroup Parser
  */
+use MediaWiki\Linker\LinkRendererFactory;
 
 use MediaWiki\Special\SpecialPageFactory;
 
@@ -43,6 +44,9 @@ class ParserFactory {
        /** @var Config */
        private $siteConfig;
 
+       /** @var LinkRendererFactory */
+       private $linkRendererFactory;
+
        /**
         * @param array $parserConf See $wgParserConf documentation
         * @param MagicWordFactory $magicWordFactory
@@ -50,11 +54,12 @@ class ParserFactory {
         * @param string $urlProtocols As returned from wfUrlProtocols()
         * @param SpecialPageFactory $spFactory
         * @param Config $siteConfig
+        * @param LinkRendererFactory $linkRendererFactory
         * @since 1.32
         */
        public function __construct(
                array $parserConf, MagicWordFactory $magicWordFactory, Language $contLang, $urlProtocols,
-               SpecialPageFactory $spFactory, Config $siteConfig
+               SpecialPageFactory $spFactory, Config $siteConfig, LinkRendererFactory $linkRendererFactory
        ) {
                $this->parserConf = $parserConf;
                $this->magicWordFactory = $magicWordFactory;
@@ -62,6 +67,7 @@ class ParserFactory {
                $this->urlProtocols = $urlProtocols;
                $this->specialPageFactory = $spFactory;
                $this->siteConfig = $siteConfig;
+               $this->linkRendererFactory = $linkRendererFactory;
        }
 
        /**
@@ -70,6 +76,7 @@ class ParserFactory {
         */
        public function create() : Parser {
                return new Parser( $this->parserConf, $this->magicWordFactory, $this->contLang, $this,
-                       $this->urlProtocols, $this->specialPageFactory, $this->siteConfig );
+                       $this->urlProtocols, $this->specialPageFactory, $this->siteConfig,
+                       $this->linkRendererFactory );
        }
 }
index 3bcd012..4ed6b79 100644 (file)
@@ -858,6 +858,9 @@ class PPDStack {
                return $this->accum;
        }
 
+       /**
+        * @return bool|PPDPart
+        */
        public function getCurrentPart() {
                if ( $this->top === false ) {
                        return false;
@@ -970,6 +973,9 @@ class PPDStackElement {
                $this->parts[] = new $class( $s );
        }
 
+       /**
+        * @return PPDPart
+        */
        public function getCurrentPart() {
                return $this->parts[count( $this->parts ) - 1];
        }
index 4cf8735..02e02bb 100644 (file)
@@ -240,8 +240,6 @@ class ResourceLoader implements LoggerAwareInterface {
         * @param LoggerInterface|null $logger [optional]
         */
        public function __construct( Config $config = null, LoggerInterface $logger = null ) {
-               global $IP;
-
                $this->logger = $logger ?: new NullLogger();
 
                if ( !$config ) {
@@ -254,8 +252,9 @@ class ResourceLoader implements LoggerAwareInterface {
                // Add 'local' source first
                $this->addSource( 'local', $config->get( 'LoadScript' ) );
 
-               // Register core modules
-               $this->register( include "$IP/resources/Resources.php" );
+               // Special module that always exists
+               $this->register( 'startup', [ 'class' => ResourceLoaderStartUpModule::class ] );
+
                // Register extension modules
                $this->register( $config->get( 'ResourceModules' ) );
 
@@ -1173,7 +1172,7 @@ MESSAGE;
         */
        private function ensureNewline( $str ) {
                $end = substr( $str, -1 );
-               if ( $end === false || $end === "\n" ) {
+               if ( $end === false || $end === '' || $end === "\n" ) {
                        return $str;
                }
                return $str . "\n";
@@ -1445,7 +1444,7 @@ MESSAGE;
                        }
                }
 
-               array_walk( $modules, [ 'self', 'trimArray' ] );
+               array_walk( $modules, [ self::class, 'trimArray' ] );
 
                return Xml::encodeJsCall(
                        'mw.loader.register',
index f11f294..372d12d 100644 (file)
@@ -132,7 +132,6 @@ class ResourceLoaderContext implements MessageLocalizer {
         * things that don't "really" need a context.
         *
         * Use cases:
-        * - Creating html5shiv script tag in OutputPage.
         * - Unit tests (deprecated, create empty instance directly or use RLTestCase).
         *
         * @return ResourceLoaderContext
index d10be12..27fa5ad 100644 (file)
@@ -20,6 +20,8 @@
  * @file
  */
 
+use MediaWiki\Shell\Shell;
+
 /**
  * Class encapsulating an image used in a ResourceLoaderImageModule.
  *
@@ -373,7 +375,7 @@ class ResourceLoaderImage {
                if ( strpos( $wgSVGConverter, 'rsvg' ) === 0 ) {
                        $command = 'rsvg-convert';
                        if ( $wgSVGConverterPath ) {
-                               $command = wfEscapeShellArg( "$wgSVGConverterPath/" ) . $command;
+                               $command = Shell::escape( "$wgSVGConverterPath/" ) . $command;
                        }
 
                        $process = proc_open(
index 6d5f117..74ee552 100644 (file)
@@ -23,6 +23,8 @@
  * @file
  * @ingroup Search
  */
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\SlotRecord;
 
 /**
  * Search engine hook base class for Postgres
@@ -137,10 +139,16 @@ class SearchPostgres extends SearchDatabase {
                $top = $res->fetchRow()[0];
 
                $this->searchTerms = [];
+               $slotRoleStore = MediaWikiServices::getInstance()->getSlotRoleStore();
                if ( $top === "" ) { # # e.g. if only stopwords are used XXX return something better
                        $query = "SELECT page_id, page_namespace, page_title, 0 AS score " .
-                               "FROM page p, revision r, pagecontent c WHERE p.page_latest = r.rev_id " .
-                               "AND r.rev_text_id = c.old_id AND 1=0";
+                               "FROM page p, revision r, slots s, content c, pagecontent pc " .
+                               "WHERE p.page_latest = r.rev_id " .
+                               "AND s.slot_revision_id = r.rev_id " .
+                               "AND s.slot_role_id = " . $slotRoleStore->getId( SlotRecord::MAIN ) . " " .
+                               "AND c.content_id = s.slot_content_id " .
+                               "AND pc.old_id = substring( c.content_address from '^tt:([0-9]+)$' )::int " .
+                               "AND 1=0";
                } else {
                        $m = [];
                        if ( preg_match_all( "/'([^']+)'/", $top, $m, PREG_SET_ORDER ) ) {
@@ -151,10 +159,14 @@ class SearchPostgres extends SearchDatabase {
 
                        $query = "SELECT page_id, page_namespace, page_title, " .
                                "ts_rank($fulltext, to_tsquery($searchstring), 5) AS score " .
-                               "FROM page p, revision r, pagecontent c WHERE p.page_latest = r.rev_id " .
-                               "AND r.rev_text_id = c.old_id AND $fulltext @@ to_tsquery($searchstring)";
+                               "FROM page p, revision r, slots s, content c, pagecontent pc " .
+                               "WHERE p.page_latest = r.rev_id " .
+                               "AND s.slot_revision_id = r.rev_id " .
+                               "AND s.slot_role_id = " . $slotRoleStore->getId( SlotRecord::MAIN ) . " " .
+                               "AND c.content_id = s.slot_content_id " .
+                               "AND pc.old_id = substring( c.content_address from '^tt:([0-9]+)$' )::int " .
+                               "AND $fulltext @@ to_tsquery($searchstring)";
                }
-
                # # Namespaces - defaults to 0
                if ( !is_null( $this->namespaces ) ) { // null -> search all
                        if ( count( $this->namespaces ) < 1 ) {
@@ -178,9 +190,17 @@ class SearchPostgres extends SearchDatabase {
 
        function update( $pageid, $title, $text ) {
                # # We don't want to index older revisions
-               $sql = "UPDATE pagecontent SET textvector = NULL WHERE textvector IS NOT NULL and old_id IN " .
-                               "(SELECT DISTINCT rev_text_id FROM revision WHERE rev_page = " . intval( $pageid ) .
-                               " ORDER BY rev_text_id DESC OFFSET 1)";
+               $slotRoleStore = MediaWikiServices::getInstance()->getSlotRoleStore();
+               $sql = "UPDATE pagecontent SET textvector = NULL " .
+                       "WHERE textvector IS NOT NULL " .
+                       "AND old_id IN " .
+                       "(SELECT DISTINCT substring( c.content_address from '^tt:([0-9]+)$' )::int AS old_rev_text_id " .
+                       " FROM content c, slots s, revision r " .
+                       " WHERE r.rev_page = $pageid " .
+                       " AND s.slot_revision_id = r.rev_id " .
+                       " AND s.slot_role_id = " . $slotRoleStore->getId( SlotRecord::MAIN ) . " " .
+                       " AND c.content_id = s.slot_content_id " .
+                       " ORDER BY old_rev_text_id DESC OFFSET 1)";
                $this->db->query( $sql );
                return true;
        }
index 58212dd..a3b7296 100644 (file)
@@ -34,6 +34,7 @@ use RequestContext;
 use SpecialPage;
 use Title;
 use User;
+use Wikimedia\Assert\Assert;
 
 /**
  * Factory for handling the special page list and generating SpecialPage objects.
@@ -215,17 +216,36 @@ class SpecialPageFactory {
        private $aliases;
 
        /** @var Config */
-       private $config;
+       private $options;
 
        /** @var Language */
        private $contLang;
 
        /**
-        * @param Config $config
+        * TODO Make this a const when HHVM support is dropped (T192166)
+        *
+        * @var array
+        * @since 1.33
+        * */
+       public static $constructorOptions = [
+               'ContentHandlerUseDB',
+               'DisableInternalSearch',
+               'EmailAuthentication',
+               'EnableEmail',
+               'EnableJavaScriptTest',
+               'PageLanguageUseDB',
+               'SpecialPages',
+       ];
+
+       /**
+        * @param array $options
         * @param Language $contLang
         */
-       public function __construct( Config $config, Language $contLang ) {
-               $this->config = $config;
+       public function __construct( array $options, Language $contLang ) {
+               Assert::parameter( count( $options ) === count( self::$constructorOptions ) &&
+                       !array_diff( self::$constructorOptions, array_keys( $options ) ),
+                       '$options', 'Wrong set of options present' );
+               $this->options = $options;
                $this->contLang = $contLang;
        }
 
@@ -248,32 +268,32 @@ class SpecialPageFactory {
                if ( !is_array( $this->list ) ) {
                        $this->list = self::$coreList;
 
-                       if ( !$this->config->get( 'DisableInternalSearch' ) ) {
+                       if ( !$this->options['DisableInternalSearch'] ) {
                                $this->list['Search'] = \SpecialSearch::class;
                        }
 
-                       if ( $this->config->get( 'EmailAuthentication' ) ) {
+                       if ( $this->options['EmailAuthentication'] ) {
                                $this->list['Confirmemail'] = \EmailConfirmation::class;
                                $this->list['Invalidateemail'] = \EmailInvalidation::class;
                        }
 
-                       if ( $this->config->get( 'EnableEmail' ) ) {
+                       if ( $this->options['EnableEmail'] ) {
                                $this->list['ChangeEmail'] = \SpecialChangeEmail::class;
                        }
 
-                       if ( $this->config->get( 'EnableJavaScriptTest' ) ) {
+                       if ( $this->options['EnableJavaScriptTest'] ) {
                                $this->list['JavaScriptTest'] = \SpecialJavaScriptTest::class;
                        }
 
-                       if ( $this->config->get( 'PageLanguageUseDB' ) ) {
+                       if ( $this->options['PageLanguageUseDB'] ) {
                                $this->list['PageLanguage'] = \SpecialPageLanguage::class;
                        }
-                       if ( $this->config->get( 'ContentHandlerUseDB' ) ) {
+                       if ( $this->options['ContentHandlerUseDB'] ) {
                                $this->list['ChangeContentModel'] = \SpecialChangeContentModel::class;
                        }
 
                        // Add extension special pages
-                       $this->list = array_merge( $this->list, $this->config->get( 'SpecialPages' ) );
+                       $this->list = array_merge( $this->list, $this->options['SpecialPages'] );
 
                        // This hook can be used to disable unwanted core special pages
                        // or conditionally register special pages.
index ded0891..5f80215 100644 (file)
@@ -248,7 +248,8 @@ class SpecialEmailUser extends UnlistedSpecialPage {
         * @param User $user
         * @param string $editToken Edit token
         * @param Config|null $config optional for backwards compatibility
-        * @return string|null Null on success or string on error
+        * @return null|string|array Null on success, string on error, or array on
+        *  hook error
         */
        public static function getPermissionsError( $user, $editToken, Config $config = null ) {
                if ( $config === null ) {
index fe55d94..24d58c8 100644 (file)
@@ -261,6 +261,15 @@ class SpecialUploadStash extends UnlistedSpecialPage {
                $scalerThumbUrl = $scalerBaseUrl . '/' . $file->getUrlRel() .
                        '/' . rawurlencode( $scalerThumbName );
 
+               // If a thumb proxy is set up for the repo, we favor that, as that will
+               // keep the request internal
+               $thumbProxyUrl = $file->getRepo()->getThumbProxyUrl();
+
+               if ( strlen( $thumbProxyUrl ) ) {
+                       $scalerThumbUrl = $thumbProxyUrl . 'temp/' . $file->getUrlRel() .
+                       '/' . rawurlencode( $scalerThumbName );
+               }
+
                // make an http request based on wgUploadStashScalerBaseUrl to lazy-create
                // a thumbnail
                $httpOptions = [
@@ -268,6 +277,14 @@ class SpecialUploadStash extends UnlistedSpecialPage {
                        'timeout' => 5 // T90599 attempt to time out cleanly
                ];
                $req = MWHttpRequest::factory( $scalerThumbUrl, $httpOptions, __METHOD__ );
+
+               $secret = $file->getRepo()->getThumbProxySecret();
+
+               // Pass a secret key shared with the proxied service if any
+               if ( strlen( $secret ) ) {
+                       $req->setHeader( 'X-Swift-Secret', $secret );
+               }
+
                $status = $req->execute();
                if ( !$status->isOK() ) {
                        $errors = $status->getErrorsArray();
index 2ad0def..2632092 100644 (file)
@@ -206,6 +206,7 @@ class SpecialVersion extends SpecialPage {
                        'Victor Vasiliev', 'Rotem Liss', 'Platonides', 'Antoine Musso',
                        'Timo Tijhof', 'Daniel Kinzler', 'Jeroen De Dauw', 'Brad Jorsch',
                        'Bartosz Dziewoński', 'Ed Sanders', 'Moriel Schottlender',
+                       'Kunal Mehta', 'James D. Forrester', 'Brian Wolff', 'Adam Shorland',
                        $othersLink, $translatorsLink
                ];
 
@@ -703,7 +704,7 @@ class SpecialVersion extends SpecialPage {
                                [ 'class' => 'mw-version-ext-name' ]
                        );
                } else {
-                       $extensionNameLink = $extensionName;
+                       $extensionNameLink = htmlspecialchars( $extensionName );
                }
 
                // ... and the version information
diff --git a/includes/title/NamespaceInfo.php b/includes/title/NamespaceInfo.php
new file mode 100644 (file)
index 0000000..3726202
--- /dev/null
@@ -0,0 +1,526 @@
+<?php
+/**
+ * Provide things related to namespaces.
+ *
+ * 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
+ */
+
+/**
+ * This is a utility class for dealing with namespaces that encodes all the "magic" behaviors of
+ * them based on index.  The textual names of the namespaces are handled by Language.php.
+ *
+ * @since 1.33
+ */
+class NamespaceInfo {
+
+       /**
+        * These namespaces should always be first-letter capitalized, now and
+        * forevermore. Historically, they could've probably been lowercased too,
+        * but some things are just too ingrained now. :)
+        */
+       private $alwaysCapitalizedNamespaces = [ NS_SPECIAL, NS_USER, NS_MEDIAWIKI ];
+
+       /** @var string[]|null Canonical namespaces cache */
+       private $canonicalNamespaces = null;
+
+       /** @var array|false Canonical namespaces index cache */
+       private $namespaceIndexes = false;
+
+       /** @var int[]|null Valid namespaces cache */
+       private $validNamespaces = null;
+
+       /** @var Config */
+       private $config;
+
+       /**
+        * @param Config $config
+        */
+       public function __construct( Config $config ) {
+               $this->config = $config;
+       }
+
+       /**
+        * Throw an exception when trying to get the subject or talk page
+        * for a given namespace where it does not make sense.
+        * Special namespaces are defined in includes/Defines.php and have
+        * a value below 0 (ex: NS_SPECIAL = -1 , NS_MEDIA = -2)
+        *
+        * @param int $index
+        * @param string $method
+        *
+        * @throws MWException
+        * @return bool
+        */
+       private function isMethodValidFor( $index, $method ) {
+               if ( $index < NS_MAIN ) {
+                       throw new MWException( "$method does not make any sense for given namespace $index" );
+               }
+               return true;
+       }
+
+       /**
+        * Can pages in the given namespace be moved?
+        *
+        * @param int $index Namespace index
+        * @return bool
+        */
+       public function isMovable( $index ) {
+               $result = !( $index < NS_MAIN ||
+                       ( $index == NS_FILE && !$this->config->get( 'AllowImageMoving' ) ) );
+
+               /**
+                * @since 1.20
+                */
+               Hooks::run( 'NamespaceIsMovable', [ $index, &$result ] );
+
+               return $result;
+       }
+
+       /**
+        * Is the given namespace is a subject (non-talk) namespace?
+        *
+        * @param int $index Namespace index
+        * @return bool
+        */
+       public function isSubject( $index ) {
+               return !$this->isTalk( $index );
+       }
+
+       /**
+        * Is the given namespace a talk namespace?
+        *
+        * @param int $index Namespace index
+        * @return bool
+        */
+       public function isTalk( $index ) {
+               return $index > NS_MAIN
+                       && $index % 2;
+       }
+
+       /**
+        * Get the talk namespace index for a given namespace
+        *
+        * @param int $index Namespace index
+        * @return int
+        */
+       public function getTalk( $index ) {
+               $this->isMethodValidFor( $index, __METHOD__ );
+               return $this->isTalk( $index )
+                       ? $index
+                       : $index + 1;
+       }
+
+       /**
+        * Get the subject namespace index for a given namespace
+        * Special namespaces (NS_MEDIA, NS_SPECIAL) are always the subject.
+        *
+        * @param int $index Namespace index
+        * @return int
+        */
+       public function getSubject( $index ) {
+               # Handle special namespaces
+               if ( $index < NS_MAIN ) {
+                       return $index;
+               }
+
+               return $this->isTalk( $index )
+                       ? $index - 1
+                       : $index;
+       }
+
+       /**
+        * Get the associated namespace.
+        * For talk namespaces, returns the subject (non-talk) namespace
+        * For subject (non-talk) namespaces, returns the talk namespace
+        *
+        * @param int $index Namespace index
+        * @return int|null If no associated namespace could be found
+        */
+       public function getAssociated( $index ) {
+               $this->isMethodValidFor( $index, __METHOD__ );
+
+               if ( $this->isSubject( $index ) ) {
+                       return $this->getTalk( $index );
+               } elseif ( $this->isTalk( $index ) ) {
+                       return $this->getSubject( $index );
+               } else {
+                       return null;
+               }
+       }
+
+       /**
+        * Returns whether the specified namespace exists
+        *
+        * @param int $index
+        *
+        * @return bool
+        */
+       public function exists( $index ) {
+               $nslist = $this->getCanonicalNamespaces();
+               return isset( $nslist[$index] );
+       }
+
+       /**
+        * Returns whether the specified namespaces are the same namespace
+        *
+        * @note It's possible that in the future we may start using something
+        * other than just namespace indexes. Under that circumstance making use
+        * of this function rather than directly doing comparison will make
+        * sure that code will not potentially break.
+        *
+        * @param int $ns1 The first namespace index
+        * @param int $ns2 The second namespace index
+        *
+        * @return bool
+        */
+       public function equals( $ns1, $ns2 ) {
+               return $ns1 == $ns2;
+       }
+
+       /**
+        * Returns whether the specified namespaces share the same subject.
+        * eg: NS_USER and NS_USER wil return true, as well
+        *     NS_USER and NS_USER_TALK will return true.
+        *
+        * @param int $ns1 The first namespace index
+        * @param int $ns2 The second namespace index
+        *
+        * @return bool
+        */
+       public function subjectEquals( $ns1, $ns2 ) {
+               return $this->getSubject( $ns1 ) == $this->getSubject( $ns2 );
+       }
+
+       /**
+        * Returns array of all defined namespaces with their canonical
+        * (English) names.
+        *
+        * @return array
+        */
+       public function getCanonicalNamespaces() {
+               if ( $this->canonicalNamespaces === null ) {
+                       $this->canonicalNamespaces =
+                               [ NS_MAIN => '' ] + $this->config->get( 'CanonicalNamespaceNames' );
+                       $this->canonicalNamespaces +=
+                               ExtensionRegistry::getInstance()->getAttribute( 'ExtensionNamespaces' );
+                       if ( is_array( $this->config->get( 'ExtraNamespaces' ) ) ) {
+                               $this->canonicalNamespaces += $this->config->get( 'ExtraNamespaces' );
+                       }
+                       Hooks::run( 'CanonicalNamespaces', [ &$this->canonicalNamespaces ] );
+               }
+               return $this->canonicalNamespaces;
+       }
+
+       /**
+        * Returns the canonical (English) name for a given index
+        *
+        * @param int $index Namespace index
+        * @return string|bool If no canonical definition.
+        */
+       public function getCanonicalName( $index ) {
+               $nslist = $this->getCanonicalNamespaces();
+               return $nslist[$index] ?? false;
+       }
+
+       /**
+        * Returns the index for a given canonical name, or NULL
+        * The input *must* be converted to lower case first
+        *
+        * @param string $name Namespace name
+        * @return int
+        */
+       public function getCanonicalIndex( $name ) {
+               if ( $this->namespaceIndexes === false ) {
+                       $this->namespaceIndexes = [];
+                       foreach ( $this->getCanonicalNamespaces() as $i => $text ) {
+                               $this->namespaceIndexes[strtolower( $text )] = $i;
+                       }
+               }
+               if ( array_key_exists( $name, $this->namespaceIndexes ) ) {
+                       return $this->namespaceIndexes[$name];
+               } else {
+                       return null;
+               }
+       }
+
+       /**
+        * Returns an array of the namespaces (by integer id) that exist on the
+        * wiki. Used primarily by the api in help documentation.
+        * @return array
+        */
+       public function getValidNamespaces() {
+               if ( is_null( $this->validNamespaces ) ) {
+                       foreach ( array_keys( $this->getCanonicalNamespaces() ) as $ns ) {
+                               if ( $ns >= 0 ) {
+                                       $this->validNamespaces[] = $ns;
+                               }
+                       }
+                       // T109137: sort numerically
+                       sort( $this->validNamespaces, SORT_NUMERIC );
+               }
+
+               return $this->validNamespaces;
+       }
+
+       /*
+
+       /**
+        * Does this namespace ever have a talk namespace?
+        *
+        * @param int $index Namespace ID
+        * @return bool True if this namespace either is or has a corresponding talk namespace.
+        */
+       public function hasTalkNamespace( $index ) {
+               return $index >= NS_MAIN;
+       }
+
+       /**
+        * Does this namespace contain content, for the purposes of calculating
+        * statistics, etc?
+        *
+        * @param int $index Index to check
+        * @return bool
+        */
+       public function isContent( $index ) {
+               return $index == NS_MAIN || in_array( $index, $this->config->get( 'ContentNamespaces' ) );
+       }
+
+       /**
+        * Might pages in this namespace require the use of the Signature button on
+        * the edit toolbar?
+        *
+        * @param int $index Index to check
+        * @return bool
+        */
+       public function wantSignatures( $index ) {
+               return $this->isTalk( $index ) ||
+                       in_array( $index, $this->config->get( 'ExtraSignatureNamespaces' ) );
+       }
+
+       /**
+        * Can pages in a namespace be watched?
+        *
+        * @param int $index
+        * @return bool
+        */
+       public function isWatchable( $index ) {
+               return $index >= NS_MAIN;
+       }
+
+       /**
+        * Does the namespace allow subpages?
+        *
+        * @param int $index Index to check
+        * @return bool
+        */
+       public function hasSubpages( $index ) {
+               return !empty( $this->config->get( 'NamespacesWithSubpages' )[$index] );
+       }
+
+       /**
+        * Get a list of all namespace indices which are considered to contain content
+        * @return array Array of namespace indices
+        */
+       public function getContentNamespaces() {
+               $contentNamespaces = $this->config->get( 'ContentNamespaces' );
+               if ( !is_array( $contentNamespaces ) || $contentNamespaces === [] ) {
+                       return [ NS_MAIN ];
+               } elseif ( !in_array( NS_MAIN, $contentNamespaces ) ) {
+                       // always force NS_MAIN to be part of array (to match the algorithm used by isContent)
+                       return array_merge( [ NS_MAIN ], $contentNamespaces );
+               } else {
+                       return $contentNamespaces;
+               }
+       }
+
+       /**
+        * List all namespace indices which are considered subject, aka not a talk
+        * or special namespace. See also NamespaceInfo::isSubject
+        *
+        * @return array Array of namespace indices
+        */
+       public function getSubjectNamespaces() {
+               return array_filter(
+                       $this->getValidNamespaces(),
+                       [ $this, 'isSubject' ]
+               );
+       }
+
+       /**
+        * List all namespace indices which are considered talks, aka not a subject
+        * or special namespace. See also NamespaceInfo::isTalk
+        *
+        * @return array Array of namespace indices
+        */
+       public function getTalkNamespaces() {
+               return array_filter(
+                       $this->getValidNamespaces(),
+                       [ $this, 'isTalk' ]
+               );
+       }
+
+       /**
+        * Is the namespace first-letter capitalized?
+        *
+        * @param int $index Index to check
+        * @return bool
+        */
+       public function isCapitalized( $index ) {
+               // Turn NS_MEDIA into NS_FILE
+               $index = $index === NS_MEDIA ? NS_FILE : $index;
+
+               // Make sure to get the subject of our namespace
+               $index = $this->getSubject( $index );
+
+               // Some namespaces are special and should always be upper case
+               if ( in_array( $index, $this->alwaysCapitalizedNamespaces ) ) {
+                       return true;
+               }
+               $overrides = $this->config->get( 'CapitalLinkOverrides' );
+               if ( isset( $overrides[$index] ) ) {
+                       // CapitalLinkOverrides is explicitly set
+                       return $overrides[$index];
+               }
+               // Default to the global setting
+               return $this->config->get( 'CapitalLinks' );
+       }
+
+       /**
+        * Does the namespace (potentially) have different aliases for different
+        * genders. Not all languages make a distinction here.
+        *
+        * @param int $index Index to check
+        * @return bool
+        */
+       public function hasGenderDistinction( $index ) {
+               return $index == NS_USER || $index == NS_USER_TALK;
+       }
+
+       /**
+        * It is not possible to use pages from this namespace as template?
+        *
+        * @param int $index Index to check
+        * @return bool
+        */
+       public function isNonincludable( $index ) {
+               $namespaces = $this->config->get( 'NonincludableNamespaces' );
+               return $namespaces && in_array( $index, $namespaces );
+       }
+
+       /**
+        * Get the default content model for a namespace
+        * This does not mean that all pages in that namespace have the model
+        *
+        * @note To determine the default model for a new page's main slot, or any slot in general,
+        * use SlotRoleHandler::getDefaultModel() together with SlotRoleRegistry::getRoleHandler().
+        *
+        * @param int $index Index to check
+        * @return null|string Default model name for the given namespace, if set
+        */
+       public function getNamespaceContentModel( $index ) {
+               return $this->config->get( 'NamespaceContentModels' )[$index] ?? null;
+       }
+
+       /**
+        * Determine which restriction levels it makes sense to use in a namespace,
+        * optionally filtered by a user's rights.
+        *
+        * @param int $index Index to check
+        * @param User|null $user User to check
+        * @return array
+        */
+       public function getRestrictionLevels( $index, User $user = null ) {
+               if ( !isset( $this->config->get( 'NamespaceProtection' )[$index] ) ) {
+                       // All levels are valid if there's no namespace restriction.
+                       // But still filter by user, if necessary
+                       $levels = $this->config->get( 'RestrictionLevels' );
+                       if ( $user ) {
+                               $levels = array_values( array_filter( $levels, function ( $level ) use ( $user ) {
+                                       $right = $level;
+                                       if ( $right == 'sysop' ) {
+                                               $right = 'editprotected'; // BC
+                                       }
+                                       if ( $right == 'autoconfirmed' ) {
+                                               $right = 'editsemiprotected'; // BC
+                                       }
+                                       return ( $right == '' || $user->isAllowed( $right ) );
+                               } ) );
+                       }
+                       return $levels;
+               }
+
+               // First, get the list of groups that can edit this namespace.
+               $namespaceGroups = [];
+               $combine = 'array_merge';
+               foreach ( (array)$this->config->get( 'NamespaceProtection' )[$index] as $right ) {
+                       if ( $right == 'sysop' ) {
+                               $right = 'editprotected'; // BC
+                       }
+                       if ( $right == 'autoconfirmed' ) {
+                               $right = 'editsemiprotected'; // BC
+                       }
+                       if ( $right != '' ) {
+                               $namespaceGroups = call_user_func( $combine, $namespaceGroups,
+                                       User::getGroupsWithPermission( $right ) );
+                               $combine = 'array_intersect';
+                       }
+               }
+
+               // Now, keep only those restriction levels where there is at least one
+               // group that can edit the namespace but would be blocked by the
+               // restriction.
+               $usableLevels = [ '' ];
+               foreach ( $this->config->get( 'RestrictionLevels' ) as $level ) {
+                       $right = $level;
+                       if ( $right == 'sysop' ) {
+                               $right = 'editprotected'; // BC
+                       }
+                       if ( $right == 'autoconfirmed' ) {
+                               $right = 'editsemiprotected'; // BC
+                       }
+                       if ( $right != '' && ( !$user || $user->isAllowed( $right ) ) &&
+                               array_diff( $namespaceGroups, User::getGroupsWithPermission( $right ) )
+                       ) {
+                               $usableLevels[] = $level;
+                       }
+               }
+
+               return $usableLevels;
+       }
+
+       /**
+        * Returns the link type to be used for categories.
+        *
+        * This determines which section of a category page titles
+        * in the namespace will appear within.
+        *
+        * @param int $index Namespace index
+        * @return string One of 'subcat', 'file', 'page'
+        */
+       public function getCategoryLinkType( $index ) {
+               $this->isMethodValidFor( $index, __METHOD__ );
+
+               if ( $index == NS_CATEGORY ) {
+                       return 'subcat';
+               } elseif ( $index == NS_FILE ) {
+                       return 'file';
+               } else {
+                       return 'page';
+               }
+       }
+}
index 9e92e78..2bbe7c3 100644 (file)
@@ -21,6 +21,7 @@
  * @ingroup Upload
  */
 use MediaWiki\MediaWikiServices;
+use MediaWiki\Shell\Shell;
 
 /**
  * @defgroup Upload Upload related
@@ -1863,10 +1864,10 @@ abstract class UploadBase {
 
                if ( strpos( $command, "%f" ) === false ) {
                        # simple pattern: append file to scan
-                       $command .= " " . wfEscapeShellArg( $file );
+                       $command .= " " . Shell::escape( $file );
                } else {
                        # complex pattern: replace "%f" with file to scan
-                       $command = str_replace( "%f", wfEscapeShellArg( $file ), $command );
+                       $command = str_replace( "%f", Shell::escape( $file ), $command );
                }
 
                wfDebug( __METHOD__ . ": running virus scan: $command \n" );
index 311cac2..5b07315 100644 (file)
@@ -1671,11 +1671,11 @@ class User implements IDBAccessObject, UserIdentity {
         * protected against race conditions using a compare-and-set (CAS) mechanism
         * based on comparing $this->mTouched with the user_touched field.
         *
-        * @param Database $db
+        * @param IDatabase $db
         * @param array $conditions WHERE conditions for use with Database::update
         * @return array WHERE conditions for use with Database::update
         */
-       protected function makeUpdateConditions( Database $db, array $conditions ) {
+       protected function makeUpdateConditions( IDatabase $db, array $conditions ) {
                if ( $this->mTouched ) {
                        // CAS check: only update if the row wasn't changed sicne it was loaded.
                        $conditions['user_touched'] = $db->timestamp( $this->mTouched );
@@ -2205,6 +2205,9 @@ class User implements IDBAccessObject, UserIdentity {
 
                // Set the user limit key
                if ( $userLimit !== false ) {
+                       // phan is confused because &can-bypass's value is a bool, so it assumes
+                       // that $userLimit is also a bool here.
+                       // @phan-suppress-next-line PhanTypeInvalidExpressionArrayDestructuring
                        list( $max, $period ) = $userLimit;
                        wfDebug( __METHOD__ . ": effective user limit: $max in {$period}s\n" );
                        $keys[$cache->makeKey( 'limiter', $action, 'user', $id )] = $userLimit;
@@ -2236,6 +2239,9 @@ class User implements IDBAccessObject, UserIdentity {
 
                $triggered = false;
                foreach ( $keys as $key => $limit ) {
+                       // phan is confused because &can-bypass's value is a bool, so it assumes
+                       // that $userLimit is also a bool here.
+                       // @phan-suppress-next-line PhanTypeInvalidExpressionArrayDestructuring
                        list( $max, $period ) = $limit;
                        $summary = "(limit $max in {$period}s)";
                        $count = $cache->get( $key );
index f72ac1a..fe94704 100644 (file)
@@ -2854,7 +2854,7 @@ class Language {
        }
 
        /**
-        * @return array
+        * @return string
         */
        function fallback8bitEncoding() {
                return self::$dataCache->getItem( $this->mCode, 'fallback8bitEncoding' );
@@ -3697,6 +3697,7 @@ class Language {
                                        }
                                } elseif ( $dispLen > $length && $dispLen > strlen( $ellipsis ) ) {
                                        # String in fact does need truncation, the truncation point was OK.
+                                       // @phan-suppress-next-line PhanTypeInvalidExpressionArrayDestructuring
                                        list( $ret, $openTags ) = $maybeState; // reload state
                                        $ret = $this->removeBadCharLast( $ret ); // multi-byte char fix
                                        $ret .= $ellipsis; // add ellipsis
index f3ad0e3..c5af1e9 100644 (file)
        "delete-confirm": "حذف \"$1\"",
        "delete-legend": "حذف",
        "historywarning": "'''تنبيه:''' الصفحة التي تريد حذفها بها {{PLURAL:$1|نسخة|نسخة واحدة|نسختان|$1 نسخ|$1 نسخة}}. انظر",
-       "historyaction-submit": "أظÙ\87ر",
+       "historyaction-submit": "عرض Ø§Ù\84Ù\85راجعات",
        "confirmdeletetext": "أنت على وشك أن تقوم بحذف صفحة بالإضافة إلى كل تاريخها.\nمن فضلك التأكد من عزمك على الحذف، وبأنك مدرك للعواقب، وبأنك تقوم بهذا بالتوافق مع [[{{MediaWiki:Policy-url}}|السياسة]].",
        "actioncomplete": "انتهاء العملية",
        "actionfailed": "الفعل فشل",
index b66914b..77a312a 100644 (file)
        "accmailtext": "Unvióse a $2 una contraseña xenerada al debalu pal usuariu [[User talk:$1|$1]]. Pue camudase na páxina ''[[Special:ChangePassword|camudar contraseña]]'' depués d'aniciar sesión.",
        "newarticle": "(Nuevu)",
        "newarticletext": "Siguisti un enllaz a un artículu qu'inda nun esiste.\nPa crear la páxina, empecipia a escribir nel cuadru d'embaxo (mira la [$1 páxina d'ayuda] pa más información).\nSi llegasti equí por enquivocu, calca nel botón <strong>atrás</strong> del to restolador.",
-       "anontalkpagetext": "----\n''Esta ye la páxina d'alderique pa un usuariu anónimu qu'inda nun creó una cuenta o que nun la usa.''\nPola mor d'ello ha usase la direición numbérica IP pa identificalu/la.\nTala IP pue compartise por varios usuarios.\nSi yes un usuariu anónimu y notes qu'hai comentarios irrelevantes empobinaos pa ti, por favor [[Special:CreateAccount|crea una cuenta]] o [[Special:UserLogin|anicia sesiín]] pa torgar futures confusiones con otros usuarios anónimos.",
+       "anontalkpagetext": "----\n<em>Esta ye la páxina d'alderique pa un usuariu anónimu qu'inda nun creó una cuenta o que nun la usa.</em>\nPola mor d'ello ha usase la direición numbérica IP como identificación.\nTala IP pué compartise por dellos usuarios.\nSi yes un usuariu anónimu y notes qu'hai comentarios ensin relevancia dirixíos pa ti, por favor [[Special:CreateAccount|crea una cuenta]] o [[Special:UserLogin|anicia sesión]] pa torgar futures confusiones con otros usuarios anónimos.",
        "noarticletext": "Nestos momentos nun hai testu nesta páxina.\nPuedes [[Special:Search/{{PAGENAME}}|buscar esti títulu de páxina]] n'otres páxines,\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} buscar los rexistros rellacionaos],\no [{{fullurl:{{FULLPAGENAME}}|action=edit}} crear esta páxina]</span>.",
        "noarticletext-nopermission": "Nestos momentos nun hai testu nesta páxina.\nPuedes [[Special:Search/{{PAGENAME}}|buscar esti títulu de páxina]] n'otres páxines o <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} buscar los rexistros rellacionaos]</span>, pero nun tienes permisu pa crear esta páxina.",
        "missing-revision": "La revisión #$1 de la páxina llamada \"{{FULLPAGENAME}}\" nun esiste.\n\nDe vezu la causa d'esto ye siguir un enllaz antiguu del historial a una páxina que se desanició.\nSe puen alcontrar más detalles nel [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} rexistru de desanicios].",
index e82a793..19de701 100644 (file)
        "userpage-userdoesnotexist": "\"<nowiki>$1</nowiki>\" istifadəçi adı qeydiyyata alınmayıb.\nƏgər siz bu səhifəni yaratmaq/redaktə etmək istəyirsinizsə, xahiş edirik bunu yoxlayın.",
        "userpage-userdoesnotexist-view": "\"$1\" istifadəçi hesabı qeydiyyatda deyil",
        "blocked-notice-logextract": "Bu istifadəçi hal-hazırda bloklanmışdır.\nBloklama qeydlərinin sonuncusu aşağıda göstərilmişdir:",
+       "clearyourcache": "<strong>Qeyd:</strong> Yaddaşa verdikdən sonra dəyişikliklərin görünməsi üçün brauzerinizin keşindən yan keçilməsi lazım ola bilər. \n<strong>Mozilla / Firefox / Safari:</strong>''' <em>Reload</em> düyməsini basarkən eyni vaxtda <em>Shift</em>-i basılı tutun və ya ''Ctrl-Shift-R''-a basın. (Apple Mac-da ''Cmd-Shift-R''). '''İE''': ''Refresh'' düyməsini basarkən eyni vaxtda ''Ctrl''-i basılı tutun və ya ''Ctrl-F5''-ə basın. '''Conqueror:''' Sadəcə ''Reload'' düyməsinə və ya ''F5''-ə basın. '''Opera''' brauzeri istifadəçiləri ''Tools→Preferences''-dən ''cache''-lərini tamamilə təmizləməli ola bilərlər.",
        "usercssyoucanpreview": "'''İpucu:''' Qeyd etmədən əvvəl \"{{int:showpreview}}\"ə klikləyərək yeni CSSinizi yoxlayın.",
+       "userjsonyoucanpreview": "'''İpucu:''' Qeyd etməzdən əvvəl \"{{int:showpreview}}\" düyməsinə klikləyərək yeni JSON-unuzu yoxlayın.",
        "userjsyoucanpreview": "'''İpucu:''' Qeyd etmədən əvvəl \"{{int:showpreview}}\"ə klikləyərək yeni JavaScriptinizi yoxlayın.",
        "usercsspreview": "''Xatırladırıq ki, siz yalnız CSS-də sınaq göstərişi etmisiniz.'''\n'''Bu hələ yaddaşda saxlanılmayıb!'''",
+       "userjsonpreview": "''Xatırladırıq ki, siz yalnız JSON-da sınaq göstərişi etmisiniz.'''\n'''Bu hələ yaddaşda saxlanılmayıb!'''",
        "userjspreview": "''Xatırladırıq ki, siz yalnız JavaScript-də test/sınaq göstərişi etmisiniz.'''\n'''Bu hələ yaddaşda saxlanılmayıb!'''",
        "sitecsspreview": "''Xatırladırıq ki, siz yalnız CSS-də sınaq göstərişi etmisiniz.'''\n'''Bu hələ yaddaşda saxlanılmayıb!'''",
+       "sitejsonpreview": "''Xatırladırıq ki, siz yalnız JSON-da sınaq göstərişi etmisiniz.'''\n'''Bu hələ yaddaşda saxlanılmayıb!'''",
        "sitejspreview": "''Xatırladırıq ki, siz yalnız JavaScript kodunda sınaq göstərişi etmisiniz.'''\n'''Bu hələ yaddaşda saxlanılmayıb!'''",
+       "userinvalidconfigtitle": "<strong>Xəbərdarlıq:</strong> \"$1\" dərisi (skin) yoxdur.\nXüsusi css, .json, və .js səhifələri kiçik hərflə başlayır. Məsələn: {{ns:user}}:Foo/vector.css",
        "updated": "(yeniləndi)",
        "note": "'''Qeyd:'''",
        "previewnote": "<strong>Unutmayın ki, bu yalnız sınaq göstərişidir.</strong> Dəyişiklikləriniz hal-hazırda qeyd edilməmişdir!",
        "continue-editing": "Redaktə sahəsinə qayıt",
        "previewconflict": "Bu sınaq göstərişidir və yaddaşda saxlayacağınız təqdirdə mətnin redaktə səhifəsinin yuxarı hissəsində nəticənin necə olacağını göstərir.",
        "session_fail_preview": "Üzr istəyirik! Sizin redaktəniz saxlanmadı. \n\nOla bilsin ki, profilinizdən çıxmısınız. <strong>Lütfən hesabınıza daxil olmağınızı yoxlayın və təkrar cəhd edin</strong>. Problem həll olunmazsa, [[Special:UserLogout|hesabınızdan çıxın]] və yenidən daxil olun, həmçinin brauzerinizin bu saytdan kukiləri qəbul etdiyini yoxlayın.",
+       "session_fail_preview_html": "Üzr istəyirik! Sessiya məlumatlarının itirilməsi səbəbindən düzəlişiniz yayımlanmadı.\n\n<em> {{SITENAME}} saytında xammal HTML effektiv olduğundan, sınaq görüntüsü JavaScript hücumlarına qarşı tədbir kimi gizlidir.</em>\n\n<strong>Bu qanuni bir redaktə cəhdidirsə, yenidən cəhd edin. </strong>\nƏgər hələ də işləməsə, [[Special:UserLogout|çıxış]] edin və yenidən daxil olun və brauzerinizin bu saytda çərəzlərə (cookies) icazə verdiyini yoxlayın.",
+       "token_suffix_mismatch": "<strong>Redaktəniz rədd edildi, çünki müştəri düzəliş jetonundakı punktuasiya simvollarını qarışdırdı.</strong>\nDüzəliş səhifə mətninin korrupsiyasının qarşısını almaq üçün rədd edildi.\nBu, bir arqument \"web-based anonim proxy\" xidməti istifadə edərkən bəzən olur.",
+       "edit_form_incomplete": "<strong>Düzəliş formasının bir hissəsi serverə çatmadı; Redaktələrinizin sağlam olduğunu və yenidən cəhd etdiyini təkrarlayın.</strong>",
        "editing": "Redaktə $1",
        "creating": "Qurulur $1",
        "editingsection": "Redaktə $1 (bölmə)",
        "yourtext": "Mətniniz",
        "storedversion": "Qeyd edilmiş versiya",
        "editingold": "'''DİQQƏT! Siz bu səhifənin köhnə versiyasını redaktə edirsiniz. Məqaləni yaddaşda saxlayacağınız halda bu versiyadan sonra edilmiş hər bir dəyişiklik itiriləcək.'''",
+       "unicode-support-fail": "Brauzeriniz Unicode-nu dəstəkləmir. Səhifələri redaktə edərkən, redaktəniz saxlanmadı.",
        "yourdiff": "Fərqlər",
        "copyrightwarning": "Xahiş olunur diqqətə alasınız ki, {{SITENAME}}dakı bütün fəaliyyətləriniz $2 lisenziyasına tabe olduğu hesab edilir (təfərrüat üçün bax: $1). Əgər yazdıqlarınızın əsaslı şəkildə redaktə edilməsini və istənildiyi vaxt başqa yerə ötürülməsini istəmirsinizsə, yazılarınızı burada dərc etməyin.\n<br />\nSiz eyni zamanda söz verirsiniz ki, bu yazıları siz özünüz yazmısınız və ya onları hamıya açıq mühitdən ya da buna bənzər mənbədən köçürmüsünüz.\n\n----\n\n<div style=\"font-weight: bold; font-size: 110%; color:red;\">MÜƏLLİF HÜQUQLARI İLƏ QORUNMUŞ HEÇ BİR İŞİ İCAZƏSİZ DƏRC ETMƏYİN!</div>",
+       "copyrightwarning2": "{{SITENAME}} saytında edilən bütün töhfələr digər istifadəçilər tərəfindən redaktə, dəyişdirilə və ya silinə bilər.\nYazılarınızın redaktə edilməsini istəmirsinizsə, buraya təqdim etməyin. <br />\nSiz də bunu özünüz yazdığınızı və ya ictimai bir domendən və ya digər bir etibarlı mənbədən kopyaladığınızı vəd edirsiniz (ətraflı məlumat üçün $1-ə baxın).",
+       "editpage-cannot-use-custom-model": "Bu səhifənin məzmunu modeli dəyişdirilə bilməz.",
+       "longpageerror": "<strong>Səhv: Siz təqdim etdiyiniz mətn {{PLURAL:$11 kilobayt|$1 kilobayt}} uzundur; bu {{PLURAL: $2 |bir kilobayt | $2 kilobayt}} maksimumdan daha uzundur.</strong>\nSaxlanıla bilməz.",
+       "readonlywarning": "<strong>Xəbərdarlıq: Verilənlər bazası saxlamaq üçün kilidlənib, beləliklə, düzəlişlərinizi hazırda saxlaya bilməyəcəksiniz.</strong>\nMətni mətn faylına kopyalayıp yapışdırılmasını və daha sonra saxlamağınızı tövsiyə edirik.\n\nBunu kilidləyən sistem idarəçisi bu izahatı verdi: $1",
+       "protectedpagewarning": "<strong>Xəbərdarlıq:</strong> Bu səhifə mühafizə edildiyi üçün yalnız idarəçilər redaktə edə bilərlər.\n\nƏn son jurnal qeydi aşağıda verilmişdir:",
        "semiprotectedpagewarning": "'''Qeyd:''' Bu səhifə mühafizəli olduğu üçün onu yalnız qeydiyyatdan keçmiş istifadəçilər redaktə edə bilərlər.",
+       "cascadeprotectedwarning": "<strong>Xəbərdarlıq:</strong> Bu səhifə yalnız [[Special:ListGroupRights|xüsusi hüquqlara malik]] istifadəçilər tərəfindən redaktə ediləcək şəkildə mühafizə edilir. Çünki bu səhifə aşağıdakı kaskad mühafizə ilə qorunan {{PLURAL:$1|səhifəyə|səhifələrə}} daxildir:",
        "titleprotectedwarning": "'''DİQQƏT! Bu səhifə mühafizəlidir, yalnız [[Special:ListGroupRights|icazəsi olan]] istifadəçilər onu redaktə edə bilərlər.'''",
        "templatesused": "Bu səhifədə istifadə edilmiş {{PLURAL:$1|şablon|şablonlar}}:",
        "templatesusedpreview": "Bu sınaq göstərişində istifadə edilmiş {{PLURAL:$1|şablon|şablonlar}}:",
        "permissionserrors": "İcazə xətası",
        "permissionserrorstext": "Siz, bunu aşağıdakı {{PLURAL:$1|səbəbə|səbəblərə}} görə edə bilməzsiniz:",
        "permissionserrorstext-withaction": "Aşağıdakı {{PLURAL:$1|səbəbə|səbəblərə}} görə, $2 hüququnuz yoxdur:",
+       "contentmodelediterror": "<code>$1</code> <code>$2</code> səhifəsinin mövcud məzmun modelindən fərqləndiyi üçün bu versiyanı redaktə edə bilməzsiniz.",
        "recreate-moveddeleted-warn": "'''Diqqət! Siz əvvəllər silinmiş səhifəni bərpa etmək istəyirsiz.'''\n\nBu səhifəni yenidən yaratmağın nə qədər zəruri olduğunu bir daha yoxlayın.\nBu səhifə üçün silmə qeydləri aşağıda göstərilmişdir:",
        "moveddeleted-notice": "Bu səhifə silinmişdir.\nMəlumat üçün aşağıda bu səhifənin tarixçəsindən müvafiq silmə, mühafizə və köçmə qeydləri göstərilmişdir.",
+       "moveddeleted-notice-recent": "Üzr istəyirik, bu səhifə son vaxtlarda silinib (son 24 saat ərzində)\nBu səhifənin silinməsi, mühafizə edilməsi və yerdəyişmə qeydləri aşağıda verilmişdir.",
        "log-fulllog": "Bütöv məlumatı göstər",
        "edit-hook-aborted": "Düzəlişlər qarmaq-prosedur tərəfindən geri qaytarılıb.\nƏlavə izahat verilməyib.",
        "edit-gone-missing": "Səhifəni yeniləmək mümkün deyil.\nÇox güman ki, səhifə silinmişdir.",
index 1d04cc6..7a5b6e0 100644 (file)
        "allpages": "Усе старонкі",
        "nextpage": "Наступная старонка ($1)",
        "prevpage": "Папярэдняя старонка ($1)",
-       "allpagesfrom": "Паказаць старонкі, пачынаючы з:",
+       "allpagesfrom": "Паказаць старонкі ад:",
        "allpagesto": "Паказаць старонкі да:",
        "allarticles": "Усе старонкі",
        "allinnamespace": "Усе старонкі (прастора назваў: $1)",
        "delete-confirm": "Выдаліць «$1»",
        "delete-legend": "Выдаліць",
        "historywarning": "<strong>Папярэджаньне</strong>: старонка, якую Вы зьбіраецеся выдаліць, мае гісторыю з $1 {{PLURAL:$1|вэрсіі|вэрсіяў|вэрсіяў}}:",
-       "historyaction-submit": "Паказаць",
+       "historyaction-submit": "Паказаць вэрсіі",
        "confirmdeletetext": "Зараз Вы выдаліце старонку разам з усёй гісторыяй зьменаў.\nКалі ласка, пацьвердзіце, што Вы зьбіраецеся гэта зрабіць і што Вы разумееце ўсе наступствы, а таксама робіце гэта ў адпаведнасьці з [[{{MediaWiki:Policy-url}}|правіламі]].",
        "actioncomplete": "Дзеяньне выкананае",
        "actionfailed": "Дзеяньне ня выкананае",
index 8f7bc9b..7d6e3f0 100644 (file)
@@ -81,6 +81,7 @@
        "tog-norollbackdiff": "Не паказваць розніцу ў выніку адкату",
        "tog-useeditwarning": "Папярэдзіць мяне, калі я пакідаю старонку з незахаванымі праўкамі",
        "tog-prefershttps": "Заўсёды выкарыстоўваць бяспечнае злучэнне па ўваходзе ў сістэму",
+       "tog-showrollbackconfirmation": "Паказваць акно пацвярджэння пры націску спасылкі адкату",
        "underline-always": "Заўсёды",
        "underline-never": "Ніколі",
        "underline-default": "Як у браўзеры",
        "returnto": "Вярнуцца да $1.",
        "tagline": "З пляцоўкі {{SITENAME}}",
        "help": "Даведка",
+       "help-mediawiki": "Дапамога пра MediaWiki",
        "search": "Знайсці",
        "search-ignored-headings": " #<!-- не змяняйце гэты радок --> <pre>\n# Загалоўкі, якія будзе ігнараваць рухавік пошуку.\n# Змены набудуць моц па наступным індэксаванні старонкі.\n# Вы можаце змусіць пераіндэксаванне старонкі, зрабіўшы пустое рэдагаванне.\n# Сінтаксіс наступны:\n#   * Усё ад сімвала \"#\" да канца радка - каментарый.\n#   * Кожны непусты радок - дакладны загаловак, які трэба ігнараваць, з рэгістрам і інш.\nКрыніцы\nСпасылкі\nГл. таксама\n #</pre> <!-- не змяняйце гэты радок -->",
        "searchbutton": "Знайсці",
        "ns-specialprotected": "Не дазволена правіць старонкі ў прасторы назваў {{ns:special}}.",
        "titleprotected": "Назва засцерагаецца ад стварэння; ахова пастаўлена ўдзельнікам: [[User:$1|$1]].\nТлумачэнне пастаноўкі пад ахову: <em>$2</em>.",
        "filereadonlyerror": "Немагчыма змяніць файл \"$1\", таму што файлавае сховішча \"$2\" зараз у рэжыме \"толькі для чытання\".\n\nСістэмны адміністратар, які абмежаваў доступ, патлумачыў гэта так: \"$3\".",
+       "invalidtitle": "Няслушная назва",
        "invalidtitle-knownnamespace": "Недапушчальны загаловак з прасторай імёнаў \"$2\" і тэкстам \"$3\"",
        "invalidtitle-unknownnamespace": "Недапушчальны загаловак з невядомым лікам прасторы імён $1 і тэкстам \"$2\"",
        "exception-nologin": "Вы не ўвайшлі ў сістэму",
        "resetpass-temp-password": "Тымчасовы пароль:",
        "resetpass-abort-generic": "Змяненне пароля было спынена прыстаўкаю.",
        "resetpass-expired": "Ваш пароль пратэрмінаваны. Калі ласка, устанавіце новы пароль для ўваходу ў сістэму.",
-       "resetpass-expired-soft": "Ваш пароль пратэрмінаваны, яго трэба замяніць. Калі ласка, выберыце новы пароль зараз, ці націсніце \"{{int:authprovider-resetpass-skip-label}}\", каб змяніць яго пазней.",
+       "resetpass-expired-soft": "Ваш пароль пратэрмінаваны і яго трэба замяніць. Калі ласка, выберыце новы пароль зараз, ці націсніце \"{{int:authprovider-resetpass-skip-label}}\", каб змяніць яго пазней.",
+       "resetpass-validity": "Ваш пароль няверны: $1 \n\nКалі ласка, устанавіце новы пароль для ўваходу ў сістэму.",
        "resetpass-validity-soft": "Ваш пароль недапушчальны: $1\n\nКалі ласка, выберыце новы пароль зараз, або націсніце \"{{int:authprovider-resetpass-skip-label}}\", каб скінуць яго пазней.",
        "passwordreset": "Выслаць мне новы пароль",
        "passwordreset-text-one": "Запоўніце гэту форму, каб атрымаць часовы пароль па эл.пошце.",
        "histfirst": "найстарэйшыя",
        "histlast": "найноўшыя",
        "historysize": "({{PLURAL:$1|1 байт|$1 байты|$1 байтаў}})",
-       "historyempty": "(пуста)",
+       "historyempty": "пуста",
        "history-feed-title": "Гісторыя версій",
        "history-feed-description": "Гісторыя версій гэтай старонкі",
        "history-feed-item-nocomment": "$1 на $2",
        "difference-missing-revision": "{{PLURAL:$2|$2 версія|$2 версіі|$2 версій}} гэтай розніцы ($1) {{PLURAL:$2|не знойдзена|не знойдзены}}.\n\nЗвычайна такое здараецца з-за пераходу па састарэлай спасылцы на розніцу ў старонцы, якая была выдалена.\nПадрабязнасці могуць быць у [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} журнале выдаленняў].",
        "searchresults": "Вынікі пошуку",
        "search-filter-title-prefix": "Шукаць толькі на старонках, назва якіх пачынаецца з «$1»",
+       "search-filter-title-prefix-reset": "Шукаць усе старонкі",
        "searchresults-title": "Вынікі пошуку «$1»",
        "titlematches": "Знойдзена ў назвах",
        "textmatches": "Знойдзена ў тэкстах",
        "group-autoconfirmed-member": "{{GENDER:$1|аўта-пацверджаны ўдзельнік|аўта-пацверджаная ўдзельніца}}",
        "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}}:Аўтапацверджаныя ўдзельнікі",
        "grouppage-bot": "{{ns:project}}:Робаты",
        "grouppage-sysop": "{{ns:project}}:Адміністратары",
+       "grouppage-interface-admin": "{{ns:project}}:Адміністратары інтэрфейсу",
        "grouppage-bureaucrat": "{{ns:project}}:Бюракраты",
        "grouppage-suppress": "{{ns:project}}:Схоўваючыя",
        "right-read": "Чытаць старонкі",
        "right-reupload-own": "Запісваць паўзверх існуючага файла, які ўкладвалі самі",
        "right-reupload-shared": "Перамагаць файлы з агульнага сховішча тутэйшымі файламі",
        "right-upload_by_url": "Укладваць файлы з сеціўнага адраса (URL)",
-       "right-purge": "ЧÑ\8bÑ\81Ñ\86Ñ\96Ñ\86Ñ\8c ÐºÑ\8dÑ\88 Ð¿Ð»Ñ\8fÑ\86оÑ\9eкÑ\96 Ð´Ð»Ñ\8f Ñ\81Ñ\82аÑ\80онкÑ\96 Ð±ÐµÐ· Ð¿Ð°Ñ\86веÑ\80джаннÑ\8f",
+       "right-purge": "Ð\90Ñ\87Ñ\8bÑ\81Ñ\82ка ÐºÑ\8dÑ\88Ñ\83 Ð¿Ð»Ñ\8fÑ\86оÑ\9eкÑ\96 Ð´Ð»Ñ\8f Ñ\81Ñ\82аÑ\80онкÑ\96",
        "right-autoconfirmed": "Не падпарадкоўвацца абмежаванням хуткасці, накладзеным на IP",
        "right-bot": "Лічыцца аўтаматычным працэсам",
        "right-nominornewtalk": "Не паведамляць пра новыя паведамленні ў адказ на дробныя праўкі размоўных старонак",
        "grant-createaccount": "Ствараць уліковыя запісы",
        "grant-createeditmovepage": "Ствараць, правіць і пераносіць старонкі",
        "grant-delete": "Выдаляць старонкі, версіі і запісы ў журналах",
-       "grant-editinterface": "Правіць прасторы назваў MediaWiki і CSS/JSON/JavaScript удзельніка",
+       "grant-editinterface": "Правіць прасторы назваў MediaWiki і JSON сайту/удзельніка",
        "grant-editmycssjs": "Правіць Ваш карыстальніцкі CSS/JSON/JavaScript",
        "grant-editmyoptions": "Змяняць вашы настройкі",
        "grant-editmywatchlist": "Правіць ваш спіс назірання",
        "rcfilters-activefilters-hide": "Схаваць",
        "rcfilters-activefilters-show": "Паказаць",
        "rcfilters-activefilters-hide-tooltip": "Схаваць вобласць актыўных фільтраў",
+       "rcfilters-activefilters-show-tooltip": "Паказаць вобласць актыўных фільтраў",
        "rcfilters-advancedfilters": "Пашыраныя фільтры",
        "rcfilters-limit-title": "Вынікі для паказу",
        "rcfilters-limit-and-date-label": "$1 {{PLURAL:$1|змена|змены|змен}}, $2",
        "rcfilters-watchlist-markseen-button": "Пазначыць усе змены як прагледжаныя",
        "rcfilters-watchlist-edit-watchlist-button": "Рэдагаваць Ваш спіс назіраных старонак",
        "rcfilters-watchlist-showupdated": "Змены старонак, якія Вы не наведвалі з таго часу, як яны адбыліся, выдзелены <strong>тлустым</strong> і пазначаны маркерам.",
-       "rcfilters-preference-label": "СÑ\85аваÑ\86Ñ\8c Ð¿Ð°Ð»ÐµÐ¿Ñ\88анÑ\83Ñ\8e Ð²ÐµÑ\80Ñ\81Ñ\96Ñ\8e Â«Ð\90поÑ\88нÑ\96Ñ\85 Ð·Ð¼ÐµÐ½Â»",
+       "rcfilters-preference-label": "Ð\92Ñ\8bкаÑ\80Ñ\8bÑ\81Ñ\82оÑ\9eваÑ\86Ñ\8c Ñ\96нÑ\82Ñ\8dÑ\80Ñ\84ейÑ\81 Ð±ÐµÐ· JavaScript",
        "rcfilters-preference-help": "Адкатвае рэдызайн інтэрфейсу 2017 года і ўсе інструменты, дададзеныя з тых часоў.",
-       "rcfilters-watchlist-preference-label": "СÑ\85аваÑ\86Ñ\8c Ð¿Ð°Ð»ÐµÐ¿Ñ\88анÑ\83Ñ\8e Ð²ÐµÑ\80Ñ\81Ñ\96Ñ\8e Ñ\81пÑ\96Ñ\81а Ð½Ð°Ð·Ñ\96Ñ\80аннÑ\8f",
+       "rcfilters-watchlist-preference-label": "Ð\92Ñ\8bкаÑ\80Ñ\8bÑ\81Ñ\82оÑ\9eваÑ\86Ñ\8c Ñ\96нÑ\82Ñ\8dÑ\80Ñ\84ейÑ\81 Ð±ÐµÐ· JavaScript",
        "rcfilters-watchlist-preference-help": "Адкатвае рэдызайн інтэрфейсу 2017 года і ўсе інструменты, дададзеныя з тых часоў.",
        "rcnotefrom": "Ніжэй {{PLURAL:$5|паказана змяненне|паказаны змены}} з <strong>$3, $4</strong> (не больш за <strong>$1</strong>).",
        "rclistfrom": "Паказаць змены з $3 $2",
        "recentchangeslinked-feed": "Звязаныя праўкі",
        "recentchangeslinked-toolbox": "Звязаныя праўкі",
        "recentchangeslinked-title": "Змяненні, якія датычаць \"$1\"",
-       "recentchangeslinked-summary": "Увядзіце імя старонкі, каб убачыць змяненні на старонках, якія спасылаюцца на дадзеную старонку ці на якія яна спасылаецца. (Каб убачыць складнікі катэгорыі, увядзіце Катэгорыя:Назва катэгорыі). Старонкі, якія ўваходзяць у [[Special:Watchlist|Ваш спіс назірання]], выдзелены <strong>тлустым шрыфтам</strong>.",
+       "recentchangeslinked-summary": "Увядзіце імя старонкі, каб убачыць змяненні на старонках, на якія яна спасылаецца або якія спасылаюцца на яе (каб убачыць складнікі катэгорыі, увядзіце {{ns:category}}:Назва катэгорыі). Змены старонак з [[Special:Watchlist|Вашага спісу назірання]] выдзелены <strong>тлустым шрыфтам</strong>.",
        "recentchangeslinked-page": "Назва старонкі:",
        "recentchangeslinked-to": "Паказваць, замест гэтага, змяненні на старонках, што спасылаюцца сюды",
        "recentchanges-page-added-to-category": "[[:$1]] дададзена ў катэгорыю",
        "listfiles_size": "Памер у байтах",
        "listfiles_description": "Апісанне",
        "listfiles_count": "Версіі",
-       "listfiles-show-all": "Уключыць старыя версіі відарысаў",
+       "listfiles-show-all": "Уключыць старыя версіі файлаў",
        "listfiles-latestversion": "Актуальная версія",
        "listfiles-latestversion-yes": "Так",
        "listfiles-latestversion-no": "Не",
        "filehist-filesize": "Аб'ём файла",
        "filehist-comment": "Тлумачэнне",
        "imagelinks": "Выкарыстанне файла",
-       "linkstoimage": "Наступн{{PLURAL:$1|ая старонка спасылаецца|ыя $1 старонкі спасылаюцца}} на гэты файл:",
+       "linkstoimage": "{{PLURAL:$1|Наступная $1 старонка выкарыстоўвае|Наступныя $1 старонкі выкарыстоўваюць|Наступныя $1 старонак выкарыстоўваюць}} гэты файл:",
        "linkstoimage-more": "На гэты файл існуюць спасылкі з больш як $1 {{PLURAL:$1|старонкі|старонак}}.\nНаступны пералік паказвае толькі {{PLURAL:$1|першую спасылку|першыя $1 з іх}}.\nТаксама ёсць [[Special:WhatLinksHere/$2|поўны пералік]].",
        "nolinkstoimage": "Няма старонак, якія б спасылаліся на файл.",
        "morelinkstoimage": "Паказаць [[Special:WhatLinksHere/$1|больш спасылак]] на гэты файл.",
        "statistics-files": "Укладзеныя файлы",
        "statistics-edits": "Праўкі старонак ад часу інсталяцыі {{SITENAME}}",
        "statistics-edits-average": "Колькасць правак на 1 старонку",
-       "statistics-users": "Registered [[Special:ListUsers|users]]",
+       "statistics-users": "Зарэгістраваныя ўдзельнікі",
        "statistics-users-active": "Актыўныя ўдзельнікі",
        "statistics-users-active-desc": "Удзельнікі, якія хоць нешта зрабілі за апошн{{PLURAL:$1|і дзень|ія $1 дзён}}",
        "pageswithprop": "Старонкі з уласцівасцю старонкі",
        "pageswithprop-legend": "Старонкі з пэўнай уласцівасцю",
        "pageswithprop-text": "На гэтай старонцы пералічаны старонкі, якія выкарыстоўваюць пэўную уласцівасць.",
        "pageswithprop-prop": "Назва ўласцівасці:",
+       "pageswithprop-reverse": "Сартаваць у зваротным парадку",
+       "pageswithprop-sortbyvalue": "Сартаваць па значэнню ўласцівасці",
        "pageswithprop-submit": "Перайсці",
        "pageswithprop-prophidden-long": "доўгае тэкставае значэнне ўласцівасці схавана ($1)",
        "pageswithprop-prophidden-binary": "двайковае значэнне ўласцівасці схавана ($1)",
        "doubleredirects": "Падвойныя перасылкі",
        "doubleredirectstext": "Тут пералічаныя старонкі-перасылкі, якія паказваюць на іншыя перасылкі.\nКожны радок утрымлівае спасылкі на першую і другую перасылкі, а таксама мэту другой перасылкі, якая звычайна і ёсць \"сапраўдная\" мэтавая старонка, на якую павінна была паказваць першая перасылка.\n<del>Закрэсленыя складнікі</del> ўжо былі папраўленыя.",
        "double-redirect-fixed-move": "Назва [[$1]] была перанесена.\nПасля аўтаматычнага абнаўлення зараз яна перасылае да [[$2]]",
-       "double-redirect-fixed-maintenance": "Аўтаматычнае выпраўленне падвойнай перасылкі з [[$1]] на [[$2]] цягам тэхнічнага абслугоўвання.",
+       "double-redirect-fixed-maintenance": "Аўтаматычнае выпраўленне падвойнай перасылкі з [[$1]] на [[$2]] цягам тэхнічнага абслугоўвання",
        "double-redirect-fixer": "Выпраўляльнік перасылак",
        "brokenredirects": "Паламаныя перасылкі",
        "brokenredirectstext": "Гэтыя перасылкі паказваюць на старонкі, якіх няма:",
        "prefixindex": "Старонкі з назвамі на ўзор",
        "prefixindex-namespace": "Усе старонкі з прэфіксам (прастора назваў «{{ns:$1}}»)",
        "prefixindex-submit": "Паказаць",
-       "prefixindex-strip": "Ð\9fÑ\80Ñ\8bбÑ\80аÑ\86Ñ\8c Ð¿Ñ\80Ñ\8dÑ\84Ñ\96кÑ\81 Ñ\83 Ð¿ÐµÑ\80алÑ\96кÑ\83",
+       "prefixindex-strip": "Ð\9fÑ\80Ñ\8bбÑ\80аÑ\86Ñ\8c Ð¿Ñ\80Ñ\8dÑ\84Ñ\96кÑ\81 Ñ\83 Ð²Ñ\8bнÑ\96каÑ\85",
        "shortpages": "Старонкі малога аб'ёму",
        "longpages": "Старонкі вялікага аб'ёму",
        "deadendpages": "Старонкі без спасылак",
        "protectedtitles-submit": "Паказаць назвы",
        "listusers": "Усе ўдзельнікі",
        "listusers-editsonly": "Толькі ўдзельнікі, якія маюць праўкі",
+       "listusers-temporarygroupsonly": "Паказваць толькі ўдзельнікаў у тымчасовых групах удзельнікаў",
        "listusers-creationsort": "У парадку датаў стварэння",
        "listusers-desc": "Парадкаваць да памяншэння",
        "usereditcount": "$1 {{PLURAL:$1|праўка|праўкі|правак}}",
        "deleting-backlinks-warning": "<strong>Увага:</strong> [[Special:WhatLinksHere/{{FULLPAGENAME}}|Іншыя старонкі]] спасылаюцца на ці ўключаюць старонку, якую вы збіраецеся выдаліць.",
        "deleting-subpages-warning": "<strong>Увага:</strong> Старонка, якую Вы хочаце выдаліць, мае [[Special:PrefixIndex/{{FULLPAGENAME}}/|{{PLURAL:$1|$1 падстаронка|$1 падстаронкі|$1 падстаронак|51=болей за 50 падстаронак}}]].",
        "rollback": "Адкаціць праўкі",
+       "rollback-confirmation-confirm": "Калі ласка, пацвердзіце:",
+       "rollback-confirmation-yes": "Адкат",
+       "rollback-confirmation-no": "Адмяніць",
        "rollbacklink": "адкат",
        "rollbacklinkcount": "адкаціць $1 {{PLURAL:$1|праўку|праўкі|правак}}",
        "rollbacklinkcount-morethan": "адкаціць больш за $1 {{PLURAL:$1|праўку|праўкі|правак}}",
index 18327aa..3fccc3c 100644 (file)
@@ -86,6 +86,7 @@
        "tog-norollbackdiff": "রোলব্যাকের পরে সংস্করণগুলির পার্থক্য না দেখানো হোক",
        "tog-useeditwarning": "কোনো সম্পাদনা পাতা ত্যাগের সময় পরিবর্তনগুলি সংরক্ষিত না হয়ে থাকলে আমাকে সাবধান করা হোক",
        "tog-prefershttps": "অ্যাকাউন্টে প্রবেশ করার সময় সবসময় নিরাপদ সংযোগ ব্যবহার করুন",
+       "tog-showrollbackconfirmation": "একটি রোলব্যাক লিঙ্ক ক্লিক করার সময় একটি নিশ্চিতকরণ বার্তা দেখান",
        "underline-always": "সব সময়",
        "underline-never": "কখনো নয়",
        "underline-default": "আবরণ বা ব্রাউজারে যেমনভাবে নির্দিষ্ট করা আছে",
        "badretype": "আপনার প্রবেশ করানো পাসওয়ার্ডটি মিলছে না।",
        "usernameinprogress": "এই ব্যবহারকারী নামের জন্য একটি অ্যাকাউন্ট তৈরি আগে থেকেই চলছে। দয়া করে অপেক্ষা করুন।",
        "userexists": "এই ব্যবহারকারী নামটি ইতমধ্যে ব্যবহার করা হয়েছে।\nঅনুগ্রহ করে অন্য নাম বেছে নিন।",
+       "createacct-normalization": "কারিগরি সীমাবদ্ধতার কারণে আপনার ব্যবহারকারীর নাম সামঞ্জস্য করে \"$2\" করা হবে।",
        "loginerror": "প্রবেশ করতে সমস্যা হয়েছে",
        "createacct-error": "অ্যাকাউন্ট তৈরিতে ত্রুটি",
        "createaccounterror": "অ্যাকাউন্ট তৈরি হয়নি: $1",
        "specialpages-group-developer": "ডেভলপারের সরঞ্জাম",
        "blankpage": "খালি পাতা",
        "intentionallyblankpage": "এই পাতাটি ইচ্ছা করে খালি রাখা হয়েছে",
+       "disabledspecialpage-disabled": "এই পৃষ্ঠাটি একজন সিস্টেম প্রশাসক দ্বারা নিষ্ক্রিয় করা হয়েছে।",
        "external_image_whitelist": "  #এই লাইন ঠিক যেমন আছে<প্রাক> তেমন রাখুন<pre>\n #রেগুলার এক্সপ্রেশনের টুকরা নীচে (শুধুমাত্র অংশ / / মধ্যে যে যায়) বসান\n#এইগুলি এক্সটার্নাল (hotlinked) ইমেজের URL-এর সাথে মেলানো হবে\n#যেগুলি মিলবে, সেগুলি চিত্র হিসাবে প্রদর্শিত হবে, অন্যথায় শুধুমাত্র ইমেজ লিঙ্ক প্রদর্শিত হবে\n#যে লাইনের প্রারম্ভে # আছে সেই লাইনগুলি মন্তব্যসমূহ হিসাবে ব্যবহার করা হয়\n#এটি কেস-অসংবেদী\n\n#এই রেখার উপরের regex টুকরা বসান. এই লাইন ঠিক যেমন আছে তেমন রাখুন</pre>",
        "tags": "বৈধ পরিবর্তন ট্যাগ",
        "tag-filter": "[[Special:Tags|ট্যাগ]] ছাঁকনি:",
index 7a05ff7..99ee8dc 100644 (file)
        "delete-confirm": "Supressió de la pàgina «$1»",
        "delete-legend": "Suprimeix",
        "historywarning": "<strong>Avís:</strong> la pàgina que esteu a punt d'eliminar té un historial amb $1 {{PLURAL:$1|revisió|revisions}}:",
-       "historyaction-submit": "Mostra",
+       "historyaction-submit": "Mostra les revisions",
        "confirmdeletetext": "Esteu a punt d'esborrar de forma permanent una pàgina o imatge i tot el seu historial de la base de dades.\nConfirmeu que realment ho voleu fer, que enteneu les\nconseqüències, i que el que esteu fent està d'acord amb la [[{{MediaWiki:Policy-url}}|política]] del projecte.",
        "actioncomplete": "Acció realitzada",
        "actionfailed": "L'acció ha fallat",
index f8e43e4..7050e30 100644 (file)
        "logentry-import-upload": "$1 {{GENDER:$2|بارکرد}} $3 بە بەکارھێنانی [[special:Import|بارکەر]]",
        "logentry-import-interwiki": "$1 $3ی لە ویکییەکی ترەوە ھاوردەکرد",
        "logentry-import-interwiki-details": "$1 $3ی لە $5ەوە ھەناردە کرد ($4 بەسەرداچوونەوە)",
+       "logentry-merge-merge": "$1 $3ی لەگەڵ $4 تێکەڵکرد (بەسەرداچوونەکان لە $5 زیاترن)",
        "logentry-move-move": "$1 پەڕەی $3ی {{GENDER:$2|گواستەوە}} بۆ $4",
        "logentry-move-move-noredirect": "$1 پەڕەی $3ی بە بێ بەجێھشتنی ڕەوانەکەرێک {{GENDER:$2|گواستەوە}} بۆ $4",
        "logentry-move-move_redir": "$1 پەڕەی $3 {{GENDER:$2|گواستەوە}} بۆ $4 کە پێشتر ڕەوانەکەر بوو",
index a39d2be..6dfc160 100644 (file)
        "delete-confirm": "Smazání stránky „$1“",
        "delete-legend": "Smazat",
        "historywarning": "<strong>Varování:</strong> Stránka, kterou se chystáte smazat, má historii s $1 {{PLURAL:$1|revizí|revizemi}}:",
-       "historyaction-submit": "Zobrazit",
+       "historyaction-submit": "Zobrazit revize",
        "confirmdeletetext": "Chystáte se smazat stránku s celou její historií. Prosím potvrďte, že to opravdu chcete učinit, že si uvědomujete důsledky a že je to v souladu s [[{{MediaWiki:Policy-url}}|pravidly]].",
        "actioncomplete": "Provedeno",
        "actionfailed": "Operace se nezdařila",
index 7087387..5a1c423 100644 (file)
@@ -72,7 +72,8 @@
                        "Jorn Ari",
                        "Fnielsen",
                        "Weblars",
-                       "Kranix"
+                       "Kranix",
+                       "Psl85"
                ]
        },
        "tog-underline": "Understreg link:",
        "tog-norollbackdiff": "Vis ikke forskel efter udførelse af en tilbagerulning",
        "tog-useeditwarning": "Advar mig, hvis jeg forlader en redigeringsside med ændringer, der ikke er gemt.",
        "tog-prefershttps": "Brug altid en sikker forbindelse, når jeg er logget ind",
+       "tog-showrollbackconfirmation": "Bed om bekræftelse når der bliver klikket på et tilbagerulningslink",
        "underline-always": "Altid",
        "underline-never": "Aldrig",
        "underline-default": "Standard for browseren eller udseendet",
        "edithelp": "Hjælp til redigering",
        "helppage-top-gethelp": "Hjælp",
        "mainpage": "Forside",
-       "mainpage-description": "Hovedside",
+       "mainpage-description": "Forside",
        "policy-url": "Project:Politik",
        "portal": "Fællesskabs portal",
        "portal-url": "Project:Fællesskabs portal",
        "cannotlogoutnow-title": "Kan ikke logge af på nuværende tidspunkt",
        "cannotlogoutnow-text": "Det er ikke muligt at logge af når du bruger $1.",
        "welcomeuser": "Velkommen, $1!",
-       "welcomecreation-msg": "Din konto er blevet oprettet.\nDu kan ændre dine {{SITENAME}} [[Special:Indstilling|indstillinger]] hvis du ønsker.",
+       "welcomecreation-msg": "Din konto er blevet oprettet.\nDu kan ændre dine {{SITENAME}} [[Special:Preferences|indstillinger]] hvis du ønsker.",
        "yourname": "Brugernavn:",
        "userlogin-yourname": "Brugernavn",
        "userlogin-yourname-ph": "Indtast dit brugernavn",
        "badretype": "De indtastede adgangskoder er ikke ens.",
        "usernameinprogress": "En oprettelse af konto for dette brugernavn er allerede i gang.\nVent venligst.",
        "userexists": "Det brugernavn, du har valgt, er allerede i brug.\nVælg venligst et andet brugernavn.",
+       "createacct-normalization": "Dit brugernavn vil blive ændret til «$2» på grund af tekniske begrænsninger.",
        "loginerror": "Logon mislykket",
        "createacct-error": "Fejl ved kontooprettelse",
        "createaccounterror": "Kunne ikke oprette brugerkonto: $1",
        "content-json-empty-array": "Tomt matrix",
        "deprecated-self-close-category": "Sider, der bruger ugyldige, selvlukkende HTML-tags",
        "deprecated-self-close-category-desc": "Siden bruger ugyldige selvlukkende HTML tags, som <code>&lt;b/></code> eller <code>&lt;span/></code>. De vil snart blive ændret i overensstemmelse med HTML5-specifikationen, så de ikke kan bruges i wikitext.",
+       "duplicate-args-warning": "<strong>Advarsel</strong>: [[:$1]] kaldes [[:$2]] med flere end en værdi for \"$3\"-parameteren. Bare den sidst angitte værdien vil bruges.",
        "duplicate-args-category": "Sider der bruger samme argument mere end en gang i en skabelon",
        "duplicate-args-category-desc": "Siden indeholder en skabelon hvor et argument er brugt mere end en gang, som <code><nowiki>{{foo|bar=1|bar=2}}</nowiki></code> eller <code><nowiki>{{foo|bar|1=baz}}</nowiki></code>.",
        "expensive-parserfunction-warning": "Advarsel: Der er for mange beregningstunge oversætter-funktionskald på denne side.\n\nDer bør være færre end {{PLURAL:$2|$2 kald}}, lige nu er der {{PLURAL:$1|$1 kald}}.",
        "post-expand-template-argument-category": "Sider med udeladte skabelonparametre",
        "parser-template-loop-warning": "Skabelonløkke fundet: [[$1]]",
        "template-loop-category": "Sider med skabelonløkker",
+       "template-loop-category-desc": "Siden indeholder en malløkke, altså en skabelon som kalder sig selv rekursivt.",
        "parser-template-recursion-depth-warning": "En skabelon er rekursivt inkluderet for mange gange ($1)",
        "language-converter-depth-warning": "Dybdegrænse for sprogkonvertering overskredet ($1)",
        "node-count-exceeded-category": "Sider hvor antal noder er overskredet",
        "histfirst": "ældste",
        "histlast": "nyeste",
        "historysize": "($1 {{PLURAL:$1|Byte|Bytes}})",
-       "historyempty": "(tom)",
+       "historyempty": "tom",
        "history-feed-title": "Versionshistorie",
        "history-feed-description": "Versionshistorie for denne side i {{SITENAME}}",
        "history-feed-item-nocomment": "$1 med $2",
        "right-reupload-own": "Overskrive en eksisterende fil, som er lagt op af brugeren selv",
        "right-reupload-shared": "Lægge en lokal fil op, selvom den allerede findes centralt",
        "right-upload_by_url": "Lægge en fil op fra en URL",
-       "right-purge": "Nulstille sidens cache uden bekræftelse",
+       "right-purge": "Nulstil sidens cache for en side",
        "right-autoconfirmed": "Påvirkes ikke af IP-baserede hastighedsgrænser",
        "right-bot": "Redigeringer markeres som robot",
        "right-nominornewtalk": "Mindre ændringer på diskussionssider markerer ikke disse med nyt indhold",
        "deleteprotected": "Du kan ikke slette denne side, fordi den er beskyttet.",
        "deleting-backlinks-warning": "<strong>Advarsel:</strong> [[Special:WhatLinksHere/{{FULLPAGENAME}}|Andre sider]] henviser til eller inkluderer den side, du er ved at slette.",
        "rollback": "Fjern redigeringer",
+       "rollback-confirmation-confirm": "Bekræft venligst:",
+       "rollback-confirmation-yes": "Tilbagerul",
+       "rollback-confirmation-no": "Fortryd",
        "rollbacklink": "rul tilbage",
        "rollbacklinkcount": "rul {{PLURAL:$1|en redigering|$1 redigeringer}} tilbage",
        "rollbacklinkcount-morethan": "rul mere end {{PLURAL:$1|en redigering|$1 redigeringer}} tilbage",
        "ipb-change-block": "Forny brugerens blokering med disse indstillinger",
        "ipb-confirm": "Bekræft blokering",
        "ipb-partial": "Delvist",
+       "ipb-partial-help": "Specifikke sider eller navnerum.",
        "ipb-pages-label": "Sider",
        "ipb-namespaces-label": "Navnerum",
        "badipaddress": "IP-adressen/brugernavnet er udformet forkert eller eksistere ikke.",
index 9612ca7..4bcd9a1 100644 (file)
        "delete-confirm": "Löschen von „$1“",
        "delete-legend": "Löschen",
        "historywarning": "<strong>Achtung:</strong> Die Seite, die du löschen möchtest, hat eine Versionsgeschichte mit {{PLURAL:$1|einer Version|$1 Versionen}}:",
-       "historyaction-submit": "Anzeigen",
+       "historyaction-submit": "Versionen anzeigen",
        "confirmdeletetext": "Du bist dabei, eine Seite mit allen zugehörigen älteren Versionen zu löschen. Bitte bestätige dazu, dass du dir der Konsequenzen bewusst bist, und dass du in Übereinstimmung mit den [[{{MediaWiki:Policy-url}}|Richtlinien]] handelst.",
        "actioncomplete": "Aktion beendet",
        "actionfailed": "Aktion fehlgeschlagen",
index 8994708..dd50dea 100644 (file)
        "watchthis": "Na pele de seyr ke",
        "savearticle": "Pele qeyd ke",
        "savechanges": "Vurnayışan qeyd ke",
-       "publishpage": "Riperri bare ke",
-       "publishchanges": "Vırnayışan qeyd ke",
+       "publishpage": "Pele qeyd ke",
+       "publishchanges": "Vurnayışan qeyd ke",
        "savearticle-start": "Pele qeyd ke...",
        "savechanges-start": "Vurnayışan qeyd ke...",
        "publishpage-start": "Pele weşane...",
        "template-protected": "(şeveknaye)",
        "template-semiprotected": "(nime staryayış)",
        "hiddencategories": "Ena per de {{PLURAL:$1|1 kategoriyo nımıte|$1 kategoriyê nımıtey}} muhtewa benê:",
-       "edittools": "<div id=\"specialcharss\" class=\"toccolours specialchars\" style=\"margin-top:.5em; padding: .3em .5em; font-size: 100%; color:#aaa; text-align:left;\" title=\"{{int:bw-edittools-tooltip}}\">\n<p class=\"specialbasic\" id=\"Standard\">\n'''{{int:bw-edittools-lead-in}}''' \n<charinsert>Á á É é Í í Ó ó Ú ú Ý ý</charinsert> –\n<charinsert>À à È è Ì ì Ò ò Ù ù </charinsert> –\n<charinsert> â Ê ê Î î Ô ô Û û </charinsert> –\n<charinsert>Ä ä Ë ë Ï ï Ö ö Ü ü Ÿ ÿ</charinsert> –\n<charinsert>Æ æ Ø ø Œ œ ẞ ß </charinsert> –\n<charinsert>Å å Ů ů </charinsert> –\n<charinsert>àã Ẽ ẽ ɛ̃ Ĩ ĩ Ñ ñ Õ õ ɔ̃ Ũ ũ </charinsert> –\n<charinsert>Рð Þ þ </charinsert> –\n<charinsert>Ç ç Ģ ģ Ķ ķ Ļ ļ Ņ ņ Ŗ ŗ Ş ş Ţ ţ </charinsert> –\n<charinsert>Ć ć Ĺ ĺ Ń ń Ŕ ŕ Ś ś Ý ý Ź ź </charinsert> –\n<charinsert>Č č Ď ď Ľ ľ Ň ň Ř ř Š š Ť ť Ž ž </charinsert> –\n<charinsert>Ǎ ǎ Ě ě Ǐ ǐ Ǒ ǒ Ǔ ǔ </charinsert> –\n<charinsert>Ā ā Ē ē Ī ī Ō ō Ū ū </charinsert> –\n<charinsert>ǖ ǘ ǚ ǜ </charinsert> –\n<charinsert>Ĉ ĉ Ĝ ĝ Ĥ ĥ Ĵ ĵ Ŝ ŝ Ŵ ŵ Ŷ ŷ </charinsert> –\n<charinsert>Ă ă Ğ ğ Ŭ ŭ </charinsert> –\n<charinsert>Ċ ċ Ė ė Ġ ġ Għ għ İ ı Ż ż </charinsert> –\n<charinsert>Ą ą Ę ę Į į Ų ų </charinsert> –\n<charinsert>Ő ő Ű ű </charinsert> –\n<charinsert>Đ đ Ħ ħ Ł ł Ŀ ŀ </charinsert> –\n<charinsert>Ɖ ɖ Ɛ ɛ Ƒ ƒ Ɣ ɣ Ŋ ŋ Ɔ ɔ Ʋ ʋ </charinsert> -\n<charinsert>Ə ə </charinsert> –\n<charinsert>– — ’</charinsert> –\n<charinsert>~ | ° ¹ ² ³ ⅛ ¼ ⅓ ⅜ ½ ⅝ ¾ ⅔ ⅞ € $ ¥ £ † × ← → ↔ ↑ ± ≠ © ® ™ ‰ «+» ‹+› „+“ „+” ‚+‘ ¡ ¿ …</charinsert> –\n<charinsert>&amp;nbsp; &nbsp; [[Category:+]] #REDIRECT[[+]] {{msg-mw|+|notext=1}} &#33;!FUZZY!! ~~~~  &lt;nowiki>+</nowiki></charinsert>\n<charinsert>ڈ ڑ ٹ </charinsert>\n<charinsert>ټ څ ځ ډ ړ ږ ښ ڼ ؤ ي ې ۍ ئ </charinsert>\n<charinsert>{{{+}}} {{+}} {{subst:+}} <noinclude>+</noinclude></charinsert>\n<charinsert>&lt;!--&nbsp;+&nbsp;--> &lt;br&nbsp;/></charinsert>\n</p></div>",
+       "edittools": "<!-- Beno ke no metın bınê formanê vurnayışi û barkerdışi de bımocniyo. -->",
        "edittools-upload": "-",
        "nocreatetext": "{{SITENAME}}, Perrê newey vıraştış rê destur çıniyo.\nŞıma şenê tepeya şorê u eke şıma qeydbiyayey [[Special:UserLogin|şıma şenê yew hesab akerê]], eke şıma niyê [[Special:UserLogin|şıma şenê qeyd bıbê]].",
        "nocreate-loggedin": "Desturê şıma çıniyo ke pelanê neweyan vırazê.",
        "page_first": "verên",
        "page_last": "peyên",
        "histlegend": "Ferqê weçinayışi: Qutiya versiyonan qandé  têversanayış işaret ke u dest be ''enter''i ya zi gocega cêrêne rone.<br />\nCetwel: <strong>({{int:ferq}})</strong> = ferqê versiyonê peyêni, <strong>({{int:peyên}})</strong> = ferqê versiyonê verêni, <strong>{{int:q}}</strong> = vırnayışo werdiyo.",
-       "history-fieldset-title": "Çım ra viyarnayışan cı geyre",
+       "history-fieldset-title": "Çımraviyarnayışan cı geyre",
        "history-show-deleted": "Tenya çımraviyarnayışanê esterıteyan bımocne",
        "histfirst": "Verênêr",
        "histlast": "Peyênêr",
        "prevn": "{{PLURAL:$1|$1}} verên",
        "nextn": "{{PLURAL:$1|$1}} peyên",
        "prev-page": "Perra verêne",
-       "next-page": "pela peyco",
+       "next-page": "pela bahdoyêne",
        "prevn-title": "$1o verên  {{PLURAL:$1|netice|neticeyan}}",
        "nextn-title": "$1o ke yeno {{PLURAL:$1|netice|neticey}}",
        "shown-title": "Her pele sero $1 {{PLURAL:$1|netici|netica}} bımocne",
        "searchprofile-articles": "Pelê zerreki",
        "searchprofile-images": "Multimedya",
        "searchprofile-everything": "Her çi",
-       "searchprofile-advanced": "Hirayên",
+       "searchprofile-advanced": "Herayên",
        "searchprofile-articles-tooltip": "$1 de cı geyre",
        "searchprofile-images-tooltip": "Dosya cı geyre",
        "searchprofile-everything-tooltip": "Tedeesteyan hemine cı geyre (pelanê werênayışi zi tey)",
        "unusedtemplates": "Şablonê ke nêguriyenê",
        "unusedtemplatestext": "no pel, {{ns:template}} pelê ke pelê binan de nêaseni, ninan keno.",
        "unusedtemplateswlh": "linkanê binî",
-       "randompage": "Perra raştameyiye",
+       "randompage": "Pela raştameyiye",
        "randompage-nopages": "Na {{PLURAL:$2|heruna namey|heruna nameyan}} de nê peli çıniyê: $1.",
        "randomincategory": "Ena kategoriye dı pela raştameye",
        "randomincategory-invalidcategory": "\"$1\" yew nameyê kategoriya vêrdiye niyo.",
        "checkbox-none": "Çıniyo",
        "checkbox-invert": "Dimlaşt ke",
        "allpages": "Pêro peli",
-       "nextpage": "Pela peyco ($1)",
+       "nextpage": "Pela bahdoyêne ($1)",
        "prevpage": "Pela veri ($1)",
        "allpagesfrom": "Herfa kı pa liste bo:",
        "allpagesto": "Perranê ke ena herfe qediyenê bımotne:",
        "delete-confirm": "\"$1\" bestere",
        "delete-legend": "Bestere",
        "historywarning": "'''Teme:''' Pela ke şıma esterenê tede yew viyarte be teqriben $1 {{PLURAL:$1|versiyon esto|versiyoni estê}}:",
-       "historyaction-submit": "Bımocne",
+       "historyaction-submit": "Versiyonan bımocne",
        "confirmdeletetext": "Tı ho yew pele u tarixê pele wederneno.\nTı ra rica keno, tı zani tı ho sekeno, tı zani neticeyanê eno wedarnayışi u tı zani tı ser [[{{MediaWiki:Policy-url}}|poliçe]] kar keno.",
        "actioncomplete": "Kar bi temam",
        "actionfailed": "kar nêbı",
        "delete-warning-toobig": "no pel wayirê tarixê vurnayiş ê derg o, $1 {{PLURAL:$1|revizyonê|revizyonê}} seri de.\nhewn a kerdışê ıney {{SITENAME}} şuxul bıne gırano;\nbı diqqet dewam kerê.",
        "deleteprotected": "Şıma nêşenê ena perer esternê,  çıkı per starya ya.",
        "rollback": "vurnayişan tepiya bıger",
+       "rollback-confirmation-yes": "Peyser biya",
        "rollback-confirmation-no": "Bıtexelne",
        "rollbacklink": "ageyrayış",
        "rollbacklinkcount": "$1 {{PLURAL:$1|vurnayış|vurnayışi}} peyd gıroti",
        "tooltip-search-go": "Ebe nê namey tami şo yew pela ke esta",
        "tooltip-search-fulltext": "Pelan miyan de nê metıni cı geyre",
        "tooltip-p-logo": "Şo perra seri",
-       "tooltip-n-mainpage": "Şo perra seri",
+       "tooltip-n-mainpage": "Şo pela seri",
        "tooltip-n-mainpage-description": "Şo perra seri",
        "tooltip-n-portal": "Heqa procey de, kes çı şeno bıkero, çı kamca vêniyeno",
        "tooltip-n-currentevents": "Vurnayışanê peyênan de melumatê pey bıvêne",
        "tooltip-ca-nstab-category": "Pela kategoriye bıvêne",
        "tooltip-minoredit": "Ney vırnayışo werdi nışan bıkerê",
        "tooltip-save": "Vurnayışanê xo qeyd ke",
-       "tooltip-publish": "Vırnayışê xo aşkera ke",
+       "tooltip-publish": "Vurnayışê xo qeyd ke",
        "tooltip-preview": "Vurnayışanê xo çım ra bıviyarnê. Qeydkerdış ra ver bıgurê cı!",
        "tooltip-diff": "Kamci vırnayışê ke şıma nuştey sero kerdê, inan bıvênê.",
        "tooltip-compareselectedversions": "Ena per de ferqê rewziyonan de dı weçinaya bıvinê",
        "brackets": "[$1]",
        "quotation-marks": "\"$1\"",
        "imgmultipageprev": "← pela veri",
-       "imgmultipagenext": "pela peyco →",
+       "imgmultipagenext": "pela bahdoyêne →",
        "imgmultigo": "Şo!",
        "imgmultigoto": "Şo pela da $1",
        "img-lang-default": "(zıwano hesabiyaye)",
        "img-lang-go": "Şo",
        "ascending_abbrev": "berz",
        "descending_abbrev": "nızm",
-       "table_pager_next": "Pela peyco",
+       "table_pager_next": "Pela bahdoyêne",
        "table_pager_prev": "Pela veri",
        "table_pager_first": "Pela sıfteyêne",
        "table_pager_last": "Pela peyêne",
index 36d1847..70bf7e3 100644 (file)
        "pageinfo-category-subcats": "Αριθμός υποκατηγοριών",
        "pageinfo-category-files": "Αριθμός αρχείων",
        "pageinfo-user-id": "Αναγνωριστικό χρήση",
+       "pageinfo-view-protect-log": "Δείτε το αρχείο καταγραφών προστασίας για αυτή τη σελίδα.",
        "markaspatrolleddiff": "Σήμανση ως ελεγμένο",
        "markaspatrolledtext": "Σήμανση αυτής της σελίδας ως ελεγμένης",
        "markaspatrolledtext-file": "Επισημάνετε αυτή τη έκδοση του αρχείου ως ελεγμένη",
index 2a22fda..197499c 100644 (file)
        "delete-confirm": "Delete \"$1\"",
        "delete-legend": "Delete",
        "historywarning": "<strong>Warning:</strong> The page you are about to delete has a history with $1 {{PLURAL:$1|revision|revisions}}:",
-       "historyaction-submit": "Show",
+       "historyaction-submit": "Show revisions",
        "confirmdeletetext": "You are about to delete a page along with all of its history.\nPlease confirm that you intend to do this, that you understand the consequences, and that you are doing this in accordance with [[{{MediaWiki:Policy-url}}|the policy]].",
        "actioncomplete": "Action complete",
        "actionfailed": "Action failed",
index 72a383e..5078817 100644 (file)
        "mycontris": "Contribuciones",
        "anoncontribs": "Contribuciones",
        "contribsub2": "Para {{GENDER:$3|$1}} ($2)",
+       "contributions-subtitle": "Para {{GENDER:$3|$1}} ($2)",
        "contributions-userdoesnotexist": "La cuenta de usuario «$1» no está registrada.",
        "negative-namespace-not-supported": "Los espacios de nombres con valores negativos no están permitidos",
        "nocontribs": "No se encontraron cambios que cumplieran estos criterios.",
index 686ea02..6ae07b3 100644 (file)
        "logentry-rights-autopromote": "$1 automatikoki $4tik $5ra  {{GENDER:$2|igo}} egin zaio",
        "logentry-upload-upload": "$1(e)k $3 {{GENDER:$2|igo du}}",
        "logentry-upload-overwrite": "$1(e)k $3(r)en bertsio berria {{GENDER:$2|igo du}}",
-       "logentry-upload-revert": "$1(e)k $3 {{GENDER:$2|igo du}}",
+       "logentry-upload-revert": "$1(e)k $3 bertsio zahar batera {{GENDER:$2|itzuli du}}",
        "log-name-managetags": "Etiketa kudeatze erregistroa",
        "log-description-managetags": "Orrialde honetan [[Special:Tags|etiketekin]] lotutako kudeaketa-zereginak zerrendatzen dira. Saioak administratzaileak eskuz egiten dituen ekintzak soilik ditu; Wiki softwarerrarekin etiketak sortu edo ezabatzeko ahalmenarekin erregistro honetan erregistratutako sarrerarik gabe.",
        "logentry-managetags-create": "$1 lankideak \"$4\" etiketa {{GENDER:$2|sortu du}}",
index da07076..d64121d 100644 (file)
        "nstab-category": "Luokka",
        "mainpage-nstab": "Etusivu",
        "nosuchaction": "Toimintoa ei ole olemassa",
-       "nosuchactiontext": "URL:ssä määritelty toiminto ei ole kelvollinen.\nOlet saattanut kirjoittaa URL:in väärin tai olet seurannut virheellistä linkkiä.\nKyseessä voi myös mahdollisesti olla virhe sivuston {{SITENAME}} käyttämässä ohjelmistossa.",
+       "nosuchactiontext": "URL:ssä määritelty toiminto ei ole kelvollinen.\nOlet saattanut kirjoittaa URL:in väärin tai olet seurannut virheellistä linkkiä.\nKyseessä voi myös mahdollisesti olla virhe {{GRAMMAR:genitive|{{SITENAME}}}} käyttämässä ohjelmistossa.",
        "nosuchspecialpage": "Kyseistä toimintosivua ei ole",
        "nospecialpagetext": "<strong>Ohjelmisto ei tunnista pyytämääsi toimintosivua.</strong>\n\nLuettelo toimintosivuista löytyy sivulta [[Special:SpecialPages|{{int:specialpages}}]].",
        "error": "Virhe",
        "passwordreset-email": "Sähköpostiosoite:",
        "passwordreset-emailtitle": "Tunnuksen tiedot {{GRAMMAR:inessive|{{SITENAME}}}}",
        "passwordreset-emailtext-ip": "Joku (todennäköisesti sinä, IP-osoitteesta $1) pyysi salasanasi\nvaihtamista sivustolla {{SITENAME}} ($4). {{PLURAL:$3|Seuraava käyttäjätunnus on|Seuraavat käyttäjätunnukset ovat}}\nyhdistettynä tähän sähköpostiosoitteeseen:\n\n$2\n\n{{PLURAL:$3|Tämä väliaikainen salasana vanhentuu|Nämä väliaikaiset salasanat vanhentuvat}} {{PLURAL:$5|yhden päivän|$5 päivän}} kuluttua.\nKirjaudu sisään nyt ja valitse uusi salasana heti. Jos joku toinen teki tämän pyynnön \ntai jos muistitkin vanhan salasanasi etkä halua enää muuttaa sitä,\nvoit jättää tämän viestin huomiotta ja jatkaa vanhan salasanasi käyttämistä.",
-       "passwordreset-emailtext-user": "Käyttäjä $1 pyysi muistutusta tunnuksesi tiedoista sivustolla {{SITENAME}} ($4).\n{{PLURAL:$3|Seuraava käyttäjätunnus on|Seuraavat käyttäjätunnukset ovat}} liitetty tähän sähköpostiosoitteeseen:\n\n$2\n\n{{PLURAL:$3|Tämä väliaikainen salasana vanhentuu|Nämä väliaikaiset salasanat vanhentuvat}} {{PLURAL:$5|yhden päivän|$5 päivän}} kuluttua.\nSinun kannattaa kirjautua sisään ja valita uusi salasana. Jos joku toinen teki tämän\npyynnön, tai muistat sittenkin vanhan salasanasi, etkä halua muuttaa sitä,\nvoit jättää tämän viestin huomiotta ja jatkaa vanhan salasanan käyttöä.",
+       "passwordreset-emailtext-user": "Käyttäjä $1 pyysi muistutusta tunnuksesi tiedoista {{GRAMMAR:inessive|{{SITENAME}}}} ($4).\n{{PLURAL:$3|Seuraava käyttäjätunnus on|Seuraavat käyttäjätunnukset ovat}} liitetty tähän sähköpostiosoitteeseen:\n\n$2\n\n{{PLURAL:$3|Tämä väliaikainen salasana vanhentuu|Nämä väliaikaiset salasanat vanhentuvat}} {{PLURAL:$5|yhden päivän|$5 päivän}} kuluttua.\nSinun kannattaa kirjautua sisään ja valita uusi salasana. Jos joku toinen teki tämän\npyynnön, tai muistat sittenkin vanhan salasanasi, etkä halua muuttaa sitä,\nvoit jättää tämän viestin huomiotta ja jatkaa vanhan salasanan käyttöä.",
        "passwordreset-emailelement": "Käyttäjätunnus: \n$1\n\nVäliaikainen salasana: \n$2",
        "passwordreset-emailsentemail": "Jos tämä on sinun tunnuksellesi rekisteröity sähköpostiosoite, salasanan uudistamisesta kertova viesti lähetetään.",
        "passwordreset-emailsentusername": "Jos on olemassa vastaava rekisteröity sähköpostiosoite, salasanan uudistamisesta kertova viesti lähetetään.",
        "continue-editing": "Siirry muokkauskenttään",
        "previewconflict": "Tämä esikatselu näyttää miltä muokkausalueella oleva teksti näyttää tallennettuna.",
        "session_fail_preview": "Muokkaustasi ei voitu tallentaa, koska istuntosi tiedot ovat kadonneet.\n\nSaatat olla kirjautunut ulos. '''Varmista, että olet edelleen kirjautunut sisään ja yritä uudelleen'''. Jos ongelma ei katoa, yritä [[Special:UserLogout|kirjautua ulos]] ja takaisin sisään, ja varmista, että selaimesi sallii evästeet tältä sivustolta.",
-       "session_fail_preview_html": "Valitettavasti muokkaustasi ei voitu käsitellä istunnon tietojen katoamisen vuoksi.\n\n<em>Koska sivustolla {{SITENAME}} on käytössä suodattamaton HTML-koodi, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi</em>\n\n<strong>Jos tämä on oikea muokkausyritys, yritä uudelleen.</strong> Jos ongelma ei katoa, yritä [[Special:UserLogout|kirjautua ulos]] ja takaisin sisään. Tarkista myös, että selaimesi sallii evästeet tältä sivustolta.",
+       "session_fail_preview_html": "Valitettavasti muokkaustasi ei voitu käsitellä istunnon tietojen katoamisen vuoksi.\n\n<em>Koska {{GRAMMAR:inessive|{{SITENAME}}}} on käytössä suodattamaton HTML-koodi, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi</em>\n\n<strong>Jos tämä on oikea muokkausyritys, yritä uudelleen.</strong> Jos ongelma ei katoa, yritä [[Special:UserLogout|kirjautua ulos]] ja takaisin sisään. Tarkista myös, että selaimesi sallii evästeet tältä sivustolta.",
        "token_suffix_mismatch": "'''Muokkauksesi on hylätty, koska asiakasohjelmasi ei osaa käsitellä välimerkkejä muokkaustarkisteessa. Syynä voi olla viallinen välityspalvelin.'''",
        "edit_form_incomplete": "'''Osa muokkauslomakkeesta ei saavuttanut palvelinta. Tarkista, että muokkauksesi ovat vahingoittumattomia ja yritä uudelleen.'''",
        "editing": "Muokataan sivua $1",
        "edit-gone-missing": "Sivun päivitys ei onnistunut.\nSe on ilmeisesti poistettu.",
        "edit-conflict": "Päällekkäinen muokkaus.",
        "edit-no-change": "Muokkauksesi sivuutettiin, koska tekstiin ei tehty mitään muutoksia.",
+       "edit-slots-cannot-add": "{{PLURAL:$1|Seuraava slotti ei ole tuettu|Seuraavat slotit eivät ole tuettuja}} täällä: $2.",
+       "edit-slots-cannot-remove": "{{PLURAL:$1|Seuraava slotti vaaditaan eikä sitä voi poistaa|Seuraavat alueet vaaditaan eikä niitä voida poistaa}}: $2.",
+       "edit-slots-missing": "{{PLURAL:$1|Seuraava slotti puuttuu|Seuraavat slotit puuttuvat}}: $2.",
        "postedit-confirmation-created": "Sivu on nyt luotu.",
        "postedit-confirmation-restored": "Sivu on nyt palautettu (aiempaan versioonsa).",
        "postedit-confirmation-saved": "Muokkauksesi on tallennettu.",
        "defaultmessagetext": "Viestin oletusteksti",
        "content-failed-to-parse": "Sisältö tyypiltään $2 ei jäsenny tyypiksi $1: $3",
        "invalid-content-data": "Virheellinen sisältö",
-       "content-not-allowed-here": "Sivun [[:$2]] sisältö ei voi olla tyyppiä $1.",
+       "content-not-allowed-here": "Sivun [[:$2]] sisältö ei voi olla tyyppiä \"$1\" slotissa \"$3\"",
        "editwarning-warning": "Tältä sivulta poistuminen saattaa aiheuttaa kaikkien tekemiesi muutosten katoamisen.\nJos olet kirjautunut sisään, voit poistaa tämän varoituksen käytöstä omien asetuksien osiossa ”{{int:prefs-editing}}”.",
        "editpage-invalidcontentmodel-title": "Sisältömalli ei ole tuettu",
        "editpage-invalidcontentmodel-text": "Sisältömalli ”$1” ei ole tuettu.",
        "upload-form-label-infoform-categories": "Luokat",
        "upload-form-label-infoform-date": "Päivämäärä",
        "upload-form-label-own-work-message-generic-local": "Vakuutan, että tallennan tämän tiedoston noudattaen {{GRAMMAR:inessive|{{SITENAME}}}} voimassa olevia käyttöehtoja sekä lisenssejä koskevia käytäntöjä.",
-       "upload-form-label-not-own-work-message-generic-local": "Jos et kykene tallentamaan tätä tiedostoa noudattaen niitä käytäntöjä, jotka ovat voimassa sivustolla {{SITENAME}}, sulje tämä dialogi ja kokeile jotain toista menetelmää.",
+       "upload-form-label-not-own-work-message-generic-local": "Jos et kykene tallentamaan tätä tiedostoa noudattaen niitä käytäntöjä, jotka ovat voimassa {{GRAMMAR:inessive|{{SITENAME}}}}, sulje tämä dialogi ja kokeile jotain toista menetelmää.",
        "upload-form-label-not-own-work-local-generic-local": "Voit myös kokeilla [[Special:Upload|yleistä tallentamista]].",
        "upload-form-label-own-work-message-generic-foreign": "Ymmärrän, että olen tallentamassa tätä tiedostoa yhteiseen mediasäilöön. Vakuutan, että teen tämän noudattaen asiaankuuluvia käyttöehtoja ja lisenssejä koskevia käytäntöjä.",
        "upload-form-label-not-own-work-message-generic-foreign": "Jos et kykene tallentamaan tätä tiedostoa noudattaen niitä käytäntöjä, jotka ovat voimassa yhteisessä mediasäilössä, sulje tämä dialogi ja kokeile jotain toista menetelmää.",
-       "upload-form-label-not-own-work-local-generic-foreign": "Voit myös kokeilla [[Special:Upload|tallennussivua sivustolla {{SITENAME}}]]. Saattaa olla, että tämän tiedoston tallentaminen sinne on mahdollista siellä voimassa olevien käytäntöjen mukaisesti.",
+       "upload-form-label-not-own-work-local-generic-foreign": "Voit myös kokeilla [[Special:Upload|tallennussivua {{GRAMMAR:inessive|{{SITENAME}}}}]]. Saattaa olla, että tämän tiedoston tallentaminen sinne on mahdollista siellä voimassa olevien käytäntöjen mukaisesti.",
        "backend-fail-stream": "Tiedoston $1 virtauttaminen epäonnistui.",
        "backend-fail-backup": "Tiedostoa $1 ei voitu varmuuskopioida.",
        "backend-fail-notexists": "Tiedostoa $1 ei ole olemassa.",
        "emailccsubject": "Kopio lähettämästäsi viestistä osoitteeseen $1: $2",
        "emailsent": "Sähköposti lähetetty",
        "emailsenttext": "Sähköpostiviestisi on lähetetty.",
-       "emailuserfooter": "Tämän sähköpostin {{GENDER:$1|lähetti}} $1 vastaanottajalle {{GENDER:$2|$2}} käyttämällä ”{{int:emailuser}}” -toimintoa {{GRAMMAR:inessive|{{SITENAME}}}}. Jos vastaat tähän sähköpostiin, sinun sähköpostiviestisi lähetetään suoraan {{GENDER:$1|alkuperäiselle lähettäjälle}} ja samalla paljastetaan {{GENDER:$2|sinun}} sähköpostiosoitteesi {{GENDER:$1|hänelle}}.",
+       "emailuserfooter": "Tämän sähköpostin {{GENDER:$1|lähetti}} $1 vastaanottajalle {{GENDER:$2|$2}} käyttämällä ”{{int:emailuser}}” -toimintoa {{GRAMMAR:inessive|{{SITENAME}}}}. Jos vastaat tähän sähköpostiin, sähköpostiviestisi lähetetään suoraan {{GENDER:$1|alkuperäiselle lähettäjälle}} ja samalla paljastetaan {{GENDER:$2|sinun}} sähköpostiosoitteesi {{GENDER:$1|hänelle}}.",
        "usermessage-summary": "Jätetään järjestelmäviesti.",
        "usermessage-editor": "Järjestelmäviestittäjä",
        "watchlist": "Tarkkailulista",
        "watcherrortext": "Sivun ”$1” tarkkailulista-asetusten muutoksissa tapahtui virhe.",
        "enotif_reset": "Merkitse kaikki sivut nähdyiksi",
        "enotif_impersonal_salutation": "{{GRAMMAR:genitive|{{SITENAME}}}} käyttäjä",
-       "enotif_subject_deleted": "{{GENDER:$2|$2}} poisti {{GRAMMAR:elative|{{SITENAME}}}} sivun $1",
-       "enotif_subject_created": "{{GENDER:$2|$2}} loi {{GRAMMAR:illative|{{SITENAME}}}} sivun $1",
-       "enotif_subject_moved": "{{GENDER:$2|$2}} siirsi {{GRAMMAR:inessive|{{SITENAME}}}} sivun $1",
-       "enotif_subject_restored": "{{GENDER:$2|$2}} palautti {{GRAMMAR:inessive|{{SITENAME}}}} sivun $1",
-       "enotif_subject_changed": "{{GENDER:$2|$2}} muutti {{GRAMMAR:inessive|{{SITENAME}}}} sivua $1",
-       "enotif_body_intro_deleted": "{{GENDER:$2|$2}} poisti {{GRAMMAR:elative|{{SITENAME}}}} sivun $1 $PAGEEDITDATE ($3).",
+       "enotif_subject_deleted": "$2 {{GENDER:$2|poisti}} {{GRAMMAR:elative|{{SITENAME}}}} sivun $1",
+       "enotif_subject_created": "$2 {{GENDER:$2|loi}} {{GRAMMAR:illative|{{SITENAME}}}} sivun $1",
+       "enotif_subject_moved": "$2 {{GENDER:$2|siirsi}} {{GRAMMAR:inessive|{{SITENAME}}}} sivun $1",
+       "enotif_subject_restored": "$2 {{GENDER:$2|palautti}} {{GRAMMAR:inessive|{{SITENAME}}}} sivun $1",
+       "enotif_subject_changed": "$2 {{GENDER:$2|muutti}} {{GRAMMAR:inessive|{{SITENAME}}}} sivua $1",
+       "enotif_body_intro_deleted": "$2 {{GENDER:$2|poisti}} {{GRAMMAR:elative|{{SITENAME}}}} sivun $1 $PAGEEDITDATE ($3).",
        "enotif_body_intro_created": "{{GENDER:$2|$2}} loi {{GRAMMAR:inessive|{{SITENAME}}}} sivun $1 $PAGEEDITDATE. Sivun nykyinen versio on osoitteessa $3.",
        "enotif_body_intro_moved": "{{GENDER:$2|$2}} siirsi {{GRAMMAR:inessive|{{SITENAME}}}} sivun $1 $PAGEEDITDATE. Sivun nykyinen versio on osoitteessa $3.",
        "enotif_body_intro_restored": "{{GENDER:$2|$2}} palautti {{GRAMMAR:inessive|{{SITENAME}}}} sivun $1 $PAGEEDITDATE. Sivun nykyinen versio on osoitteessa $3.",
        "deletereasonotherlist": "Muu syy",
        "deletereason-dropdown": "* Yleiset poistosyyt\n** Spam tai mainossivu\n** Vandalismi\n** Tekijänoikeusrikkomus\n** Sivun tekijän pyyntö\n** Virheellinen ohjaus",
        "delete-edit-reasonlist": "Muokkaa poistosyitä",
-       "delete-toobig": "Tällä sivulla on pitkä muokkaushistoria, yli $1 {{PLURAL:$1|versio|versiota}}. \nTämänkaltaisten sivujen poistamista on rajoitettu. Tällä ehkäistään sivuston {{SITENAME}} vaurioitumista tahattomasti.",
-       "delete-warning-toobig": "Tällä sivulla on pitkä muutoshistoria – yli $1 {{PLURAL:$1|versio|versiota}}. Näin suurien muutoshistorioiden poistaminen voi haitata sivuston suorituskykyä.",
+       "delete-toobig": "Tällä sivulla on pitkä muokkaushistoria, yli $1 {{PLURAL:$1|versio|versiota}}. \nTämänkaltaisten sivujen poistamista on rajoitettu. Tällä ehkäistään {{GRAMMAR:genitive|{{SITENAME}}}} vaurioitumista tahattomasti.",
+       "delete-warning-toobig": "Tällä sivulla on pitkä muutoshistoria – yli $1 {{PLURAL:$1|versio|versiota}}. Näin suurien muutoshistorioiden poistaminen voi haitata {{GRAMMAR:genitive|{{SITENAME}}}} suorituskykyä.",
        "deleteprotected": "Et voi poistaa tätä sivua, koska se on suojattu.",
        "deleting-backlinks-warning": "<strong>Varoitus:</strong> Sivulle, jota olet poistamassa, johtaa [[Special:WhatLinksHere/{{FULLPAGENAME}}|linkkejä muilta sivuilta]], taikka sivu on sisällytetty muuhun sivuun.",
        "deleting-subpages-warning": "<strong>Varoitus:</strong> Sivu jota olet poistamassa on [[Special:PrefixIndex/{{FULLPAGENAME}}/|{{PLURAL:$1|alasivu|$1 alasivua|51=yli 50 alasivua}}]].",
        "mycontris": "Muokkaukset",
        "anoncontribs": "Muokkaukset",
        "contribsub2": "Käyttäjän {{GENDER:$3|$1}} ($2) muokkaukset",
+       "contributions-subtitle": "Käyttäjän {{GENDER:$3|$1}} muokkaukset",
        "contributions-userdoesnotexist": "Käyttäjätunnusta ”$1” ei ole rekisteröity.",
+       "negative-namespace-not-supported": "Negatiivisia arvoja sisältäviä nimiavaruuksia ei tueta.",
        "nocontribs": "Näihin ehtoihin sopivia muokkauksia ei löytynyt.",
        "uctop": "uusin",
        "month": "Alkaen kuukaudesta (ja aiemmin):",
        "ip_range_toolow": "IP-alueet eivät käytännöllisesti katsoen ole sallittuja.",
        "proxyblocker": "Välityspalvelinesto",
        "proxyblockreason": "IP-osoitteestasi on estetty muokkaukset, koska se on avoin välityspalvelin. Ota yhteyttä Internet-palveluntarjoajaasi tai tekniseen tukeen ja kerro heille tästä tietoturvaongelmasta.",
-       "sorbsreason": "IP-osoitteesi on listattu avoimena välityspalvelimena DNSBL:n mustalla listalla sivustolla {{SITENAME}}.",
-       "sorbs_create_account_reason": "IP-osoitteesi on listattu avoimena välityspalvelimena DNSBL:n mustalla listalla sivustolla {{SITENAME}}. \nEt voi luoda käyttäjätunnusta.",
+       "sorbsreason": "IP-osoitteesi on listattu avoimena välityspalvelimena DNSBL:n mustalla listalla {{GRAMMAR:inessive|{{SITENAME}}}}.",
+       "sorbs_create_account_reason": "IP-osoitteesi on listattu avoimena välityspalvelimena DNSBL:n mustalla listalla {{GRAMMAR:inessive|{{SITENAME}}}}.\nEt voi luoda käyttäjätunnusta.",
        "softblockrangesreason": "Anonyymit muokkaukset eivät ole sallittuja IP-osoitteestasi ($1). Ole hyvä ja kirjaudu.",
        "xffblockreason": "Yhteydet IP-osoitteesta, joka löytyy sinun tai käyttämäsi välipalvelimen X-Forwarded-For-otsakkeesta, on estetty. Alkuperäinen estämisen syy oli: $1",
        "cant-see-hidden-user": "Käyttäjä, jota yrität estää, on jo estetty ja käyttäjänimi on piilotettu. \nKoska sinulla ei ole hideuser-oikeutta, et voi nähdä tai muuttaa käyttäjän estoasetuksia.",
        "invalidateemail": "Sähköpostiosoitteen varmennuksen peruuttaminen",
        "notificationemail_subject_changed": "{{GRAMMAR:genitive|{{SITENAME}}}} rekisteröity sähköpostiosoite on vaihdettu",
        "notificationemail_subject_removed": "{{GRAMMAR:genitive|{{SITENAME}}}} rekisteröity sähköpostiosoite on poistettu",
-       "notificationemail_body_changed": "Joku, todennäköisesti sinä, IP-osoitteesta $1 on vaihtanut tunnuksen ”$2” sähköpostiosoitteeksi ”$3” sivustolla {{SITENAME}}.\n\nJos se et ollut sinä, ota yhteyttä sivuston ylläpitäjään välittömästi.",
-       "notificationemail_body_removed": "Joku, todennäköisesti sinä, IP-osoitteesta $1 on poistanut tunnuksen ”$2” sähköpostiosoitteen sivustolla {{SITENAME}}.\n\nJos se et ollut sinä, ota yhteyttä sivuston ylläpitäjään välittömästi.",
+       "notificationemail_body_changed": "Joku, todennäköisesti sinä, IP-osoitteesta $1 on vaihtanut tunnuksen ”$2” sähköpostiosoitteeksi ”$3” {{GRAMMAR:inessive|{{SITENAME}}}}.\n\nJos se et ollut sinä, ota yhteyttä sivuston ylläpitäjään välittömästi.",
+       "notificationemail_body_removed": "Joku, todennäköisesti sinä, IP-osoitteesta $1 on poistanut tunnuksen ”$2” sähköpostiosoitteen {{GRAMMAR:inessive|{{SITENAME}}}}.\n\nJos se et ollut sinä, ota yhteyttä sivuston ylläpitäjään välittömästi.",
        "scarytranscludedisabled": "[Wikienvälinen sisällytys ei ole käytössä]",
        "scarytranscludefailed": "[Mallineen hakeminen epäonnistui: $1]",
        "scarytranscludefailed-httpstatus": "[Mallineen hakeminen epäonnistui: $1 HTTP $2]",
        "logentry-partialblock-block-page": "the {{PLURAL:$1|page|pages}} $2",
        "logentry-partialblock-block-ns": "the {{PLURAL:$1|namespace|namespaces}} $2",
        "logentry-partialblock-block": "$1 {{GENDER:$2|blocked}} {{GENDER:$4|$3}} from editing $7 with an expiration time of $5 $6",
-       "logentry-partialblock-reblock": "$1 {{GENDER:$2|muutti}} käyttäjän {{GENDER:$4|$3}} muokkauseston asetuksia estäen muokkausten tekemisen {{PLURAL:$8||sivuihin}} $7. Eston kesto on $5 $6",
+       "logentry-partialblock-reblock": "$1 {{GENDER:$2|muutti}} käyttäjän {{GENDER:$4|$3}} muokkauseston asetuksia estäen muokkausten tekemisen $7. Eston kesto on $5 $6",
        "logentry-non-editing-block-block": "$1 {{GENDER:$2|esti}} käyttäjää {{GENDER:$4|$3}} suorittamasta määrättyjä toimenpiteitä (lukuun ottamatta muokkaamista). Eston kesto on $5 $6",
        "logentry-non-editing-block-reblock": "$1 {{GENDER:$2|muutti}} käyttäjän {{GENDER:$4|$3}} toimintaeston asetuksia, jotka koskevat määrättyjä toimenpiteitä. Eston kesto on $5 $6",
        "logentry-suppress-block": "$1 {{GENDER:$2|esti}} käyttäjän {{GENDER:$4|$3}}. Eston kesto on $5 $6",
        "expand_templates_generate_xml": "Näytä XML-jäsennyspuu",
        "expand_templates_generate_rawhtml": "Näytä raaka HTML",
        "expand_templates_preview": "Esikatselu",
-       "expand_templates_preview_fail_html": "<em>Koska sivustolla {{SITENAME}} on käytössä suodattamaton HTML-koodi ja koska istunnon tiedot ovat kadonneet, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi.</em>\n\n<strong>Jos yritit esikatsella sivua, yritä uudestaan.</strong>\nJos esikatselu ei vieläkään toimi, yritä [[Special:UserLogout|kirjautua ulos]] ja sitten kirjautua uudestaan sisään. Tarkista myös, että selaimesi sallii evästeet tältä sivustolta.",
-       "expand_templates_preview_fail_html_anon": "<em>Koska sivustolla {{SITENAME}} on käytössä puhdas HTML-koodi ja koska et ole kirjautunut sisään, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi.</em>\n\n<strong>Jos olet oikealla asialla, [[Special:UserLogin|kirjaudu sisään]] ja yritä uudestaan.</strong>",
+       "expand_templates_preview_fail_html": "<em>Koska {{GRAMMAR:inessive|{{SITENAME}}}} on käytössä suodattamaton HTML-koodi ja koska istunnon tiedot ovat kadonneet, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi.</em>\n\n<strong>Jos yritit esikatsella sivua, yritä uudestaan.</strong>\nJos esikatselu ei vieläkään toimi, yritä [[Special:UserLogout|kirjautua ulos]] ja sitten kirjautua uudestaan sisään. Tarkista myös, että selaimesi sallii evästeet tältä sivustolta.",
+       "expand_templates_preview_fail_html_anon": "<em>Koska {{GRAMMAR:inessive|{{SITENAME}}}} on käytössä puhdas HTML-koodi ja et ole kirjautunut sisään, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi.</em>\n\n<strong>Jos olet oikealla asialla, [[Special:UserLogin|kirjaudu sisään]] ja yritä uudestaan.</strong>",
        "expand_templates_input_missing": "Sinun on annettava ainakin jotakin wikitekstiä syötteeksi.",
        "pagelanguage": "Sivun kielen vaihto",
        "pagelang-name": "Sivu",
index 6826cf6..84ba5a1 100644 (file)
        "delete-confirm": "Supprimer « $1 »",
        "delete-legend": "Supprimer",
        "historywarning": "<strong>Attention :</strong> la page que vous êtes sur le point de supprimer a un historique avec $1 {{PLURAL:$1|version|versions}} :",
-       "historyaction-submit": "Lister",
+       "historyaction-submit": "Afficher les révisions",
        "confirmdeletetext": "Vous êtes sur le point de supprimer une page ou un fichier, ainsi que toutes ses versions antérieures historisées. Veuillez confirmer que c’est bien là ce que vous voulez faire, que vous en comprenez les conséquences et que vous faites ceci en accord avec les [[{{MediaWiki:Policy-url}}|règles internes]].",
        "actioncomplete": "Action effectuée",
        "actionfailed": "L'action a échoué",
index af36afc..d1d9de9 100644 (file)
        "create-local": "Lokale beskriuwing tafoegje",
        "delete": "Fuortsmite",
        "undelete_short": "$1 {{PLURAL:$1|ferzje|ferzjes}} weromsette",
-       "protect": "Skoattel",
+       "protect": "Skoattelje",
        "protect_change": "feroarje nivo fan skoatteljen",
        "unprotect": "Jou frij",
        "newpage": "Nije side",
        "nstab-special": "Bysûndere side",
        "nstab-project": "Projektside",
        "nstab-image": "Bestân",
-       "nstab-mediawiki": "Berjocht",
+       "nstab-mediawiki": "Systeemberjocht",
        "nstab-template": "Berjocht",
        "nstab-help": "Helpside",
        "nstab-category": "Kategory",
        "mainpage-nstab": "Haadside",
        "nosuchaction": "Unbekende aksje.",
-       "nosuchactiontext": "De opdracht yn de URL is ûnjildich.\nMooglik hasto in typefout makke yn de URL of in ferkearde keppeling folge.\nIt soe likegoed in programmatuerflater fan {{SITENAME}} wêze kinne.",
+       "nosuchactiontext": "De opdracht yn de URL is ûnjildich.\nMooglik hawwe jo in typflater yn 'e URL makke of in ferkearde keppeling folge.\nIt soe likegoed in programmatuerflater fan {{SITENAME}} wêze kinne.",
        "nosuchspecialpage": "Gjin soksoarte bysûndere side",
        "nospecialpagetext": "<strong>Jo hawwe in ûnjildige bysûndere side opfrege.</strong>\n\nIn list fan jildige bysûndere siden stiet op [[Special:SpecialPages|{{int:specialpages}}]].",
        "error": "Flater",
        "noname": "Jo hawwe gjin jildige meidochnamme opjûn.",
        "loginsuccesstitle": "Oanmelden slagge.",
        "loginsuccess": "<strong>Jo binne no oanmeld op {{SITENAME}} as \"$1\".</strong>",
-       "nosuchuser": "Der is gjin meidogger \"$1\".\nKontrolearje de stavering, of [[Special:CreateAccount|meitsje in nije meidogger oan]].",
+       "nosuchuser": "Der is gjin meidogger mei de namme \"$1\".\nMeidochnammen binne haadlettergefoelich.\nSjoch de stavering nei, of [[Special:CreateAccount|meitsje in nij akkount oan]].",
        "nosuchusershort": "Der is gjin meidogger mei de namme \"$1\". It is goed skreaun?",
        "nouserspecified": "Jo moatte in meidochnamme opjaan.",
        "wrongpassword": "Ferkearde meidochnamme of wachtwurd ynfolle.\nBesykje it nochris.",
        "passwordremindertitle": "Nij tydlik wachtwurd foar {{SITENAME}}",
        "passwordremindertext": "Immen (nei alle gedachten jo, fan ynternetadres $1) had in nij wachtwurd\nfoar {{SITENAME}} ($4) oanfrege. Der is in tydlik wachtwurd foar meidogger\n\"$2\"  makke en ynstelt as \"$3\". As dat jo bedoeling wie, melde jo jo dan\nno oan en kies in nij wachtwurd. Dyn tydlik wachtwurd komt yn {{PLURAL:$5|ien dei|$5 dagen}} te ferfallen.\nDer is in tydlik wachtwurd oanmakke foar meidogger \"$2\": \"$3\".\n\nAs immen oars as jo dit fersyk dien hat of at it wachtwurd jo tuskentiidsk wer yn 't sin kommen is en\njo it net langer feroarje wolle, dan kinne jo dit berjocht ferjitte en\nfierdergean mei it brûken fan jo âlde wachtwurd.",
        "noemail": "Der is gjin e-postadres foar meidogger \"$1\".",
-       "passwordsent": "In nij wachtwurd is tastjoerd oan it e-postadres foar \"$1\". Jo kinne jo wer oanmelde as jo it wachtwurd ûntfongen hawwe.",
+       "passwordsent": "Der is in nij wachtwurd ferstjoerd nei it opjûne e-mailadres fan \"$1\".\nMeld jo nei ûntfangst op 'e nij oan.",
        "blocked-mailpassword": "Jo IP-adres is blokkearre foar it meitsjen fan feroarings. Om misbrûk tefoaren te kommen is it net mûglik in oar wachtwurd oan te freegjen.",
        "eauthentsent": "Foar befêstiging is jo in netpostberjocht tastjoerd op it adres dat jo ynsteld hawwe. Der wurdt gjin oare netpost stjoerd, oant jo it adres befêstigje sa't it yn it netpostberjocht stiet.",
        "throttled-mailpassword": "Yn {{PLURAL:$1|de lêste oere|de lêste $1 oeren}} is der al in wachtwurdwink ferstjoerd.\nOm misbrûk tefoaren te kommen wurdt der mar ien wachtwurdwink yn 'e {{PLURAL:$1|oere|$1 oeren}} ferstjoerd.",
        "headline_sample": "Koptekst",
        "headline_tip": "Underkopke",
        "nowiki_sample": "Foechje hjir platte tekst yn",
-       "nowiki_tip": "Negearje it wiki formaat",
+       "nowiki_tip": "Wiki-opmaak negearje",
        "image_sample": "Foarbyld.jpg",
        "image_tip": "Mediabestân",
        "media_tip": "Link nei bestân",
        "editing": "Bewurkje \"$1\"",
        "creating": "$1 oanmeitsje",
        "editingsection": "Bewurkje $1 (seksje)",
-       "editingcomment": "Dwaande mei bewurkjen fan $1 (opmerking)",
+       "editingcomment": "Bewurkjen fan $1 (nij mêd)",
        "editconflict": "Tagelyk bewurke: \"$1\"",
        "explainconflict": "In oar hat de side feroare sûnt jo begûn binne mei it bewurkjen.\nIt earste bewurkingsfjild is hoe't de tekst wilens wurden is.\nJo feroarings stean yn it twadde fjild.\nDy wurde allinnich tapast safier as jo se yn it earste fjild ynpasse.\n'''Allinnich''' de tekst út it earste fjild kin fêstlein wurde.",
        "yourtext": "Jo tekst",
        "showingresults": "Totaal {{PLURAL:$1|<strong>1</strong> resultaat|<strong>$1</strong> resultaten}}, hjirûnder werjûn fan #<strong>$2</strong> ôf.",
        "search-showingresults": "{{PLURAL:$4|Resultaat <strong>$1</strong> fan <strong>$3</strong>|Resultaten <strong>$1 - $2</strong> fan <strong>$3</strong>}}",
        "search-nonefound": "Der binne gjin resultaten foar jo sykopdracht.",
-       "powersearch-legend": "Sykje",
+       "powersearch-legend": "Utwreidich sykje",
        "powersearch-ns": "Sykje yn nammeromten:",
        "powersearch-togglelabel": "Oantikje:",
        "powersearch-toggleall": "Alle",
        "powersearch-togglenone": "Gjin",
        "powersearch-remember": "Seleksje ûnthâlde foar sykopdrachten yn 'e takomst",
        "search-external": "Utwindich sykje",
-       "searchdisabled": "<p>Op it stuit stiet it trochsykjen fan tekst út omdat dizze funksje tefolle kompjûterkapasiteit ferget. As we nije apparatuer krije, en dy is ûnderweis, dan wurdt dizze funksje wer aktyf. Oant salang kinne jo sykje fia Google:</p>",
+       "searchdisabled": "Sykjen op {{SITENAME}} is útskeakele.\nJo kinne yn 'e tuskentiid mei Google sykje.\nTink derom dat harren yndeksearring fan 'e ynhâld op {{SITENAME}} ferâldere wêze kin.",
        "preferences": "Foarkarren",
        "mypreferences": "Foarkarren",
        "prefs-edits": "Tal bewurkings:",
        "skin-preview": "Proefbyld",
        "datedefault": "Gjin foarkar",
        "prefs-user-pages": "Meidoggersiden",
-       "prefs-personal": "Persoanlike gegevens",
+       "prefs-personal": "Meidogger",
        "prefs-rc": "Koartlyn feroare",
        "prefs-watchlist": "Folchlist",
        "prefs-editwatchlist": "Folchlist bewurkje",
        "right-deletedhistory": "Wiske ferzjes besjen, sûnder sjen te kinnen wat wiske is.",
        "right-browsearchive": "Wiske siden besjen",
        "right-undelete": "Wiske siden tebeksette",
-       "right-suppressrevision": "Ferskûle ferzjes besjen en tebeksette",
+       "right-suppressrevision": "Beskate sideferzjes fan in meidogger besjen, ferbergje of net ferbergje",
        "right-suppressionlog": "Net-publike logboeken besjen",
        "right-block": "Oare meidoggers de mûglikheid ta bewurkjen ôfnimme",
        "right-blockemail": "In meidogger it rjocht ta it ferstjoeren fan e-mail ôfnimme",
        "recentchanges-legend-heading": "<strong>Leginda:</strong>",
        "recentchanges-legend-newpage": "{{int:recentchanges-label-newpage}}<br />(sjoch ek de [[Special:NewPages|list mei nije siden]])",
        "rcfilters-legend-heading": "<strong>List fan ôfkoartings:</strong>",
-       "rcnotefrom": "Dit binne de feroarings sûnt <b>$2</b> (maksimaal <b>$1</b>).",
+       "rcnotefrom": "Hjirûnder {{PLURAL:$5|stiet de feroaring|steane de feroarings}} sûnt <strong>$3, $4</strong> (maksimaal <strong>$1</strong> werjûn).",
        "rclistfrom": "Jou nije feroarings, begjinnende mei $3 $2",
        "rcshowhideminor": "Lytse feroarings $1",
        "rcshowhideminor-show": "werjaan",
        "rcshowhidemine": "Myn bewurkings $1",
        "rcshowhidemine-show": "werjaan",
        "rcshowhidemine-hide": "ferbergje",
-       "rclinks": "Jou $1 nije feroarings yn de lêste $2 dagen",
+       "rclinks": "Jou de lêste $1 feroarings fan 'e lêste $2 dagen",
        "diff": "fersk.",
        "hist": "skied.",
        "hide": "gjin",
        "recentchangeslinked-to": "Feroarings oan siden mei ferwizings nei dizze side besjen",
        "upload": "Bied bestân oan",
        "uploadbtn": "Bied bestân oan",
-       "reuploaddesc": "Werom nei oanbied-side.",
+       "reuploaddesc": "Opladen annulearje en weromgean nei it oanbiedformulier",
        "uploadnologin": "Net oanmeld",
        "uploadnologintext": "Jo moatte $1 om bestannen oplade te kinnen.",
        "upload_directory_missing": "De heechlaadmap ($1) is der net en koe net oanmakke wurde troch de webserver.",
        "tooltip-pt-createaccount": "Jo wurde fan herten útnûge in akkount oan te meitsjen en jo oan te melden, mar it hoecht net.",
        "tooltip-ca-talk": "Oerlis oer de ynhâldlike side",
        "tooltip-ca-edit": "Dizze side bewurkje",
-       "tooltip-ca-addsection": "In opmerking tafoegje oan de oerlis-side.",
+       "tooltip-ca-addsection": "In nij mêd tafoegje",
        "tooltip-ca-viewsource": "Dizze side is skoattele, mar jo kinne de boarne wol besjen.",
        "tooltip-ca-history": "Eardere ferzjes fan dizze side",
        "tooltip-ca-protect": "Dizze side befeiligje",
        "tooltip-ca-nstab-special": "Dit is in bysûndere side, en kin net bewurke wurde",
        "tooltip-ca-nstab-project": "Projektside sjen litte",
        "tooltip-ca-nstab-image": "De bestânsside sjen litte",
-       "tooltip-ca-nstab-mediawiki": "Systeemberjocht sjen litte",
-       "tooltip-ca-nstab-template": "Sjabloan sjen litte",
+       "tooltip-ca-nstab-mediawiki": "It systeemberjocht sjen litte",
+       "tooltip-ca-nstab-template": "It berjocht sjen litte",
        "tooltip-ca-nstab-help": "Helpside sjen litte",
        "tooltip-ca-nstab-category": "Kategory-side sjen litte",
        "tooltip-minoredit": "Markearje dizze feroaring as fan lytse betsjutting",
index 3a9e31c..4c10d9c 100644 (file)
        "mainpage": "Գլխավոր էջ",
        "mainpage-description": "Գլխավոր էջ",
        "policy-url": "Project:Կանոնակարգ",
-       "portal": "Խորհրդարան",
+       "portal": "Համայնքային պորտալ",
        "portal-url": "Project:Համայնքային պորտալ",
        "privacy": "Գաղտնիության քաղաքականություն",
        "privacypage": "Project:Գաղտնիության քաղաքականություն",
        "mypreferencesprotected": "Դուք բավարար իրավունքներ չունեք Ձեր նախընտրությունները խմբագրելու համար։",
        "ns-specialprotected": "«{{ns:special}}» անվանատարածքի էջերը չեն կարող խմբագրվել։",
        "titleprotected": "Այս անվանմամբ էջի ստեղծումը արգելվել է [[User:$1|$1]] մասնակցի կողմից։\nՏրված պատճառն է՝ <em>$2</em>։",
+       "invalidtitle": "Սխալ վերնագիր",
        "exception-nologin": "Չեք մտել համակարգ",
        "exception-nologin-text": "Խնդրում ենք, մուտք գործեք՝ այս էջը դիտելու կամ գործողությունը կատարելու համար։",
        "virus-badscanner": "Սխալ կարգավորւմ։ Անծանոթ վիրուսների զննիչ. ''$1''",
        "savechanges": "Պահպանել փոփոխությունները",
        "publishpage": "Ստեղծել էջը",
        "publishchanges": "Պահպանել",
+       "savearticle-start": "Հիշել էջը...",
+       "savechanges-start": "Հիշել փոփոխությունները...",
+       "publishpage-start": "Հրատարակել էջը...",
        "publishchanges-start": "Պահպանել…",
        "preview": "Նախադիտում",
        "showpreview": "Նախադիտել",
        "diff-empty": "(Տարբերություն չկա)",
        "diff-multi-sameuser": "(Միևնույն մասնակցի {{PLURAL:$1|մեկ միջանկյալ տարբերակ|$1 միջանկյալ տարբերակներ}} թաքցված է)",
        "searchresults": "Որոնման արդյունքներ",
+       "search-filter-title-prefix-reset": "Որոնել բոլոր էջեր",
        "searchresults-title": "«$1»-ի որոնման արդյունքներ",
        "titlematches": "Համընկած հոդվածների անվանումներ",
        "textmatches": "Համընկած տեքստերով էջեր",
        "prefs-rc": "Վերջին փոփոխություններ",
        "prefs-watchlist": "Հսկացանկ",
        "prefs-editwatchlist": "Խմբագրել հսկացանկը",
+       "prefs-editwatchlist-clear": "Մաքրել հսկացանկը",
        "prefs-watchlist-days": "Հսկացանկում ցուցադրվող օրերի թիվը՝",
        "prefs-watchlist-days-max": "Առավելագույնը $1 {{PLURAL:$1|օր}}",
        "prefs-watchlist-edits": "Ընդարձակված հսկացանկում ցուցադրվող օրերի թիվը՝",
        "grant-group-email": "Ուղարկել էլ. նամակ",
        "grant-createaccount": "Ստեղծել հաշիվներ",
        "grant-createeditmovepage": "Ստեղծել․ խմբագրել և տեղափոխել էջեր",
+       "grant-uploadfile": "Բեռնել նոր նիշքեր",
        "grant-basic": "Հիմնական իրավունքներ",
+       "grant-viewmywatchlist": "Դիտել ձեր հսկացանկը",
        "newuserlogpage": "Մասնակիցների գրանցման տեղեկամատյան",
        "newuserlogpagetext": "Սա նոր մասնակիցների գրանցման տեղեկամատյանն է.",
        "rightslog": "Մասնակցի իրավունքների տեղեկամատյան",
        "action-read": "կարդալ այս էջը",
        "action-edit": "խմբագրել այս էջը",
        "action-createpage": "Ստեղծել էջ",
+       "action-createtalk": "Ստեղծել քննարկման էջ",
        "action-createaccount": "ստեղծել այս մասնակցային հաշիվը",
        "action-move": "տեղափոխել այս էջը",
        "action-move-rootuserpages": "տեղափոխել մասնակցի էջի արմատը",
        "listfiles_size": "Չափ",
        "listfiles_description": "Նկարագրություն",
        "listfiles_count": "Տարբերակ",
+       "listfiles-latestversion": "Ընթացիկ տարբերակ",
        "listfiles-latestversion-yes": "Այո",
        "listfiles-latestversion-no": "Ոչ",
        "file-anchor-link": "Նիշք",
        "unusedtemplateswlh": "այլ հղումներ",
        "randompage": "Պատահական էջ",
        "randompage-nopages": "Այս անվանատարածքում էջեր չկան։",
+       "randomincategory-category": "Կատեգորիա:",
+       "randomincategory-legend": "Կատեգորիայի պատահական էջ",
        "randomincategory-submit": "Անցնել",
        "randomredirect": "Պատահական վերահղում",
        "randomredirect-nopages": "Այս անվանատարածքում վերահղումներ չկան։",
        "deletedcontributions-title": "Մասնակցի ջնջված ներդրում",
        "sp-deletedcontributions-contribs": "ներդրում",
        "linksearch": "Արտաքին հղումներ",
+       "linksearch-ns": "Անվանատարածք.",
        "linksearch-ok": "Որոնել",
        "linksearch-line": " \n$1-ը հղվել է $2 ից",
        "listusersfrom": "Ցուցադրել մասնակիցներին՝ սկսած.",
        "activeusers-noresult": "Այդպիսի մասնակիցներ չեն գտնվել։",
        "activeusers-submit": "Ցույց տալ ակտիվ մասնակիցներին",
        "listgrouprights-group": "Խումբ",
+       "listgrouprights-rights": "Իրավունքներ",
        "listgrouprights-members": "(անդամների ցանկ)",
        "listgrouprights-addgroup": "Ավելացնեել {{PLURAL:$2|խումբ|խմբեր}}՝  $1",
+       "listgrants-rights": "Իրավունքներ",
        "mailnologin": "Ուղարկման հասցե չկա",
        "mailnologintext": "Անհրաժեշտ է [[Special:UserLogin|մտնել համակարգ]] և ունենալ գործող էլ-փոստի հասցե ձեր [[Special:Preferences|նախընտրություններում]]՝ ուրիշ մասնակիցներին էլեկտրոնային նամակներ ուղարկելու համար։",
        "emailuser": "էլ-նամակ ուղարկել այս մասնակցին",
        "delete-edit-reasonlist": "Խմբագրել ջնջման պատճառները",
        "deleting-backlinks-warning": "'''Զգուշացում''', ձեր կողմից ջնջվող էջին հղվում են [[Special:WhatLinksHere/{{FULLPAGENAME}}|այլ հոդվածներ]]:",
        "rollback": "Հետ գլորել խմբագրումները",
+       "rollback-confirmation-confirm": "Խնդրում ենք հաստատել.",
        "rollback-confirmation-no": "Չեղարկել",
        "rollbacklink": "հետ գլորել",
        "rollbacklinkcount": "հետ գլորել $1 {{PLURAL:$1|խմբագրում}}",
        "rollback-success": "Հետ են շրջվել $1 մասնակցի խմբագրումները. վերադարձվել է $2 մասնակցի վերջին տարբերակին։",
        "sessionfailure-title": "Սեսիայի խափանում",
        "sessionfailure": "Կարծես խնդիր է առաջացել կապված ձեր ընթացիկ աշխատանքային սեսիայի հետ.\nայս գործողությունը բեկանվել է սեսիայի հափշտակման կանխման նպատակով։\nԽնդրում ենք սեղմել «back» կոճակը և վերբեռնել այն էջը որտեղից եկել եք ու փորձել կրկին։",
+       "changecontentmodel-reason-label": "Պատճառ՝",
        "changecontentmodel-submit": "Փոխել",
+       "logentry-contentmodel-change-revertlink": "հետ շրջել",
+       "logentry-contentmodel-change-revert": "հետ շրջել",
        "protectlogpage": "Պաշտպանման տեղեկամատյան",
        "protectlogtext": "Ստորև բերված է պաշտպանված և պաշտպանումից հանված էջերի ցանկը։ Տես նաև [[Special:ProtectedPages|ներկայումս պաշտպանված էջերի ցանկը]]։",
        "protectedarticle": "պաշտպանվեց «[[$1]]» էջը",
        "mycontris": "Ներդրում",
        "anoncontribs": "Ներդրումներ",
        "contribsub2": "{{GENDER:$3|$1}}-ի ներդրումները ($2)",
+       "contributions-subtitle": "{{GENDER:$3|$1}}-ի համար",
        "nocontribs": "Այս չափանիշներին համապատասխանող փոփոխություններ չեն գտնվել։",
        "uctop": " վերջինը",
        "month": "Սկսած ամսից (և վաղ)՝",
        "sp-contributions-username": "IP-հասցե կամ մասնակցի անուն.",
        "sp-contributions-toponly": "Ցույց տալ միայն այն խմբագրումները, որոնք վերջին փոփոխություն են",
        "sp-contributions-newonly": "Ցույց տալ միայն նոր էջերի խմբագրումները",
+       "sp-contributions-hideminor": "Թագցնել չնչին խմբագրումներ",
        "sp-contributions-submit": "Որոնել",
        "whatlinkshere": "Այստեղ հղվող էջերը",
        "whatlinkshere-title": "Էջեր, որոնք հղում են դեպի «$1»",
        "ipbwatchuser": "Հսկացանկում ավելացնել մասնակցի էջն ու քննարկման էջը",
        "ipb-disableusertalk": "Արգելել մասնակցին խմբագրել իր քննարկման էջն արգելափակման ընթացքում",
        "ipb-pages-label": "Էջեր",
+       "ipb-namespaces-label": "Անվանատարածքներ",
        "badipaddress": "Սխալ IP-հասցե",
        "blockipsuccesssub": "Արգելափակումը կատարված է",
        "blockipsuccesstext": "[[Special:Contributions/$1|«$1»]] արգելափակված է։\n<br />Տես [[Special:BlockList|արգելափակված IP-հասցեների ցանկը]]։",
        "ipb-blocklist": "Դիտել գործող արգելափակումները",
        "ipb-blocklist-contribs": "$1 մասնակցի ներդրումը",
        "block-expiry": "Մարման ժամկետ.",
+       "block-reason": "Պատճառ՝",
        "unblockip": "Արգելափակումից հանել մասնակցին",
        "unblockiptext": "Օգտագործեք ստորև ձևը՝ նախկինում արգելափակված IP-հասցեի կամ մասնակցի գրելու հնարավորությունը վերականգնելու համար։",
        "ipusubmit": "Հանել արգելափակումը",
        "unblocked": "[[User:$1|$1]] մասնակիցը անարգելված է։",
        "unblocked-id": "$1 արգելափակումը հանված է",
        "blocklist": "Արգելափակված մասնակիցներ։",
+       "autoblocklist-submit": "Որոնել",
        "ipblocklist": "Արգելափակված IP-հասցեները և մասնակիցները",
        "ipblocklist-legend": "Արգելափակված մասնակցի որոնում",
        "blocklist-expiry": "Լրանում է",
+       "blocklist-reason": "Պատճառ",
        "ipblocklist-submit": "Որոնել",
        "infiniteblock": "ընդմիշտ",
        "expiringblock": "կմարվի $1 $2",
        "import": "Էջերի ներմուծում",
        "importinterwiki": "Միջվիքի ներմուծում",
        "import-interwiki-text": "Նշեք վիքի և ներմուծվող էջի անվանումը։\nՓոփոխումների ժամանակները և խմբագրողների անունները կպահպանվեն։\nԲոլոր միջվիքի ներմուծման գործողությունները գրանցվում են [[Special:Log/import|ներմուծման տեղեկամատյանում]]։",
+       "import-interwiki-sourcewiki": "Աղբյուր վիքի.",
+       "import-interwiki-sourcepage": "Աղբյուր էջ.",
        "import-interwiki-history": "Պատճենել այս էջի փոփոխումների լրիվ պատմությունը",
        "import-interwiki-submit": "Ներմուծել",
        "import-upload-filename": "Նիշքի անուն․",
        "pageinfo-contentpage": "Հաշվառված որպես բովանդակային էջ",
        "pageinfo-contentpage-yes": "Այո",
        "pageinfo-protect-cascading-yes": "Այո",
+       "pageinfo-category-pages": "Էջերի քանակ",
+       "pageinfo-category-subcats": "Ենթակատեգորիաների քանակ",
+       "pageinfo-category-files": "Հոդվածների քանակ",
+       "pageinfo-user-id": "Մասնակցի ID-ն",
        "markaspatrolleddiff": "Նշել որպես ստուգված",
        "markaspatrolledtext": "Նշել այս էջը որպես ստուգված",
        "markedaspatrolled": "Նշված է որպես ստուգված",
        "imgmultipagenext": "հաջորդ էջ →",
        "imgmultigo": "Անցնե՛լ",
        "imgmultigoto": "Անցնել $1 էջը",
+       "img-lang-go": "Անցնել",
        "ascending_abbrev": "աճմ. կարգ.",
        "descending_abbrev": "նվազ",
        "table_pager_next": "Հաջորդ էջ",
        "watchlistedit-raw-done": "Ձեր հսկացանկը թարմացված է։",
        "watchlistedit-raw-added": "Ավելացվեց {{PLURAL:$1|1 անվանում|$1 անվանում}}.",
        "watchlistedit-raw-removed": "Հեռացվեց {{PLURAL:$1|1 անվանում|$1 անվանում}}.",
+       "watchlistedit-clear-title": "Մաքրել հսկացանկը",
+       "watchlistedit-clear-legend": "Մաքրել հսկացանկը",
        "watchlisttools-clear": "Մաքրել հսկացանկը",
        "watchlisttools-view": "Փոփոխությունները հսկացանկում",
        "watchlisttools-edit": "Դիտել և խմբագրել հսկացանկը",
        "watchlisttools-raw": "Խմբագրել հում հսկացանկը",
        "signature": "[[{{ns:user}}:$1|$2]] ([[{{ns:user_talk}}:$1|քննարկում]])",
        "version": "ՄեդիաՎիքի տարբերակը",
+       "version-editors": "Խմբագիրներ",
        "version-ext-license": "Արտոնագիր",
        "version-ext-colheader-name": "Ընդլայնում",
        "version-ext-colheader-version": "Տարբերակ",
        "version-ext-colheader-license": "Արտոնագիր",
+       "version-ext-colheader-description": "Նկարագրություն",
+       "version-ext-colheader-credits": "Հեղինակներ",
        "version-license-title": "Արտոնագիր $1-ի համար",
        "version-poweredby-credits": "Այս վիքին աշխատում է '''[https://www.mediawiki.org/ MediaWiki]'''֊ով, copyright © 2001-$1 $2։",
+       "version-poweredby-translators": "translatewiki.net թարգմանիչներ",
+       "version-software-version": "Տարբերակ",
+       "version-libraries-library": "Գրադարան",
+       "version-libraries-version": "Տարբերակ",
+       "version-libraries-license": "Արտոնագիր",
+       "version-libraries-description": "Նկարագրություն",
+       "version-libraries-authors": "Հեղինակներ",
        "redirect": "Վերահղում նիշքի, մասնակցի, էջի, տարբերակի կամ գրանցամատյանի նույնականացման համարից",
        "redirect-submit": "Անցնել",
        "redirect-lookup": "Որոնում՝",
        "tag-filter": "[[Special:Tags|Պիտակների]] զտիչ՝",
        "tag-filter-submit": "Ֆիլտրել",
        "tag-list-wrapper": "[[Special:Tags|{{PLURAL:$1|Պիտակ}}]]: $2",
+       "tag-mw-new-redirect": "Նոր վերահղում",
        "tags-title": "Պիտակներ",
        "tags-source-header": "Աղբյուր",
        "tags-actions-header": "Գործողություններ",
        "compare-invalid-title": "Ձեր նշած վերնագիրը անվավեր է։",
        "compare-title-not-exists": "Ձեր նշած վերնագիրը գոյություն չունի:",
        "compare-revision-not-exists": "Ձեր նշած փոփոխությունը գոյություն չունի։",
+       "diff-form": "Տարբերությունները",
+       "permanentlink": "Մշտական հղում",
        "dberr-problems": "Այս կայքում առաջացել են տեխնիկական խնդիրներ։ Հայցում ենք ձեր ներողությունը։",
        "dberr-again": "Փորձեք մի քանի րոպե սպասել և վերաբեռնել էջը։",
        "htmlform-submit": " \nՀաշվել",
+       "htmlform-selectorother-other": "Այլ",
+       "htmlform-no": "Ոչ",
+       "htmlform-yes": "Այո",
+       "htmlform-title-not-exists": "$1՝ գոյություն չունի",
        "logentry-delete-delete": "$1 {{GENDER:$2|ջնջեց}} $3 էջը",
        "logentry-delete-restore": "$1 վերականգնեց $3 ($4) էջը",
        "logentry-delete-event": "$1 փոխեց տեղեկամատյանի {{PLURAL:$5|1 գրանցման|$5 գրանցումների}} տեսանելությունը $3-ում. $4",
        "feedback-close": "Արված է",
        "feedback-message": "Հաղորդագրություն․",
        "feedback-subject": "Թեմա.",
+       "feedback-thanks-title": "Շնորհակալություն",
        "searchsuggest-search": "Որոնել {{SITENAME}} կայքում",
        "duration-seconds": "$1 {{PLURAL:$1|վայրկյան}}",
        "duration-minutes": "$1 {{PLURAL:$1|րոպե}}",
        "duration-centuries": "$1 {{PLURAL:$1|դար}}",
        "duration-millennia": "$1 {{PLURAL:$1|հազարամյակ}}",
        "expandtemplates": "Կաղապարների ընդարձակում",
+       "expand_templates_ok": "Լավ",
+       "pagelang-name": "Էջ",
+       "pagelang-select-lang": "Ընտրեք լեզուն",
+       "pagelang-reason": "Պատճառ",
+       "pagelang-submit": "Հաստատել",
        "pagelang-nonexistent-page": "$1 էջը գոյություն չունի",
+       "mediastatistics-header-total": "Բոլոր նիշքեր",
        "special-characters-group-latin": "Լատիներեն",
        "special-characters-group-latinextended": "Լատիներեն ընդլայնված",
        "special-characters-group-ipa": "ՄՀԱ (IPA)",
        "date-range-from": "Սկսած՝",
        "date-range-to": "Մինչև՝",
        "randomrootpage": "Պատահական կենտրոնական էջ",
-       "authmanager-create-from-login": "Հաշիվ ստեղծելու համար, խնդրում ենք լրացնել ստորև դաշտերը"
+       "authmanager-create-from-login": "Հաշիվ ստեղծելու համար, խնդրում ենք լրացնել ստորև դաշտերը",
+       "authprovider-resetpass-skip-label": "Բաց թողնել",
+       "passwordpolicies-group": "Խումբ"
 }
index 32b76ab..b0fb634 100644 (file)
@@ -5,7 +5,8 @@
                        "Armeniki",
                        "Azniv Stepanian",
                        "Rajemian",
-                       "Դավիթ Սարոյան"
+                       "Դավիթ Սարոյան",
+                       "Kareyac"
                ]
        },
        "underline-always": "Միշտ",
        "pt-userlogout": "Դուրս գալ",
        "php-mail-error-unknown": "Անյայտ սխալ PHP-ի mail() կախարկութեան մէջ:",
        "changepassword": "Գաղտնաբառը փոխել",
+       "oldpassword": "Հին գաղտնաբառը.",
        "newpassword": "Նոր գաղտնաբառը.",
        "retypenew": "Նորէն մուտքագրէք գաղտնաբառը",
        "changepassword-success": "Ձեր գաղտնաբառը փոխուեցաւ։",
        "recreate-moveddeleted-warn": "<strong>Զգուշացում. Նախապէս ջնջուած էջ մը պիտի վերստեղծուի։<strong>\n\nԿը խնդրուի մտածել այս էջի խմբագրման նպատակայարմարութեան մասին։ \nՁեր դիւրութեան համար ներքեւ կը գտնէք այս էջի ջնջումին և տեղափոխումին տեղեկատետրերը։",
        "moveddeleted-notice": "Այս էջը ջնջուած է։\nԷջին ջնջումի, պահպանումի եւ փոխադրումի տեղեկատետրը տրամադրելի է ներքեւ որպէս տեղեկութիւն։",
        "edit-conflict": "Խմբագրման ընհարում։",
+       "slot-name-main": "Գլխաւոր",
        "content-model-wikitext": "ուիքիթէքսթ",
        "content-model-text": "պարզ բնաբան",
        "content-model-javascript": "ՃաւաՍքրիփթ",
        "history-fieldset-title": "Որոնել տարբերակներ",
        "histfirst": "հնագոյն",
        "histlast": "նորագոյն",
+       "historyempty": "պարապ",
        "history-feed-title": "Վերանայումներու ցուցակ",
        "history-feed-description": "Ուիքիի այս էջին վերանայումներու ցուցակը",
        "history-feed-item-nocomment": "$1՝ $2",
        "rev-showdeleted": "Ցուցադրել",
        "revdelete-show-file-submit": "Այո",
        "revdelete-log": "Պատճառ.",
+       "pagehist": "Էջի պատմութիւն",
        "revdelete-reasonotherlist": "Ուրիշ պատճառ.",
        "mergehistory-reason": "Պատճառ.",
        "mergelog": "Ձուլման տեղեկատետր",
        "searchall": "բոլոր",
        "search-showingresults": "{{PLURAL:$4|<strong>$1</strong> արդիւնք <strong>$3</strong>-էն|<strong>$1 - $2</strong> արդիւնքներ <strong>$3</strong>-էն}}",
        "search-nonefound": "Որոնումին համապատասխանող արդիւնքներ չգտնուեցան",
+       "powersearch-toggleall": "Բոլոր",
+       "powersearch-togglenone": "Ոչ մէկ",
+       "preferences": "Նախընտրութիւններ",
        "mypreferences": "Նախընտրութիւններ",
        "skin-preview": "Նախադիտել",
+       "prefs-watchlist": "Հսկողութեան ցանկ",
+       "saveprefs": "Յիշել",
+       "searchresultshead": "Որոնել",
        "stub-threshold-sample-link": "օրինակ",
        "timezoneregion-africa": "Ափրիկէ",
        "timezoneregion-america": "Ամերիկա",
        "timezoneregion-europe": "Եւրոպա",
        "timezoneregion-indian": "Հնդկական Ովկիանոս",
        "timezoneregion-pacific": "Խաղաղ Ովկիանոս",
+       "prefs-searchoptions": "Որոնել",
        "youremail": "Էլեկտրական Նամակ",
+       "yourlanguage": "Լեզու.",
        "email": "Էլեկտրական Նամակ",
+       "prefs-info": "Հիմնական տուեալներ",
+       "prefs-editor": "Խմբագրող",
+       "prefs-preview": "Նախադիտել",
        "group": "Խումբ.",
        "group-bot": "Մեքենայիկներ",
        "group-sysop": "Վարիչներ",
        "logentry-newusers-autocreate": "$1 մասնակցային հաշիւը {{GENDER:$2|ստեղծուած է}} ինքնաբերաբար",
        "logentry-upload-upload": "$1 {{GENDER:$2|ներբեռնուած է}} $3",
        "logentry-upload-overwrite": "$1 {{GENDER:$2|վերբեռնեց}} $3ի նոր տարբերակ",
+       "feedback-cancel": "Չեղարկել",
        "searchsuggest-search": "Որոնել {{SITENAME}} կայքին մէջ",
        "duration-days": "$1 {{PLURAL:$1|օր}}",
        "randomrootpage": "Պատահական արմատ էջ"
index 215df67..bf65f50 100644 (file)
        "undelete_short": "Restaurar {{PLURAL:$1|1 redakto|$1 redakti}}",
        "viewdeleted_short": "Vidar {{PLURAL:$1|1 redakto efacita|$1 redakti efacita}}",
        "protect": "Protektar",
-       "protect_change": "chanjar",
+       "protect_change": "modifikar",
        "unprotect": "Desprotektar",
        "newpage": "Nova pagino",
        "talkpagelinktext": "Diskutez",
        "right-suppressrevision": "Vidar, celar e deskovrar specifika revizi di pagini de irga uzero",
        "right-blockemail": "Blokusar uzero pri sendar e-posto",
        "right-unblockself": "Desblokusar su",
+       "right-protect": "Modifikar la protekto-nivelo e la pagini protektata en serio",
        "right-viewmywatchlist": "Vidar vua propra atenco-listo",
        "right-editmyoptions": "Modifikar vua propra preferaji",
        "right-rollback": "Rapide retrorular la redakti da la lasta uzero qua redaktis specigita pagino",
        "sessionfailure": "Semblas ke eventis problemo kun vua sesiono di 'login';\nta agado abrogesis, quale presorgo kontre sequestro di sesiono ('hijacking').\nVoluntez risendar la formulario, plenigita.",
        "changecontentmodel": "Chanjar la konteno-modelo di (u)la pagino",
        "changecontentmodel-title-label": "Titulo di la pagino",
+       "changecontentmodel-submit": "Modifikar",
        "log-name-contentmodel": "Registro di la modifikuri en la modelo pri kontenajo",
        "logentry-contentmodel-change-revertlink": "restaurar",
        "logentry-contentmodel-change-revert": "restaurar",
        "expand_templates_output": "Rezulto",
        "expand_templates_ok": "O.K.",
        "expand_templates_preview": "Previdar",
+       "pagelanguage": "Modifikar la linguo di la pagino",
        "pagelang-language": "Linguo",
+       "right-pagelang": "Modifikar l'idiomo di la pagino",
        "mediastatistics-nbytes": "{{PLURAL:$1|$1 bicoko*|$1 bicoki*}} ($2; $3%)",
        "special-characters-group-latin": "Latina",
        "special-characters-group-latinextended": "Latina extensita",
index 54c5788..50b9441 100644 (file)
        "delete-confirm": "Cancella \"$1\"",
        "delete-legend": "Cancella",
        "historywarning": "'''Attenzione:''' La pagina che stai per cancellare ha una cronologia con $1 {{PLURAL:$1|versione|versioni}}:",
-       "historyaction-submit": "Mostra",
+       "historyaction-submit": "Mostra versioni",
        "confirmdeletetext": "Stai per cancellare una pagina con tutta la sua cronologia. Per cortesia, conferma che è tua intenzione procedere a tale cancellazione, che hai piena consapevolezza delle conseguenze della tua azione e che essa è conforme alle linee guida stabilite in [[{{MediaWiki:Policy-url}}]].",
        "actioncomplete": "Azione completata",
        "actionfailed": "Azione fallita",
index 358ef27..5e81e28 100644 (file)
        "right-reupload-own": "自身がアップロードした既存のファイルに上書き",
        "right-reupload-shared": "共有メディアリポジトリ上のファイルにローカルで上書き",
        "right-upload_by_url": "URL からファイルをアップロード",
-       "right-purge": "確認なしでサイト上のページ・キャッシュを破棄",
+       "right-purge": "サイト上のページ キャッシュを破棄",
        "right-autoconfirmed": "IPベースの速度制限を受けない",
        "right-bot": "自動処理と認識させる",
        "right-nominornewtalk": "議論ページの細部の編集をした際に、新着メッセージとして通知しない",
        "rcfilters-view-tags-help-icon-tooltip": "タグ付けされた編集とは",
        "rcfilters-liveupdates-button": "自動読み込み",
        "rcfilters-liveupdates-button-title-on": "自動読み込みを停止",
-       "rcfilters-liveupdates-button-title-off": "æ\96°ã\81\97ã\81\84ç·¨é\9b\86ã\82\92å\8d³åº§ã\81«èª­ã\81¿è¡¨ç¤ºã\81\99ã\82\8b",
+       "rcfilters-liveupdates-button-title-off": "新しい編集を即座に表示する",
        "rcfilters-watchlist-markseen-button": "すべての変更を訪問済みにする",
        "rcfilters-watchlist-edit-watchlist-button": "ウォッチリストを編集",
        "rcfilters-watchlist-showupdated": "最終訪問以降に変更されたページは、塗りつぶされた丸印と一緒に、<strong>太字</strong>で表示されます。",
        "delete-warning-toobig": "このページには、 $1版を超える編集履歴があります。\n削除すると、{{SITENAME}}のデータベース処理に大きな負荷がかかります。\n十分に注意してください。",
        "deleteprotected": "このページは保護されているため削除できません。",
        "deleting-backlinks-warning": "<strong>警告:</strong> 削除しようとしているページは、[[Special:WhatLinksHere/{{FULLPAGENAME}}|他のページ]]からリンクまたは参照読み込みされています。",
-       "deleting-subpages-warning": "<strong>警告:</strong>削除しようとしているページは、[[Special:PrefixIndex/{{FULLPAGENAME}}/|{{PLURAL:$1|サブページ|$1 個のサブページ|51=50 個以上のサブページ}}]]があります。",
+       "deleting-subpages-warning": "<strong>警告:</strong> 削除しようとしているページには、[[Special:PrefixIndex/{{FULLPAGENAME}}/|{{PLURAL:$1|下位ページ|$1 件の下位ページ|51=50 件以上の下位ページ}}]]があります。",
        "rollback": "編集を巻き戻し",
+       "rollback-confirmation-yes": "巻き戻し",
+       "rollback-confirmation-no": "キャンセル",
        "rollbacklink": "巻き戻し",
        "rollbacklinkcount": "$1{{PLURAL:$1|編集}}を巻き戻し",
        "rollbacklinkcount-morethan": "$1{{PLURAL:$1|編集}}以上を巻き戻し",
        "cant-move-category-page": "カテゴリのページを移動させる権限がありません。",
        "cant-move-to-category-page": "ページをカテゴリのページに移動させる権限がありません。",
        "cant-move-subpages": "下位のページを移動する権限がありません。",
-       "namespace-nosubpages": "名前空間「$1」はサブページが許可されていません。",
+       "namespace-nosubpages": "名前空間「$1」は下位ページが許可されていません。",
        "newtitle": "新しいページ名:",
        "move-watch": "移動元と移動先ページをウォッチ",
        "movepagebtn": "ページを移動",
        "log-action-filter-suppress-reblock": "再ブロックによる利用者の秘匿",
        "log-action-filter-upload-upload": "新規アップロード",
        "log-action-filter-upload-overwrite": "再アップロード",
+       "log-action-filter-upload-revert": "差し戻し",
        "authmanager-authn-not-in-progress": "認証が行われていない、またはセッションデータが失われました。最初からやり直してください。",
        "authmanager-authn-no-primary": "指定された証明情報を認証できませんでした。",
        "authmanager-authn-no-local-user": "指定された証明情報は、このウィキのどの利用者にも関連付けられていません。",
        "unlinkaccounts": "アカウントの関連付け解除",
        "unlinkaccounts-success": "アカウントの関連付けが解除されました。",
        "authenticationdatachange-ignored": "認証データの変更は処理されませんでした。プロバイダーが設定されていない可能性があります。",
-       "userjsispublic": "注意: JavaScript のサブページは第三者が閲覧可能なため、機微な情報を含めないでください。",
-       "userjsonispublic": "注意: JSON のサブページは第三者が閲覧可能なため、機微な情報を含めないでください。",
-       "usercssispublic": "注意: CSS のサブページは第三者が閲覧可能なため、機微な情報を含めないでください。",
+       "userjsispublic": "注意: JavaScript の下位ページは第三者が閲覧可能なため、機微な情報を含めないでください。",
+       "userjsonispublic": "注意: JSON の下位ページは第三者が閲覧可能なため、機微な情報を含めないでください。",
+       "usercssispublic": "注意: CSS の下位ページは第三者が閲覧可能なため、機微な情報を含めないでください。",
        "restrictionsfield-badip": "無効な IP アドレス、またはその範囲: $1",
        "restrictionsfield-label": "許可する IP の範囲:",
        "restrictionsfield-help": "一行につき、単一の IP アドレス、もしくは CIDR による範囲。全帯域からの接続を許可する場合: <pre>0.0.0.0/0\n::/0</pre>",
        "undelete-cantedit": "このページを編集する許可がないため復元できません。",
        "undelete-cantcreate": "同名のページが存在せず、このページを作成する許可がないため復元できません。",
        "pagedata-title": "ページ・データ",
-       "pagedata-text": "このページは、ページへのデータインターフェースを提供します。サブページの構文を使用して、URLにページタイトルを入力してください。\n* コンテンツのネゴシエーションは、クライアントのAcceptヘッダーに基づいて適用されます。つまり、ページデータはクライアントが優先する形式で提供されます。",
+       "pagedata-text": "このページは、ページへのデータインターフェースを提供します。下位ページの構文を使用して、URLにページ名を入力してください。\n* コンテンツのネゴシエーションは、クライアントのAcceptヘッダーに基づいて適用されます。つまり、ページデータはクライアントが優先する形式で提供されます。",
        "pagedata-not-acceptable": "該当する形式が見つかりません。対応している MIME タイプ: $1",
        "pagedata-bad-title": "「$1」は無効なページ名です。",
-       "unregistered-user-config": "セキュリティ上の理由から、JavaScript、CSSおよびJSONの利用者サブページは、登録されていない利用者に対しては読み込みできません。",
+       "unregistered-user-config": "セキュリティ上の理由から、JavaScript、CSSおよびJSONの利用者下位ページは、登録されていない利用者に対しては読み込みできません。",
        "passwordpolicies": "パスワードポリシー",
        "passwordpolicies-summary": "これは、このウィキで定義されている利用者グループの有効なパスワードポリシーの一覧です。",
        "passwordpolicies-group": "グループ",
        "passwordpolicies-policy-maximalpasswordlength": "パスワードは$1{{PLURAL:$1|文字}}以下でなければなりません",
        "passwordpolicies-policy-passwordcannotbepopular": "パスワードは{{PLURAL:$1|一般的なものにすることはできません|一般的な$1個のパスワードのリストと一致するものにすることはできません}}",
        "easydeflate-invaliddeflate": "提供されたコンテンツが適切に圧縮されていません",
-       "unprotected-js": "ã\82»ã\82­ã\83¥ã\83ªã\83\86ã\82£ä¸\8aã\81®ç\90\86ç\94±ã\81\8bã\82\89ã\80\81JavaScriptã\81¯ä¿\9dè­·ã\81\95ã\82\8cã\81¦ã\81\84ã\81ªã\81\84ã\83\9aã\83¼ã\82¸ã\81\8bã\82\89ã\83­ã\83¼ã\83\89ã\81\99ã\82\8bã\81\93ã\81¨ã\81¯ã\81§ã\81\8dã\81¾ã\81\9bã\82\93ã\80\82MediaWiki: å\90\8då\89\8d空é\96\93å\86\85ã\80\81ã\81¾ã\81\9fã\81¯å\88©ç\94¨è\80\85ã\82µã\83\96ã\83\9aã\83¼ã\82¸ã\81¨ã\81\97ã\81¦のみjavascriptを作成してください。"
+       "unprotected-js": "ã\82»ã\82­ã\83¥ã\83ªã\83\86ã\82£ä¸\8aã\81®ç\90\86ç\94±ã\81\8bã\82\89ã\80\81JavaScriptã\81¯ä¿\9dè­·ã\81\95ã\82\8cã\81¦ã\81\84ã\81ªã\81\84ã\83\9aã\83¼ã\82¸ã\81\8bã\82\89ã\81¯èª­ã\81¿è¾¼ã\81¿ã\81§ã\81\8dã\81¾ã\81\9bã\82\93ã\80\82MediaWiki: å\90\8då\89\8d空é\96\93å\86\85ã\80\81å\88©ç\94¨è\80\85ä¸\8bä½\8dã\83\9aã\83¼ã\82¸ã\81®ã\81\84ã\81\9aã\82\8cã\81\8bã\81§のみjavascriptを作成してください。"
 }
index 39680c1..d4b3e7f 100644 (file)
        "right-reupload-own": "E Fichier iwwerschreiwen deen Dir selwer eropgelueden hutt",
        "right-reupload-shared": "Lokalt Iwwerschreiwe vun engem Fichier deen an engem gemeinsam benotzte Repertoire steet",
        "right-upload_by_url": "Fichiere vun enger URL-Adress eroplueden",
-       "right-purge": "De Säitecache eidel maachen ouni nozefroen",
+       "right-purge": "De Säitecache fir eng Säit eidel maachen",
        "right-autoconfirmed": "Net betraff vun IP-baséierten Zäitlimiten",
        "right-bot": "Als automatesche Prozess behandelen (Bot)",
        "right-nominornewtalk": "Kleng Ännerungen op Diskussiounssäite léisen de Banner vun de neie Messagen net aus",
        "delete-confirm": "Läsche vu(n) \"$1\"",
        "delete-legend": "Läschen",
        "historywarning": "<strong>Opgepasst:</strong> D'Säit déi Dir läsche wëllt huet en Historique mat $1 {{PLURAL:$1|Versioun|Versiounen}}:",
-       "historyaction-submit": "Weisen",
+       "historyaction-submit": "Versioune weisen",
        "confirmdeletetext": "Dir sidd am Gaang, eng Säit mat hirem kompletten Historique vollstänneg aus der Datebank ze läschen.\nW.e.g. confirméiert, datt Dir dëst wierklech wëllt, datt Dir d'Konsequenze verstitt, an datt dat Ganzt am Aklang mat de [[{{MediaWiki:Policy-url}}|Richtlinne]] geschitt.",
        "actioncomplete": "Aktioun ofgeschloss",
        "actionfailed": "Aktioun huet net funktionéiert",
        "deleting-backlinks-warning": "<strong>Opgepasst:</strong> [[Special:WhatLinksHere/{{FULLPAGENAME}}|Aner Säite]] linken op déi Säit déi Dir am Gaang sidd ze läschen oder déi Säit Déi Dir am Gaang sidd ze läschen ass an aner Säiten agebonn.",
        "deleting-subpages-warning": "<strong>Opgepasst:</strong> D'Säit, déi Dir läsche wëllt, huet [[Special:PrefixIndex/{{FULLPAGENAME}}/|{{PLURAL:$1|eng Ënnersäit|$1 Ënnersäiten|51=méi wéi 50 Ënnersäiten}}]].",
        "rollback": "Ännerungen zrécksetzen",
+       "rollback-confirmation-confirm": "W.e.g. Konfirméieren",
        "rollback-confirmation-yes": "Zrécksetzen",
        "rollback-confirmation-no": "Ofbriechen",
        "rollbacklink": "Zrécksetzen",
index 7458dea..7679db6 100644 (file)
        "thu": "پٱن شٱمٱ",
        "fri": "جۏمٱ",
        "sat": "شٱمٱ",
-       "january": "جانڤیٱ",
+       "january": "ژانڤیٱ",
        "february": "فڤریٱ",
        "march": "مارس",
        "april": "آڤریل",
        "may_long": "ماٛی",
-       "june": "جوئٱن",
+       "june": "ژوئٱن",
        "july": "جۊلای",
        "august": "آگوست",
        "september": "سپتامر",
        "october-gen": "اوکتوبر",
        "november-gen": "نوڤامر",
        "december-gen": "دسامر",
-       "jan": "جانڤیٱ",
+       "jan": "ژانڤیٱ",
        "feb": "فڤریٱ",
        "mar": "مارس",
        "apr": "آڤریل",
        "may": "ماٛی",
-       "jun": "جوئٱن",
+       "jun": "ژوئٱن",
        "jul": "جۊلای",
        "aug": "آگوست",
        "sep": "سپتامر",
        "history": "ڤیرگار بٱلگٱ",
        "history_short": "ڤیرگار",
        "updatedmarker": "د آخئری دییئن مئنە ڤئ هنگوم کو",
-       "printableversion": "نۏسخٱ پلا بینی",
+       "printableversion": "نۏسخٱ پلا بیئنی",
        "permalink": "هوم پاٛڤٱن هٱمیشاٛیی",
        "print": "چاپ گئرئتئن",
        "view": "دیئن",
        "pool-errorunknown": "خأطا نادیار",
        "pool-servererror": "پوٙل ئشمار خئذمأتگە د دأسرئس نی($1).",
        "poolcounter-usage-error": "خأطا ڤئ کار گئرئتئن:$1",
-       "aboutsite": "دبارٱ {{SITENAME}}",
-       "aboutpage": "Project:دبارٱ",
+       "aboutsite": "دٱربارٱ {{SITENAME}}",
+       "aboutpage": "Project:دٱربارٱ",
        "copyright": "مینۊنٱیا هان د دٱسرس $1 مٱر یٱ کاٛ ڤ یاٛ گاٛل شیڤاٛ هٱنی نیسٱنٱ بۊٱ.",
        "copyrightpage": "{{ns:project}}:کوپی رایت",
        "currentevents": "روخ ڤنؽا ایسنی",
        "tooltip-ca-watch": "اْزاف کردن اؽ بٱلگٱ ڤ نوم نڤشت پاٛگیریاتو",
        "tooltip-ca-unwatch": "ڤرداشتن اؽ بٱلگٱ ڤ نوم نڤشت پاٛگیریاتو",
        "tooltip-search": "پاٛ جۊری {{SITENAME}}",
-       "tooltip-search-go": "رۉ د بٱلگاٛیؽ کاْ یٱ نوم روسی ها مؽنش ٱلڤٱت ٱر دش بۊئٱ",
+       "tooltip-search-go": "رۉ د بٱلگاٛیؽ کاْ یٱ نوم راسی ها مؽنش ٱلڤٱت ٱر دش بۊئٱ",
        "tooltip-search-fulltext": "بٱلگٱیاناْ سی چنی نیسساٛیؽ پاٛ جۊری بٱکو.",
        "tooltip-p-logo": "ساٛلٛ سرآسونٱ بٱکؽت",
        "tooltip-n-mainpage": "سرآسونٱ ناْ ساٛلٛ بٱکؽت",
        "tooltip-n-mainpage-description": "سرآسونٱ ناْ ساٛلٛ بٱکؽت",
        "tooltip-n-portal": "دبارٱ پرۉژٱ؛ شما مؽ تونؽت چؽ بٱکؽت؛ د کوجا اؽ چیاناْ بٱجۊرؽت.",
-       "tooltip-n-currentevents": "ساڤند دونسمنیایؽ کا هان د روخ ڤنؽا تازٱ باڤ دؽاری بٱک",
+       "tooltip-n-currentevents": "ساڤن دونسمنیایؽ کا هان د روخ ڤٱنؽا تازٱ بۊ دؽاری بٱک",
        "tooltip-n-recentchanges": "یاٛ نومگٱ سی آلشتکاریا د ڤیکی",
        "tooltip-n-randompage": "سڤار کرد بٱلگٱ بٱختٱکی",
        "tooltip-n-help": "یاٛ جاگٱ سی فٱمسن",
index 4daa9ba..72d86c1 100644 (file)
        "mycontris": "Indėlis",
        "anoncontribs": "Indėlis",
        "contribsub2": "Naudotojas: {{GENDER:$3|$1}} ($2)",
+       "contributions-subtitle": "{{GENDER:$3|$1}}",
        "contributions-userdoesnotexist": "Naudotojo paskyra „$1“ neužregistruota.",
        "nocontribs": "Jokie keitimai neatitiko šių kriterijų.",
        "uctop": "dabartinis",
index 2857255..c7ce646 100644 (file)
        "viewsourceold": "преглед на кодот",
        "editlink": "уреди",
        "viewsourcelink": "преглед на кодот",
-       "editsectionhint": "УÑ\80еди Ð³Ð¾ Ð¿Ð°Ñ\81Ñ\83Ñ\81оÑ\82: $1",
+       "editsectionhint": "УÑ\80еди Ð³Ð¾ Ð¾Ð´Ð´ÐµÐ»Ð¾Ñ\82 â\80\9e$1â\80\9c",
        "toc": "Содржина",
        "showtoc": "прикажи",
        "hidetoc": "скриј",
        "accountcreatedtext": "Корисничката сметка за [[{{ns:User}}:$1|$1]] ([[{{ns:User talk}}:$1|разговор]]) е направена.",
        "createaccount-title": "Создавање на сметка за {{SITENAME}}",
        "createaccount-text": "Некој направил сметка со вашата е-поштенска адреса на {{SITENAME}} ($4) со име „$2“ и  лозинка „$3“.\nБи требало сега да се пријавите и да ја промените вашата лозинка.\n\nМожете да ја занемарите оваа порака ако сметката била направена по грешка.",
-       "login-throttled": "Ð\98маÑ\82е Ð¿Ñ\80емногÑ\83 Ð¾Ð±Ð¸Ð´Ð¸ Ð·Ð° Ð½Ð°Ñ\98ава Ð·Ð° ÐºÑ\80аÑ\82ко Ð²Ñ\80еме.\nПочекајте $1 пред да се обидете повторно.",
+       "login-throttled": "Ð\9dапÑ\80авивÑ\82е Ð¿Ñ\80емногÑ\83 Ð¾Ð±Ð¸Ð´Ð¸ Ð·Ð° Ð½Ð°Ñ\98ава.\nПочекајте $1 пред да се обидете повторно.",
        "login-abort-generic": "Најавата е неуспешна — Откажано",
        "login-migrated-generic": "Вашата сметка е пренесена и корисничкото име веќе не постои на ова вики.",
        "loginlanguagelabel": "Јазик: $1",
        "histfirst": "најстари",
        "histlast": "најнови",
        "historysize": "({{PLURAL:$1|1 бајт|$1 бајти}})",
-       "historyempty": "(празно)",
+       "historyempty": "празно",
        "history-feed-title": "Историја на измените",
        "history-feed-description": "Историја на измените на оваа страница на викито",
        "history-feed-item-nocomment": "$1 на $2",
        "prefs-custom-json": "Прилагоден JSON",
        "prefs-custom-js": "Посебно JS",
        "prefs-common-config": "Заеднички CSS/JSON/JavaScript за сите рува:",
-       "prefs-reset-intro": "Може да ја користите оваа страница за враќање на вашите нагодувања на основно-зададените нагодувања на викито. Ова дејство е неповратно.",
+       "prefs-reset-intro": "Може да ја користите оваа страница за враќање на вашите нагодувања на стандардните нагодувања на мрежното место.\nОва дејство е неповратно.",
        "prefs-emailconfirm-label": "Потврда на е-пошта:",
        "youremail": "Е-пошта:",
        "username": "{{GENDER:$1|Корисничко име}}:",
        "right-reupload-own": "Преснимување на постоечка податотека подигната од вас",
        "right-reupload-shared": "Презапис на едни податотеки врз други на заедничкото мултимедијално складиште месно",
        "right-upload_by_url": "Подигање на податотека од URL-адреса",
-       "right-purge": "Ð\91Ñ\80иÑ\88еÑ\9aе Ð¾Ð´ Ð¾Ð¿Ñ\81лÑ\83жÑ\83ваÑ\87ки Ð¼ÐµÑ\93Ñ\83Ñ\81клад Ð½Ð° Ñ\81Ñ\82Ñ\80аниÑ\86аÑ\82а Ð±ÐµÐ· Ð±Ð°Ñ\80аÑ\9aе Ð¿Ð¾Ñ\82вÑ\80да Ð·Ð° Ñ\82оа",
+       "right-purge": "Ð\91Ñ\80иÑ\88еÑ\9aе Ð½Ð° Ð¼ÐµÑ\93Ñ\83Ñ\81клад Ð½Ð° Ñ\81Ñ\82Ñ\80аниÑ\86а",
        "right-autoconfirmed": "Без ограничувања на стапки за IP-адреса",
        "right-bot": "Третиран како автоматски процес",
        "right-nominornewtalk": "Ситните уредувања на разговорни страници да не поттикнуваат потсетник за нова порака",
        "grant-delete": "Бришење страници, преработки и дневнички записи",
        "grant-editinterface": "Уредување на именскиот простор „МедијаВики“ и JSON за цело вики/за корисник",
        "grant-editmycssjs": "Уредување на вашиот кориснички CSS/JSON/JavaScript",
-       "grant-editmyoptions": "Уредување на вашите кориснички нагодувања и поставеноста на JSON",
+       "grant-editmyoptions": "Уредување на вашите кориснички нагодувања и JSON-конфигурацијата",
        "grant-editmywatchlist": "Уредување на вашите набљудувани",
        "grant-editsiteconfig": "Уредување на CSS/JS за цело вики и за корисник",
        "grant-editpage": "Менување постоечки страници",
        "rcfilters-restore-default-filters": "Поврати основни филтри",
        "rcfilters-clear-all-filters": "Тргни ги сите филтри",
        "rcfilters-show-new-changes": "Погл. најнови промени",
-       "rcfilters-search-placeholder": "Филтрирање на промени (со менито или пребарајте назив на филтер)",
+       "rcfilters-search-placeholder": "Филтрирање на промени (користете го менито или пребарајте назив на филтер)",
        "rcfilters-invalid-filter": "Неважечки филтер",
        "rcfilters-empty-filter": "Нема активни филтри. Прикажани се сите придонеси.",
        "rcfilters-filterlist-title": "Филтри",
        "windows-nonascii-filename": "Опслужувачот не поддржува податотечни имиња со псоебни знаци.",
        "fileexists": "Податотека со ова име веќе постои. Проверете <strong>[[:$1]]</strong> ако не {{GENDER:|сте}} сигурни дали сакате да ја промените.\n[[$1|thumb]]",
        "filepageexists": "Описната страница на оваа податотека е веќе создадена на <strong>[[:$1]]</strong>, но не постои податотека со тоа име.\nОписот кој го внесовте нема да стои на описната страница.\nДоколку сакате описот да стои тука, ќе морате да го уредите рачно.\n[[$1|thumb]]",
-       "fileexists-extension": "Податотека со слично име веќе постои: [[$2|thumb]]\n* Име на податотека која се подигнува: <strong>[[:$1]]</strong>\n* Име на постоечката податотека: <strong>[[:$2]]</strong>\nДали можеби би сакале да користите покарактеристично име.",
+       "fileexists-extension": "Податотека со слично име веќе постои: [[$2|thumb]]\n* Име на податотека која се подигнува: <strong>[[:$1]]</strong>\n* Име на постоечката податотека: <strong>[[:$2]]</strong>\nДали можеби би сакале да користите покарактеристично име?",
        "fileexists-thumbnail-yes": "Се чини дека податотеката е слика со намалена големина ''(минијатура)''. [[$1|thumb]]\nПроверете ја податотеката <strong>[[:$1]]</strong>.\nАко податотеката која ја проверувате е истата слика во својата изворна големина тогаш не мора да ја подигате дополнително.",
        "file-thumbnail-no": "Името на податотеката почнува со <strong>$1</strong>.\nИзгледа дека е слика со намалена големина ''(мини, thumbnail)''.\nАко ја имате оваа слика во изворна големина, подигнете ја неја. Во спротивно сменете го името на податотеката.",
        "fileexists-forbidden": "Податотека со тоа име веќе постои и не може да биде заменета.\nАко и понатаму сакате да ја подигнете вашата податотеката, ве молиме вратете се назад и подигнете ја под друго име. [[File:$1|thumb|center|$1]]",
        "upload-form-label-own-work": "Ова е мое дело",
        "upload-form-label-infoform-categories": "Категории",
        "upload-form-label-infoform-date": "Датум",
-       "upload-form-label-own-work-message-generic-local": "Потврдувам дека податотекава ја подигам во согласност со условите на користење и правилата за лиценцирање на {{SITENAME}}.",
+       "upload-form-label-own-work-message-generic-local": "Потврдувам дека ја подигам податотекава во согласност со условите на користење и правилата за лиценцирање на {{SITENAME}}.",
        "upload-form-label-not-own-work-message-generic-local": "Ако не сте во можност да ја подигнете податотекава согласно правилата на {{SITENAME}}, затворете го дијалогов и обидете се на друг начин.",
        "upload-form-label-not-own-work-local-generic-local": "Можете да ја пробате и [[Special:Upload|стандардната страница за подигање]].",
        "upload-form-label-own-work-message-generic-foreign": "Разбирам дека ја подигам податотекава на заедничко складиште. Потврдувам дека со тоа ги почитувам тамошните услови на користење и лиценцните правила.",
        "delete-confirm": "Бришење на „$1“",
        "delete-legend": "Бришење",
        "historywarning": "<strong>Предупредување:</strong> Страницата што сакате да ја избришете има историја со {{PLURAL:$1|една преработка|$1 преработки}}:",
-       "historyaction-submit": "Прикажи",
+       "historyaction-submit": "Прикажи преработки",
        "confirmdeletetext": "На пат сте трајно да избришете страница заедно со нејзината историја.\nПотврдете дека имате намера да го направите ова, дека ги разбирате последиците од тоа и дека го правите во согласност со [[{{MediaWiki:Policy-url}}|правилата]].",
        "actioncomplete": "Дејството е извршено",
        "actionfailed": "Неуспешно дејство",
        "pageinfo-category-files": "Број на податотеки",
        "pageinfo-user-id": "Корисничка назнака",
        "pageinfo-file-hash": "Тарабна вредност",
-       "pageinfo-view-protect-log": "Погл. го дневникот на заштити за страницава.",
+       "pageinfo-view-protect-log": "Преглед на дневникот на заштити за страницава.",
        "markaspatrolleddiff": "Означи како проверена верзија",
        "markaspatrolledtext": "Означи ја верзијата како проверена",
        "markaspatrolledtext-file": "Означи ја верзијава како испатролирана",
        "duration-hours": "{{PLURAL:$1|еден час|$1 часа}}",
        "duration-days": "{{PLURAL:$1|еден ден|$1 дена}}",
        "duration-weeks": "$1 {{PLURAL:$1|недела|недели}}",
-       "duration-years": "{{PLURAL:$1|$1 година|$1 години}}",
+       "duration-years": "$1 {{PLURAL:$1|година|години}}",
        "duration-decades": "$1 {{PLURAL:$1|деценија|децении}}",
        "duration-centuries": "$1 {{PLURAL:$1|век|века}}",
        "duration-millennia": "$1 {{PLURAL:$1|милениум|милениуми}}",
        "mediastatistics-summary": "Статистики за подигнати типови податотеки. Се зема предвид само последната верзија на податотеката. Старите и избришаните верзии не се бројат.",
        "mediastatistics-nfiles": "$1 ($2 %)",
        "mediastatistics-nbytes": "{{PLURAL:$1|Еден бајт|$1 бајти}} ($2; $3%)",
-       "mediastatistics-bytespertype": "Ð\92кÑ\83пен Ð¾Ð±ÐµÐ¼ Ð½Ð° Ð¿Ð°Ñ\81Ñ\83Ñ\81от: {{PLURAL:$1|$1 бајт|$1 бајти}} ($2; $3%).",
+       "mediastatistics-bytespertype": "Ð\92кÑ\83пен Ð¾Ð±ÐµÐ¼ Ð½Ð° Ð¾Ð´Ð´ÐµÐ»от: {{PLURAL:$1|$1 бајт|$1 бајти}} ($2; $3%).",
        "mediastatistics-allbytes": "Вкупен обем на сите податотеки: {{PLURAL:$1|$1 бајт|$1 бајти}} ($2).",
        "mediastatistics-table-mimetype": "MIME-тип",
        "mediastatistics-table-extensions": "Можни додатоци",
index 70afeed..a6dc7e4 100644 (file)
        "and": "&#32;ഒപ്പം",
        "faq": "പതിവുചോദ്യങ്ങൾ",
        "actions": "നടപടികൾ",
-       "namespaces": "നാമമേഖല",
+       "namespaces": "നാമമേഖലകൾ",
        "variants": "രൂപഭേദങ്ങൾ",
        "navigation-heading": "ഗമന വഴികാട്ടി",
        "errorpagetitle": "പിഴവ്",
        "history": "നാൾവഴി",
        "history_short": "നാൾവഴി",
        "history_small": "നാൾവഴി",
-       "updatedmarker": "à´\95à´´à´¿à´\9eàµ\8dà´\9e à´¸à´¨àµ\8dദർശനതàµ\8dതിനàµ\81 ശേഷം മാറ്റം വന്നത്",
+       "updatedmarker": "à´\95à´´à´¿à´\9eàµ\8dà´\9e à´¸à´¨àµ\8dദർശനതàµ\8dതിനàµ\8d ശേഷം മാറ്റം വന്നത്",
        "printableversion": "അച്ചടിരൂപം",
        "permalink": "സ്ഥിരംകണ്ണി",
        "print": "അച്ചടിയ്ക്കുക",
        "viewdeleted_short": "{{PLURAL:$1|മായ്ക്കപ്പെട്ട ഒരു തിരുത്തൽ|മായ്ക്കപ്പെട്ട $1 തിരുത്തലുകൾ}} കാണുക",
        "protect": "സം‌രക്ഷിക്കുക",
        "protect_change": "സംരക്ഷണമാനത്തിൽ മാറ്റം വരുത്തുക",
-       "unprotect": "à´¸à´\82à´°à´\95àµ\8dà´·à´£à´\82",
+       "unprotect": "à´¸à´\82à´°à´\95àµ\8dഷണതàµ\8dതിൽ à´®à´¾à´±àµ\8dà´±à´\82വരàµ\81à´¤àµ\8dà´¤àµ\81à´\95",
        "newpage": "പുതിയ താൾ",
        "talkpagelinktext": "സംവാദം",
        "specialpage": "പ്രത്യേക താൾ",
-       "personaltools": "à´¸àµ\8dà´µà´\95ാരàµ\8dയതാളàµ\81à´\95ൾ",
+       "personaltools": "à´µàµ\8dà´¯à´\95àµ\8dതിà´\97à´¤ à´\89à´ªà´\95à´°à´£à´\99àµ\8dà´\99ൾ",
        "talk": "സംവാദം",
        "views": "ദർശനീയത",
        "toolbox": "ഉപകരണങ്ങൾ",
        "versionrequired": "മീഡിയാവിക്കിയുടെ പതിപ്പ് $1 ആവശ്യമാണ്",
        "versionrequiredtext": "ഈ താൾ ഉപയോഗിക്കാൻ മീഡിയവിക്കി പതിപ്പ് $1 ആവശ്യമാണ്. കൂടുതൽ വിവരങ്ങൾക്ക് [[Special:Version|മീഡിയാവിക്കി പതിപ്പ് താൾ]] കാണുക.",
        "ok": "ശരി",
-       "retrievedfrom": "\"$1\" à´\8eà´¨àµ\8dà´¨ à´¤à´¾à´³à´¿àµ½à´¨à´¿à´¨àµ\8dà´¨àµ\81 ശേഖരിച്ചത്",
+       "retrievedfrom": "\"$1\" à´\8eà´¨àµ\8dà´¨ à´¤à´¾à´³à´¿àµ½à´¨à´¿à´¨àµ\8dà´¨àµ\8d ശേഖരിച്ചത്",
        "youhavenewmessages": "താങ്കൾക്ക് $1 ഉണ്ട് ($2).",
        "youhavenewmessagesfromusers": "താങ്കൾക്ക് {{PLURAL:$3|ഒരു ഉപയോക്താവ്|$3 ഉപയോക്താക്കൾ}} $1 ചേർത്തിട്ടുണ്ട് ($2).",
        "youhavenewmessagesmanyusers": "താങ്കൾക്ക് പലർ $1 ചേർത്തിട്ടുണ്ട് ($2).",
        "red-link-title": "$1 (ഇതുവരെ എഴുതപ്പെട്ടിട്ടില്ല)",
        "sort-descending": "അവരോഹണമായി ക്രമപ്പെടുത്തുക",
        "sort-ascending": "ആരോഹണമായി ക്രമപ്പെടുത്തുക",
-       "nstab-main": "à´²àµ\87à´\96à´¨à´\82",
+       "nstab-main": "താൾ",
        "nstab-user": "ഉപയോക്തൃതാൾ",
        "nstab-media": "മീഡിയാ താൾ",
        "nstab-special": "പ്രത്യേക താൾ",
        "badretype": "താങ്കൾ നൽകിയ രഹസ്യവാക്കുകൾ ഒത്തുപോകുന്നില്ല.",
        "usernameinprogress": "ഈ ഉപയോക്തൃനാമത്തിലുള്ള അംഗത്വ നിർമ്മിതി നടന്നുകൊണ്ടിരിക്കുന്നു.\nദയവായി കാത്തിരിക്കുക.",
        "userexists": "നൽകിയ ഉപയോക്തൃനാമം മുമ്പേ നിലവിലുണ്ട്.\nദയവായി മറ്റൊരു ഉപയോക്തൃനാമം തിരഞ്ഞെടുക്കുക.",
+       "createacct-normalization": "സാങ്കേതിക കാരണങ്ങളാൽ താങ്കളുടെ ഉപയോക്തൃനാമം \"$2\" എന്നാക്കി മാറ്റുന്നതാണ്.",
        "loginerror": "പ്രവേശനം സാധിച്ചില്ല",
        "createacct-error": "അംഗത്വസൃഷ്ടിക്കിടെ പിഴവുണ്ടായി",
        "createaccounterror": "അംഗത്വമെടുക്കാൻ കഴിഞ്ഞില്ല:$1",
        "linkaccounts-submit": "അംഗത്വങ്ങൾ കണ്ണി ചേർക്കുക",
        "unlinkaccounts": "അംഗത്വങ്ങൾ കണ്ണി മാറ്റുക",
        "unlinkaccounts-success": "അംഗത്വം കണ്ണി മാറ്റി.",
+       "userjsispublic": "ദയവായി ശ്രദ്ധിക്കുക: ജാവാസ്ക്രിപ്റ്റ് ഉപതാളുകൾ മറ്റ് ഉപയോക്താക്കൾക്ക് കാണാൻ കഴിയും, അവയിൽ സ്വകാര്യവിവരങ്ങൾ ചേർക്കാതിരിക്കുക.",
+       "userjsonispublic": "ദയവായി ശ്രദ്ധിക്കുക: ജെസൺ ഉപതാളുകൾ മറ്റ് ഉപയോക്താക്കൾക്ക് കാണാൻ കഴിയും, അവയിൽ സ്വകാര്യവിവരങ്ങൾ ചേർക്കാതിരിക്കുക.",
+       "usercssispublic": "ദയവായി ശ്രദ്ധിക്കുക: സി.എസ്.എസ്. ഉപതാളുകൾ മറ്റ് ഉപയോക്താക്കൾക്ക് കാണാൻ കഴിയും, അവയിൽ സ്വകാര്യവിവരങ്ങൾ ചേർക്കാതിരിക്കുക.",
        "restrictionsfield-badip": "അസാധുവായ ഐ.പി. വിലാസം അല്ലെങ്കിൽ പരിധി:$1",
        "restrictionsfield-label": "അനുവദിച്ചിട്ടുള്ള ഐ.പി. പരിധികൾ:",
        "edit-error-short": "പിഴവ്: $1",
index 5220e22..50dfe18 100644 (file)
@@ -72,6 +72,7 @@
        "tog-norollbackdiff": "နောက်ပြန်ပြင်ဆင်ခြင်း လုပ်ဆောင်ပြီးပါက ကွဲပြားမှုကိုမပြပါနှင့်။",
        "tog-useeditwarning": "မသိမ်းရသေးသော ပြောင်းလဲမှုများ နှင့် တည်းဖြတ်ဆဲစာမျက်နှာမှ ထွက်သွားလျှင် သတိပေးပါ",
        "tog-prefershttps": "လော့ဂ်အင်ဝင်ချိန်တွင် လုံခြုံသော ဆက်သွယ်မှုကို အမြဲတမ်း အသုံးပြုရန်",
+       "tog-showrollbackconfirmation": "နောက်ပြန် ပြန်သွားရန်လင့်ခ်ကို နှိပ်သောအခါ အတည်ပြုချက်တောင်းခံပုံအား ပြသရန်",
        "underline-always": "အမြဲ",
        "underline-never": "ဘယ်သောအခါမျှ",
        "underline-default": "ဘရောက်ဆာ သို့ Skin default အတိုင်း",
        "histfirst": "အဟောင်းဆုံး",
        "histlast": "အသစ်ဆုံး",
        "historysize": "({{PLURAL:$1|1 ဘိုက်|$1 ဘိုက်}})",
-       "historyempty": "(ဘာမှမရှိ)",
+       "historyempty": "ဘာမှမရှိ",
        "history-feed-title": "မူရာဇဝင်မှတ်တမ်း",
        "history-feed-description": "ဝီကီပေါ်ရှိ ဤစာမျက်နှာ၏ တည်းဖြတ်မှုရာဇဝင်",
        "history-feed-item-nocomment": "$2 က $1",
        "deleting-backlinks-warning": "<strong>သတိပေးချက်။</strong> သင်ဖျက်ပစ်တော့မည့် စာမျက်နှာအား [[Special:WhatLinksHere/{{FULLPAGENAME}}|အခြားစာမျက်နှာများမှ]] ချိတ်ဆက်ထားခြင်း သို့မဟုတ် ထည့်သွင်းထားခြင်း ရှိနေသည်။",
        "deleting-subpages-warning": "<strong>သတိပေးချက်။</strong> သင်ဖျက်တော့မည့် စာမျက်နှာတွင် [[Special:PrefixIndex/{{FULLPAGENAME}}/|{{PLURAL:$1|စာမျက်နှာခွဲ တစ်ခု|စာမျက်နှာခွဲ $1 ခု|51=စာမျက်နှာခွဲ ၅၀ ကျော်}}]] ရှိနေသည်။",
        "rollback": "နောက်ပြန်ပြင် တည်းဖြတ်မှုများ",
+       "rollback-confirmation-yes": "နောက်ပြန် ပြန်သွားရန်",
+       "rollback-confirmation-no": "မလုပ်တော့ပါ",
        "rollbacklink": "နောက်ပြန် ပြန်သွားရန်",
        "rollbacklinkcount": "{{PLURAL:$1|တည်းဖြတ်မှု|တည်းဖြတ်မှုများ}} $1 ကို နောက်ပြန်ပြင်ရန်",
        "rollbacklinkcount-morethan": "$1 ထက်ပိုသော {{PLURAL:$1|တည်းဖြတ်မှု|တည်းဖြတ်မှုများ}}ကို နောက်ပြန်ပြင်ရန်",
index dfaf649..489fba6 100644 (file)
        "userpage-userdoesnotexist": "Gebruikersaccount \"$1\" bestaat niet.\nControleer of u deze pagina wel wilt aanmaken/bewerken.",
        "userpage-userdoesnotexist-view": "Gebruikersaccount \"$1\" bestaat niet.",
        "blocked-notice-logextract": "Deze gebruiker is momenteel geblokkeerd.\nDe laatste regel uit het blokkeerlogboek wordt hieronder ter referentie weergegeven:",
-       "clearyourcache": "<strong>Opmerking:</strong> nadat u de wijzigingen hebt opgeslagen is het wellicht nodig uw browsercache te legen.\n* <strong>Firefox / Safari:</strong> houd <em>Shift</em> ingedrukt terwijl u op <em>Vernieuwen</em> klikt of druk op <em>Ctrl-F5</em> of <em>Ctrl-R</em> (<em>⌘-Shift-R</em> op een Mac)\n* <strong>Google Chrome:</strong> druk op <em>Ctrl-Shift-R</em> (<em>⌘-Shift-R</em> op een Mac)\n* <strong>Internet Explorer:</strong> houd <em>Ctrl</em> ingedrukt terwijl u op <em>Vernieuwen</em> klikt of druk op <em>Ctrl-F5</em>\n* '''Opera:''' ga naar <em>Menu → Instellingen</em> (<em>Opera → Voorkeuren</em> op een Mac) en daarna naar <em>Privacy & beveiliging → Browsegegevens wissen... →  Tijdelijk opgeslgen afbeeldingen en bestanden</em>.",
+       "clearyourcache": "<strong>Opmerking:</strong> nadat u de wijzigingen hebt opgeslagen is het wellicht nodig uw browsercache te legen.\n* <strong>Firefox / Safari:</strong> houd <em>Shift</em> ingedrukt terwijl u op <em>Vernieuwen</em> klikt of druk op <em>Ctrl-F5</em> of <em>Ctrl-R</em> (<em>⌘-Shift-R</em> op een Mac)\n* <strong>Google Chrome:</strong> druk op <em>Ctrl-Shift-R</em> (<em>⌘-Shift-R</em> op een Mac)\n* <strong>Internet Explorer:</strong> houd <em>Ctrl</em> ingedrukt terwijl u op <em>Vernieuwen</em> klikt of druk op <em>Ctrl-F5</em>\n* '''Opera:''' ga naar <em>Menu → Instellingen</em> (<em>Opera → Voorkeuren</em> op een Mac) en daarna naar <em>Privacy & beveiliging → Browsegegevens wissen... →  Tijdelijk opgeslagen afbeeldingen en bestanden</em>.",
        "usercssyoucanpreview": "'''Tip:''' gebruik de knop \"{{int:showpreview}}\" om uw nieuwe CSS te testen alvorens op te slaan.",
        "userjsonyoucanpreview": "'''Tip:''' gebruik de knop \"{{int:showpreview}}\" om uw nieuwe JSON te testen alvorens op te slaan.",
        "userjsyoucanpreview": "'''Tip:''' gebruik de knop \"{{int:showpreview}}\" om uw nieuwe JavaScript te testen alvorens op te slaan.",
        "delete-confirm": "\"$1\" verwijderen",
        "delete-legend": "Verwijderen",
        "historywarning": "<strong>Waarschuwing:</strong> de pagina die u wilt verwijderen heeft ongeveer $1 {{PLURAL:$1|versie|versies}}:",
-       "historyaction-submit": "Weergeven",
+       "historyaction-submit": "Versies weergeven",
        "confirmdeletetext": "U staat op het punt een pagina te verwijderen, inclusief de geschiedenis.\nBevestig hieronder dat dit inderdaad uw bedoeling is, dat u de gevolgen begrijpt en dat de verwijdering overeenstemt met het [[{{MediaWiki:Policy-url}}|beleid]].",
        "actioncomplete": "Handeling voltooid",
        "actionfailed": "Handeling mislukt",
index 213ce6d..aa6717e 100644 (file)
        "logentry-block-block": "$1 {{GENDER:$2|zablokował|zablokowała|zablokował(a)}} {{GENDER:$4|$3}}, czas blokady: $5 $6",
        "logentry-block-unblock": "$1 {{GENDER:$2|zdjął|zdjęła}} blokadę z {{GENDER:$4|$3}}",
        "logentry-block-reblock": "$1 {{GENDER:$2|zmienił|zmieniła}} ustawienia blokady dla {{GENDER:$4|$3}}, czas blokady: $5 $6",
-       "logentry-partialblock-block-page": "{{PLURAL:$1|strony|stron}}: $2",
-       "logentry-partialblock-block-ns": "przestrzeni nazw{{PLURAL:$1|:|}} $2",
+       "logentry-partialblock-block-page": "{{PLURAL:$1|strony|stron:}} $2",
+       "logentry-partialblock-block-ns": "przestrzeni {{PLURAL:$1|nazw:|nazw}} $2",
        "logentry-partialblock-block": "$1 {{GENDER:$2|wyłączył|wyłączyła}} {{GENDER:$4|$3}} z edytowania $7, czas blokady: $5 $6",
        "logentry-partialblock-reblock": "$1 {{GENDER:$2|zmienił|zmieniła}} ustawienia wyłączenia {{GENDER:$4|$3}} z edytowania $7, czas blokady: $5 $6",
        "logentry-non-editing-block-block": "$1 {{GENDER:$2|zablokował|zablokowała}} {{GENDER:$4|$3}} wykonywanie określonych operacji nieedycyjnych, czas blokady: $5 $6",
index e45dda0..a658900 100644 (file)
        "delete-confirm": "Used as page title. Parameters:\n* $1 - the page title\n{{Identical|Delete}}",
        "delete-legend": "{{Identical|Delete}}",
        "historywarning": "Warning when about to delete a page that has history.\n\nFollowed by a link which points to the history page.\n\nParameters:\n* $1 - the number of revisions that the page has",
-       "historyaction-submit": "Submit button on history pages\n{{Identical|Show}}",
+       "historyaction-submit": "Submit button to show revisions on revision history pages",
        "confirmdeletetext": "Introduction shown when deleting a page.\n\nRefers to {{msg-mw|Policy-url}}.",
        "actioncomplete": "Used in several situations, for example when a page has been deleted.\n\nSee also:\n* {{msg-mw|Actionfailed|page title}}",
        "actionfailed": "Used as page title when the submit operation failed, in [[Special:RevisionDelete]].\n\nSee also:\n* {{msg-mw|Actioncomplete|page title}}",
index d16e9aa..a7d26b1 100644 (file)
        "tog-norollbackdiff": "Не показывать разницу версий после выполнения отката",
        "tog-useeditwarning": "Предупреждать, когда я покидаю страницу с несохранёнными изменениями",
        "tog-prefershttps": "Всегда использовать защищённое соединение после представления системе",
+       "tog-showrollbackconfirmation": "Показывать подтверждение при нажатии ссылки для отката",
        "underline-always": "Всегда",
        "underline-never": "Никогда",
        "underline-default": "Использовать настройки браузера",
        "histfirst": "старейшие",
        "histlast": "новейшие",
        "historysize": "($1 {{PLURAL:$1|байт|байта|байт}})",
-       "historyempty": "(пусто)",
+       "historyempty": "пусто",
        "history-feed-title": "История изменений",
        "history-feed-description": "История изменений этой страницы в вики",
        "history-feed-item-nocomment": "$1 в $2",
        "right-reupload-own": "Перезапись файлов, загруженных тем же участником",
        "right-reupload-shared": "Замена файлов из общих хранилищ локальными",
        "right-upload_by_url": "Загрузка файлов с адреса URL",
-       "right-purge": "Очистка кэша страниц без подтверждения",
+       "right-purge": "Очистка кэша страницы",
        "right-autoconfirmed": "Обход ограничений скорости на IP-адрес",
        "right-bot": "Автоматический процесс",
        "right-nominornewtalk": "Малые правки на страницах обсуждений участников не создают для них уведомление о новом сообщении",
        "ipb-confirm": "Подтвердить блокировку",
        "ipb-sitewide": "Во всём проекте",
        "ipb-partial": "Частичная",
+       "ipb-sitewide-help": "Каждая страница вики и все другие действия вклада.",
+       "ipb-partial-help": "Конкретные страницы или пространства имён.",
        "ipb-pages-label": "Страницы",
        "ipb-namespaces-label": "Пространства имён",
        "badipaddress": "IP-адрес записан в неправильном формате, или участника с таким именем не существует.",
        "confirm-unwatch-top": "Удалить эту страницу из вашего списка наблюдения?",
        "confirm-rollback-button": "ОК",
        "confirm-rollback-top": "Откатить правки на этой странице?",
+       "confirm-rollback-bottom": "Это действие немедленно откатит выбранные изменения этой страницы.",
        "confirm-mcrrestore-title": "Восстановить версию",
        "confirm-mcrundo-title": "Отменить изменение",
        "mcrundofailed": "Отменить не удалось",
        "logentry-rights-autopromote": "$1 был{{GENDER:$2||а}} автоматически переведен{{GENDER:$2||а}} из $4 в $5",
        "logentry-upload-upload": "$1 загрузил{{GENDER:$2||а}} $3",
        "logentry-upload-overwrite": "$1 загрузил{{GENDER:$2||а}} новую версию $3",
-       "logentry-upload-revert": "$1 Ð·Ð°Ð³Ñ\80Ñ\83зил{{GENDER:$2||а}} $3",
+       "logentry-upload-revert": "$1 Ð¾Ñ\82каÑ\82ил{{GENDER:$2||а}} $3 Ðº Ñ\81Ñ\82аÑ\80ой Ð²ÐµÑ\80Ñ\81ии",
        "log-name-managetags": "Журнал управления метками",
        "log-description-managetags": "На этой странице перечислены задачи, связанные с управлением [[Special:Tags|метками]]. Журнал содержит только действия, выполненные администратором вручную. Метки могут быть созданы или удалены с помощью программного обеспечения вики без добавления записей в этот журнал.",
        "logentry-managetags-create": "$1 создал{{GENDER:$2||а}} метку «$4»",
        "log-action-filter-suppress-reblock": "Сокрытие пользователя через повторное блокирование",
        "log-action-filter-upload-upload": "Новая загрузка",
        "log-action-filter-upload-overwrite": "Повторно загрузить",
+       "log-action-filter-upload-revert": "Откатить",
        "authmanager-authn-not-in-progress": "Проверка подлинности не выполняется или данные сессии были утеряны. Пожалуйста, начните снова с самого начала.",
        "authmanager-authn-no-primary": "Предоставленные учётные данные не могут быть проверены на подлинность.",
        "authmanager-authn-no-local-user": "Предоставленные учётные данные не связаны ни с одним участником этой вики.",
        "passwordpolicies-policy-passwordcannotbepopular": "Пароль не может соответствовать {{PLURAL:$1|самому часто используемому паролю|какому-либо из $1 самых часто используемых паролей}}",
        "passwordpolicies-policy-passwordnotinlargeblacklist": "Пароль не может соответствовать какому-либо из 100 000 самых часто используемых паролей.",
        "passwordpolicies-policyflag-forcechange": "необходимо изменить при входе",
+       "passwordpolicies-policyflag-suggestchangeonlogin": "предложить изменение при входе",
        "easydeflate-invaliddeflate": "Предоставленное содержимое не спущено надлежащим образом",
        "unprotected-js": "По соображениям безопасности JavaScript нельзя загружать с незащищённых страниц. Пожалуйста, создавайте скрипты только в пространстве имён MediaWiki: или как подстраницы участника."
 }
index d5e5473..fb18ded 100644 (file)
        "view": "Видіти",
        "view-foreign": "Видіти на $1",
        "edit": "Едітовати",
+       "edit-local": "Едітовати локальный опис",
        "create": "Створити",
+       "create-local": "Придати локальный опис",
        "delete": "Вымазати",
        "undelete_short": "Обновити $1 {{PLURAL:$1|верзію|верзії|верзії}}",
        "viewdeleted_short": "Видїти {{PLURAL:$1|змазанов едітаціёв|$1 змазаны едітації|$1 змазаных едітацій}}",
        "jumptonavigation": "навіґація",
        "jumptosearch": "Найти",
        "view-pool-error": "Перебачте, серверы суть теперь переладованы.\nТоту сторінку собі теперь пoзерать много хоснователїв.\nПросиме Вас, почекайте і спробуйте доступность пізнїше.\n\n$1",
+       "generic-pool-error": "Перебачте, серверы суть теперь переладованы.\nТоту сторінку собі теперь пoзерать много хоснователїв.\nПросиме Вас, почекайте і спробуйте доступность пізнїше.",
        "pool-timeout": "Час скінчіня чекать про замок",
        "pool-queuefull": "Фронта є повна",
        "pool-errorunknown": "Незнама хыба",
        "ns-specialprotected": "Шпеціалны сторінкы не є можне едітовати.",
        "titleprotected": "Створїня сторінкы з таков назвов было заборонене хоснователём [[User:$1|$1]] з причінов: <em>$2</em>.",
        "filereadonlyerror": "Не годно змінити файл „$1“, бо архів файлів „$2“ є теперь лем на чітаня.\n\nАдміністратор сервера, котрый архів заблоковав, додав тото пояснїня: „''$3''“.",
+       "invalidtitle": "Неприпустна назва",
        "invalidtitle-knownnamespace": "Непряавилна назва в просторї назв „$2“ і текстом „$3“",
        "invalidtitle-unknownnamespace": "Неправилна назва з незнамым чіслом простору назв $1 і текстом „$2“",
        "exception-nologin": "Не сьте приголошеный(а)",
        "resetpass_submit": "Наставити гесло і приголосити ся",
        "changepassword-success": "Ваше гесло было змінено!",
        "changepassword-throttled": "Зробили сьте дуже много спроб о приголошіня.\nПросиме Вас, почекайте $1 перед далшов спробов.",
+       "botpasswords-label-create": "Створити",
+       "botpasswords-label-update": "Обновити",
+       "botpasswords-label-cancel": "Зрушыти",
+       "botpasswords-label-delete": "Вымазати",
        "resetpass_forbidden": "Гесла не є можне змінити",
        "resetpass_forbidden-reason": "Гесла не є можне змінити: $1",
        "resetpass-no-info": "Ку тій сторінцї мають прямый приступ лем приголошены хоснователї.",
        "action-createpage": "створити тоту сторінку",
        "action-createtalk": "створити тоту діскузну сторінку",
        "action-createaccount": "Вытворїня того конта хоснователя",
+       "action-history": "зобразити історію той сторінкы",
        "action-minoredit": "означіти тото едітованя як мале",
        "action-move": "Переменовати тоту сторінку",
        "action-move-subpages": "переменованя той сторінкы зо вшыткыма єй підсторінками",
        "action-upload_by_url": "наладовати тот файл з URL адресы",
        "action-writeapi": "хосновати API про писаня",
        "action-delete": "Вымазати тоту сторінку",
-       "action-deleterevision": "вымазати тоту ревізію сторінкы",
-       "action-deletedhistory": "зобразити історію змазаных ревізій той сторінкы",
+       "action-deleterevision": "вымазаня ревізій",
+       "action-deletedhistory": "зобразити історію змазаных ревізій сторінкы",
+       "action-deletedtext": "зобразити змазаны тексты ревізії",
        "action-browsearchive": "глядати змазаны сторінкы",
        "action-undelete": "обновити тоту сторінку",
-       "action-suppressrevision": "Ñ\81конÑ\82Ñ\80олÑ\91ваÑ\82и Ñ\96 Ð¾Ð±Ð½Ð¾Ð²Ð¸Ñ\82и Ñ\82оÑ\82Ñ\83 Ñ\81Ñ\85ованÑ\83 Ñ\80евÑ\96зÑ\96Ñ\8e",
+       "action-suppressrevision": "Ñ\81конÑ\82Ñ\80олÑ\91ваÑ\82и Ñ\96 Ð¾Ð±Ð½Ð¾Ð²Ð¸Ñ\82и Ñ\81Ñ\85ованÑ\8b Ñ\80евÑ\96зÑ\96Ñ\97",
        "action-suppressionlog": "перегляд того пріватного лоґу",
        "action-block": "блокованя того хоснователя",
        "action-protect": "змінити рівень охраны той сторінкы",
        "action-rollback": "швыдко вернути управы послїднёго хоснователя едітуючого дану сторінку",
-       "action-import": "Ñ\96мпоÑ\80Ñ\82 Ñ\82ой Ñ\81Ñ\82оÑ\80Ñ\96нкÑ\8b з іншой вікі",
-       "action-importupload": "Ñ\96мпоÑ\80Ñ\82 Ñ\82ой Ñ\81Ñ\82оÑ\80Ñ\96нкÑ\8b Ð· файлу",
+       "action-import": "Ñ\96мпоÑ\80Ñ\82 Ñ\81Ñ\82оÑ\80Ñ\96нок з іншой вікі",
+       "action-importupload": "Ñ\96мпоÑ\80Ñ\82 Ñ\81Ñ\82оÑ\80Ñ\96нок Ð· Ð½Ð°Ð»Ð°Ð´Ð¾Ð²Ð°Ð½Ñ\8f файлу",
        "action-patrol": "позначіти чуджі едітованя як перевірены",
        "action-autopatrol": "означіти властных едітовань як патролованы",
        "action-unwatchedpages": "зображіня списку неслїдованых сторінок",
        "action-viewmywatchlist": "перезерати ваш список слїдованых сторінок",
        "action-viewmyprivateinfo": "перезерати вашы пріватны даны",
        "action-editmyprivateinfo": "едітовати вашы пріватны інформації",
+       "action-editcontentmodel": "едітовати модел обсягу сторінкы",
        "nchanges": "$1 {{PLURAL:$1|зміна|зміны|змін}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|од остатнёй навщівы}}",
        "enhancedrc-history": "історія",
        "uploaderror": "Під час ладованя ся притрафила хыба",
        "upload-recreate-warning": "'''Увага: Файл з тов назвов быв оперед змазаный ці переменованый.'''\n\nТу є про перегляд зображеный список мазаня і переменованя той сторінкы:",
        "uploadtext": "Ниже даный формуларь служыть на наладовованя файлів. Уж наладованы файлы собі можете перезерати і глядати помочов [[Special:FileList|списку наладованых файлів]], кажде наладованя ся тыж зазначує до [[Special:Log/upload|книгы наладованя]], змазаня суть в [[Special:Log/delete|книзї змазаных сторінок]].\n\nПро вложіня образчіка до сторінкы хоснуйте єден із слїдуючіх способів запису:\n* '''<code><nowiki>[[</nowiki>{{ns:file}}<nowiki>:Файл.jpg]]</nowiki></code>''' до сторінкы вложыть цїлый образок,\n* '''<code><nowiki>[[</nowiki>{{ns:file}}<nowiki>:Файл.png|thumb|left|Попис]]</nowiki></code>''' вложыть нагляд в рамику зарівнанім на лівый бік, з пописом „Попис“,\n* '''<code><nowiki>[[</nowiki>{{ns:media}}<nowiki>:Файл.ogg]]</nowiki></code>''' вложыть дірект одказ на файл, без того жебы ся зобразив на сторінцї.",
-       "upload-permitted": "Дозволены тіпы  файлів: $1.",
-       "upload-preferred": "Преферованы тіпы файлів: $1",
-       "upload-prohibited": "Заказаны тіпы файлів: $1.",
+       "upload-permitted": "{{PLURAL:$2|Дозволеный формат|Дозволены форматы}} файлів: $1.",
+       "upload-preferred": "{{PLURAL:$2|Преферованый формат|Преферованы форматы}} файлів: $1.",
+       "upload-prohibited": "{{PLURAL:$2|Заказаный формат|Заказаны форматы}} файлів: $1.",
        "uploadlogpage": "Лоґ наладованых файлів",
        "uploadlogpagetext": "Ниже найдете список найновшых файлів. Смотьте [[Special:NewFiles|ґалерію новых образків]] про веце візуалного нагляду.",
        "filename": "Назва файлу:",
        "largefileserver": "Розмір файлу є векшый як ліміт наставленый на сервері.",
        "emptyfile": "Файл, котрый сьте вложыли ся видить быти порожнїй. Могла то запричінити хыба в назві файлу. Просиме, перевірте ці сьте справды хотїли вложыти тот файл.",
        "windows-nonascii-filename": "Тота вікі не підпорує назвы файлів з шпеціалныма сімболами.",
-       "fileexists": "Файл з тов назвов уж екзістує, просиме посмотьте ся на <strong>[[:$1]]</strong>, покы не знаєте напевно, ці хочете тот файл нагородити.\n[[$1|thumb]]",
+       "fileexists": "Файл з тов назвов уж екзістує, просиме сконтролёвати <strong>[[:$1]]</strong>, покы не знаєте напевно, ці хочете тот файл нагородити.\n[[$1|thumb]]",
        "filepageexists": "Пописова сторінка про файл з тов назвов уж была на  <strong>[[:$1]]</strong> створена, але одповідаючій файл дотеперь не екзістує.\nЗгорнутя, котре ту зазначіте, ся на пописовій сторінцї не зобразить.\nКідь там хочете своє згорнутя зобразити, будете мусити дану сторінку едітовати мануално. [[$1|thumb]]",
        "fileexists-extension": "Уже екзістує файл з подобным іменом: [[$2|thumb]]\n* Назва наладованого файлу: <strong>[[:$1]]</strong>\n* Назва екзістуючого файлу: <strong>[[:$2]]</strong>\nВыберте іншу назву.",
        "fileexists-thumbnail-yes": "Тот файл є асі образчік в зменшеній великости ''(нагляд)''. [[$1|thumb]]\nПеревірте файл <strong>[[:$1]]</strong>.\nКідь є вказаный файл векшый, але інакше єднакый, не треба окремо наладовати ёго зменшену верзію.",
        "upload-too-many-redirects": "URL обсягує барз велё напрямлінь",
        "upload-http-error": "Стала ся хыба HTTP: $1",
        "upload-copy-upload-invalid-domain": "Наладовованя копірованём негодно з той домены.",
+       "upload-dialog-title": "Наладовати файл",
+       "upload-dialog-button-cancel": "Зрушыти",
+       "upload-dialog-button-back": "Назад",
+       "upload-dialog-button-done": "Готово",
+       "upload-dialog-button-save": "Уложыти",
+       "upload-dialog-button-upload": "Наладовати",
+       "upload-form-label-infoform-title": "Детайлы",
+       "upload-form-label-infoform-name": "Назва",
+       "upload-form-label-infoform-name-tooltip": "Куртый унікатный тітулок того файлу, котрый буде служыти як ёго назва. Можете хосновати обычайный текст і з павзами. Не вказуйте росшырїня файлу.",
+       "upload-form-label-infoform-description": "Опис",
+       "upload-form-label-infoform-description-tooltip": "Курто попиште вшыткы важны інформації о дїли.\nУ фотоґрафіях спомяните головны зображены обєкты, місце і окацію.",
+       "upload-form-label-usage-title": "Хоснованя",
+       "upload-form-label-infoform-categories": "Катеґорії",
+       "upload-form-label-infoform-date": "Датум",
        "backend-fail-stream": "Не вдало ся транслёвати файл $1.",
        "backend-fail-backup": "Не вдало ся створити резервну копію файлу $1.",
        "backend-fail-notexists": "Файл $1 не існує.",
index 92f4efd..277dd77 100644 (file)
        "logeventslist-tag-log": "Evidencija oznaka",
        "all-logs-page": "Sve evidencije - Све евиденције",
        "alllogstext": "Zajednički prikaz svih dostupnih evidencija sa {{SITENAME}}.\nMožete specificirati prikaz izabiranjem specifičnog spiska, korisničkog imena ili promjenjenog članka (razlikovati velika slova).",
-       "logempty": "Ne postoji takav zapis.",
+       "logempty": "Nema pronađenih stavki u evidenciji.",
        "log-title-wildcard": "Traži naslove koji počinju s ovim tekstom",
        "showhideselectedlogentries": "Prikaži/sakrij izabrane zapise u evidenciji",
        "log-edit-tags": "Uredi oznake izabranih zapisničkih unosa",
index 0f44ffe..7bcf82a 100644 (file)
        "histfirst": "najstarejše",
        "histlast": "najnovejše",
        "historysize": "({{PLURAL:$1|$1 zlog|$1 zloga|$1 zlogi|$1 zlogov}})",
-       "historyempty": "(prazno)",
+       "historyempty": "prazno",
        "history-feed-title": "Zgodovina strani",
        "history-feed-description": "Zgodovina navedene strani {{GRAMMAR:rodilnik|{{SITENAME}}}}",
        "history-feed-item-nocomment": "$1 ob $2",
index 24fd18f..cb2de8b 100644 (file)
        "histfirst": "äldsta",
        "histlast": "nyaste",
        "historysize": "({{PLURAL:$1|1 byte|$1 byte}})",
-       "historyempty": "(tom)",
+       "historyempty": "tom",
        "history-feed-title": "Versionshistorik",
        "history-feed-description": "Versionshistorik för denna sida på wikin",
        "history-feed-item-nocomment": "$1 den $2",
index e81bb47..9c5db0f 100644 (file)
        "logeventslist-tag-log": "Журнал міток",
        "all-logs-page": "Усі публічні журнали",
        "alllogstext": "Комбінований показ журналів {{grammar:genitive|{{SITENAME}}}}.\nВи можете відфільтрувати результати за типом журналу, іменем користувача (враховується регістр) або зазначеною сторінкою (також враховується регістр).",
-       "logempty": "У Ð¶Ñ\83Ñ\80налÑ\96 Ð½ÐµÐ¼Ð°Ñ\94 Ð¿Ð¾Ð´Ñ\96бних записів.",
+       "logempty": "У Ð¶Ñ\83Ñ\80налÑ\96 Ð½ÐµÐ¼Ð°Ñ\94 Ð²Ñ\96дповÑ\96дних записів.",
        "log-title-wildcard": "Знайти заголовки, що починаються з цих символів",
        "showhideselectedlogentries": "Показати/приховати виділені записи журналу",
        "log-edit-tags": "Змінити мітки для вибраних записів журналів",
index 611501f..6fcb108 100644 (file)
@@ -40,7 +40,8 @@
                        "Fitoschido",
                        "Dcljr",
                        "Bukhari",
-                       "Sajidkhan"
+                       "Sajidkhan",
+                       "Hmfs.ind"
                ]
        },
        "tog-underline": "ربط کی خط کشیدگی:",
        "nstab-category": "زمرہ",
        "mainpage-nstab": "صفحۂ اول",
        "nosuchaction": "مطلوبہ اقدام موجود نہیں",
-       "nosuchactiontext": "URL کی جانب سے مختص کیا گیا عمل درست نہیں.\nآپ نے شاید URL غلط لکھا، یا کسی غیر صحیح ربط کی پیروی کی ہے.\n{{اِس سے SITENAME کے زیرِ استعمال مصنع لطیف میں کھٹمل کی نشاندہی کا بھی اندیشہ ہے}}.",
+       "nosuchactiontext": "URL کی جانب سے مختص کیا گیا عمل درست نہیں۔\nآپ نے شاید URL غلط لکھا ہے یا کسی نادرست ربط پر کلک کیا ہے۔\nاِس سے {{SITENAME}} کے زیرِ استعمال سافٹ ویئر میں کسی خامی کی موجودگی کا بھی اندیشہ ہے۔",
        "nosuchspecialpage": "کوئی ایسا خاص صفحہ نہیں",
        "nospecialpagetext": "<strong>آپ نے ایک غیر موجود خصوصی صفحہ کی درخواست کی ہے۔</strong>\n\nدرست خاص صفحات کی ایک فہرست [[Special:SpecialPages|{{int:specialpages}}]] پر دیکھی جاسکتی ہے۔",
        "error": "نقص",
index 9c1093b..6a763f2 100644 (file)
@@ -24,6 +24,8 @@
  * @ingroup Maintenance
  */
 
+use MediaWiki\Shell\Shell;
+
 /**
  * Stream wrapper around 7za filter program.
  * Required since we can't pass an open file resource to XMLReader->open()
@@ -48,7 +50,7 @@ class SevenZipStream {
                } else {
                        return false;
                }
-               $arg = wfEscapeShellArg( $this->stripPath( $path ) );
+               $arg = Shell::escape( $this->stripPath( $path ) );
                $command = "7za $options $arg";
                if ( !wfIsWindows() ) {
                        // Suppress the stupid messages on stderr
index 6e545a6..0d4f14c 100644 (file)
@@ -1615,10 +1615,10 @@ abstract class Maintenance {
                $bash = ExecutableFinder::findInDefaultPaths( 'bash' );
                if ( !wfIsWindows() && $bash ) {
                        $retval = false;
-                       $encPrompt = wfEscapeShellArg( $prompt );
+                       $encPrompt = Shell::escape( $prompt );
                        $command = "read -er -p $encPrompt && echo \"\$REPLY\"";
-                       $encCommand = wfEscapeShellArg( $command );
-                       $line = wfShellExec( "$bash -c $encCommand", $retval, [], [ 'walltime' => 0 ] );
+                       $encCommand = Shell::escape( $command );
+                       $line = Shell::escape( "$bash -c $encCommand", $retval, [], [ 'walltime' => 0 ] );
 
                        if ( $retval == 0 ) {
                                return $line;
index 61c63e9..7566fe0 100644 (file)
@@ -29,6 +29,7 @@ require_once __DIR__ . '/7zip.inc';
 require_once __DIR__ . '/../includes/export/WikiExporter.php';
 
 use MediaWiki\MediaWikiServices;
+use MediaWiki\Shell\Shell;
 use MediaWiki\Storage\BlobAccessException;
 use MediaWiki\Storage\SqlBlobStore;
 use Wikimedia\Rdbms\IMaintainableDatabase;
@@ -756,7 +757,7 @@ TEXT
 
                if ( file_exists( "$IP/../multiversion/MWScript.php" ) ) {
                        $cmd = implode( " ",
-                               array_map( 'wfEscapeShellArg',
+                               array_map( [ Shell::class, 'escape' ],
                                        [
                                                $this->php,
                                                "$IP/../multiversion/MWScript.php",
@@ -764,7 +765,7 @@ TEXT
                                                '--wiki', wfWikiID() ] ) );
                } else {
                        $cmd = implode( " ",
-                               array_map( 'wfEscapeShellArg',
+                               array_map( [ Shell::class, 'escape' ],
                                        [
                                                $this->php,
                                                "$IP/maintenance/fetchText.php",
index a8a0c71..9502cdc 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+use MediaWiki\Shell\Shell;
+
 require __DIR__ . '/../Maintenance.php';
 
 class HHVMMakeRepo extends Maintenance {
@@ -103,7 +105,7 @@ class HHVMMakeRepo extends Maintenance {
 
                $hhvm = $this->getOption( 'hhvm', 'hhvm' );
                $verbose = $this->getOption( 'verbose', 3 );
-               $cmd = wfEscapeShellArg(
+               $cmd = Shell::escape(
                        $hhvm,
                        '--hphp',
                        '--target', 'hhbc',
index d84e02f..e1deb4c 100755 (executable)
@@ -1,6 +1,8 @@
 #!/usr/bin/hhvm -f
 <?php
 
+use MediaWiki\Shell\Shell;
+
 require __DIR__ . '/../Maintenance.php';
 
 class RunHipHopServer extends Maintenance {
@@ -12,8 +14,8 @@ class RunHipHopServer extends Maintenance {
                global $IP;
 
                passthru(
-                       'cd ' . wfEscapeShellArg( $IP ) . " && " .
-                       wfEscapeShellArg(
+                       'cd ' . Shell::escape( $IP ) . " && " .
+                       Shell::escape(
                                'hhvm',
                                '-c', __DIR__."/server.conf",
                                '--mode=server',
index 2d6a0be..791b360 100644 (file)
@@ -33,6 +33,8 @@
  * @version first release
  */
 
+use MediaWiki\Shell\Shell;
+
 require_once __DIR__ . '/Maintenance.php';
 
 /**
@@ -88,7 +90,7 @@ class MWDocGen extends Maintenance {
 
                // Do not use wfShellWikiCmd, because mwdoc-filter.php is not
                // a Maintenance script.
-               $this->inputFilter = wfEscapeShellArg( [
+               $this->inputFilter = Shell::escape( [
                        $wgPhpCli,
                        $IP . '/maintenance/mwdoc-filter.php'
                ] );
index 212a20d..a71abb6 100644 (file)
@@ -21,6 +21,8 @@
  * @ingroup Maintenance
  */
 
+use MediaWiki\Shell\Shell;
+
 require_once __DIR__ . '/Maintenance.php';
 
 /**
@@ -107,9 +109,9 @@ class PopulateImageSha1 extends LoggedUpdateMaintenance {
                        // in the pipe buffer. This can improve performance by up to a
                        // factor of 2.
                        global $wgDBuser, $wgDBserver, $wgDBpassword, $wgDBname;
-                       $cmd = 'mysql -u' . wfEscapeShellArg( $wgDBuser ) .
-                               ' -h' . wfEscapeShellArg( $wgDBserver ) .
-                               ' -p' . wfEscapeShellArg( $wgDBpassword, $wgDBname );
+                       $cmd = 'mysql -u' . Shell::escape( $wgDBuser ) .
+                               ' -h' . Shell::escape( $wgDBserver ) .
+                               ' -p' . Shell::escape( $wgDBpassword, $wgDBname );
                        $this->output( "Using pipe method\n" );
                        $pipe = popen( $cmd, 'w' );
                }
index 26d4e79..68184ea 100644 (file)
@@ -22,6 +22,7 @@
  */
 
 use MediaWiki\MediaWikiServices;
+use MediaWiki\Shell\Shell;
 
 if ( !defined( 'MEDIAWIKI' ) ) {
        $optionsWithoutArgs = [ 'fix' ];
@@ -451,7 +452,7 @@ class CheckStorage {
                echo "Filtering XML dump...\n";
                $exitStatus = 0;
                passthru( 'mwdumper ' .
-                       wfEscapeShellArg(
+                       Shell::escape(
                                "--output=file:$filteredXmlFileName",
                                "--filter=revlist:$revFileName",
                                $xml
index 7f89ce9..de52e7a 100644 (file)
@@ -24,6 +24,7 @@
 
 use MediaWiki\Logger\LegacyLogger;
 use MediaWiki\MediaWikiServices;
+use MediaWiki\Shell\Shell;
 use Wikimedia\Rdbms\IDatabase;
 
 $optionsWithArgs = RecompressTracked::getOptionsWithArgs();
@@ -215,19 +216,19 @@ class RecompressTracked {
         * writing are all slow.
         */
        function startReplicaProcs() {
-               $cmd = 'php ' . wfEscapeShellArg( __FILE__ );
+               $cmd = 'php ' . Shell::escape( __FILE__ );
                foreach ( self::$cmdLineOptionMap as $cmdOption => $classOption ) {
                        if ( $cmdOption == 'replica-id' ) {
                                continue;
                        } elseif ( in_array( $cmdOption, self::$optionsWithArgs ) && isset( $this->$classOption ) ) {
-                               $cmd .= " --$cmdOption " . wfEscapeShellArg( $this->$classOption );
+                               $cmd .= " --$cmdOption " . Shell::escape( $this->$classOption );
                        } elseif ( $this->$classOption ) {
                                $cmd .= " --$cmdOption";
                        }
                }
                $cmd .= ' --child' .
-                       ' --wiki ' . wfEscapeShellArg( wfWikiID() ) .
-                       ' ' . wfEscapeShellArg( ...$this->destClusters );
+                       ' --wiki ' . Shell::escape( wfWikiID() ) .
+                       ' ' . Shell::escape( ...$this->destClusters );
 
                $this->replicaPipes = $this->replicaProcs = [];
                for ( $i = 0; $i < $this->numProcs; $i++ ) {
index ab40e48..ebace75 100644 (file)
@@ -26,6 +26,7 @@
 
 require_once __DIR__ . '/Maintenance.php';
 
+use MediaWiki\MediaWikiServices;
 use Wikimedia\Rdbms\IDatabase;
 
 /**
@@ -186,7 +187,8 @@ TEXT
                                }
                                # cl_type will be wrong for lots of pages if cl_collation is 0,
                                # so let's update it while we're here.
-                               $type = MWNamespace::getCategoryLinkType( $title->getNamespace() );
+                               $type = MediaWikiServices::getInstance()->getNamespaceInfo()->
+                                       getCategoryLinkType( $title->getNamespace() );
                                $newSortKey = $collation->getSortKey(
                                        $title->getCategorySortkey( $prefix ) );
                                if ( $verboseStats ) {
index af40b73..ba61488 100644 (file)
@@ -27,12 +27,6 @@ if ( !defined( 'MEDIAWIKI' ) ) {
 global $wgResourceBasePath;
 
 return [
-
-       /**
-        * Special modules who have their own classes
-        */
-       'startup' => [ 'class' => ResourceLoaderStartUpModule::class ],
-
        // Scripts managed by the local wiki (stored in the MediaWiki namespace)
        'site' => [ 'class' => ResourceLoaderSiteModule::class ],
        'site.styles' => [ 'class' => ResourceLoaderSiteStylesModule::class ],
@@ -1441,12 +1435,13 @@ return [
                ],
        ],
        'mediawiki.action.history' => [
+               'dependencies' => [ 'jquery.makeCollapsible' ],
                'scripts' => 'resources/src/mediawiki.action/mediawiki.action.history.js',
                'styles' => 'resources/src/mediawiki.action/mediawiki.action.history.css',
        ],
        'mediawiki.action.history.styles' => [
                'skinStyles' => [
-                       'default' => 'resources/src/mediawiki.action/mediawiki.action.history.styles.css',
+                       'default' => 'resources/src/mediawiki.action/mediawiki.action.history.styles.less',
                ],
                'targets' => [ 'desktop', 'mobile' ],
        ],
index ec96cb6..0f922c9 100644 (file)
@@ -124,6 +124,12 @@ li,
        }
 }
 
+fieldset.mw-collapsible .mw-collapsible-toggle {
+       position: absolute;
+       right: 0;
+       z-index: 1;
+}
+
 // special treatment for list items to match above
 // !important necessary to override overly-specific float left and right above.
 ol.mw-collapsible:not( @{exclude} ):before,
diff --git a/resources/src/mediawiki.action/mediawiki.action.history.styles.css b/resources/src/mediawiki.action/mediawiki.action.history.styles.css
deleted file mode 100644 (file)
index 257f153..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-/* Basic styles for the history page */
-
-#pagehistory .history-user {
-       margin-left: 0.4em;
-       margin-right: 0.2em;
-}
-
-#pagehistory li {
-       border: 1px solid #fff;
-}
-
-#pagehistory li.selected {
-       background-color: #f8f9fa;
-       color: #222;
-       border: 1px dashed #a2a9b1;
-}
-
-.mw-history-revisionactions {
-       float: right;
-}
-
-.updatedmarker {
-       background-color: #b7f430;
-}
diff --git a/resources/src/mediawiki.action/mediawiki.action.history.styles.less b/resources/src/mediawiki.action/mediawiki.action.history.styles.less
new file mode 100644 (file)
index 0000000..257f153
--- /dev/null
@@ -0,0 +1,24 @@
+/* Basic styles for the history page */
+
+#pagehistory .history-user {
+       margin-left: 0.4em;
+       margin-right: 0.2em;
+}
+
+#pagehistory li {
+       border: 1px solid #fff;
+}
+
+#pagehistory li.selected {
+       background-color: #f8f9fa;
+       color: #222;
+       border: 1px dashed #a2a9b1;
+}
+
+.mw-history-revisionactions {
+       float: right;
+}
+
+.updatedmarker {
+       background-color: #b7f430;
+}
diff --git a/resources/src/mediawiki.cookie/.eslintrc.json b/resources/src/mediawiki.cookie/.eslintrc.json
new file mode 100644 (file)
index 0000000..ad8dbb3
--- /dev/null
@@ -0,0 +1,5 @@
+{
+       "parserOptions": {
+               "sourceType": "module"
+       }
+}
index 61379ae..b04b57a 100644 (file)
-( function () {
-       'use strict';
+'use strict';
 
-       var config = require( './config.json' ),
-               defaults = {
-                       prefix: config.prefix,
-                       domain: config.domain,
-                       path: config.path,
-                       expires: config.expires,
-                       secure: false
-               };
+var config = require( './config.json' ),
+       defaults = {
+               prefix: config.prefix,
+               domain: config.domain,
+               path: config.path,
+               expires: config.expires,
+               secure: false
+       };
+
+/**
+ * Manage cookies in a way that is syntactically and functionally similar
+ * to the `WebRequest#getCookie` and `WebResponse#setcookie` methods in PHP.
+ *
+ * @author Sam Smith <samsmith@wikimedia.org>
+ * @author Matthew Flaschen <mflaschen@wikimedia.org>
+ *
+ * @class mw.cookie
+ * @singleton
+ */
+mw.cookie = {
 
        /**
-        * Manage cookies in a way that is syntactically and functionally similar
-        * to the `WebRequest#getCookie` and `WebResponse#setcookie` methods in PHP.
+        * Set or delete a cookie.
         *
-        * @author Sam Smith <samsmith@wikimedia.org>
-        * @author Matthew Flaschen <mflaschen@wikimedia.org>
+        * **Note:** If explicitly passing `null` or `undefined` for an options key,
+        * that will override the default. This is natural in JavaScript, but noted
+        * here because it is contrary to MediaWiki's `WebResponse#setcookie()` method
+        * in PHP.
         *
-        * @class mw.cookie
-        * @singleton
+        * @param {string} key
+        * @param {string|null} value Value of cookie. If `value` is `null` then this method will
+        *   instead remove a cookie by name of `key`.
+        * @param {Object|Date|number} [options] Options object, or expiry date
+        * @param {Date|number|null} [options.expires=wgCookieExpiration] The expiry date of the cookie,
+        *  or lifetime in seconds. If `options.expires` is null or 0, then a session cookie is set.
+        * @param {string} [options.prefix=wgCookiePrefix] The prefix of the key
+        * @param {string} [options.domain=wgCookieDomain] The domain attribute of the cookie
+        * @param {string} [options.path=wgCookiePath] The path attribute of the cookie
+        * @param {boolean} [options.secure=false] Whether or not to include the secure attribute.
+        *   (Does **not** use the wgCookieSecure configuration variable)
         */
-       mw.cookie = {
+       set: function ( key, value, options ) {
+               var date;
 
-               /**
-                * Set or delete a cookie.
-                *
-                * **Note:** If explicitly passing `null` or `undefined` for an options key,
-                * that will override the default. This is natural in JavaScript, but noted
-                * here because it is contrary to MediaWiki's `WebResponse#setcookie()` method
-                * in PHP.
-                *
-                * @param {string} key
-                * @param {string|null} value Value of cookie. If `value` is `null` then this method will
-                *   instead remove a cookie by name of `key`.
-                * @param {Object|Date|number} [options] Options object, or expiry date
-                * @param {Date|number|null} [options.expires=wgCookieExpiration] The expiry date of the cookie,
-                *  or lifetime in seconds. If `options.expires` is null or 0, then a session cookie is set.
-                * @param {string} [options.prefix=wgCookiePrefix] The prefix of the key
-                * @param {string} [options.domain=wgCookieDomain] The domain attribute of the cookie
-                * @param {string} [options.path=wgCookiePath] The path attribute of the cookie
-                * @param {boolean} [options.secure=false] Whether or not to include the secure attribute.
-                *   (Does **not** use the wgCookieSecure configuration variable)
-                */
-               set: function ( key, value, options ) {
-                       var date;
+               // The 'options' parameter may be a shortcut for the expiry.
+               if ( arguments.length > 2 && ( !options || options instanceof Date || typeof options === 'number' ) ) {
+                       options = { expires: options };
+               }
+               // Apply defaults
+               options = $.extend( {}, defaults, options );
 
-                       // The 'options' parameter may be a shortcut for the expiry.
-                       if ( arguments.length > 2 && ( !options || options instanceof Date || typeof options === 'number' ) ) {
-                               options = { expires: options };
-                       }
-                       // Apply defaults
-                       options = $.extend( {}, defaults, options );
+               // Handle prefix
+               key = options.prefix + key;
+               // Don't pass invalid option to $.cookie
+               delete options.prefix;
 
-                       // Handle prefix
-                       key = options.prefix + key;
-                       // Don't pass invalid option to $.cookie
-                       delete options.prefix;
+               if ( !options.expires ) {
+                       // Session cookie (null or zero)
+                       // Normalize to absent (undefined) for $.cookie.
+                       delete options.expires;
+               } else if ( typeof options.expires === 'number' ) {
+                       // Lifetime in seconds
+                       date = new Date();
+                       date.setTime( Number( date ) + ( options.expires * 1000 ) );
+                       options.expires = date;
+               }
 
-                       if ( !options.expires ) {
-                               // Session cookie (null or zero)
-                               // Normalize to absent (undefined) for $.cookie.
-                               delete options.expires;
-                       } else if ( typeof options.expires === 'number' ) {
-                               // Lifetime in seconds
-                               date = new Date();
-                               date.setTime( Number( date ) + ( options.expires * 1000 ) );
-                               options.expires = date;
-                       }
+               if ( value !== null ) {
+                       value = String( value );
+               }
 
-                       if ( value !== null ) {
-                               value = String( value );
-                       }
+               $.cookie( key, value, options );
+       },
 
-                       $.cookie( key, value, options );
-               },
+       /**
+        * Get the value of a cookie.
+        *
+        * @param {string} key
+        * @param {string} [prefix=wgCookiePrefix] The prefix of the key. If `prefix` is
+        *   `undefined` or `null`, then `wgCookiePrefix` is used
+        * @param {Mixed} [defaultValue=null]
+        * @return {string|null|Mixed} If the cookie exists, then the value of the
+        *   cookie, otherwise `defaultValue`
+        */
+       get: function ( key, prefix, defaultValue ) {
+               var result;
 
-               /**
-                * Get the value of a cookie.
-                *
-                * @param {string} key
-                * @param {string} [prefix=wgCookiePrefix] The prefix of the key. If `prefix` is
-                *   `undefined` or `null`, then `wgCookiePrefix` is used
-                * @param {Mixed} [defaultValue=null]
-                * @return {string|null|Mixed} If the cookie exists, then the value of the
-                *   cookie, otherwise `defaultValue`
-                */
-               get: function ( key, prefix, defaultValue ) {
-                       var result;
+               if ( prefix === undefined || prefix === null ) {
+                       prefix = defaults.prefix;
+               }
 
-                       if ( prefix === undefined || prefix === null ) {
-                               prefix = defaults.prefix;
-                       }
+               // Was defaultValue omitted?
+               if ( arguments.length < 3 ) {
+                       defaultValue = null;
+               }
 
-                       // Was defaultValue omitted?
-                       if ( arguments.length < 3 ) {
-                               defaultValue = null;
-                       }
+               result = $.cookie( prefix + key );
 
-                       result = $.cookie( prefix + key );
+               return result !== null ? result : defaultValue;
+       }
+};
 
-                       return result !== null ? result : defaultValue;
+if ( window.QUnit ) {
+       module.exports = {
+               setDefaults: function ( value ) {
+                       var prev = defaults;
+                       defaults = value;
+                       return prev;
                }
        };
-
-       if ( window.QUnit ) {
-               module.exports = {
-                       setDefaults: function ( value ) {
-                               var prev = defaults;
-                               defaults = value;
-                               return prev;
-                       }
-               };
-       }
-}() );
+}
diff --git a/resources/src/mediawiki.jqueryMsg/.eslintrc.json b/resources/src/mediawiki.jqueryMsg/.eslintrc.json
new file mode 100644 (file)
index 0000000..ad8dbb3
--- /dev/null
@@ -0,0 +1,5 @@
+{
+       "parserOptions": {
+               "sourceType": "module"
+       }
+}
index 3b89a74..6416612 100644 (file)
 * @author neilk@wikimedia.org
 * @author mflaschen@wikimedia.org
 */
-( function () {
-       /**
-        * @class mw.jqueryMsg
-        * @singleton
-        */
 
-       var oldParser,
-               slice = Array.prototype.slice,
-               parserDefaults = {
-                       // Magic words and their expansions. Server-side data is added to this below.
-                       magic: {
-                               PAGENAME: mw.config.get( 'wgPageName' ),
-                               PAGENAMEE: mw.util.wikiUrlencode( mw.config.get( 'wgPageName' ) )
-                       },
-                       // Whitelist for allowed HTML elements in wikitext.
-                       // Self-closing tags are not currently supported.
-                       // Filled in with server-side data below
-                       allowedHtmlElements: [],
-                       // Key tag name, value allowed attributes for that tag.
-                       // See Sanitizer::setupAttributeWhitelist
-                       allowedHtmlCommonAttributes: [
-                               // HTML
-                               'id',
-                               'class',
-                               'style',
-                               'lang',
-                               'dir',
-                               'title',
-
-                               // WAI-ARIA
-                               'role'
-                       ],
-
-                       // Attributes allowed for specific elements.
-                       // Key is element name in lower case
-                       // Value is array of allowed attributes for that element
-                       allowedHtmlAttributesByElement: {},
-                       messages: mw.messages,
-                       language: mw.language,
-
-                       // Same meaning as in mediawiki.js.
-                       //
-                       // Only 'text', 'parse', and 'escaped' are supported, and the
-                       // actual escaping for 'escaped' is done by other code (generally
-                       // through mediawiki.js).
-                       //
-                       // However, note that this default only
-                       // applies to direct calls to jqueryMsg. The default for mediawiki.js itself
-                       // is 'text', including when it uses jqueryMsg.
-                       format: 'parse'
-               };
-
-       // Add in server-side data (allowedHtmlElements and magic words)
-       $.extend( true, parserDefaults, require( './parserDefaults.json' ) );
+/**
+ * @class mw.jqueryMsg
+ * @singleton
+ */
+
+var oldParser,
+       slice = Array.prototype.slice,
+       parserDefaults = {
+               // Magic words and their expansions. Server-side data is added to this below.
+               magic: {
+                       PAGENAME: mw.config.get( 'wgPageName' ),
+                       PAGENAMEE: mw.util.wikiUrlencode( mw.config.get( 'wgPageName' ) )
+               },
+               // Whitelist for allowed HTML elements in wikitext.
+               // Self-closing tags are not currently supported.
+               // Filled in with server-side data below
+               allowedHtmlElements: [],
+               // Key tag name, value allowed attributes for that tag.
+               // See Sanitizer::setupAttributeWhitelist
+               allowedHtmlCommonAttributes: [
+                       // HTML
+                       'id',
+                       'class',
+                       'style',
+                       'lang',
+                       'dir',
+                       'title',
+
+                       // WAI-ARIA
+                       'role'
+               ],
+
+               // Attributes allowed for specific elements.
+               // Key is element name in lower case
+               // Value is array of allowed attributes for that element
+               allowedHtmlAttributesByElement: {},
+               messages: mw.messages,
+               language: mw.language,
+
+               // Same meaning as in mediawiki.js.
+               //
+               // Only 'text', 'parse', and 'escaped' are supported, and the
+               // actual escaping for 'escaped' is done by other code (generally
+               // through mediawiki.js).
+               //
+               // However, note that this default only
+               // applies to direct calls to jqueryMsg. The default for mediawiki.js itself
+               // is 'text', including when it uses jqueryMsg.
+               format: 'parse'
+       };
 
-       /**
-        * Wrapper around jQuery append that converts all non-objects to TextNode so append will not
-        * convert what it detects as an htmlString to an element.
-        *
-        * If our own HtmlEmitter jQuery object is given, its children will be unwrapped and appended to
-        * new parent.
-        *
-        * Object elements of children (jQuery, HTMLElement, TextNode, etc.) will be left as is.
-        *
-        * @private
-        * @param {jQuery} $parent Parent node wrapped by jQuery
-        * @param {Object|string|Array} children What to append, with the same possible types as jQuery
-        * @return {jQuery} $parent
-        */
-       function appendWithoutParsing( $parent, children ) {
-               var i, len;
+// Add in server-side data (allowedHtmlElements and magic words)
+$.extend( true, parserDefaults, require( './parserDefaults.json' ) );
+
+/**
+ * Wrapper around jQuery append that converts all non-objects to TextNode so append will not
+ * convert what it detects as an htmlString to an element.
+ *
+ * If our own HtmlEmitter jQuery object is given, its children will be unwrapped and appended to
+ * new parent.
+ *
+ * Object elements of children (jQuery, HTMLElement, TextNode, etc.) will be left as is.
+ *
+ * @private
+ * @param {jQuery} $parent Parent node wrapped by jQuery
+ * @param {Object|string|Array} children What to append, with the same possible types as jQuery
+ * @return {jQuery} $parent
+ */
+function appendWithoutParsing( $parent, children ) {
+       var i, len;
+
+       if ( !Array.isArray( children ) ) {
+               children = [ children ];
+       }
 
-               if ( !Array.isArray( children ) ) {
-                       children = [ children ];
+       for ( i = 0, len = children.length; i < len; i++ ) {
+               if ( typeof children[ i ] !== 'object' ) {
+                       children[ i ] = document.createTextNode( children[ i ] );
                }
-
-               for ( i = 0, len = children.length; i < len; i++ ) {
-                       if ( typeof children[ i ] !== 'object' ) {
-                               children[ i ] = document.createTextNode( children[ i ] );
-                       }
-                       if ( children[ i ] instanceof $ && children[ i ].hasClass( 'mediaWiki_htmlEmitter' ) ) {
-                               children[ i ] = children[ i ].contents();
-                       }
+               if ( children[ i ] instanceof $ && children[ i ].hasClass( 'mediaWiki_htmlEmitter' ) ) {
+                       children[ i ] = children[ i ].contents();
                }
-
-               return $parent.append( children );
        }
 
-       /**
-        * Decodes the main HTML entities, those encoded by mw.html.escape.
-        *
-        * @private
-        * @param {string} encoded Encoded string
-        * @return {string} String with those entities decoded
-        */
-       function decodePrimaryHtmlEntities( encoded ) {
-               return encoded
-                       .replace( /&#039;/g, '\'' )
-                       .replace( /&quot;/g, '"' )
-                       .replace( /&lt;/g, '<' )
-                       .replace( /&gt;/g, '>' )
-                       .replace( /&amp;/g, '&' );
+       return $parent.append( children );
+}
+
+/**
+ * Decodes the main HTML entities, those encoded by mw.html.escape.
+ *
+ * @private
+ * @param {string} encoded Encoded string
+ * @return {string} String with those entities decoded
+ */
+function decodePrimaryHtmlEntities( encoded ) {
+       return encoded
+               .replace( /&#039;/g, '\'' )
+               .replace( /&quot;/g, '"' )
+               .replace( /&lt;/g, '<' )
+               .replace( /&gt;/g, '>' )
+               .replace( /&amp;/g, '&' );
+}
+
+/**
+ * Turn input into a string.
+ *
+ * @private
+ * @param {string|jQuery} input
+ * @return {string} Textual value of input
+ */
+function textify( input ) {
+       if ( input instanceof $ ) {
+               input = input.text();
        }
-
-       /**
-        * Turn input into a string.
-        *
-        * @private
-        * @param {string|jQuery} input
-        * @return {string} Textual value of input
-        */
-       function textify( input ) {
-               if ( input instanceof $ ) {
-                       input = input.text();
+       return String( input );
+}
+
+/**
+ * Given parser options, return a function that parses a key and replacements, returning jQuery object
+ *
+ * Try to parse a key and optional replacements, returning a jQuery object that may be a tree of jQuery nodes.
+ * If there was an error parsing, return the key and the error message (wrapped in jQuery). This should put the error right into
+ * the interface, without causing the page to halt script execution, and it hopefully should be clearer how to fix it.
+ *
+ * @private
+ * @param {Object} options Parser options
+ * @return {Function}
+ * @return {Array} return.args First element is the key, replacements may be in array in 2nd element, or remaining elements.
+ * @return {jQuery} return.return
+ */
+function getFailableParserFn( options ) {
+       return function ( args ) {
+               var fallback,
+                       parser = new mw.jqueryMsg.Parser( options ),
+                       key = args[ 0 ],
+                       argsArray = Array.isArray( args[ 1 ] ) ? args[ 1 ] : slice.call( args, 1 );
+               try {
+                       return parser.parse( key, argsArray );
+               } catch ( e ) {
+                       fallback = parser.settings.messages.get( key );
+                       mw.log.warn( 'mediawiki.jqueryMsg: ' + key + ': ' + e.message );
+                       mw.track( 'mediawiki.jqueryMsg.error', {
+                               messageKey: key,
+                               errorMessage: e.message
+                       } );
+                       return $( '<span>' ).text( fallback );
                }
-               return String( input );
+       };
+}
+
+mw.jqueryMsg = {};
+
+/**
+ * Initialize parser defaults.
+ *
+ * ResourceLoaderJqueryMsgModule calls this to provide default values from
+ * Sanitizer.php for allowed HTML elements. To override this data for individual
+ * parsers, pass the relevant options to mw.jqueryMsg.Parser.
+ *
+ * @private
+ * @param {Object} data New data to extend parser defaults with
+ * @param {boolean} [deep=false] Whether the extend is done recursively (deep)
+ */
+mw.jqueryMsg.setParserDefaults = function ( data, deep ) {
+       if ( deep ) {
+               $.extend( true, parserDefaults, data );
+       } else {
+               $.extend( parserDefaults, data );
        }
-
-       /**
-        * Given parser options, return a function that parses a key and replacements, returning jQuery object
-        *
-        * Try to parse a key and optional replacements, returning a jQuery object that may be a tree of jQuery nodes.
-        * If there was an error parsing, return the key and the error message (wrapped in jQuery). This should put the error right into
-        * the interface, without causing the page to halt script execution, and it hopefully should be clearer how to fix it.
-        *
-        * @private
-        * @param {Object} options Parser options
-        * @return {Function}
-        * @return {Array} return.args First element is the key, replacements may be in array in 2nd element, or remaining elements.
-        * @return {jQuery} return.return
-        */
-       function getFailableParserFn( options ) {
-               return function ( args ) {
-                       var fallback,
-                               parser = new mw.jqueryMsg.Parser( options ),
-                               key = args[ 0 ],
-                               argsArray = Array.isArray( args[ 1 ] ) ? args[ 1 ] : slice.call( args, 1 );
-                       try {
-                               return parser.parse( key, argsArray );
-                       } catch ( e ) {
-                               fallback = parser.settings.messages.get( key );
-                               mw.log.warn( 'mediawiki.jqueryMsg: ' + key + ': ' + e.message );
-                               mw.track( 'mediawiki.jqueryMsg.error', {
-                                       messageKey: key,
-                                       errorMessage: e.message
-                               } );
-                               return $( '<span>' ).text( fallback );
-                       }
-               };
+};
+
+/**
+ * Get current parser defaults.
+ *
+ * Primarily used for the unit test. Returns a copy.
+ *
+ * @private
+ * @return {Object}
+ */
+mw.jqueryMsg.getParserDefaults = function () {
+       return $.extend( {}, parserDefaults );
+};
+
+/**
+ * Returns a function suitable for static use, to construct strings from a message key (and optional replacements).
+ *
+ * Example:
+ *
+ *       var format = mediaWiki.jqueryMsg.getMessageFunction( options );
+ *       $( '#example' ).text( format( 'hello-user', username ) );
+ *
+ * Tthis returns only strings, so it destroys any bindings. If you want to preserve bindings, use the
+ * jQuery plugin version instead. This was originally created to ease migration from `window.gM()`,
+ * from a time when the parser used by `mw.message` was not extendable.
+ *
+ * N.B. replacements are variadic arguments or an array in second parameter. In other words:
+ *    somefunction( a, b, c, d )
+ * is equivalent to
+ *    somefunction( a, [b, c, d] )
+ *
+ * @param {Object} options parser options
+ * @return {Function} Function The message formatter
+ * @return {string} return.key Message key.
+ * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
+ * @return {string} return.return Rendered HTML.
+ */
+mw.jqueryMsg.getMessageFunction = function ( options ) {
+       var failableParserFn, format;
+
+       if ( options && options.format !== undefined ) {
+               format = options.format;
+       } else {
+               format = parserDefaults.format;
        }
 
-       mw.jqueryMsg = {};
-
-       /**
-        * Initialize parser defaults.
-        *
-        * ResourceLoaderJqueryMsgModule calls this to provide default values from
-        * Sanitizer.php for allowed HTML elements. To override this data for individual
-        * parsers, pass the relevant options to mw.jqueryMsg.Parser.
-        *
-        * @private
-        * @param {Object} data New data to extend parser defaults with
-        * @param {boolean} [deep=false] Whether the extend is done recursively (deep)
-        */
-       mw.jqueryMsg.setParserDefaults = function ( data, deep ) {
-               if ( deep ) {
-                       $.extend( true, parserDefaults, data );
+       return function () {
+               var failableResult;
+               if ( !failableParserFn ) {
+                       failableParserFn = getFailableParserFn( options );
+               }
+               failableResult = failableParserFn( arguments );
+               if ( format === 'text' || format === 'escaped' ) {
+                       return failableResult.text();
                } else {
-                       $.extend( parserDefaults, data );
+                       return failableResult.html();
                }
        };
-
+};
+
+/**
+ * Returns a jQuery plugin which parses the message in the message key, doing replacements optionally, and appends the nodes to
+ * the current selector. Bindings to passed-in jquery elements are preserved. Functions become click handlers for [$1 linktext] links.
+ * e.g.
+ *
+ *        $.fn.msg = mediaWiki.jqueryMsg.getPlugin( options );
+ *        var $userlink = $( '<a>' ).click( function () { alert( "hello!!" ) } );
+ *        $( 'p#headline' ).msg( 'hello-user', $userlink );
+ *
+ * N.B. replacements are variadic arguments or an array in second parameter. In other words:
+ *    somefunction( a, b, c, d )
+ * is equivalent to
+ *    somefunction( a, [b, c, d] )
+ *
+ * We append to 'this', which in a jQuery plugin context will be the selected elements.
+ *
+ * @param {Object} options Parser options
+ * @return {Function} Function suitable for assigning to jQuery plugin, such as jQuery#msg
+ * @return {string} return.key Message key.
+ * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
+ * @return {jQuery} return.return
+ */
+mw.jqueryMsg.getPlugin = function ( options ) {
+       var failableParserFn;
+
+       return function () {
+               var $target;
+               if ( !failableParserFn ) {
+                       failableParserFn = getFailableParserFn( options );
+               }
+               $target = this.empty();
+               appendWithoutParsing( $target, failableParserFn( arguments ) );
+               return $target;
+       };
+};
+
+/**
+ * The parser itself.
+ * Describes an object, whose primary duty is to .parse() message keys.
+ *
+ * @class
+ * @private
+ * @param {Object} options
+ */
+mw.jqueryMsg.Parser = function ( options ) {
+       this.settings = $.extend( {}, parserDefaults, options );
+       this.settings.onlyCurlyBraceTransform = ( this.settings.format === 'text' || this.settings.format === 'escaped' );
+       this.astCache = {};
+
+       this.emitter = new mw.jqueryMsg.HtmlEmitter( this.settings.language, this.settings.magic );
+};
+// Backwards-compatible alias
+// @deprecated since 1.31
+mw.jqueryMsg.parser = mw.jqueryMsg.Parser;
+
+mw.jqueryMsg.Parser.prototype = {
        /**
-        * Get current parser defaults.
-        *
-        * Primarily used for the unit test. Returns a copy.
+        * Where the magic happens.
+        * Parses a message from the key, and swaps in replacements as necessary, wraps in jQuery
+        * If an error is thrown, returns original key, and logs the error
         *
-        * @private
-        * @return {Object}
+        * @param {string} key Message key.
+        * @param {Array} replacements Variable replacements for $1, $2... $n
+        * @return {jQuery}
         */
-       mw.jqueryMsg.getParserDefaults = function () {
-               return $.extend( {}, parserDefaults );
-       };
+       parse: function ( key, replacements ) {
+               var ast = this.getAst( key, replacements );
+               return this.emitter.emit( ast, replacements );
+       },
 
        /**
-        * Returns a function suitable for static use, to construct strings from a message key (and optional replacements).
-        *
-        * Example:
-        *
-        *       var format = mediaWiki.jqueryMsg.getMessageFunction( options );
-        *       $( '#example' ).text( format( 'hello-user', username ) );
-        *
-        * Tthis returns only strings, so it destroys any bindings. If you want to preserve bindings, use the
-        * jQuery plugin version instead. This was originally created to ease migration from `window.gM()`,
-        * from a time when the parser used by `mw.message` was not extendable.
+        * Fetch the message string associated with a key, return parsed structure. Memoized.
+        * Note that we pass '⧼' + key + '⧽' back for a missing message here.
         *
-        * N.B. replacements are variadic arguments or an array in second parameter. In other words:
-        *    somefunction( a, b, c, d )
-        * is equivalent to
-        *    somefunction( a, [b, c, d] )
-        *
-        * @param {Object} options parser options
-        * @return {Function} Function The message formatter
-        * @return {string} return.key Message key.
-        * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
-        * @return {string} return.return Rendered HTML.
+        * @param {string} key
+        * @param {Array} replacements Variable replacements for $1, $2... $n
+        * @return {string|Array} string of '⧼key⧽' if message missing, simple string if possible, array of arrays if needs parsing
         */
-       mw.jqueryMsg.getMessageFunction = function ( options ) {
-               var failableParserFn, format;
-
-               if ( options && options.format !== undefined ) {
-                       format = options.format;
-               } else {
-                       format = parserDefaults.format;
-               }
+       getAst: function ( key, replacements ) {
+               var wikiText;
 
-               return function () {
-                       var failableResult;
-                       if ( !failableParserFn ) {
-                               failableParserFn = getFailableParserFn( options );
-                       }
-                       failableResult = failableParserFn( arguments );
-                       if ( format === 'text' || format === 'escaped' ) {
-                               return failableResult.text();
+               if ( !Object.prototype.hasOwnProperty.call( this.astCache, key ) ) {
+                       if ( mw.config.get( 'wgUserLanguage' ) === 'qqx' ) {
+                               wikiText = '(' + key + '$*)';
                        } else {
-                               return failableResult.html();
+                               wikiText = this.settings.messages.get( key );
+                               if ( typeof wikiText !== 'string' ) {
+                                       wikiText = '⧼' + key + '⧽';
+                               }
                        }
-               };
-       };
+                       wikiText = mw.internalDoTransformFormatForQqx( wikiText, replacements );
+                       this.astCache[ key ] = this.wikiTextToAst( wikiText );
+               }
+               return this.astCache[ key ];
+       },
 
        /**
-        * Returns a jQuery plugin which parses the message in the message key, doing replacements optionally, and appends the nodes to
-        * the current selector. Bindings to passed-in jquery elements are preserved. Functions become click handlers for [$1 linktext] links.
-        * e.g.
-        *
-        *        $.fn.msg = mediaWiki.jqueryMsg.getPlugin( options );
-        *        var $userlink = $( '<a>' ).click( function () { alert( "hello!!" ) } );
-        *        $( 'p#headline' ).msg( 'hello-user', $userlink );
-        *
-        * N.B. replacements are variadic arguments or an array in second parameter. In other words:
-        *    somefunction( a, b, c, d )
-        * is equivalent to
-        *    somefunction( a, [b, c, d] )
+        * Parses the input wikiText into an abstract syntax tree, essentially an s-expression.
         *
-        * We append to 'this', which in a jQuery plugin context will be the selected elements.
-        *
-        * @param {Object} options Parser options
-        * @return {Function} Function suitable for assigning to jQuery plugin, such as jQuery#msg
-        * @return {string} return.key Message key.
-        * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array).
-        * @return {jQuery} return.return
-        */
-       mw.jqueryMsg.getPlugin = function ( options ) {
-               var failableParserFn;
-
-               return function () {
-                       var $target;
-                       if ( !failableParserFn ) {
-                               failableParserFn = getFailableParserFn( options );
-                       }
-                       $target = this.empty();
-                       appendWithoutParsing( $target, failableParserFn( arguments ) );
-                       return $target;
-               };
-       };
-
-       /**
-        * The parser itself.
-        * Describes an object, whose primary duty is to .parse() message keys.
+        * CAVEAT: This does not parse all wikitext. It could be more efficient, but it's pretty good already.
+        * n.b. We want to move this functionality to the server. Nothing here is required to be on the client.
         *
-        * @class
-        * @private
-        * @param {Object} options
+        * @param {string} input Message string wikitext
+        * @throws Error
+        * @return {Mixed} abstract syntax tree
         */
-       mw.jqueryMsg.Parser = function ( options ) {
-               this.settings = $.extend( {}, parserDefaults, options );
-               this.settings.onlyCurlyBraceTransform = ( this.settings.format === 'text' || this.settings.format === 'escaped' );
-               this.astCache = {};
-
-               this.emitter = new mw.jqueryMsg.HtmlEmitter( this.settings.language, this.settings.magic );
-       };
-       // Backwards-compatible alias
-       // @deprecated since 1.31
-       mw.jqueryMsg.parser = mw.jqueryMsg.Parser;
+       wikiTextToAst: function ( input ) {
+               var pos,
+                       regularLiteral, regularLiteralWithoutBar, regularLiteralWithoutSpace, regularLiteralWithSquareBrackets,
+                       doubleQuote, singleQuote, backslash, anyCharacter, asciiAlphabetLiteral,
+                       escapedOrLiteralWithoutSpace, escapedOrLiteralWithoutBar, escapedOrRegularLiteral,
+                       whitespace, dollar, digits, htmlDoubleQuoteAttributeValue, htmlSingleQuoteAttributeValue,
+                       htmlAttributeEquals, openHtmlStartTag, optionalForwardSlash, openHtmlEndTag, closeHtmlTag,
+                       openExtlink, closeExtlink, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon,
+                       templateContents, openTemplate, closeTemplate,
+                       nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result,
+                       settings = this.settings,
+                       concat = Array.prototype.concat;
+
+               // Indicates current position in input as we parse through it.
+               // Shared among all parsing functions below.
+               pos = 0;
+
+               // =========================================================
+               // parsing combinators - could be a library on its own
+               // =========================================================
 
-       mw.jqueryMsg.Parser.prototype = {
                /**
-                * Where the magic happens.
-                * Parses a message from the key, and swaps in replacements as necessary, wraps in jQuery
-                * If an error is thrown, returns original key, and logs the error
+                * Try parsers until one works, if none work return null
                 *
-                * @param {string} key Message key.
-                * @param {Array} replacements Variable replacements for $1, $2... $n
-                * @return {jQuery}
+                * @private
+                * @param {Function[]} ps
+                * @return {string|null}
                 */
-               parse: function ( key, replacements ) {
-                       var ast = this.getAst( key, replacements );
-                       return this.emitter.emit( ast, replacements );
-               },
+               function choice( ps ) {
+                       return function () {
+                               var i, result;
+                               for ( i = 0; i < ps.length; i++ ) {
+                                       result = ps[ i ]();
+                                       if ( result !== null ) {
+                                               return result;
+                                       }
+                               }
+                               return null;
+                       };
+               }
 
                /**
-                * Fetch the message string associated with a key, return parsed structure. Memoized.
-                * Note that we pass '⧼' + key + '⧽' back for a missing message here.
+                * Try several ps in a row, all must succeed or return null.
+                * This is the only eager one.
                 *
-                * @param {string} key
-                * @param {Array} replacements Variable replacements for $1, $2... $n
-                * @return {string|Array} string of '⧼key⧽' if message missing, simple string if possible, array of arrays if needs parsing
+                * @private
+                * @param {Function[]} ps
+                * @return {string|null}
                 */
-               getAst: function ( key, replacements ) {
-                       var wikiText;
-
-                       if ( !Object.prototype.hasOwnProperty.call( this.astCache, key ) ) {
-                               if ( mw.config.get( 'wgUserLanguage' ) === 'qqx' ) {
-                                       wikiText = '(' + key + '$*)';
-                               } else {
-                                       wikiText = this.settings.messages.get( key );
-                                       if ( typeof wikiText !== 'string' ) {
-                                               wikiText = '⧼' + key + '⧽';
-                                       }
+               function sequence( ps ) {
+                       var i, res,
+                               originalPos = pos,
+                               result = [];
+                       for ( i = 0; i < ps.length; i++ ) {
+                               res = ps[ i ]();
+                               if ( res === null ) {
+                                       pos = originalPos;
+                                       return null;
                                }
-                               wikiText = mw.internalDoTransformFormatForQqx( wikiText, replacements );
-                               this.astCache[ key ] = this.wikiTextToAst( wikiText );
+                               result.push( res );
                        }
-                       return this.astCache[ key ];
-               },
+                       return result;
+               }
 
                /**
-                * Parses the input wikiText into an abstract syntax tree, essentially an s-expression.
-                *
-                * CAVEAT: This does not parse all wikitext. It could be more efficient, but it's pretty good already.
-                * n.b. We want to move this functionality to the server. Nothing here is required to be on the client.
+                * Run the same parser over and over until it fails.
+                * Must succeed a minimum of n times or return null.
                 *
-                * @param {string} input Message string wikitext
-                * @throws Error
-                * @return {Mixed} abstract syntax tree
+                * @private
+                * @param {number} n
+                * @param {Function} p
+                * @return {string|null}
                 */
-               wikiTextToAst: function ( input ) {
-                       var pos,
-                               regularLiteral, regularLiteralWithoutBar, regularLiteralWithoutSpace, regularLiteralWithSquareBrackets,
-                               doubleQuote, singleQuote, backslash, anyCharacter, asciiAlphabetLiteral,
-                               escapedOrLiteralWithoutSpace, escapedOrLiteralWithoutBar, escapedOrRegularLiteral,
-                               whitespace, dollar, digits, htmlDoubleQuoteAttributeValue, htmlSingleQuoteAttributeValue,
-                               htmlAttributeEquals, openHtmlStartTag, optionalForwardSlash, openHtmlEndTag, closeHtmlTag,
-                               openExtlink, closeExtlink, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon,
-                               templateContents, openTemplate, closeTemplate,
-                               nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result,
-                               settings = this.settings,
-                               concat = Array.prototype.concat;
-
-                       // Indicates current position in input as we parse through it.
-                       // Shared among all parsing functions below.
-                       pos = 0;
-
-                       // =========================================================
-                       // parsing combinators - could be a library on its own
-                       // =========================================================
-
-                       /**
-                        * Try parsers until one works, if none work return null
-                        *
-                        * @private
-                        * @param {Function[]} ps
-                        * @return {string|null}
-                        */
-                       function choice( ps ) {
-                               return function () {
-                                       var i, result;
-                                       for ( i = 0; i < ps.length; i++ ) {
-                                               result = ps[ i ]();
-                                               if ( result !== null ) {
-                                                       return result;
-                                               }
-                                       }
+               function nOrMore( n, p ) {
+                       return function () {
+                               var originalPos = pos,
+                                       result = [],
+                                       parsed = p();
+                               while ( parsed !== null ) {
+                                       result.push( parsed );
+                                       parsed = p();
+                               }
+                               if ( result.length < n ) {
+                                       pos = originalPos;
                                        return null;
-                               };
-                       }
-
-                       /**
-                        * Try several ps in a row, all must succeed or return null.
-                        * This is the only eager one.
-                        *
-                        * @private
-                        * @param {Function[]} ps
-                        * @return {string|null}
-                        */
-                       function sequence( ps ) {
-                               var i, res,
-                                       originalPos = pos,
-                                       result = [];
-                               for ( i = 0; i < ps.length; i++ ) {
-                                       res = ps[ i ]();
-                                       if ( res === null ) {
-                                               pos = originalPos;
-                                               return null;
-                                       }
-                                       result.push( res );
                                }
                                return result;
-                       }
-
-                       /**
-                        * Run the same parser over and over until it fails.
-                        * Must succeed a minimum of n times or return null.
-                        *
-                        * @private
-                        * @param {number} n
-                        * @param {Function} p
-                        * @return {string|null}
-                        */
-                       function nOrMore( n, p ) {
-                               return function () {
-                                       var originalPos = pos,
-                                               result = [],
-                                               parsed = p();
-                                       while ( parsed !== null ) {
-                                               result.push( parsed );
-                                               parsed = p();
-                                       }
-                                       if ( result.length < n ) {
-                                               pos = originalPos;
-                                               return null;
-                                       }
-                                       return result;
-                               };
-                       }
+                       };
+               }
 
-                       /**
-                        * There is a general pattern -- parse a thing, if that worked, apply transform, otherwise return null.
-                        *
-                        * TODO: But using this as a combinator seems to cause problems when combined with #nOrMore().
-                        * May be some scoping issue
-                        *
-                        * @private
-                        * @param {Function} p
-                        * @param {Function} fn
-                        * @return {string|null}
-                        */
-                       function transform( p, fn ) {
-                               return function () {
-                                       var result = p();
-                                       return result === null ? null : fn( result );
-                               };
-                       }
+               /**
+                * There is a general pattern -- parse a thing, if that worked, apply transform, otherwise return null.
+                *
+                * TODO: But using this as a combinator seems to cause problems when combined with #nOrMore().
+                * May be some scoping issue
+                *
+                * @private
+                * @param {Function} p
+                * @param {Function} fn
+                * @return {string|null}
+                */
+               function transform( p, fn ) {
+                       return function () {
+                               var result = p();
+                               return result === null ? null : fn( result );
+                       };
+               }
 
-                       /**
-                        * Just make parsers out of simpler JS builtin types
-                        *
-                        * @private
-                        * @param {string} s
-                        * @return {Function}
-                        * @return {string} return.return
-                        */
-                       function makeStringParser( s ) {
-                               var len = s.length;
-                               return function () {
-                                       var result = null;
-                                       if ( input.substr( pos, len ) === s ) {
-                                               result = s;
-                                               pos += len;
-                                       }
-                                       return result;
-                               };
-                       }
+               /**
+                * Just make parsers out of simpler JS builtin types
+                *
+                * @private
+                * @param {string} s
+                * @return {Function}
+                * @return {string} return.return
+                */
+               function makeStringParser( s ) {
+                       var len = s.length;
+                       return function () {
+                               var result = null;
+                               if ( input.substr( pos, len ) === s ) {
+                                       result = s;
+                                       pos += len;
+                               }
+                               return result;
+                       };
+               }
 
-                       /**
-                        * Makes a regex parser, given a RegExp object.
-                        * The regex being passed in should start with a ^ to anchor it to the start
-                        * of the string.
-                        *
-                        * @private
-                        * @param {RegExp} regex anchored regex
-                        * @return {Function} function to parse input based on the regex
-                        */
-                       function makeRegexParser( regex ) {
-                               return function () {
-                                       var matches = input.slice( pos ).match( regex );
-                                       if ( matches === null ) {
-                                               return null;
-                                       }
-                                       pos += matches[ 0 ].length;
-                                       return matches[ 0 ];
-                               };
-                       }
+               /**
+                * Makes a regex parser, given a RegExp object.
+                * The regex being passed in should start with a ^ to anchor it to the start
+                * of the string.
+                *
+                * @private
+                * @param {RegExp} regex anchored regex
+                * @return {Function} function to parse input based on the regex
+                */
+               function makeRegexParser( regex ) {
+                       return function () {
+                               var matches = input.slice( pos ).match( regex );
+                               if ( matches === null ) {
+                                       return null;
+                               }
+                               pos += matches[ 0 ].length;
+                               return matches[ 0 ];
+                       };
+               }
 
-                       // ===================================================================
-                       // General patterns above this line -- wikitext specific parsers below
-                       // ===================================================================
-
-                       // Parsing functions follow. All parsing functions work like this:
-                       // They don't accept any arguments.
-                       // Instead, they just operate non destructively on the string 'input'
-                       // As they can consume parts of the string, they advance the shared variable pos,
-                       // and return tokens (or whatever else they want to return).
-                       // some things are defined as closures and other things as ordinary functions
-                       // converting everything to a closure makes it a lot harder to debug... errors pop up
-                       // but some debuggers can't tell you exactly where they come from. Also the mutually
-                       // recursive functions seem not to work in all browsers then. (Tested IE6-7, Opera, Safari, FF)
-                       // This may be because, to save code, memoization was removed
-
-                       /* eslint-disable no-useless-escape */
-                       regularLiteral = makeRegexParser( /^[^{}\[\]$<\\]/ );
-                       regularLiteralWithoutBar = makeRegexParser( /^[^{}\[\]$\\|]/ );
-                       regularLiteralWithoutSpace = makeRegexParser( /^[^{}\[\]$\s]/ );
-                       regularLiteralWithSquareBrackets = makeRegexParser( /^[^{}$\\]/ );
-                       /* eslint-enable no-useless-escape */
-
-                       backslash = makeStringParser( '\\' );
-                       doubleQuote = makeStringParser( '"' );
-                       singleQuote = makeStringParser( '\'' );
-                       anyCharacter = makeRegexParser( /^./ );
-
-                       openHtmlStartTag = makeStringParser( '<' );
-                       optionalForwardSlash = makeRegexParser( /^\/?/ );
-                       openHtmlEndTag = makeStringParser( '</' );
-                       htmlAttributeEquals = makeRegexParser( /^\s*=\s*/ );
-                       closeHtmlTag = makeRegexParser( /^\s*>/ );
-
-                       function escapedLiteral() {
-                               var result = sequence( [
-                                       backslash,
-                                       anyCharacter
-                               ] );
-                               return result === null ? null : result[ 1 ];
-                       }
-                       escapedOrLiteralWithoutSpace = choice( [
-                               escapedLiteral,
-                               regularLiteralWithoutSpace
-                       ] );
-                       escapedOrLiteralWithoutBar = choice( [
-                               escapedLiteral,
-                               regularLiteralWithoutBar
-                       ] );
-                       escapedOrRegularLiteral = choice( [
-                               escapedLiteral,
-                               regularLiteral
+               // ===================================================================
+               // General patterns above this line -- wikitext specific parsers below
+               // ===================================================================
+
+               // Parsing functions follow. All parsing functions work like this:
+               // They don't accept any arguments.
+               // Instead, they just operate non destructively on the string 'input'
+               // As they can consume parts of the string, they advance the shared variable pos,
+               // and return tokens (or whatever else they want to return).
+               // some things are defined as closures and other things as ordinary functions
+               // converting everything to a closure makes it a lot harder to debug... errors pop up
+               // but some debuggers can't tell you exactly where they come from. Also the mutually
+               // recursive functions seem not to work in all browsers then. (Tested IE6-7, Opera, Safari, FF)
+               // This may be because, to save code, memoization was removed
+
+               /* eslint-disable no-useless-escape */
+               regularLiteral = makeRegexParser( /^[^{}\[\]$<\\]/ );
+               regularLiteralWithoutBar = makeRegexParser( /^[^{}\[\]$\\|]/ );
+               regularLiteralWithoutSpace = makeRegexParser( /^[^{}\[\]$\s]/ );
+               regularLiteralWithSquareBrackets = makeRegexParser( /^[^{}$\\]/ );
+               /* eslint-enable no-useless-escape */
+
+               backslash = makeStringParser( '\\' );
+               doubleQuote = makeStringParser( '"' );
+               singleQuote = makeStringParser( '\'' );
+               anyCharacter = makeRegexParser( /^./ );
+
+               openHtmlStartTag = makeStringParser( '<' );
+               optionalForwardSlash = makeRegexParser( /^\/?/ );
+               openHtmlEndTag = makeStringParser( '</' );
+               htmlAttributeEquals = makeRegexParser( /^\s*=\s*/ );
+               closeHtmlTag = makeRegexParser( /^\s*>/ );
+
+               function escapedLiteral() {
+                       var result = sequence( [
+                               backslash,
+                               anyCharacter
                        ] );
-                       // Used to define "literals" without spaces, in space-delimited situations
-                       function literalWithoutSpace() {
-                               var result = nOrMore( 1, escapedOrLiteralWithoutSpace )();
-                               return result === null ? null : result.join( '' );
-                       }
-                       // Used to define "literals" within template parameters. The pipe character is the parameter delimeter, so by default
-                       // it is not a literal in the parameter
-                       function literalWithoutBar() {
-                               var result = nOrMore( 1, escapedOrLiteralWithoutBar )();
-                               return result === null ? null : result.join( '' );
-                       }
+                       return result === null ? null : result[ 1 ];
+               }
+               escapedOrLiteralWithoutSpace = choice( [
+                       escapedLiteral,
+                       regularLiteralWithoutSpace
+               ] );
+               escapedOrLiteralWithoutBar = choice( [
+                       escapedLiteral,
+                       regularLiteralWithoutBar
+               ] );
+               escapedOrRegularLiteral = choice( [
+                       escapedLiteral,
+                       regularLiteral
+               ] );
+               // Used to define "literals" without spaces, in space-delimited situations
+               function literalWithoutSpace() {
+                       var result = nOrMore( 1, escapedOrLiteralWithoutSpace )();
+                       return result === null ? null : result.join( '' );
+               }
+               // Used to define "literals" within template parameters. The pipe character is the parameter delimeter, so by default
+               // it is not a literal in the parameter
+               function literalWithoutBar() {
+                       var result = nOrMore( 1, escapedOrLiteralWithoutBar )();
+                       return result === null ? null : result.join( '' );
+               }
 
-                       function literal() {
-                               var result = nOrMore( 1, escapedOrRegularLiteral )();
-                               return result === null ? null : result.join( '' );
-                       }
+               function literal() {
+                       var result = nOrMore( 1, escapedOrRegularLiteral )();
+                       return result === null ? null : result.join( '' );
+               }
 
-                       function curlyBraceTransformExpressionLiteral() {
-                               var result = nOrMore( 1, regularLiteralWithSquareBrackets )();
-                               return result === null ? null : result.join( '' );
-                       }
+               function curlyBraceTransformExpressionLiteral() {
+                       var result = nOrMore( 1, regularLiteralWithSquareBrackets )();
+                       return result === null ? null : result.join( '' );
+               }
 
-                       asciiAlphabetLiteral = makeRegexParser( /^[A-Za-z]+/ );
-                       htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ );
-                       htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ );
+               asciiAlphabetLiteral = makeRegexParser( /^[A-Za-z]+/ );
+               htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ );
+               htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ );
 
-                       whitespace = makeRegexParser( /^\s+/ );
-                       dollar = makeStringParser( '$' );
-                       digits = makeRegexParser( /^\d+/ );
+               whitespace = makeRegexParser( /^\s+/ );
+               dollar = makeStringParser( '$' );
+               digits = makeRegexParser( /^\d+/ );
 
-                       function replacement() {
-                               var result = sequence( [
-                                       dollar,
-                                       digits
-                               ] );
-                               if ( result === null ) {
-                                       return null;
-                               }
-                               return [ 'REPLACE', parseInt( result[ 1 ], 10 ) - 1 ];
-                       }
-                       openExtlink = makeStringParser( '[' );
-                       closeExtlink = makeStringParser( ']' );
-                       // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo <i>bar</i>], etc. are allowed
-                       function extlink() {
-                               var result, parsedResult, target;
-                               result = null;
-                               parsedResult = sequence( [
-                                       openExtlink,
-                                       nOrMore( 1, nonWhitespaceExpression ),
-                                       whitespace,
-                                       nOrMore( 1, expression ),
-                                       closeExtlink
-                               ] );
-                               if ( parsedResult !== null ) {
-                                       // When the entire link target is a single parameter, we can't use CONCAT, as we allow
-                                       // passing fancy parameters (like a whole jQuery object or a function) to use for the
-                                       // link. Check only if it's a single match, since we can either do CONCAT or not for
-                                       // singles with the same effect.
-                                       target = parsedResult[ 1 ].length === 1 ?
-                                               parsedResult[ 1 ][ 0 ] :
-                                               [ 'CONCAT' ].concat( parsedResult[ 1 ] );
-                                       result = [
-                                               'EXTLINK',
-                                               target,
-                                               [ 'CONCAT' ].concat( parsedResult[ 3 ] )
-                                       ];
-                               }
-                               return result;
-                       }
-                       openWikilink = makeStringParser( '[[' );
-                       closeWikilink = makeStringParser( ']]' );
-                       pipe = makeStringParser( '|' );
-
-                       function template() {
-                               var result = sequence( [
-                                       openTemplate,
-                                       templateContents,
-                                       closeTemplate
-                               ] );
-                               return result === null ? null : result[ 1 ];
+               function replacement() {
+                       var result = sequence( [
+                               dollar,
+                               digits
+                       ] );
+                       if ( result === null ) {
+                               return null;
                        }
-
-                       function pipedWikilink() {
-                               var result = sequence( [
-                                       nOrMore( 1, paramExpression ),
-                                       pipe,
-                                       nOrMore( 1, expression )
-                               ] );
-                               return result === null ? null : [
-                                       [ 'CONCAT' ].concat( result[ 0 ] ),
-                                       [ 'CONCAT' ].concat( result[ 2 ] )
+                       return [ 'REPLACE', parseInt( result[ 1 ], 10 ) - 1 ];
+               }
+               openExtlink = makeStringParser( '[' );
+               closeExtlink = makeStringParser( ']' );
+               // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo <i>bar</i>], etc. are allowed
+               function extlink() {
+                       var result, parsedResult, target;
+                       result = null;
+                       parsedResult = sequence( [
+                               openExtlink,
+                               nOrMore( 1, nonWhitespaceExpression ),
+                               whitespace,
+                               nOrMore( 1, expression ),
+                               closeExtlink
+                       ] );
+                       if ( parsedResult !== null ) {
+                               // When the entire link target is a single parameter, we can't use CONCAT, as we allow
+                               // passing fancy parameters (like a whole jQuery object or a function) to use for the
+                               // link. Check only if it's a single match, since we can either do CONCAT or not for
+                               // singles with the same effect.
+                               target = parsedResult[ 1 ].length === 1 ?
+                                       parsedResult[ 1 ][ 0 ] :
+                                       [ 'CONCAT' ].concat( parsedResult[ 1 ] );
+                               result = [
+                                       'EXTLINK',
+                                       target,
+                                       [ 'CONCAT' ].concat( parsedResult[ 3 ] )
                                ];
                        }
+                       return result;
+               }
+               openWikilink = makeStringParser( '[[' );
+               closeWikilink = makeStringParser( ']]' );
+               pipe = makeStringParser( '|' );
+
+               function template() {
+                       var result = sequence( [
+                               openTemplate,
+                               templateContents,
+                               closeTemplate
+                       ] );
+                       return result === null ? null : result[ 1 ];
+               }
 
-                       function unpipedWikilink() {
-                               var result = sequence( [
-                                       nOrMore( 1, paramExpression )
-                               ] );
-                               return result === null ? null : [
-                                       [ 'CONCAT' ].concat( result[ 0 ] )
-                               ];
-                       }
+               function pipedWikilink() {
+                       var result = sequence( [
+                               nOrMore( 1, paramExpression ),
+                               pipe,
+                               nOrMore( 1, expression )
+                       ] );
+                       return result === null ? null : [
+                               [ 'CONCAT' ].concat( result[ 0 ] ),
+                               [ 'CONCAT' ].concat( result[ 2 ] )
+                       ];
+               }
 
-                       wikilinkContents = choice( [
-                               pipedWikilink,
-                               unpipedWikilink
+               function unpipedWikilink() {
+                       var result = sequence( [
+                               nOrMore( 1, paramExpression )
                        ] );
+                       return result === null ? null : [
+                               [ 'CONCAT' ].concat( result[ 0 ] )
+                       ];
+               }
 
-                       function wikilink() {
-                               var result, parsedResult, parsedLinkContents;
-                               result = null;
+               wikilinkContents = choice( [
+                       pipedWikilink,
+                       unpipedWikilink
+               ] );
 
-                               parsedResult = sequence( [
-                                       openWikilink,
-                                       wikilinkContents,
-                                       closeWikilink
-                               ] );
-                               if ( parsedResult !== null ) {
-                                       parsedLinkContents = parsedResult[ 1 ];
-                                       result = [ 'WIKILINK' ].concat( parsedLinkContents );
-                               }
-                               return result;
-                       }
+               function wikilink() {
+                       var result, parsedResult, parsedLinkContents;
+                       result = null;
 
-                       // TODO: Support data- if appropriate
-                       function doubleQuotedHtmlAttributeValue() {
-                               var parsedResult = sequence( [
-                                       doubleQuote,
-                                       htmlDoubleQuoteAttributeValue,
-                                       doubleQuote
-                               ] );
-                               return parsedResult === null ? null : parsedResult[ 1 ];
+                       parsedResult = sequence( [
+                               openWikilink,
+                               wikilinkContents,
+                               closeWikilink
+                       ] );
+                       if ( parsedResult !== null ) {
+                               parsedLinkContents = parsedResult[ 1 ];
+                               result = [ 'WIKILINK' ].concat( parsedLinkContents );
                        }
+                       return result;
+               }
 
-                       function singleQuotedHtmlAttributeValue() {
-                               var parsedResult = sequence( [
-                                       singleQuote,
-                                       htmlSingleQuoteAttributeValue,
-                                       singleQuote
-                               ] );
-                               return parsedResult === null ? null : parsedResult[ 1 ];
-                       }
+               // TODO: Support data- if appropriate
+               function doubleQuotedHtmlAttributeValue() {
+                       var parsedResult = sequence( [
+                               doubleQuote,
+                               htmlDoubleQuoteAttributeValue,
+                               doubleQuote
+                       ] );
+                       return parsedResult === null ? null : parsedResult[ 1 ];
+               }
 
-                       function htmlAttribute() {
-                               var parsedResult = sequence( [
-                                       whitespace,
-                                       asciiAlphabetLiteral,
-                                       htmlAttributeEquals,
-                                       choice( [
-                                               doubleQuotedHtmlAttributeValue,
-                                               singleQuotedHtmlAttributeValue
-                                       ] )
-                               ] );
-                               return parsedResult === null ? null : [ parsedResult[ 1 ], parsedResult[ 3 ] ];
-                       }
+               function singleQuotedHtmlAttributeValue() {
+                       var parsedResult = sequence( [
+                               singleQuote,
+                               htmlSingleQuoteAttributeValue,
+                               singleQuote
+                       ] );
+                       return parsedResult === null ? null : parsedResult[ 1 ];
+               }
 
-                       /**
-                        * Checks if HTML is allowed
-                        *
-                        * @param {string} startTagName HTML start tag name
-                        * @param {string} endTagName HTML start tag name
-                        * @param {Object} attributes array of consecutive key value pairs,
-                        *  with index 2 * n being a name and 2 * n + 1 the associated value
-                        * @return {boolean} true if this is HTML is allowed, false otherwise
-                        */
-                       function isAllowedHtml( startTagName, endTagName, attributes ) {
-                               var i, len, attributeName;
-
-                               startTagName = startTagName.toLowerCase();
-                               endTagName = endTagName.toLowerCase();
-                               if ( startTagName !== endTagName || settings.allowedHtmlElements.indexOf( startTagName ) === -1 ) {
-                                       return false;
-                               }
+               function htmlAttribute() {
+                       var parsedResult = sequence( [
+                               whitespace,
+                               asciiAlphabetLiteral,
+                               htmlAttributeEquals,
+                               choice( [
+                                       doubleQuotedHtmlAttributeValue,
+                                       singleQuotedHtmlAttributeValue
+                               ] )
+                       ] );
+                       return parsedResult === null ? null : [ parsedResult[ 1 ], parsedResult[ 3 ] ];
+               }
 
-                               for ( i = 0, len = attributes.length; i < len; i += 2 ) {
-                                       attributeName = attributes[ i ];
-                                       if ( settings.allowedHtmlCommonAttributes.indexOf( attributeName ) === -1 &&
-                                               ( settings.allowedHtmlAttributesByElement[ startTagName ] || [] ).indexOf( attributeName ) === -1 ) {
-                                               return false;
-                                       }
-                               }
+               /**
+                * Checks if HTML is allowed
+                *
+                * @param {string} startTagName HTML start tag name
+                * @param {string} endTagName HTML start tag name
+                * @param {Object} attributes array of consecutive key value pairs,
+                *  with index 2 * n being a name and 2 * n + 1 the associated value
+                * @return {boolean} true if this is HTML is allowed, false otherwise
+                */
+               function isAllowedHtml( startTagName, endTagName, attributes ) {
+                       var i, len, attributeName;
 
-                               return true;
+                       startTagName = startTagName.toLowerCase();
+                       endTagName = endTagName.toLowerCase();
+                       if ( startTagName !== endTagName || settings.allowedHtmlElements.indexOf( startTagName ) === -1 ) {
+                               return false;
                        }
 
-                       function htmlAttributes() {
-                               var parsedResult = nOrMore( 0, htmlAttribute )();
-                               // Un-nest attributes array due to structure of jQueryMsg operations (see emit).
-                               return concat.apply( [ 'HTMLATTRIBUTES' ], parsedResult );
+                       for ( i = 0, len = attributes.length; i < len; i += 2 ) {
+                               attributeName = attributes[ i ];
+                               if ( settings.allowedHtmlCommonAttributes.indexOf( attributeName ) === -1 &&
+                                       ( settings.allowedHtmlAttributesByElement[ startTagName ] || [] ).indexOf( attributeName ) === -1 ) {
+                                       return false;
+                               }
                        }
 
-                       // Subset of allowed HTML markup.
-                       // Most elements and many attributes allowed on the server are not supported yet.
-                       function html() {
-                               var parsedOpenTagResult, parsedHtmlContents, parsedCloseTagResult,
-                                       wrappedAttributes, attributes, startTagName, endTagName, startOpenTagPos,
-                                       startCloseTagPos, endOpenTagPos, endCloseTagPos,
-                                       result = null;
-
-                               // Break into three sequence calls.  That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match.
-                               // 1. open through closeHtmlTag
-                               // 2. expression
-                               // 3. openHtmlEnd through close
-                               // This will allow recording the positions to reconstruct if HTML is to be treated as text.
-
-                               startOpenTagPos = pos;
-                               parsedOpenTagResult = sequence( [
-                                       openHtmlStartTag,
-                                       asciiAlphabetLiteral,
-                                       htmlAttributes,
-                                       optionalForwardSlash,
-                                       closeHtmlTag
-                               ] );
+                       return true;
+               }
 
-                               if ( parsedOpenTagResult === null ) {
-                                       return null;
-                               }
+               function htmlAttributes() {
+                       var parsedResult = nOrMore( 0, htmlAttribute )();
+                       // Un-nest attributes array due to structure of jQueryMsg operations (see emit).
+                       return concat.apply( [ 'HTMLATTRIBUTES' ], parsedResult );
+               }
 
-                               endOpenTagPos = pos;
-                               startTagName = parsedOpenTagResult[ 1 ];
+               // Subset of allowed HTML markup.
+               // Most elements and many attributes allowed on the server are not supported yet.
+               function html() {
+                       var parsedOpenTagResult, parsedHtmlContents, parsedCloseTagResult,
+                               wrappedAttributes, attributes, startTagName, endTagName, startOpenTagPos,
+                               startCloseTagPos, endOpenTagPos, endCloseTagPos,
+                               result = null;
 
-                               parsedHtmlContents = nOrMore( 0, expression )();
+                       // Break into three sequence calls.  That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match.
+                       // 1. open through closeHtmlTag
+                       // 2. expression
+                       // 3. openHtmlEnd through close
+                       // This will allow recording the positions to reconstruct if HTML is to be treated as text.
+
+                       startOpenTagPos = pos;
+                       parsedOpenTagResult = sequence( [
+                               openHtmlStartTag,
+                               asciiAlphabetLiteral,
+                               htmlAttributes,
+                               optionalForwardSlash,
+                               closeHtmlTag
+                       ] );
 
-                               startCloseTagPos = pos;
-                               parsedCloseTagResult = sequence( [
-                                       openHtmlEndTag,
-                                       asciiAlphabetLiteral,
-                                       closeHtmlTag
-                               ] );
+                       if ( parsedOpenTagResult === null ) {
+                               return null;
+                       }
 
-                               if ( parsedCloseTagResult === null ) {
-                                       // Closing tag failed.  Return the start tag and contents.
-                                       return [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
-                                               .concat( parsedHtmlContents );
-                               }
+                       endOpenTagPos = pos;
+                       startTagName = parsedOpenTagResult[ 1 ];
 
-                               endCloseTagPos = pos;
-                               endTagName = parsedCloseTagResult[ 1 ];
-                               wrappedAttributes = parsedOpenTagResult[ 2 ];
-                               attributes = wrappedAttributes.slice( 1 );
-                               if ( isAllowedHtml( startTagName, endTagName, attributes ) ) {
-                                       result = [ 'HTMLELEMENT', startTagName, wrappedAttributes ]
-                                               .concat( parsedHtmlContents );
-                               } else {
-                                       // HTML is not allowed, so contents will remain how
-                                       // it was, while HTML markup at this level will be
-                                       // treated as text
-                                       // E.g. assuming script tags are not allowed:
-                                       //
-                                       // <script>[[Foo|bar]]</script>
-                                       //
-                                       // results in '&lt;script&gt;' and '&lt;/script&gt;'
-                                       // (not treated as an HTML tag), surrounding a fully
-                                       // parsed HTML link.
-                                       //
-                                       // Concatenate everything from the tag, flattening the contents.
-                                       result = [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
-                                               .concat( parsedHtmlContents, input.slice( startCloseTagPos, endCloseTagPos ) );
-                               }
+                       parsedHtmlContents = nOrMore( 0, expression )();
 
-                               return result;
+                       startCloseTagPos = pos;
+                       parsedCloseTagResult = sequence( [
+                               openHtmlEndTag,
+                               asciiAlphabetLiteral,
+                               closeHtmlTag
+                       ] );
+
+                       if ( parsedCloseTagResult === null ) {
+                               // Closing tag failed.  Return the start tag and contents.
+                               return [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
+                                       .concat( parsedHtmlContents );
                        }
 
-                       // <nowiki>...</nowiki> tag. The tags are stripped and the contents are returned unparsed.
-                       function nowiki() {
-                               var parsedResult, plainText,
-                                       result = null;
+                       endCloseTagPos = pos;
+                       endTagName = parsedCloseTagResult[ 1 ];
+                       wrappedAttributes = parsedOpenTagResult[ 2 ];
+                       attributes = wrappedAttributes.slice( 1 );
+                       if ( isAllowedHtml( startTagName, endTagName, attributes ) ) {
+                               result = [ 'HTMLELEMENT', startTagName, wrappedAttributes ]
+                                       .concat( parsedHtmlContents );
+                       } else {
+                               // HTML is not allowed, so contents will remain how
+                               // it was, while HTML markup at this level will be
+                               // treated as text
+                               // E.g. assuming script tags are not allowed:
+                               //
+                               // <script>[[Foo|bar]]</script>
+                               //
+                               // results in '&lt;script&gt;' and '&lt;/script&gt;'
+                               // (not treated as an HTML tag), surrounding a fully
+                               // parsed HTML link.
+                               //
+                               // Concatenate everything from the tag, flattening the contents.
+                               result = [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ]
+                                       .concat( parsedHtmlContents, input.slice( startCloseTagPos, endCloseTagPos ) );
+                       }
 
-                               parsedResult = sequence( [
-                                       makeStringParser( '<nowiki>' ),
-                                       // We use a greedy non-backtracking parser, so we must ensure here that we don't take too much
-                                       makeRegexParser( /^.*?(?=<\/nowiki>)/ ),
-                                       makeStringParser( '</nowiki>' )
-                               ] );
-                               if ( parsedResult !== null ) {
-                                       plainText = parsedResult[ 1 ];
-                                       result = [ 'CONCAT' ].concat( plainText );
-                               }
+                       return result;
+               }
 
-                               return result;
+               // <nowiki>...</nowiki> tag. The tags are stripped and the contents are returned unparsed.
+               function nowiki() {
+                       var parsedResult, plainText,
+                               result = null;
+
+                       parsedResult = sequence( [
+                               makeStringParser( '<nowiki>' ),
+                               // We use a greedy non-backtracking parser, so we must ensure here that we don't take too much
+                               makeRegexParser( /^.*?(?=<\/nowiki>)/ ),
+                               makeStringParser( '</nowiki>' )
+                       ] );
+                       if ( parsedResult !== null ) {
+                               plainText = parsedResult[ 1 ];
+                               result = [ 'CONCAT' ].concat( plainText );
                        }
 
-                       templateName = transform(
-                               // see $wgLegalTitleChars
-                               // not allowing : due to the need to catch "PLURAL:$1"
-                               makeRegexParser( /^[ !"$&'()*,./0-9;=?@A-Z^_`a-z~\x80-\xFF+-]+/ ),
-                               function ( result ) { return result.toString(); }
-                       );
-                       function templateParam() {
-                               var expr, result;
-                               result = sequence( [
-                                       pipe,
-                                       nOrMore( 0, paramExpression )
-                               ] );
-                               if ( result === null ) {
-                                       return null;
-                               }
-                               expr = result[ 1 ];
-                               // use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw.
-                               return expr.length > 1 ? [ 'CONCAT' ].concat( expr ) : expr[ 0 ];
+                       return result;
+               }
+
+               templateName = transform(
+                       // see $wgLegalTitleChars
+                       // not allowing : due to the need to catch "PLURAL:$1"
+                       makeRegexParser( /^[ !"$&'()*,./0-9;=?@A-Z^_`a-z~\x80-\xFF+-]+/ ),
+                       function ( result ) { return result.toString(); }
+               );
+               function templateParam() {
+                       var expr, result;
+                       result = sequence( [
+                               pipe,
+                               nOrMore( 0, paramExpression )
+                       ] );
+                       if ( result === null ) {
+                               return null;
                        }
+                       expr = result[ 1 ];
+                       // use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw.
+                       return expr.length > 1 ? [ 'CONCAT' ].concat( expr ) : expr[ 0 ];
+               }
 
-                       function templateWithReplacement() {
-                               var result = sequence( [
-                                       templateName,
-                                       colon,
-                                       replacement
+               function templateWithReplacement() {
+                       var result = sequence( [
+                               templateName,
+                               colon,
+                               replacement
+                       ] );
+                       return result === null ? null : [ result[ 0 ], result[ 2 ] ];
+               }
+               function templateWithOutReplacement() {
+                       var result = sequence( [
+                               templateName,
+                               colon,
+                               paramExpression
+                       ] );
+                       return result === null ? null : [ result[ 0 ], result[ 2 ] ];
+               }
+               function templateWithOutFirstParameter() {
+                       var result = sequence( [
+                               templateName,
+                               colon
+                       ] );
+                       return result === null ? null : [ result[ 0 ], '' ];
+               }
+               colon = makeStringParser( ':' );
+               templateContents = choice( [
+                       function () {
+                               var res = sequence( [
+                                       // templates can have placeholders for dynamic replacement eg: {{PLURAL:$1|one car|$1 cars}}
+                                       // or no placeholders eg: {{GRAMMAR:genitive|{{SITENAME}}}
+                                       choice( [ templateWithReplacement, templateWithOutReplacement, templateWithOutFirstParameter ] ),
+                                       nOrMore( 0, templateParam )
                                ] );
-                               return result === null ? null : [ result[ 0 ], result[ 2 ] ];
-                       }
-                       function templateWithOutReplacement() {
-                               var result = sequence( [
+                               return res === null ? null : res[ 0 ].concat( res[ 1 ] );
+                       },
+                       function () {
+                               var res = sequence( [
                                        templateName,
-                                       colon,
-                                       paramExpression
+                                       nOrMore( 0, templateParam )
                                ] );
-                               return result === null ? null : [ result[ 0 ], result[ 2 ] ];
+                               if ( res === null ) {
+                                       return null;
+                               }
+                               return [ res[ 0 ] ].concat( res[ 1 ] );
                        }
-                       function templateWithOutFirstParameter() {
-                               var result = sequence( [
-                                       templateName,
-                                       colon
-                               ] );
-                               return result === null ? null : [ result[ 0 ], '' ];
+               ] );
+               openTemplate = makeStringParser( '{{' );
+               closeTemplate = makeStringParser( '}}' );
+               nonWhitespaceExpression = choice( [
+                       template,
+                       wikilink,
+                       extlink,
+                       replacement,
+                       literalWithoutSpace
+               ] );
+               paramExpression = choice( [
+                       template,
+                       wikilink,
+                       extlink,
+                       replacement,
+                       literalWithoutBar
+               ] );
+
+               expression = choice( [
+                       template,
+                       wikilink,
+                       extlink,
+                       replacement,
+                       nowiki,
+                       html,
+                       literal
+               ] );
+
+               // Used when only {{-transformation is wanted, for 'text'
+               // or 'escaped' formats
+               curlyBraceTransformExpression = choice( [
+                       template,
+                       replacement,
+                       curlyBraceTransformExpressionLiteral
+               ] );
+
+               /**
+                * Starts the parse
+                *
+                * @param {Function} rootExpression Root parse function
+                * @return {Array|null}
+                */
+               function start( rootExpression ) {
+                       var result = nOrMore( 0, rootExpression )();
+                       if ( result === null ) {
+                               return null;
                        }
-                       colon = makeStringParser( ':' );
-                       templateContents = choice( [
-                               function () {
-                                       var res = sequence( [
-                                               // templates can have placeholders for dynamic replacement eg: {{PLURAL:$1|one car|$1 cars}}
-                                               // or no placeholders eg: {{GRAMMAR:genitive|{{SITENAME}}}
-                                               choice( [ templateWithReplacement, templateWithOutReplacement, templateWithOutFirstParameter ] ),
-                                               nOrMore( 0, templateParam )
-                                       ] );
-                                       return res === null ? null : res[ 0 ].concat( res[ 1 ] );
-                               },
-                               function () {
-                                       var res = sequence( [
-                                               templateName,
-                                               nOrMore( 0, templateParam )
-                                       ] );
-                                       if ( res === null ) {
-                                               return null;
-                                       }
-                                       return [ res[ 0 ] ].concat( res[ 1 ] );
-                               }
-                       ] );
-                       openTemplate = makeStringParser( '{{' );
-                       closeTemplate = makeStringParser( '}}' );
-                       nonWhitespaceExpression = choice( [
-                               template,
-                               wikilink,
-                               extlink,
-                               replacement,
-                               literalWithoutSpace
-                       ] );
-                       paramExpression = choice( [
-                               template,
-                               wikilink,
-                               extlink,
-                               replacement,
-                               literalWithoutBar
-                       ] );
+                       return [ 'CONCAT' ].concat( result );
+               }
+               // everything above this point is supposed to be stateless/static, but
+               // I am deferring the work of turning it into prototypes & objects. It's quite fast enough
+               // finally let's do some actual work...
 
-                       expression = choice( [
-                               template,
-                               wikilink,
-                               extlink,
-                               replacement,
-                               nowiki,
-                               html,
-                               literal
-                       ] );
+               result = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression );
 
-                       // Used when only {{-transformation is wanted, for 'text'
-                       // or 'escaped' formats
-                       curlyBraceTransformExpression = choice( [
-                               template,
-                               replacement,
-                               curlyBraceTransformExpressionLiteral
-                       ] );
+               /*
+                * For success, the p must have gotten to the end of the input
+                * and returned a non-null.
+                * n.b. This is part of language infrastructure, so we do not throw an internationalizable message.
+                */
+               if ( result === null || pos !== input.length ) {
+                       throw new Error( 'Parse error at position ' + pos.toString() + ' in input: ' + input );
+               }
+               return result;
+       }
 
-                       /**
-                        * Starts the parse
-                        *
-                        * @param {Function} rootExpression Root parse function
-                        * @return {Array|null}
-                        */
-                       function start( rootExpression ) {
-                               var result = nOrMore( 0, rootExpression )();
-                               if ( result === null ) {
-                                       return null;
+};
+
+/**
+ * Class that primarily exists to emit HTML from parser ASTs.
+ *
+ * @private
+ * @class
+ * @param {Object} language
+ * @param {Object} magic
+ */
+mw.jqueryMsg.HtmlEmitter = function ( language, magic ) {
+       var jmsg = this;
+       this.language = language;
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( magic, function ( key, val ) {
+               jmsg[ key.toLowerCase() ] = function () {
+                       return val;
+               };
+       } );
+
+       /**
+        * (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.)
+        * Walk entire node structure, applying replacements and template functions when appropriate
+        *
+        * @param {Mixed} node Abstract syntax tree (top node or subnode)
+        * @param {Array} replacements for $1, $2, ... $n
+        * @return {Mixed} single-string node or array of nodes suitable for jQuery appending
+        */
+       this.emit = function ( node, replacements ) {
+               var ret, subnodes, operation,
+                       jmsg = this;
+               switch ( typeof node ) {
+                       case 'string':
+                       case 'number':
+                               ret = node;
+                               break;
+                       // typeof returns object for arrays
+                       case 'object':
+                               // node is an array of nodes
+                               // eslint-disable-next-line no-jquery/no-map-util
+                               subnodes = $.map( node.slice( 1 ), function ( n ) {
+                                       return jmsg.emit( n, replacements );
+                               } );
+                               operation = node[ 0 ].toLowerCase();
+                               if ( typeof jmsg[ operation ] === 'function' ) {
+                                       ret = jmsg[ operation ]( subnodes, replacements );
+                               } else {
+                                       throw new Error( 'Unknown operation "' + operation + '"' );
                                }
-                               return [ 'CONCAT' ].concat( result );
-                       }
-                       // everything above this point is supposed to be stateless/static, but
-                       // I am deferring the work of turning it into prototypes & objects. It's quite fast enough
-                       // finally let's do some actual work...
-
-                       result = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression );
-
-                       /*
-                        * For success, the p must have gotten to the end of the input
-                        * and returned a non-null.
-                        * n.b. This is part of language infrastructure, so we do not throw an internationalizable message.
-                        */
-                       if ( result === null || pos !== input.length ) {
-                               throw new Error( 'Parse error at position ' + pos.toString() + ' in input: ' + input );
-                       }
-                       return result;
+                               break;
+                       case 'undefined':
+                               // Parsing the empty string (as an entire expression, or as a paramExpression in a template) results in undefined
+                               // Perhaps a more clever parser can detect this, and return the empty string? Or is that useful information?
+                               // The logical thing is probably to return the empty string here when we encounter undefined.
+                               ret = '';
+                               break;
+                       default:
+                               throw new Error( 'Unexpected type in AST: ' + typeof node );
                }
-
+               return ret;
        };
-
+};
+
+// For everything in input that follows double-open-curly braces, there should be an equivalent parser
+// function. For instance {{PLURAL ... }} will be processed by 'plural'.
+// If you have 'magic words' then configure the parser to have them upon creation.
+//
+// An emitter method takes the parent node, the array of subnodes and the array of replacements (the values that $1, $2... should translate to).
+// Note: all such functions must be pure, with the exception of referring to other pure functions via this.language (convertPlural and so on)
+mw.jqueryMsg.HtmlEmitter.prototype = {
        /**
-        * Class that primarily exists to emit HTML from parser ASTs.
+        * Parsing has been applied depth-first we can assume that all nodes here are single nodes
+        * Must return a single node to parents -- a jQuery with synthetic span
+        * However, unwrap any other synthetic spans in our children and pass them upwards
         *
-        * @private
-        * @class
-        * @param {Object} language
-        * @param {Object} magic
+        * @param {Mixed[]} nodes Some single nodes, some arrays of nodes
+        * @return {jQuery}
         */
-       mw.jqueryMsg.HtmlEmitter = function ( language, magic ) {
-               var jmsg = this;
-               this.language = language;
+       concat: function ( nodes ) {
+               var $span = $( '<span>' ).addClass( 'mediaWiki_htmlEmitter' );
                // eslint-disable-next-line no-jquery/no-each-util
-               $.each( magic, function ( key, val ) {
-                       jmsg[ key.toLowerCase() ] = function () {
-                               return val;
-                       };
+               $.each( nodes, function ( i, node ) {
+                       // Let jQuery append nodes, arrays of nodes and jQuery objects
+                       // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings)
+                       appendWithoutParsing( $span, node );
                } );
+               return $span;
+       },
 
-               /**
-                * (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.)
-                * Walk entire node structure, applying replacements and template functions when appropriate
-                *
-                * @param {Mixed} node Abstract syntax tree (top node or subnode)
-                * @param {Array} replacements for $1, $2, ... $n
-                * @return {Mixed} single-string node or array of nodes suitable for jQuery appending
-                */
-               this.emit = function ( node, replacements ) {
-                       var ret, subnodes, operation,
-                               jmsg = this;
-                       switch ( typeof node ) {
-                               case 'string':
-                               case 'number':
-                                       ret = node;
-                                       break;
-                               // typeof returns object for arrays
-                               case 'object':
-                                       // node is an array of nodes
-                                       // eslint-disable-next-line no-jquery/no-map-util
-                                       subnodes = $.map( node.slice( 1 ), function ( n ) {
-                                               return jmsg.emit( n, replacements );
-                                       } );
-                                       operation = node[ 0 ].toLowerCase();
-                                       if ( typeof jmsg[ operation ] === 'function' ) {
-                                               ret = jmsg[ operation ]( subnodes, replacements );
-                                       } else {
-                                               throw new Error( 'Unknown operation "' + operation + '"' );
-                                       }
-                                       break;
-                               case 'undefined':
-                                       // Parsing the empty string (as an entire expression, or as a paramExpression in a template) results in undefined
-                                       // Perhaps a more clever parser can detect this, and return the empty string? Or is that useful information?
-                                       // The logical thing is probably to return the empty string here when we encounter undefined.
-                                       ret = '';
-                                       break;
-                               default:
-                                       throw new Error( 'Unexpected type in AST: ' + typeof node );
-                       }
-                       return ret;
-               };
-       };
-
-       // For everything in input that follows double-open-curly braces, there should be an equivalent parser
-       // function. For instance {{PLURAL ... }} will be processed by 'plural'.
-       // If you have 'magic words' then configure the parser to have them upon creation.
-       //
-       // An emitter method takes the parent node, the array of subnodes and the array of replacements (the values that $1, $2... should translate to).
-       // Note: all such functions must be pure, with the exception of referring to other pure functions via this.language (convertPlural and so on)
-       mw.jqueryMsg.HtmlEmitter.prototype = {
-               /**
-                * Parsing has been applied depth-first we can assume that all nodes here are single nodes
-                * Must return a single node to parents -- a jQuery with synthetic span
-                * However, unwrap any other synthetic spans in our children and pass them upwards
-                *
-                * @param {Mixed[]} nodes Some single nodes, some arrays of nodes
-                * @return {jQuery}
-                */
-               concat: function ( nodes ) {
-                       var $span = $( '<span>' ).addClass( 'mediaWiki_htmlEmitter' );
-                       // eslint-disable-next-line no-jquery/no-each-util
-                       $.each( nodes, function ( i, node ) {
-                               // Let jQuery append nodes, arrays of nodes and jQuery objects
-                               // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings)
-                               appendWithoutParsing( $span, node );
-                       } );
-                       return $span;
-               },
+       /**
+        * Return escaped replacement of correct index, or string if unavailable.
+        * Note that we expect the parsed parameter to be zero-based. i.e. $1 should have become [ 0 ].
+        * if the specified parameter is not found return the same string
+        * (e.g. "$99" -> parameter 98 -> not found -> return "$99" )
+        *
+        * TODO: Throw error if nodes.length > 1 ?
+        *
+        * @param {Array} nodes List of one element, integer, n >= 0
+        * @param {Array} replacements List of at least n strings
+        * @return {string|jQuery} replacement
+        */
+       replace: function ( nodes, replacements ) {
+               var index = parseInt( nodes[ 0 ], 10 );
 
-               /**
-                * Return escaped replacement of correct index, or string if unavailable.
-                * Note that we expect the parsed parameter to be zero-based. i.e. $1 should have become [ 0 ].
-                * if the specified parameter is not found return the same string
-                * (e.g. "$99" -> parameter 98 -> not found -> return "$99" )
-                *
-                * TODO: Throw error if nodes.length > 1 ?
-                *
-                * @param {Array} nodes List of one element, integer, n >= 0
-                * @param {Array} replacements List of at least n strings
-                * @return {string|jQuery} replacement
-                */
-               replace: function ( nodes, replacements ) {
-                       var index = parseInt( nodes[ 0 ], 10 );
+               if ( index < replacements.length ) {
+                       return replacements[ index ];
+               } else {
+                       // index not found, fallback to displaying variable
+                       return '$' + ( index + 1 );
+               }
+       },
 
-                       if ( index < replacements.length ) {
-                               return replacements[ index ];
-                       } else {
-                               // index not found, fallback to displaying variable
-                               return '$' + ( index + 1 );
-                       }
-               },
+       /**
+        * Transform wiki-link
+        *
+        * TODO:
+        * It only handles basic cases, either no pipe, or a pipe with an explicit
+        * anchor.
+        *
+        * It does not attempt to handle features like the pipe trick.
+        * However, the pipe trick should usually not be present in wikitext retrieved
+        * from the server, since the replacement is done at save time.
+        * It may, though, if the wikitext appears in extension-controlled content.
+        *
+        * @param {string[]} nodes
+        * @return {jQuery}
+        */
+       wikilink: function ( nodes ) {
+               var page, anchor, url, $el;
+
+               page = textify( nodes[ 0 ] );
+               // Strip leading ':', which is used to suppress special behavior in wikitext links,
+               // e.g. [[:Category:Foo]] or [[:File:Foo.jpg]]
+               if ( page.charAt( 0 ) === ':' ) {
+                       page = page.slice( 1 );
+               }
+               url = mw.util.getUrl( page );
 
-               /**
-                * Transform wiki-link
-                *
-                * TODO:
-                * It only handles basic cases, either no pipe, or a pipe with an explicit
-                * anchor.
-                *
-                * It does not attempt to handle features like the pipe trick.
-                * However, the pipe trick should usually not be present in wikitext retrieved
-                * from the server, since the replacement is done at save time.
-                * It may, though, if the wikitext appears in extension-controlled content.
-                *
-                * @param {string[]} nodes
-                * @return {jQuery}
-                */
-               wikilink: function ( nodes ) {
-                       var page, anchor, url, $el;
-
-                       page = textify( nodes[ 0 ] );
-                       // Strip leading ':', which is used to suppress special behavior in wikitext links,
-                       // e.g. [[:Category:Foo]] or [[:File:Foo.jpg]]
-                       if ( page.charAt( 0 ) === ':' ) {
-                               page = page.slice( 1 );
-                       }
-                       url = mw.util.getUrl( page );
+               if ( nodes.length === 1 ) {
+                       // [[Some Page]] or [[Namespace:Some Page]]
+                       anchor = page;
+               } else {
+                       // [[Some Page|anchor text]] or [[Namespace:Some Page|anchor]]
+                       anchor = nodes[ 1 ];
+               }
 
-                       if ( nodes.length === 1 ) {
-                               // [[Some Page]] or [[Namespace:Some Page]]
-                               anchor = page;
-                       } else {
-                               // [[Some Page|anchor text]] or [[Namespace:Some Page|anchor]]
-                               anchor = nodes[ 1 ];
-                       }
+               $el = $( '<a>' ).attr( {
+                       title: page,
+                       href: url
+               } );
+               return appendWithoutParsing( $el, anchor );
+       },
 
-                       $el = $( '<a>' ).attr( {
-                               title: page,
-                               href: url
-                       } );
-                       return appendWithoutParsing( $el, anchor );
-               },
+       /**
+        * Converts array of HTML element key value pairs to object
+        *
+        * @param {Array} nodes Array of consecutive key value pairs, with index 2 * n being a
+        *  name and 2 * n + 1 the associated value
+        * @return {Object} Object mapping attribute name to attribute value
+        */
+       htmlattributes: function ( nodes ) {
+               var i, len, mapping = {};
+               for ( i = 0, len = nodes.length; i < len; i += 2 ) {
+                       mapping[ nodes[ i ] ] = decodePrimaryHtmlEntities( nodes[ i + 1 ] );
+               }
+               return mapping;
+       },
 
-               /**
-                * Converts array of HTML element key value pairs to object
-                *
-                * @param {Array} nodes Array of consecutive key value pairs, with index 2 * n being a
-                *  name and 2 * n + 1 the associated value
-                * @return {Object} Object mapping attribute name to attribute value
-                */
-               htmlattributes: function ( nodes ) {
-                       var i, len, mapping = {};
-                       for ( i = 0, len = nodes.length; i < len; i += 2 ) {
-                               mapping[ nodes[ i ] ] = decodePrimaryHtmlEntities( nodes[ i + 1 ] );
-                       }
-                       return mapping;
-               },
+       /**
+        * Handles an (already-validated) HTML element.
+        *
+        * @param {Array} nodes Nodes to process when creating element
+        * @return {jQuery}
+        */
+       htmlelement: function ( nodes ) {
+               var tagName, attributes, contents, $element;
 
-               /**
-                * Handles an (already-validated) HTML element.
-                *
-                * @param {Array} nodes Nodes to process when creating element
-                * @return {jQuery}
-                */
-               htmlelement: function ( nodes ) {
-                       var tagName, attributes, contents, $element;
-
-                       tagName = nodes.shift();
-                       attributes = nodes.shift();
-                       contents = nodes;
-                       $element = $( document.createElement( tagName ) ).attr( attributes );
-                       return appendWithoutParsing( $element, contents );
-               },
+               tagName = nodes.shift();
+               attributes = nodes.shift();
+               contents = nodes;
+               $element = $( document.createElement( tagName ) ).attr( attributes );
+               return appendWithoutParsing( $element, contents );
+       },
 
-               /**
-                * Transform parsed structure into external link.
-                *
-                * The "href" can be:
-                * - a jQuery object, treat it as "enclosing" the link text.
-                * - a function, treat it as the click handler.
-                * - a string, or our HtmlEmitter jQuery object, treat it as a URI after stringifying.
-                *
-                * TODO: throw an error if nodes.length > 2 ?
-                *
-                * @param {Array} nodes List of two elements, {jQuery|Function|String} and {string}
-                * @return {jQuery}
-                */
-               extlink: function ( nodes ) {
-                       var $el,
-                               arg = nodes[ 0 ],
-                               contents = nodes[ 1 ];
-                       if ( arg instanceof $ && !arg.hasClass( 'mediaWiki_htmlEmitter' ) ) {
-                               $el = arg;
+       /**
+        * Transform parsed structure into external link.
+        *
+        * The "href" can be:
+        * - a jQuery object, treat it as "enclosing" the link text.
+        * - a function, treat it as the click handler.
+        * - a string, or our HtmlEmitter jQuery object, treat it as a URI after stringifying.
+        *
+        * TODO: throw an error if nodes.length > 2 ?
+        *
+        * @param {Array} nodes List of two elements, {jQuery|Function|String} and {string}
+        * @return {jQuery}
+        */
+       extlink: function ( nodes ) {
+               var $el,
+                       arg = nodes[ 0 ],
+                       contents = nodes[ 1 ];
+               if ( arg instanceof $ && !arg.hasClass( 'mediaWiki_htmlEmitter' ) ) {
+                       $el = arg;
+               } else {
+                       $el = $( '<a>' );
+                       if ( typeof arg === 'function' ) {
+                               $el.attr( {
+                                       role: 'button',
+                                       tabindex: 0
+                               } ).on( 'click keypress', function ( e ) {
+                                       if (
+                                               e.type === 'click' ||
+                                               e.type === 'keypress' && e.which === 13
+                                       ) {
+                                               arg.call( this, e );
+                                       }
+                               } );
                        } else {
-                               $el = $( '<a>' );
-                               if ( typeof arg === 'function' ) {
-                                       $el.attr( {
-                                               role: 'button',
-                                               tabindex: 0
-                                       } ).on( 'click keypress', function ( e ) {
-                                               if (
-                                                       e.type === 'click' ||
-                                                       e.type === 'keypress' && e.which === 13
-                                               ) {
-                                                       arg.call( this, e );
-                                               }
-                                       } );
-                               } else {
-                                       $el.attr( 'href', textify( arg ) );
-                               }
+                               $el.attr( 'href', textify( arg ) );
                        }
-                       return appendWithoutParsing( $el.empty(), contents );
-               },
-
-               /**
-                * Transform parsed structure into pluralization
-                * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number).
-                * So convert it back with the current language's convertNumber.
-                *
-                * @param {Array} nodes List of nodes, [ {string|number}, {string}, {string} ... ]
-                * @return {string|jQuery} selected pluralized form according to current language
-                */
-               plural: function ( nodes ) {
-                       var forms, firstChild, firstChildText, explicitPluralFormNumber, formIndex, form, count,
-                               explicitPluralForms = {};
+               }
+               return appendWithoutParsing( $el.empty(), contents );
+       },
 
-                       count = parseFloat( this.language.convertNumber( textify( nodes[ 0 ] ), true ) );
-                       forms = nodes.slice( 1 );
-                       for ( formIndex = 0; formIndex < forms.length; formIndex++ ) {
-                               form = forms[ formIndex ];
-
-                               if ( form instanceof $ && form.hasClass( 'mediaWiki_htmlEmitter' ) ) {
-                                       // This is a nested node, may be an explicit plural form like 5=[$2 linktext]
-                                       firstChild = form.contents().get( 0 );
-                                       if ( firstChild && firstChild.nodeType === Node.TEXT_NODE ) {
-                                               firstChildText = firstChild.textContent;
-                                               if ( /^\d+=/.test( firstChildText ) ) {
-                                                       explicitPluralFormNumber = parseInt( firstChildText.split( /=/ )[ 0 ], 10 );
-                                                       // Use the digit part as key and rest of first text node and
-                                                       // rest of child nodes as value.
-                                                       firstChild.textContent = firstChildText.slice( firstChildText.indexOf( '=' ) + 1 );
-                                                       explicitPluralForms[ explicitPluralFormNumber ] = form;
-                                                       forms[ formIndex ] = undefined;
-                                               }
+       /**
+        * Transform parsed structure into pluralization
+        * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number).
+        * So convert it back with the current language's convertNumber.
+        *
+        * @param {Array} nodes List of nodes, [ {string|number}, {string}, {string} ... ]
+        * @return {string|jQuery} selected pluralized form according to current language
+        */
+       plural: function ( nodes ) {
+               var forms, firstChild, firstChildText, explicitPluralFormNumber, formIndex, form, count,
+                       explicitPluralForms = {};
+
+               count = parseFloat( this.language.convertNumber( textify( nodes[ 0 ] ), true ) );
+               forms = nodes.slice( 1 );
+               for ( formIndex = 0; formIndex < forms.length; formIndex++ ) {
+                       form = forms[ formIndex ];
+
+                       if ( form instanceof $ && form.hasClass( 'mediaWiki_htmlEmitter' ) ) {
+                               // This is a nested node, may be an explicit plural form like 5=[$2 linktext]
+                               firstChild = form.contents().get( 0 );
+                               if ( firstChild && firstChild.nodeType === Node.TEXT_NODE ) {
+                                       firstChildText = firstChild.textContent;
+                                       if ( /^\d+=/.test( firstChildText ) ) {
+                                               explicitPluralFormNumber = parseInt( firstChildText.split( /=/ )[ 0 ], 10 );
+                                               // Use the digit part as key and rest of first text node and
+                                               // rest of child nodes as value.
+                                               firstChild.textContent = firstChildText.slice( firstChildText.indexOf( '=' ) + 1 );
+                                               explicitPluralForms[ explicitPluralFormNumber ] = form;
+                                               forms[ formIndex ] = undefined;
                                        }
-                               } else if ( /^\d+=/.test( form ) ) {
-                                       // Simple explicit plural forms like 12=a dozen
-                                       explicitPluralFormNumber = parseInt( form.split( /=/ )[ 0 ], 10 );
-                                       explicitPluralForms[ explicitPluralFormNumber ] = form.slice( form.indexOf( '=' ) + 1 );
-                                       forms[ formIndex ] = undefined;
                                }
+                       } else if ( /^\d+=/.test( form ) ) {
+                               // Simple explicit plural forms like 12=a dozen
+                               explicitPluralFormNumber = parseInt( form.split( /=/ )[ 0 ], 10 );
+                               explicitPluralForms[ explicitPluralFormNumber ] = form.slice( form.indexOf( '=' ) + 1 );
+                               forms[ formIndex ] = undefined;
                        }
+               }
 
-                       // Remove explicit plural forms from the forms. They were set undefined in the above loop.
-                       // eslint-disable-next-line no-jquery/no-map-util
-                       forms = $.map( forms, function ( form ) {
-                               return form;
-                       } );
-
-                       return this.language.convertPlural( count, forms, explicitPluralForms );
-               },
-
-               /**
-                * Transform parsed structure according to gender.
-                *
-                * Usage: {{gender:[ mw.user object | '' | 'male' | 'female' | 'unknown' ] | masculine form | feminine form | neutral form}}.
-                *
-                * The first node must be one of:
-                * - the mw.user object (or a compatible one)
-                * - an empty string - indicating the current user, same effect as passing the mw.user object
-                * - a gender string ('male', 'female' or 'unknown')
-                *
-                * @param {Array} nodes List of nodes, [ {string|mw.user}, {string}, {string}, {string} ]
-                * @return {string|jQuery} Selected gender form according to current language
-                */
-               gender: function ( nodes ) {
-                       var gender,
-                               maybeUser = nodes[ 0 ],
-                               forms = nodes.slice( 1 );
-
-                       if ( maybeUser === '' ) {
-                               maybeUser = mw.user;
-                       }
-
-                       // If we are passed a mw.user-like object, check their gender.
-                       // Otherwise, assume the gender string itself was passed .
-                       if ( maybeUser && maybeUser.options instanceof mw.Map ) {
-                               gender = maybeUser.options.get( 'gender' );
-                       } else {
-                               gender = textify( maybeUser );
-                       }
-
-                       return this.language.gender( gender, forms );
-               },
+               // Remove explicit plural forms from the forms. They were set undefined in the above loop.
+               // eslint-disable-next-line no-jquery/no-map-util
+               forms = $.map( forms, function ( form ) {
+                       return form;
+               } );
 
-               /**
-                * Transform parsed structure into grammar conversion.
-                * Invoked by putting `{{grammar:form|word}}` in a message
-                *
-                * @param {Array} nodes List of nodes [{Grammar case eg: genitive}, {string word}]
-                * @return {string|jQuery} selected grammatical form according to current language
-                */
-               grammar: function ( nodes ) {
-                       var form = nodes[ 0 ],
-                               word = nodes[ 1 ];
-                       // These could be jQuery objects (passed as message parameters),
-                       // in which case we can't transform them (like rawParams() in PHP).
-                       if ( typeof form === 'string' && typeof word === 'string' ) {
-                               return this.language.convertGrammar( word, form );
-                       }
-                       return word;
-               },
+               return this.language.convertPlural( count, forms, explicitPluralForms );
+       },
 
-               /**
-                * Tranform parsed structure into a int: (interface language) message include
-                * Invoked by putting `{{int:othermessage}}` into a message
-                *
-                * TODO Syntax in the included message is not parsed, this seems like a bug?
-                *
-                * @param {Array} nodes List of nodes
-                * @return {string} Other message
-                */
-               int: function ( nodes ) {
-                       var msg = textify( nodes[ 0 ] );
-                       return mw.jqueryMsg.getMessageFunction()( msg.charAt( 0 ).toLowerCase() + msg.slice( 1 ) );
-               },
+       /**
+        * Transform parsed structure according to gender.
+        *
+        * Usage: {{gender:[ mw.user object | '' | 'male' | 'female' | 'unknown' ] | masculine form | feminine form | neutral form}}.
+        *
+        * The first node must be one of:
+        * - the mw.user object (or a compatible one)
+        * - an empty string - indicating the current user, same effect as passing the mw.user object
+        * - a gender string ('male', 'female' or 'unknown')
+        *
+        * @param {Array} nodes List of nodes, [ {string|mw.user}, {string}, {string}, {string} ]
+        * @return {string|jQuery} Selected gender form according to current language
+        */
+       gender: function ( nodes ) {
+               var gender,
+                       maybeUser = nodes[ 0 ],
+                       forms = nodes.slice( 1 );
 
-               /**
-                * Get localized namespace name from canonical name or namespace number.
-                * Invoked by putting `{{ns:foo}}` into a message
-                *
-                * @param {Array} nodes List of nodes
-                * @return {string} Localized namespace name
-                */
-               ns: function ( nodes ) {
-                       var ns = textify( nodes[ 0 ] ).trim();
-                       if ( !/^\d+$/.test( ns ) ) {
-                               ns = mw.config.get( 'wgNamespaceIds' )[ ns.replace( / /g, '_' ).toLowerCase() ];
-                       }
-                       ns = mw.config.get( 'wgFormattedNamespaces' )[ ns ];
-                       return ns || '';
-               },
+               if ( maybeUser === '' ) {
+                       maybeUser = mw.user;
+               }
 
-               /**
-                * Takes an unformatted number (arab, no group separators and . as decimal separator)
-                * and outputs it in the localized digit script and formatted with decimal
-                * separator, according to the current language.
-                *
-                * @param {Array} nodes List of nodes
-                * @return {number|string|jQuery} Formatted number
-                */
-               formatnum: function ( nodes ) {
-                       var isInteger = !!nodes[ 1 ] && nodes[ 1 ] === 'R',
-                               number = nodes[ 0 ];
-
-                       // These could be jQuery objects (passed as message parameters),
-                       // in which case we can't transform them (like rawParams() in PHP).
-                       if ( typeof number === 'string' || typeof number === 'number' ) {
-                               return this.language.convertNumber( number, isInteger );
-                       }
-                       return number;
-               },
+               // If we are passed a mw.user-like object, check their gender.
+               // Otherwise, assume the gender string itself was passed .
+               if ( maybeUser && maybeUser.options instanceof mw.Map ) {
+                       gender = maybeUser.options.get( 'gender' );
+               } else {
+                       gender = textify( maybeUser );
+               }
 
-               /**
-                * Lowercase text
-                *
-                * @param {Array} nodes List of nodes
-                * @return {string} The given text, all in lowercase
-                */
-               lc: function ( nodes ) {
-                       return textify( nodes[ 0 ] ).toLowerCase();
-               },
+               return this.language.gender( gender, forms );
+       },
 
-               /**
-                * Uppercase text
-                *
-                * @param {Array} nodes List of nodes
-                * @return {string} The given text, all in uppercase
-                */
-               uc: function ( nodes ) {
-                       return textify( nodes[ 0 ] ).toUpperCase();
-               },
+       /**
+        * Transform parsed structure into grammar conversion.
+        * Invoked by putting `{{grammar:form|word}}` in a message
+        *
+        * @param {Array} nodes List of nodes [{Grammar case eg: genitive}, {string word}]
+        * @return {string|jQuery} selected grammatical form according to current language
+        */
+       grammar: function ( nodes ) {
+               var form = nodes[ 0 ],
+                       word = nodes[ 1 ];
+               // These could be jQuery objects (passed as message parameters),
+               // in which case we can't transform them (like rawParams() in PHP).
+               if ( typeof form === 'string' && typeof word === 'string' ) {
+                       return this.language.convertGrammar( word, form );
+               }
+               return word;
+       },
 
-               /**
-                * Lowercase first letter of input, leaving the rest unchanged
-                *
-                * @param {Array} nodes List of nodes
-                * @return {string} The given text, with the first character in lowercase
-                */
-               lcfirst: function ( nodes ) {
-                       var text = textify( nodes[ 0 ] );
-                       return text.charAt( 0 ).toLowerCase() + text.slice( 1 );
-               },
+       /**
+        * Tranform parsed structure into a int: (interface language) message include
+        * Invoked by putting `{{int:othermessage}}` into a message
+        *
+        * TODO Syntax in the included message is not parsed, this seems like a bug?
+        *
+        * @param {Array} nodes List of nodes
+        * @return {string} Other message
+        */
+       int: function ( nodes ) {
+               var msg = textify( nodes[ 0 ] );
+               return mw.jqueryMsg.getMessageFunction()( msg.charAt( 0 ).toLowerCase() + msg.slice( 1 ) );
+       },
 
-               /**
-                * Uppercase first letter of input, leaving the rest unchanged
-                *
-                * @param {Array} nodes List of nodes
-                * @return {string} The given text, with the first character in uppercase
-                */
-               ucfirst: function ( nodes ) {
-                       var text = textify( nodes[ 0 ] );
-                       return text.charAt( 0 ).toUpperCase() + text.slice( 1 );
+       /**
+        * Get localized namespace name from canonical name or namespace number.
+        * Invoked by putting `{{ns:foo}}` into a message
+        *
+        * @param {Array} nodes List of nodes
+        * @return {string} Localized namespace name
+        */
+       ns: function ( nodes ) {
+               var ns = textify( nodes[ 0 ] ).trim();
+               if ( !/^\d+$/.test( ns ) ) {
+                       ns = mw.config.get( 'wgNamespaceIds' )[ ns.replace( / /g, '_' ).toLowerCase() ];
                }
-       };
+               ns = mw.config.get( 'wgFormattedNamespaces' )[ ns ];
+               return ns || '';
+       },
 
        /**
-        * @method
-        * @member jQuery
-        * @see mw.jqueryMsg#getPlugin
+        * Takes an unformatted number (arab, no group separators and . as decimal separator)
+        * and outputs it in the localized digit script and formatted with decimal
+        * separator, according to the current language.
+        *
+        * @param {Array} nodes List of nodes
+        * @return {number|string|jQuery} Formatted number
         */
-       $.fn.msg = mw.jqueryMsg.getPlugin();
-
-       // Replace the default message parser with jqueryMsg
-       oldParser = mw.Message.prototype.parser;
-       mw.Message.prototype.parser = function () {
-               // Fall back to mw.msg's simple parser where possible
-               if (
-                       // Plain text output always uses the simple parser
-                       this.format === 'plain' ||
-                       (
-                               // jqueryMsg parser is needed for messages containing wikitext
-                               !/\{\{|[<>[&]/.test( this.map.get( this.key ) ) &&
-                               // jqueryMsg parser is needed when jQuery objects or DOM nodes are passed in as parameters
-                               !this.parameters.some( function ( param ) {
-                                       return param instanceof $ || ( param && param.nodeType !== undefined );
-                               } )
-                       )
-               ) {
-                       return oldParser.apply( this );
+       formatnum: function ( nodes ) {
+               var isInteger = !!nodes[ 1 ] && nodes[ 1 ] === 'R',
+                       number = nodes[ 0 ];
+
+               // These could be jQuery objects (passed as message parameters),
+               // in which case we can't transform them (like rawParams() in PHP).
+               if ( typeof number === 'string' || typeof number === 'number' ) {
+                       return this.language.convertNumber( number, isInteger );
                }
+               return number;
+       },
 
-               if ( !Object.prototype.hasOwnProperty.call( this.map, this.format ) ) {
-                       this.map[ this.format ] = mw.jqueryMsg.getMessageFunction( {
-                               messages: this.map,
-                               // For format 'escaped', escaping part is handled by mediawiki.js
-                               format: this.format
-                       } );
-               }
-               return this.map[ this.format ]( this.key, this.parameters );
-       };
+       /**
+        * Lowercase text
+        *
+        * @param {Array} nodes List of nodes
+        * @return {string} The given text, all in lowercase
+        */
+       lc: function ( nodes ) {
+               return textify( nodes[ 0 ] ).toLowerCase();
+       },
 
        /**
-        * Parse the message to DOM nodes, rather than HTML string like #parse.
+        * Uppercase text
         *
-        * This method is only available when jqueryMsg is loaded.
+        * @param {Array} nodes List of nodes
+        * @return {string} The given text, all in uppercase
+        */
+       uc: function ( nodes ) {
+               return textify( nodes[ 0 ] ).toUpperCase();
+       },
+
+       /**
+        * Lowercase first letter of input, leaving the rest unchanged
         *
-        * @since 1.27
-        * @method parseDom
-        * @member mw.Message
-        * @return {jQuery}
+        * @param {Array} nodes List of nodes
+        * @return {string} The given text, with the first character in lowercase
         */
-       mw.Message.prototype.parseDom = ( function () {
-               var $wrapper = $( '<div>' );
-               return function () {
-                       return $wrapper.msg( this.key, this.parameters ).contents().detach();
-               };
-       }() );
+       lcfirst: function ( nodes ) {
+               var text = textify( nodes[ 0 ] );
+               return text.charAt( 0 ).toLowerCase() + text.slice( 1 );
+       },
+
+       /**
+        * Uppercase first letter of input, leaving the rest unchanged
+        *
+        * @param {Array} nodes List of nodes
+        * @return {string} The given text, with the first character in uppercase
+        */
+       ucfirst: function ( nodes ) {
+               var text = textify( nodes[ 0 ] );
+               return text.charAt( 0 ).toUpperCase() + text.slice( 1 );
+       }
+};
+
+/**
+ * @method
+ * @member jQuery
+ * @see mw.jqueryMsg#getPlugin
+ */
+$.fn.msg = mw.jqueryMsg.getPlugin();
+
+// Replace the default message parser with jqueryMsg
+oldParser = mw.Message.prototype.parser;
+mw.Message.prototype.parser = function () {
+       // Fall back to mw.msg's simple parser where possible
+       if (
+               // Plain text output always uses the simple parser
+               this.format === 'plain' ||
+               (
+                       // jqueryMsg parser is needed for messages containing wikitext
+                       !/\{\{|[<>[&]/.test( this.map.get( this.key ) ) &&
+                       // jqueryMsg parser is needed when jQuery objects or DOM nodes are passed in as parameters
+                       !this.parameters.some( function ( param ) {
+                               return param instanceof $ || ( param && param.nodeType !== undefined );
+                       } )
+               )
+       ) {
+               return oldParser.apply( this );
+       }
 
+       if ( !Object.prototype.hasOwnProperty.call( this.map, this.format ) ) {
+               this.map[ this.format ] = mw.jqueryMsg.getMessageFunction( {
+                       messages: this.map,
+                       // For format 'escaped', escaping part is handled by mediawiki.js
+                       format: this.format
+               } );
+       }
+       return this.map[ this.format ]( this.key, this.parameters );
+};
+
+/**
+ * Parse the message to DOM nodes, rather than HTML string like #parse.
+ *
+ * This method is only available when jqueryMsg is loaded.
+ *
+ * @since 1.27
+ * @method parseDom
+ * @member mw.Message
+ * @return {jQuery}
+ */
+mw.Message.prototype.parseDom = ( function () {
+       var $wrapper = $( '<div>' );
+       return function () {
+               return $wrapper.msg( this.key, this.parameters ).contents().detach();
+       };
 }() );
index 5f20cf9..a63c5c6 100644 (file)
@@ -462,7 +462,7 @@ a.new {
 }
 
 .mw-datatable th {
-       background-color: #ddf;
+       background-color: #eaeeff;
 }
 
 .mw-datatable td {
index 4c5b4a7..4684ea2 100644 (file)
        list-style-image: e( '/* @embed */' ) url( @url );
 }
 
-.list-style-image-svg( @svg, @fallback ) {
-       list-style-image: e( '/* @embed */' ) url( @svg );
-       /* Fallback to PNG bullet for IE 8 and below using CSS hack */
-       list-style-image: e( '/* @embed */' ) url( @fallback ) e( '\9' );
-}
-
 .hyphens( @value: auto ) {
        & when ( @value = auto ) {
                // Legacy `word-wrap`; IE 6-11, Edge 12+, Firefox 3.5+, Chrome 4+, Safari 3.1+,
diff --git a/resources/src/mediawiki.rcfilters/.eslintrc.json b/resources/src/mediawiki.rcfilters/.eslintrc.json
new file mode 100644 (file)
index 0000000..ad8dbb3
--- /dev/null
@@ -0,0 +1,5 @@
+{
+       "parserOptions": {
+               "sourceType": "module"
+       }
+}
index ce5d407..b6284fb 100644 (file)
-( 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 no-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
-                               } );
+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 no-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 = {
+               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' ),
-                               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
-                                                       );
-                                               } )
-                               }
-                       ]
+                               labelPrefixKey: { default: 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
+                               separator: ';',
+                               fullCoverage: true,
+                               filters: items
+                       } ]
                };
-
-               views.display = {
+               views.invert = {
                        groups: [
                                {
-                                       name: 'display',
+                                       name: 'invertGroup',
                                        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 ) )
-                                               }
-                                       ]
-                               }
-                       ]
+                                       filters: [ {
+                                               name: 'invert',
+                                               default: '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 no-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 ( 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
+                       } ]
                };
+       }
 
-               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';
+       // 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
+                                               );
+                                       } )
                        }
-               }
-
-               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 )
-               };
+       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 ) )
+                                       }
+                               ]
+                       }
+               ]
        };
 
-       /**
-        * 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
+       // 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 no-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 ] );
                                }
-                               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 );
+                               // If the default value isn't in the group, add it
+                               if ( groupData.default !== undefined ) {
+                                       extraValues.push( String( groupData.default ) );
                                }
+                               controller.addNumberValuesToGroup( groupData, extraValues );
                        }
                } );
-       };
+       } );
 
-       /**
-        * 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() );
-       };
+       // Initialize the model
+       this.filtersModel.initializeFilters( filterStructure, views );
 
-       /**
-        * Empty all selected filters
-        */
-       Controller.prototype.emptyFilters = function () {
-               var highlightedFilterNames = this.filtersModel.getHighlightedItems()
-                       .map( function ( filterItem ) { return { name: filterItem.getName() }; } );
+       this.uriProcessor = new UriProcessor(
+               this.filtersModel,
+               { normalizeTarget: this.normalizeTarget }
+       );
 
-               if ( this.applyParamChange( {} ) ) {
-                       // Only update the changes list if there was a change to actual filters
-                       this.updateChangesList();
-               } else {
-                       this.uriProcessor.updateURL();
+       if ( !mw.user.isAnon() ) {
+               try {
+                       parsedSavedQueries = JSON.parse( mw.user.options.get( this.savedQueriesPreferenceName ) || '{}' );
+               } catch ( err ) {
+                       parsedSavedQueries = {};
                }
 
-               if ( highlightedFilterNames ) {
-                       this._trackHighlight( 'clearAll', highlightedFilterNames );
+               // 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()
        };
 
-       /**
-        * 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 );
+       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 )
        };
-
-       /**
-        * 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();
+};
+
+/**
+ * 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;
+               };
 
-                       this.filtersModel.reassessFilterInteractions( filterItem );
-
-                       // Log filter grouping
-                       this.trackFilterGroupings( 'removefilter' );
-               }
-
-               if ( isHighlighted ) {
-                       this._trackHighlight( 'clear', filterName );
-               }
-       };
+       arbitraryValues = Array.isArray( arbitraryValues ) ? arbitraryValues : [ arbitraryValues ];
 
-       /**
-        * Toggle the highlight feature on and off
-        */
-       Controller.prototype.toggleHighlight = function () {
-               this.filtersModel.toggleHighlight();
-               this.uriProcessor.updateURL();
+       // Normalize the arbitrary values and the default value for a range
+       if ( groupData.range ) {
+               arbitraryValues = arbitraryValues.map( function ( val ) {
+                       return normalizeWithinRange( groupData.range, val );
+               } );
 
-               if ( this.filtersModel.isHighlightEnabled() ) {
-                       mw.hook( 'RcFilters.highlight.enable' ).fire();
+               // Normalize the default, since that's user defined
+               if ( groupData.default !== undefined ) {
+                       groupData.default = String( normalizeWithinRange( groupData.range, groupData.default ) );
                }
-       };
+       }
 
-       /**
-        * Toggle the namespaces inverted feature on and off
-        */
-       Controller.prototype.toggleInvertedNamespaces = function () {
-               this.filtersModel.toggleInvertedNamespaces();
+       // 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 (
-                       this.filtersModel.getFiltersByView( 'namespaces' ).filter(
-                               function ( filterItem ) { return filterItem.isSelected(); }
-                       ).length
+                       // 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
                ) {
-                       // 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();
+                       // 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 );
+                       }
                }
-       };
-
-       /**
-        * 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();
+       } );
+};
+
+/**
+ * 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();
-       };
-
-       /**
-        * 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 );
+       } 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();
-               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 );
-               }
-       };
+       if ( highlightedFilterNames ) {
+               this._trackHighlight( 'clearAll', highlightedFilterNames );
+       }
+};
 
-       /**
-        * Set a timeout for the next live update.
-        * @private
-        */
-       Controller.prototype._scheduleLiveUpdate = function () {
-               setTimeout( this._doLiveUpdate.bind( this ), this.pollingRate * 1000 );
-       };
+/**
+ * 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 );
 
-       /**
-        * Perform a live update.
-        * @private
-        */
-       Controller.prototype._doLiveUpdate = function () {
-               if ( !this._shouldCheckForNewChanges() ) {
-                       // skip this turn and check back later
-                       this._scheduleLiveUpdate();
-                       return;
-               }
+       if ( !filterItem ) {
+               // If no filter was found, break
+               return;
+       }
 
-               this._checkForNewChanges()
-                       .then( function ( statusCode ) {
-                               // no result is 204 with the 'peek' param
-                               // logged out is 205
-                               var newChanges = statusCode === 200;
+       isSelected = isSelected === undefined ? !filterItem.isSelected() : isSelected;
 
-                               if ( !this._shouldCheckForNewChanges() ) {
-                                       // by the time the response is received,
-                                       // it may not be appropriate anymore
-                                       return;
-                               }
+       if ( filterItem.isSelected() !== isSelected ) {
+               this.filtersModel.toggleFilterSelected( filterName, isSelected );
 
-                               // 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();
+               this.updateChangesList();
 
-               if (
-                       currentMatchingQuery &&
-                       currentMatchingQuery.getID() === queryID
-               ) {
-                       // If the query we want to load is the one that is already
-                       // loaded, don't reload it
-                       return;
-               }
+               // 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 ( this.applyParamChange( params ) ) {
-                       // Update changes list only if there was a difference in filter selection
+               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();
-               } 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 );
+               this.filtersModel.reassessFilterInteractions( filterItem );
 
-                       // 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 no-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 );
-               }
-       };
+               // Log filter grouping
+               this.trackFilterGroupings( 'removefilter' );
+       }
 
-       /**
-        * Synchronize the URL with the current state of the filters
-        * without adding an history entry.
-        */
-       Controller.prototype.replaceUrl = function () {
+       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();
-       };
-
-       /**
-        * 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();
+       }
+};
+
+/**
+ * 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;
+                       }
 
-               // Only update and fetch new results if it is requested
-               if ( fetchChangesList ) {
-                       this.updateChangesList();
-               }
-       };
+                       // 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;
+                       }
 
-       /**
-        * 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 ) );
+                       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()
        };
-
-       /**
-        * 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();
+       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 no-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;
 
-       /**
-        * 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();
-                                       }
+                               // 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 {
-                                               content: content,
-                                               status: jqXHR.status
+                                               changes: 'NO_RESULTS',
+                                               // We need empty result set, to avoid exceptions because of undefined value
+                                               fieldset: $( [] ),
+                                               noResultsDetails: 'NO_RESULTS_NETWORK_ERROR'
                                        };
-                               },
-                               // 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 )
-                       );
-       };
+                               $parsed = $( '<div>' ).append( $( $.parseHTML(
+                                       data ? data.content : ''
+                               ) ) );
 
-       /**
-        * 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()
-                       }
+                               return this._extractChangesListInfo( $parsed, data.status );
+                       }.bind( this )
                );
-       };
-
-       /**
-        * 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;
+};
+
+/**
+ * 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()
                }
-       };
-
-       /**
-        * 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() )
-               );
-       };
+       );
+};
+
+/**
+ * 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();
+               } );
 
-       /**
-        * 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 )
-               );
-       };
+       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()
+                               }
+                       );
+               } );
 
-       module.exports = Controller;
-}() );
+               // 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;
index a4ef73b..42bfae6 100644 (file)
@@ -1,12 +1,10 @@
-( 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' ];
+/**
+ * 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;
-}() );
+module.exports = HighlightColors;
index 37874d5..3b69654 100644 (file)
-( 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(
+/* 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,
-                       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 ];
-                       }
-               }
+                       // 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 path
-               parts = mw.Uri.decode( uri.path ).match( re );
+       // target in title param
+       if ( uri.query.title ) {
+               parts = uri.query.title.match( re );
                if ( parts ) {
-                       uri.path = parts[ 1 ];
+                       uri.query.title = 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 );
+       }
+
+       // 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 ];
                }
-       };
-
-       /**
-        * 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;
-}() );
+       } );
+
+       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;
index 64d2e79..70677b9 100644 (file)
-( 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;
-}() );
+/**
+ * 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;
index db504b5..8bd5eb2 100644 (file)
-( 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() );
+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
+                       } );
 
-               // Check for filters that should be initially selected by their default value
-               if ( this.isSticky() ) {
-                       // eslint-disable-next-line no-jquery/no-each-util
-                       $.each( this.defaultFilters, function ( filterName, filterValue ) {
-                               model.getItemByName( filterName ).toggleSelected( filterValue );
+               if ( filter.subset ) {
+                       filter.subset = filter.subset.map( function ( el ) {
+                               return el.filter;
                        } );
-               }
 
-               // 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();
+                       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 );
+                       } );
 
-                       // 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;
-                       }
+                       // Set translated subset
+                       filterItem.setSubset( subsetNames );
                }
 
-               if ( this.isSticky() ) {
-                       // If this group is sticky, then change the default according to the
-                       // current selection.
-                       this.defaultParams = this.getParamRepresentation( this.getSelectedState() );
-               }
+               items.push( filterItem );
 
+               // Store default parameter state; in this case, default is defined per filter
                if (
-                       changed ||
-                       this.active !== active ||
-                       this.currSelected !== item
+                       model.getType() === 'send_unselected_if_any' ||
+                       model.getType() === 'boolean'
                ) {
-                       this.active = active;
-                       this.currSelected = item;
-
-                       this.emit( 'update' );
+                       // 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;
                }
-       };
-
-       /**
-        * 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();
-               } );
-       };
+       } );
+
+       // 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 ) );
 
-       /**
-        * Check whether all items selected
-        *
-        * @return {boolean} All items are selected
-        */
-       FilterGroup.prototype.areAllSelected = function () {
-               var selected = [],
-                       unselected = [];
+       // Store default filter state based on default params
+       this.defaultFilters = this.getFilterRepresentation( this.getDefaultParams() );
 
-               this.getItems().forEach( function ( filterItem ) {
-                       if ( filterItem.isSelected() ) {
-                               selected.push( filterItem );
-                       } else {
-                               unselected.push( filterItem );
-                       }
+       // Check for filters that should be initially selected by their default value
+       if ( this.isSticky() ) {
+               // eslint-disable-next-line no-jquery/no-each-util
+               $.each( this.defaultFilters, function ( filterName, filterValue ) {
+                       model.getItemByName( filterName ).toggleSelected( filterValue );
                } );
-
-               if ( unselected.length === 0 ) {
-                       return true;
+       }
+
+       // 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 );
+               }
+       } );
 
-               // 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();
+       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() );
                } );
-       };
-
-       /**
-        * 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 && (
+       } );
+};
+
+/**
+ * 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 ) ||
-                       // Any selected items are in conflict individually
-                       selectedItems.some( function ( selectedFilter ) {
+                       // All selected items are in conflict individually
+                       selectedItems.every( 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 no-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 no-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 = [];
-
+};
+
+/**
+ * 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 no-jquery/no-each-util
-                       $.each( filterRepresentation, function ( name, value ) {
-                               // Collect values
+                       $.each( filters, function ( name, value ) {
                                if ( value ) {
-                                       values.push( filterParamNames[ name ] );
+                                       selected.push( 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 );
+                       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;
+                       }
                }
 
-               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 );
+               if ( filterRepresentation[ item.getName() ] ) {
+                       areAnySelected = true;
                }
-
-               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 no-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();
+       } );
+
+       // 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 no-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;
                        }
-               }.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() ] );
+               } );
+       } else if ( this.getType() === 'string_options' ) {
+               values = [];
+
+               // eslint-disable-next-line no-jquery/no-each-util
+               $.each( filterRepresentation, function ( name, value ) {
+                       // Collect values
+                       if ( value ) {
+                               values.push( filterParamNames[ name ] );
                        }
+               } );
 
-                       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;
-       };
+               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();
 
-       /**
-        * Get current selected state of all filter items in this group
-        *
-        * @return {Object} Selected state
-        */
-       FilterGroup.prototype.getSelectedState = function () {
-               var state = {};
+                       expandedParams[ paramName ] = paramRepresentation[ paramName ] || '0';
+                       paramToFilterMap[ paramName ] = filterItem;
 
-               this.getItems().forEach( function ( filterItem ) {
-                       state[ filterItem.getName() ] = filterItem.getValue();
+                       if ( Number( paramRepresentation[ filterItem.getParamName() ] ) ) {
+                               areAnySelected = true;
+                       }
                } );
 
-               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 ) );
+               // eslint-disable-next-line no-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() ];
+                       }
                } );
-       };
-
-       /**
-        * 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'
+       } 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();
+                       } )
                );
-       };
-
-       /**
-        * 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;
-       };
+               // 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() ];
 
-       /**
-        * Toggle the visibility of this group
-        *
-        * @param {boolean} [isVisible] Item is visible
-        */
-       FilterGroup.prototype.toggleVisible = function ( isVisible ) {
-               isVisible = isVisible === undefined ? !this.visible : isVisible;
+                       result[ filterItem.getName() ] = selected;
+                       oneWasSelected = oneWasSelected || selected;
+               } );
+       }
 
-               if ( this.visible !== isVisible ) {
-                       this.visible = isVisible;
-                       this.emit( 'update' );
+       // 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() ] );
                }
-       };
-
-       /**
-        * 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;
-}() );
+               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;
index 1138c4e..8725f51 100644 (file)
-( 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;
+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()
        };
-
-       /* 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 no-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';
-                       }
+};
+
+/**
+ * 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 no-jquery/no-each-util
+       $.each( conflicts, function ( filterName, conflict ) {
+               if ( !conflict.item.isSelected() ) {
+                       return;
                }
 
-               if ( messageKey ) {
-                       // Build message
-                       return mw.msg(
-                               messageKey,
-                               mw.language.listToText( affectingItems ),
-                               affectingItems.length
-                       );
+               if ( !conflictMessage ) {
+                       conflictMessage = conflict[ key ];
+                       group = conflict.group;
                }
 
-               // 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' );
+               if ( group === conflict.group ) {
+                       itemLabels.push( mw.msg( 'quotation-marks', conflict.item.getLabel() ) );
                }
-       };
+       } );
 
-       /**
-        * 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' );
-               }
+       return {
+               message: conflictMessage,
+               names: itemLabels
        };
 
-       /**
-        * 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' );
-               }
-       };
+};
+
+/**
+ * @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() );
+                       }
 
-       /**
-        * 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' );
+                       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';
                }
-       };
-
-       /**
-        * Check whether the item is visible
-        *
-        * @return {boolean} Item is visible
-        */
-       FilterItem.prototype.isVisible = function () {
-               return this.visible;
-       };
-
-       module.exports = FilterItem;
-
-}() );
+       }
+
+       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;
index d1b9f7a..07c484b 100644 (file)
-( 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() );
+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 no-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 );
                                        } )
                                );
-                       } );
-
-                       // Update coverage for the changed group
-                       if ( groupModel.isFullCoverage() ) {
-                               allSelected = groupModel.areAllSelected();
-                               groupModel.getItems().forEach( function ( filterItem ) {
-                                       filterItem.toggleFullyCovered( allSelected );
-                               } );
                        }
+
+                       // 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;
                } );
 
-               // 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();
+               // 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|undefined} Conflicted item or undefined when not found
+ */
+FiltersViewModel.prototype.getFirstConflictedItem = function () {
+       var i, filterItem, items = this.getItems();
+       for ( i = 0; i < items.length; i++ ) {
+               filterItem = items[ i ];
+               if ( filterItem.isSelected() && filterItem.isConflicted() ) {
+                       return filterItem;
+               }
+       }
+};
+
+/**
+ * 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 = {};
 
-                       // For each item, see if that item is still conflicting
                        // eslint-disable-next-line no-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|undefined} Conflicted item or undefined when not found
-        */
-       FiltersViewModel.prototype.getFirstConflictedItem = function () {
-               var i, filterItem, items = this.getItems();
-               for ( i = 0; i < items.length; i++ ) {
-                       filterItem = items[ i ];
-                       if ( filterItem.isSelected() && filterItem.isConflicted() ) {
-                               return filterItem;
-                       }
-               }
-       };
-
-       /**
-        * 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 no-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(
+                       $.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: filterName,
-                                                                       item: filter
+                                                                       filter: groupItem.getName(),
+                                                                       item: groupItem
                                                                }
                                                        );
-                                               } 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 no-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 no-jquery/no-each-util
-               $.each( groupConflictResult, function ( group, conflicts ) {
-                       model.groups[ group ].setConflicts( conflicts );
-               } );
+                               result[ key ] = adjustedConflicts;
+                       } );
 
-               // Set conflicts for items
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( filterConflictResult, function ( filterName, conflicts ) {
-                       var filterItem = model.getItemByName( filterName );
-                       // set conflicts for items in the group
-                       filterItem.setConflicts( conflicts );
-               } );
+                       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 ) : '';
+               }
+       } );
 
-               // Create a map between known parameters and their models
-               // eslint-disable-next-line no-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;
+       // Collect views
+       allViews = $.extend( true, {
+               default: {
+                       title: mw.msg( 'rcfilters-filterlist-title' ),
+                       groups: filterGroups
+               }
+       }, views );
+
+       // Go over all views
+       // eslint-disable-next-line no-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 } )
+                               );
                        }
-               } );
-
-               this.setSearch( '' );
-
-               this.updateHighlightedState();
 
-               // Finish initialization
-               this.emit( 'initialize' );
-       };
+                       model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
+                       items = items.concat( model.groups[ group ].getItems() );
 
-       /**
-        * 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 no-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();
+                       // Prepare conflicts
+                       if ( groupData.conflicts ) {
+                               // Group conflicts
+                               groupConflictMap[ group ] = groupData.conflicts;
                        }
-               } );
-               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 no-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 ];
-                       }
+                       groupData.filters.forEach( function ( itemData ) {
+                               var filterItem = model.groups[ group ].getItemByParamName( itemData.name );
+                               // Filter conflicts
+                               if ( itemData.conflicts ) {
+                                       filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
+                               }
+                       } );
                } );
-
-               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(
+       } );
+
+       // 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 no-jquery/no-each-util
+       $.each( groupConflictResult, function ( group, conflicts ) {
+               model.groups[ group ].setConflicts( conflicts );
+       } );
+
+       // Set conflicts for items
+       // eslint-disable-next-line no-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 no-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 no-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.getEmptyParameterState(),
-                       this.getCurrentParameterState()
+                       this.getParametersFromFilters( {} ),
+                       this.getEmptyHighlightParameters()
                );
-       };
-
-       /**
-        * 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 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 no-jquery/no-each-util
+       $.each( this.getEmptyParameterState(), function ( param, value ) {
+               if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
+                       result[ param ] = parameters[ param ];
                }
-
-               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 no-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 no-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();
-                       }
+       } );
+
+       // 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 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 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 no-jquery/no-each-util
+       $.each( this.groups, function ( groupName, groupModel ) {
+               if ( groupModel.getView() === view ) {
+                       result[ groupName ] = groupModel;
                }
-
-               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 no-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 no-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 no-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() ] );
-                       } );
+       } );
+
+       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 no-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();
                }
-
-               // eslint-disable-next-line no-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 no-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 no-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 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 no-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 no-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 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 = {};
-
+               }
+       } );
+
+       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 no-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 ) {
-                       if ( filterItem.isHighlightSupported() ) {
-                               result[ filterItem.getName() + '_color' ] = null;
-                       }
+                       groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
+                       groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] );
                } );
+       }
+
+       // eslint-disable-next-line no-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 no-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 no-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 = [];
+       return result;
+};
 
-               if ( this.isHighlightEnabled() ) {
-                       this.getHighlightedItems().forEach( function ( filterItem ) {
-                               var color = filterItem.getHighlightColor();
+/**
+ * Get an array of currently applied highlight colors
+ *
+ * @return {string[]} Currently applied highlight colors
+ */
+FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
+       var result = [];
 
-                               if ( result.indexOf( color ) === -1 ) {
-                                       result.push( color );
-                               }
-                       } );
-               }
+       if ( this.isHighlightEnabled() ) {
+               this.getHighlightedItems().forEach( function ( filterItem ) {
+                       var color = filterItem.getHighlightColor();
 
-               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();
+                       if ( result.indexOf( color ) === -1 ) {
+                               result.push( color );
+                       }
                } );
-
-               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;
+       }
+
+       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';
                } );
-       };
-
-       /**
-        * 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 );
+};
+
+/**
+ * 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 );
                }
-       };
-
-       /**
-        * 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 );
+       }.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 ] );
                }
-               // 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
+       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 ) === 0 ||
+                               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' &&
                                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 no-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 no-jquery/no-each-util
+       $.each( this.views, function ( name, data ) {
+               if ( data.trigger === trigger ) {
+                       result = name;
                }
-
-               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 = [];
-
+       } );
+
+       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 no-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 no-jquery/no-each-util
-               $.each( this.views, function ( name, data ) {
-                       if ( data.trigger === trigger ) {
-                               result = name;
-                       }
+                       // Check if the group is visible at all
+                       groupModel.toggleVisible( visibleGroupNames.indexOf( groupName ) !== -1 );
+                       groupModel.setVisibleItems( visibleGroups[ groupName ] || [] );
                } );
 
-               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 no-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;
-
-}() );
+               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;
index 2dc578e..ae8ac5f 100644 (file)
-( 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;
+/**
+ * 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()
        };
-
-       /* 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'
-                       ];
+};
+
+/**
+ * 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 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;
-}() );
+               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;
index aa407b9..19de282 100644 (file)
-( 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 no-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
+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 no-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;
-                               }
+                       if ( obj.data && obj.data.filters ) {
+                               obj.data = model.convertToParameters( obj.data );
                        }
                } );
 
-               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 )
-               );
+               this.converted = true;
+               savedQueries.version = '2';
+       }
 
-               // Highlights: appending _color to keys
-               newData.highlights = {};
-               // eslint-disable-next-line no-jquery/no-each-util
-               $.each( data.highlights, function ( highlightedFilterName, value ) {
-                       if ( value ) {
-                               newData.highlights[ highlightedFilterName + '_color' ] = data.highlights[ highlightedFilterName ];
-                       }
-               } );
+       // Initialize the query items
+       // eslint-disable-next-line no-jquery/no-each-util
+       $.each( savedQueries.queries || {}, function ( id, obj ) {
+               var normalizedData = obj.data,
+                       isDefault = String( savedQueries.default ) === String( id );
 
-               // 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 no-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;
+               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;
                        }
-               } );
 
-               // Correct the invert state for effective selection
-               if ( normalizedData.params.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
-                       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;
+                       }
                }
-
-               // Add item
-               this.addItems( [
-                       new SavedQueryItemModel(
-                               randomID,
-                               label,
-                               normalizedData,
-                               { default: isDefault }
-                       )
-               ] );
-
-               if ( isDefault ) {
-                       this.setDefault( randomID );
+       } );
+
+       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 no-jquery/no-each-util
+       $.each( data.highlights, function ( highlightedFilterName, value ) {
+               if ( value ) {
+                       newData.highlights[ highlightedFilterName + '_color' ] = data.highlights[ highlightedFilterName ];
                }
-
-               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 ] );
+       } );
+
+       // 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 no-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;
                }
-       };
-
-       /**
-        * 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;
+       } );
+
+       // 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 );
                }
 
-               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.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 ) {
-                       obj.queries[ item.getID() ] = item.getState();
+                       item.toggleDefault( item.getID() === itemID );
                } );
 
-               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;
-}() );
+               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;
index 1774391..27e93e3 100644 (file)
-( 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;
+/**
+ * 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()
        };
-
-       /* 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;
-}() );
+};
+
+/**
+ * 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;
index a69dc55..4e5e0fe 100644 (file)
 /*!
  * JavaScript for Special:RecentChanges
  */
-( function () {
-
-       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;
+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 ];
        }
 
-       /**
-        * @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,
-                               {
-                                       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
-                                                       }
-                                               ]
-                                       }
-                               ]
-                       };
-               }
+       return namespaces;
+}
 
-               mainWrapperWidget = new mw.rcfilters.ui.MainWrapperWidget(
-                       controller,
-                       filtersModel,
-                       savedQueriesModel,
-                       changesListModel,
+/**
+ * @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
-                       getNamespaces( [ 'Media' ] ),
-                       require( './config.json' ).RCFiltersChangeTags,
-                       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.initFormWidget( specialPage );
+       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
+               }
+       );
 
-               $( 'a.mw-helplink' ).attr(
-                       'href',
-                       'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:New_filters_for_edit_review'
-               );
+       // 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.replaceUrl();
+       controller.initialize(
+               mw.config.get( 'wgStructuredChangeFilters' ),
+               // All namespaces without Media namespace
+               getNamespaces( [ 'Media' ] ),
+               require( './config.json' ).RCFiltersChangeTags,
+               conditionalViews
+       );
 
-               mainWrapperWidget.setTopSection( specialPage );
+       mainWrapperWidget.initFormWidget( specialPage );
 
-               /**
-                * 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();
-       }
+       $( 'a.mw-helplink' ).attr(
+               'href',
+               'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:New_filters_for_edit_review'
+       );
 
-       // Import i18n messages from config
-       mw.messages.set( mw.config.get( 'wgStructuredChangeFiltersMessages' ) );
+       controller.replaceUrl();
 
-       // Early execute of init
-       if ( document.readyState === 'interactive' || document.readyState === 'complete' ) {
-               init();
-       } else {
-               $( init );
-       }
+       mainWrapperWidget.setTopSection( specialPage );
+
+       /**
+        * 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();
+}
+
+// Import i18n messages from config
+mw.messages.set( mw.config.get( 'wgStructuredChangeFiltersMessages' ) );
 
-       module.exports = mw.rcfilters;
+// Early execute of init
+if ( document.readyState === 'interactive' || document.readyState === 'complete' ) {
+       init();
+} else {
+       $( init );
+}
 
-}() );
+module.exports = mw.rcfilters;
index b32fb38..5bf9916 100644 (file)
@@ -1,61 +1,59 @@
-( function () {
-       /**
-        * @class
-        * @singleton
-        */
-       mw.rcfilters = {
-               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 ) {
-                               elements = Array.isArray( elements ) ? elements : [ elements ];
-
-                               elements.forEach( function ( element ) {
-                                       if ( arr.indexOf( element ) === -1 ) {
-                                               arr.push( element );
-                                       }
-                               } );
+/**
+ * @class
+ * @singleton
+ */
+mw.rcfilters = {
+       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 ) {
+                       elements = Array.isArray( elements ) ? elements : [ elements ];
 
-                               return arr;
-                       },
-                       normalizeParamOptions: function ( givenOptions, legalOptions ) {
-                               var result = [];
-
-                               if ( givenOptions.indexOf( 'all' ) > -1 ) {
-                                       // If anywhere in the values there's 'all', we
-                                       // treat it as if only 'all' was selected.
-                                       // Example: param=valid1,valid2,all
-                                       // Result: param=all
-                                       return [ 'all' ];
+                       elements.forEach( function ( element ) {
+                               if ( arr.indexOf( element ) === -1 ) {
+                                       arr.push( element );
                                }
+                       } );
 
-                               // Get rid of any dupe and invalid parameter, only output
-                               // valid ones
-                               // Example: param=valid1,valid2,invalid1,valid1
-                               // Result: param=valid1,valid2
-                               givenOptions.forEach( function ( value ) {
-                                       if (
-                                               legalOptions.indexOf( value ) > -1 &&
-                                               result.indexOf( value ) === -1
-                                       ) {
-                                               result.push( value );
-                                       }
-                               } );
+                       return arr;
+               },
+               normalizeParamOptions: function ( givenOptions, legalOptions ) {
+                       var result = [];
 
-                               return result;
+                       if ( givenOptions.indexOf( 'all' ) > -1 ) {
+                               // If anywhere in the values there's 'all', we
+                               // treat it as if only 'all' was selected.
+                               // Example: param=valid1,valid2,all
+                               // Result: param=all
+                               return [ 'all' ];
                        }
+
+                       // Get rid of any dupe and invalid parameter, only output
+                       // valid ones
+                       // Example: param=valid1,valid2,invalid1,valid1
+                       // Result: param=valid1,valid2
+                       givenOptions.forEach( function ( value ) {
+                               if (
+                                       legalOptions.indexOf( value ) > -1 &&
+                                       result.indexOf( value ) === -1
+                               ) {
+                                       result.push( value );
+                               }
+                       } );
+
+                       return result;
                }
-       };
+       }
+};
 
-       module.exports = mw.rcfilters;
-}() );
+module.exports = mw.rcfilters;
index 23b05e8..4764bd8 100644 (file)
-( 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'
+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.$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;
-
-}() );
+               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;
index d78c42b..a0c0d80 100644 (file)
@@ -1,84 +1,82 @@
-( function () {
-       var ValuePickerWidget = require( './ValuePickerWidget.js' ),
-               ChangesLimitPopupWidget;
+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 || {};
+/**
+ * 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 );
+       // Parent
+       ChangesLimitPopupWidget.parent.call( this, config );
 
-               this.limitModel = limitModel;
-               this.groupByPageItemModel = groupByPageItemModel;
+       this.limitModel = limitModel;
+       this.groupByPageItemModel = groupByPageItemModel;
 
-               this.valuePicker = new ValuePickerWidget(
-                       this.limitModel,
-                       {
-                               label: mw.msg( 'rcfilters-limit-title' )
-                       }
-               );
+       this.valuePicker = new ValuePickerWidget(
+               this.limitModel,
+               {
+                       label: mw.msg( 'rcfilters-limit-title' )
+               }
+       );
 
-               this.groupByPageCheckbox = new OO.ui.CheckboxInputWidget( {
-                       selected: this.groupByPageItemModel.isSelected()
-               } );
+       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' } );
+       // 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
-                       );
-       };
+       // 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 */
+/* Initialization */
 
-       OO.inheritClass( ChangesLimitPopupWidget, OO.ui.Widget );
+OO.inheritClass( ChangesLimitPopupWidget, OO.ui.Widget );
 
-       /* Events */
+/* Events */
 
-       /**
       * @event limit
       * @param {string} name Item name
       *
       * A limit item was chosen
       */
+/**
+ * @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
       */
+/**
+ * @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() );
-       };
+/**
+ * Respond to group by page model update
+ */
+ChangesLimitPopupWidget.prototype.onGroupByPageModelUpdate = function () {
+       this.groupByPageCheckbox.setSelected( this.groupByPageItemModel.isSelected() );
+};
 
-       module.exports = ChangesLimitPopupWidget;
-}() );
+module.exports = ChangesLimitPopupWidget;
index ba7f4d1..09b802e 100644 (file)
-( 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();
+/**
+ * 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 {
-                       this.clearHighlight();
-               }
-       };
+                       $message
+                               .append(
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-noresult' )
+                                               .text( mw.msg( this.getMsgKeyForNoResults( noResultsDetails ) ) )
+                               );
 
-       /**
-        * 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();
+                       // 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( ' ' );
+                       );
                }
-       };
 
-       /**
-        * Respond to changes list model invalidate
-        */
-       ChangesListWrapperWidget.prototype.onModelInvalidate = function () {
-               $( 'body' ).addClass( 'mw-rcfilters-ui-loading' );
-       };
+               this.$element.append( $message );
+       } else {
+               if ( !isInitialDOM ) {
+                       this.$element.empty().append( $changesListContent );
 
-       /**
-        * 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( ' ' );
-                               } );
+                       if ( from ) {
+                               this.emphasizeNewChanges( from );
                        }
-
-                       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' ) );
+               // Apply highlight
+               this.applyHighlight();
 
-               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' );
-               } );
-       };
+       this.$element.prepend( $( '<div>' ).addClass( 'mw-changeslist-overlay' ) );
 
-       /** 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 );
-       };
+       loaderPromise.done( function () {
+               if ( !isInitialDOM && !isEmpty ) {
+                       // Make sure enhanced RC re-initializes correctly
+                       mw.hook( 'wikipage.content' ).fire( widget.$element );
+               }
 
-       /**
-        * 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 ];
+               $( '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'
        };
-
-       /**
-        * 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;
-                               }
+       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 no-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;
+       } );
+
+       if ( $firstNew ) {
+               $indicator = $( '<div>' )
+                       .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-previousChangesIndicator' );
+
+               $firstNew.after( $indicator );
+       }
+
+       // FIXME: Use CSS transition
+       // eslint-disable-next-line no-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 );
                } );
-
-               // 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 );
                        } );
-                       // 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 !OO.compare( firstChildClasses, classesInThisRow );
                } );
-       };
-
-       /**
-        * @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 () {
+               // 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.length ) {
-                               $( this ).attr( 'title', mw.msg(
-                                       'rcfilters-highlighted-filters-list',
-                                       filters.join( mw.msg( 'comma-separator' ) )
-                               ) );
+                       if ( !filters ) {
+                               filters = [];
+                               $( this ).data( 'highlightedFilters', filters );
+                       }
+                       if ( filters.indexOf( filterItem.getLabel() ) === -1 ) {
+                               filters.push( filterItem.getLabel() );
                        }
-
                } );
-               if ( this.inEnhancedMode() ) {
-                       this.updateEnhancedParentHighlight();
+       }.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' ) )
+                       ) );
                }
 
-               // Turn on highlights
-               this.$element.addClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
-       };
+       } );
+       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 ) );
 
-       /**
-        * 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' );
-       };
+       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;
-}() );
+module.exports = ChangesListWrapperWidget;
index 490d54e..b6e21cf 100644 (file)
@@ -1,66 +1,64 @@
-( 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 || {};
+/**
+ * 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 );
+       // 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 ) );
-       };
+       // 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 */
+/* Initialization */
 
-       OO.inheritClass( CheckboxInputWidget, OO.ui.CheckboxInputWidget );
+OO.inheritClass( CheckboxInputWidget, OO.ui.CheckboxInputWidget );
 
-       /* Events */
+/* Events */
 
-       /**
       * @event userChange
       * @param {boolean} Current state of the checkbox
       *
       * The user has checked or unchecked this checkbox
       */
+/**
+ * @event userChange
+ * @param {boolean} Current state of the checkbox
+ *
+ * The user has checked or unchecked this checkbox
+ */
 
-       /* Methods */
+/* 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.
-       };
+/**
+ * @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' ) );
-       };
+/**
+ * Respond to checkbox change by a user and emit 'userChange'.
+ */
+CheckboxInputWidget.prototype.onUserChange = function () {
+       this.emit( 'userChange', this.$input.prop( 'checked' ) );
+};
 
-       module.exports = CheckboxInputWidget;
-}() );
+module.exports = CheckboxInputWidget;
index 1ac0d49..226821c 100644 (file)
@@ -1,72 +1,70 @@
-( function () {
-       var ValuePickerWidget = require( './ValuePickerWidget.js' ),
-               DatePopupWidget;
+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 || {};
+/**
+ * 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 );
+       // Parent
+       DatePopupWidget.parent.call( this, config );
+       // Mixin constructors
+       OO.ui.mixin.LabelElement.call( this, config );
 
-               this.model = model;
+       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; }
-                       }
-               );
+       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' ] } );
+       // 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
-                       );
-       };
+       // Initialize
+       this.$element
+               .addClass( 'mw-rcfilters-ui-datePopupWidget' )
+               .append(
+                       this.$label
+                               .addClass( 'mw-rcfilters-ui-datePopupWidget-title' ),
+                       this.hoursValuePicker.$element,
+                       this.daysValuePicker.$element
+               );
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( DatePopupWidget, OO.ui.Widget );
-       OO.mixinClass( DatePopupWidget, OO.ui.mixin.LabelElement );
+OO.inheritClass( DatePopupWidget, OO.ui.Widget );
+OO.mixinClass( DatePopupWidget, OO.ui.mixin.LabelElement );
 
-       /* Events */
+/* Events */
 
-       /**
       * @event days
       * @param {string} name Item name
       *
       * A days item was chosen
       */
+/**
+ * @event days
+ * @param {string} name Item name
+ *
+ * A days item was chosen
+ */
 
-       module.exports = DatePopupWidget;
-}() );
+module.exports = DatePopupWidget;
index 1327755..fb591d0 100644 (file)
@@ -1,85 +1,83 @@
-( 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;
-}() );
+/**
+ * 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;
index 1396341..3735af2 100644 (file)
-( 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' )
+/**
+ * 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 )
+                                               )
+                               )
                );
-       };
-
-       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;
-}() );
+};
+
+/* 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;
index 4080f4d..b4b0e9d 100644 (file)
@@ -1,96 +1,94 @@
-( function () {
-       var ItemMenuOptionWidget = require( './ItemMenuOptionWidget.js' ),
-               FilterMenuOptionWidget;
+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 || {};
+/**
+ * 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;
+       this.controller = controller;
+       this.invertModel = invertModel;
+       this.model = itemModel;
 
-               // Parent
-               FilterMenuOptionWidget.parent.call( this, controller, filtersViewModel, this.invertModel, itemModel, highlightPopup, config );
+       // Parent
+       FilterMenuOptionWidget.parent.call( this, controller, filtersViewModel, this.invertModel, itemModel, highlightPopup, config );
 
-               // Event
-               this.model.getGroupModel().connect( this, { update: 'onGroupModelUpdate' } );
+       // Event
+       this.model.getGroupModel().connect( this, { update: 'onGroupModelUpdate' } );
 
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-filterMenuOptionWidget' );
-       };
+       this.$element
+               .addClass( 'mw-rcfilters-ui-filterMenuOptionWidget' );
+};
 
-       /* Initialization */
-       OO.inheritClass( FilterMenuOptionWidget, ItemMenuOptionWidget );
+/* Initialization */
+OO.inheritClass( FilterMenuOptionWidget, ItemMenuOptionWidget );
 
-       /* Static properties */
+/* Static properties */
 
-       // We do our own scrolling to top
-       FilterMenuOptionWidget.static.scrollIntoViewOnSelect = false;
+// We do our own scrolling to top
+FilterMenuOptionWidget.static.scrollIntoViewOnSelect = false;
 
-       /* Methods */
+/* Methods */
 
-       /**
       * @inheritdoc
       */
-       FilterMenuOptionWidget.prototype.updateUiBasedOnState = function () {
-               // Parent
-               FilterMenuOptionWidget.parent.prototype.updateUiBasedOnState.call( this );
+/**
+ * @inheritdoc
+ */
+FilterMenuOptionWidget.prototype.updateUiBasedOnState = function () {
+       // Parent
+       FilterMenuOptionWidget.parent.prototype.updateUiBasedOnState.call( this );
 
-               this.setCurrentMuteState();
-       };
+       this.setCurrentMuteState();
+};
 
-       /**
       * Respond to item group model update event
       */
-       FilterMenuOptionWidget.prototype.onGroupModelUpdate = function () {
-               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()
-                                       )
+/**
+ * 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;
-}() );
+module.exports = FilterMenuOptionWidget;
index 5b9e359..abcce81 100644 (file)
-( 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 );
+/**
+ * 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
+                       );
                }
 
-               // 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;
-
-}() );
+               // 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;
index bda898b..98eea71 100644 (file)
@@ -1,50 +1,48 @@
-( 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;
-}() );
+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;
index dc6fc12..085e22b 100644 (file)
-( 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,
+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' ]
+                               },
                                {
-                                       $overlay: this.$overlay
+                                       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
+                                               )
                                }
-                       );
-
-                       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'
-                       } );
+                       ]
+               },
+               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.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 ) {
+               this.saveQueryButton.$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' )
-                               } )
-                       ]
+               this.saveQueryButton.connect( this, {
+                       click: 'onSaveQueryButtonClick',
+                       saveCurrent: 'setSavedQueryVisibility'
                } );
-
-               // 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 )
-                                               )
-                               )
+               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' )
                );
 
-               // Event
-               this.viewsSelectWidget.connect( this, { choose: 'onViewsSelectWidgetChoose' } );
-
+       if ( !mw.user.isAnon() ) {
                rcFiltersRow.append(
                        $( '<div>' )
                                .addClass( 'mw-rcfilters-ui-cell' )
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' )
-                               .append( this.resetButton.$element )
+                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-save' )
+                               .append( this.saveQueryButton.$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 );
+       }
+
+       // 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();
-       };
-
-       /**
-        * 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( '' );
+               mw.hook( 'RcFilters.popup.open' ).fire();
 
-                       // 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();
+               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 )
+                       );
                }
-       };
-
-       /**
-        * 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() );
-                               }
+       } 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() );
-                                       }
+       }
+
+       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 );
                        }
+               }.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(
+       }
+};
+/**
+ * @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,
-                       menuConfig
+                       this.model.getInvertModel(),
+                       filterItem,
+                       {
+                               $overlay: this.$overlay
+                       }
                );
-       };
-
-       /**
-        * @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' )
-               ) {
+       }
+};
+
+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
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' )
-                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
+                               .removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' );
 
                        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 ||
+                                       .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.min === undefined ||
-                                       newScrollTop - containerScrollTop >= threshold.min
-                               ) &&
-                               (
-                                       threshold.max === undefined ||
-                                       newScrollTop - containerScrollTop <= threshold.max
-                               )
+                               threshold.max === undefined ||
+                               newScrollTop - containerScrollTop <= threshold.max
                        )
-               ) {
-                       $( container ).animate( {
-                               scrollTop: newScrollTop
-                       } );
-               }
-       };
+               )
+       ) {
+               $( container ).animate( {
+                       scrollTop: newScrollTop
+               } );
+       }
+};
 
-       module.exports = FilterTagMultiselectWidget;
-}() );
+module.exports = FilterTagMultiselectWidget;
index cb297f6..ce9656e 100644 (file)
-( 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
-                       }
+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
                );
 
-               this.liveUpdateButton = new LiveUpdateButtonWidget(
-                       this.controller,
-                       this.changesListModel
-               );
+       if ( this.controller.pollingRate ) {
+               $bottom.prepend( this.liveUpdateButton.$element );
+       }
 
-               this.numChangesAndDateWidget = new ChangesLimitAndDateButtonWidget(
-                       this.controller,
-                       this.model,
-                       {
-                               $overlay: this.$overlay
-                       }
+       this.$element
+               .addClass( 'mw-rcfilters-ui-filterWrapperWidget' )
+               .append(
+                       this.$top,
+                       this.filterTagWidget.$element,
+                       $bottom
                );
-
-               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;
-}() );
+};
+
+/* 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;
index dbf1776..7d69fb6 100644 (file)
-( 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 );
+/**
+ * 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();
                }
 
-               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();
+               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 );
                }
-
-               // 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;
-}() );
+               // 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;
index 73b874c..6634e30 100644 (file)
@@ -1,45 +1,43 @@
-( 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 no-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;
-}() );
+/**
+ * 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 no-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;
index cb5f8eb..082b65b 100644 (file)
-( 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 );
+/**
+ * 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 );
                }
 
-               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' );
-                       }
+               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;
-}() );
+       }
+};
+
+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;
index 4c467df..5a69013 100644 (file)
@@ -1,68 +1,65 @@
-( 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 || {};
+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 ) );
+       // 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 = new HighlightColorPickerWidget( controller );
 
-               this.colorPicker.connect( this, { chooseColor: 'onChooseColor' } );
+       this.colorPicker.connect( this, { chooseColor: 'onChooseColor' } );
 
-               this.$body.append( this.colorPicker.$element );
-       };
+       this.$body.append( this.colorPicker.$element );
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( HighlightPopupWidget, OO.ui.PopupWidget );
+OO.inheritClass( HighlightPopupWidget, OO.ui.PopupWidget );
 
-       /* Methods */
+/* 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 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 );
-       };
+/**
+ * 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 );
-       };
+/**
+ * When the user chooses a color in the color picker, close the popup.
+ */
+HighlightPopupWidget.prototype.onChooseColor = function () {
+       this.toggle( false );
+};
 
-       module.exports = HighlightPopupWidget;
-
-}() );
+module.exports = HighlightPopupWidget;
index 56ed628..710bd65 100644 (file)
-( 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()
-               } );
-
+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-title' )
-                               .append( $( '<bdi>' ).append( this.$label ) )
+                               .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-desc' )
+                               .append( $( '<bdi>' ).text( this.itemModel.getDescription() ) )
                );
-               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 = 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()
+       );
+       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 )
+                                               )
+                               )
                );
 
-               layout = new OO.ui.FieldLayout( this.checkboxWidget, {
-                       label: $label,
-                       align: 'inline'
+       if ( this.itemModel.getIdentifiers() ) {
+               this.itemModel.getIdentifiers().forEach( function ( ident ) {
+                       classes.push( 'mw-rcfilters-ui-itemMenuOptionWidget-identifier-' + ident );
                } );
 
-               // 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.$element.addClass( classes );
+       }
 
-               this.updateUiBasedOnState();
-       };
+       this.updateUiBasedOnState();
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( ItemMenuOptionWidget, OO.ui.MenuOptionWidget );
+OO.inheritClass( ItemMenuOptionWidget, OO.ui.MenuOptionWidget );
 
-       /* Static properties */
+/* Static properties */
 
-       // We do our own scrolling to top
-       ItemMenuOptionWidget.static.scrollIntoViewOnSelect = false;
+// We do our own scrolling to top
+ItemMenuOptionWidget.static.scrollIntoViewOnSelect = false;
 
-       /* Methods */
+/* 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() );
-       };
+/**
+ * Respond to item model update event
+ */
+ItemMenuOptionWidget.prototype.updateUiBasedOnState = function () {
+       this.checkboxWidget.setSelected( this.itemModel.isSelected() );
 
-       /**
-        * Get the name of this filter
-        *
-        * @return {string} Filter name
-        */
-       ItemMenuOptionWidget.prototype.getName = function () {
-               return this.itemModel.getName();
-       };
+       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() );
+};
 
-       ItemMenuOptionWidget.prototype.getModel = function () {
-               return this.itemModel;
-       };
+/**
+ * Get the name of this filter
+ *
+ * @return {string} Filter name
+ */
+ItemMenuOptionWidget.prototype.getName = function () {
+       return this.itemModel.getName();
+};
 
-       module.exports = ItemMenuOptionWidget;
+ItemMenuOptionWidget.prototype.getModel = function () {
+       return this.itemModel;
+};
 
-}() );
+module.exports = ItemMenuOptionWidget;
index 3ccb6e2..04289c7 100644 (file)
@@ -1,72 +1,69 @@
-( 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 || {};
+/**
+ * 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 ) );
+       // Parent
+       LiveUpdateButtonWidget.parent.call( this, $.extend( {
+               label: mw.message( 'rcfilters-liveupdates-button' ).text()
+       }, config ) );
 
-               this.controller = controller;
-               this.model = changesListModel;
+       this.controller = controller;
+       this.model = changesListModel;
 
-               // Events
-               this.connect( this, { click: 'onClick' } );
-               this.model.connect( this, { liveUpdateChange: 'onLiveUpdateChange' } );
+       // Events
+       this.connect( this, { click: 'onClick' } );
+       this.model.connect( this, { liveUpdateChange: 'onLiveUpdateChange' } );
 
-               this.$element.addClass( 'mw-rcfilters-ui-liveUpdateButtonWidget' );
+       this.$element.addClass( 'mw-rcfilters-ui-liveUpdateButtonWidget' );
 
-               this.setState( false );
-       };
+       this.setState( false );
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( LiveUpdateButtonWidget, OO.ui.ToggleButtonWidget );
+OO.inheritClass( LiveUpdateButtonWidget, OO.ui.ToggleButtonWidget );
 
-       /* Methods */
+/* Methods */
 
-       /**
       * Respond to the button being clicked
       */
-       LiveUpdateButtonWidget.prototype.onClick = function () {
-               this.controller.toggleLiveUpdate();
-       };
+/**
+ * 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() );
-       };
+/**
+ * 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 );
-       };
+/**
+ * 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;
-
-}() );
+module.exports = LiveUpdateButtonWidget;
index bc1cac8..31edb77 100644 (file)
-( 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 );
+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
+               }
+       );
 
-               /* Events */
+       this.changesListWidget = new ChangesListWrapperWidget(
+               model, changesListModel, controller, this.$changesListContainer );
 
-               // 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 ) } );
+       /* Events */
 
-               // Initialize
-               this.$filtersContainer.append( this.filtersWidget.$element );
-               $( 'body' )
-                       .append( this.$overlay )
-                       .addClass( 'mw-rcfilters-ui-initialized' );
-       };
+       // 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 ) } );
 
-       /* Initialization */
+       // Initialize
+       this.$filtersContainer.append( this.filtersWidget.$element );
+       $( 'body' )
+               .append( this.$overlay )
+               .addClass( 'mw-rcfilters-ui-initialized' );
+};
 
-       OO.inheritClass( MainWrapperWidget, OO.ui.Widget );
+/* Initialization */
 
-       /* Methods */
+OO.inheritClass( MainWrapperWidget, OO.ui.Widget );
 
-       /**
-        * Set the content of the top section, depending on the type of special page.
-        *
-        * @param {string} specialPage
-        */
-       MainWrapperWidget.prototype.setTopSection = function ( specialPage ) {
-               var topSection;
+/* Methods */
 
-               if ( specialPage === 'Recentchanges' ) {
-                       topSection = new RcTopSectionWidget(
-                               this.savedLinksListWidget, this.$topSection
-                       );
-                       this.filtersWidget.setTopSection( topSection.$element );
-               }
+/**
+ * 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 === 'Recentchangeslinked' ) {
-                       topSection = new RclTopSectionWidget(
-                               this.savedLinksListWidget, this.controller,
-                               this.model.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' ),
-                               this.model.getGroup( 'page' ).getItemByParamName( 'target' )
-                       );
+       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 );
-               }
+               this.filtersWidget.setTopSection( topSection.$element );
+       }
 
-               if ( specialPage === 'Watchlist' ) {
-                       topSection = new WatchlistTopSectionWidget(
-                               this.controller, this.changesListModel, this.savedLinksListWidget, this.$topSection
-                       );
+       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;
-}() );
+               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;
index 3914337..c7fa334 100644 (file)
@@ -1,58 +1,55 @@
-( 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;
-
-}() );
+/**
+ * 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;
index 864d0cf..1e75020 100644 (file)
-( 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
-                       }
+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 )
                );
 
-               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 );
-                               }
+       // 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 no-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 no-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();
+       }.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 no-jquery/no-each-util
+       $.each( groups, function ( groupName, groupModel ) {
+               if ( !groupModel.isHidden() ) {
+                       viewGroupCount[ groupModel.getView() ] = viewGroupCount[ groupModel.getView() ] || 0;
+                       viewGroupCount[ groupModel.getView() ]++;
                }
-
-               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;
-                               }
+       } );
+
+       // eslint-disable-next-line no-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
+                                               }
+                                       )
+                               );
                        }
 
-                       if ( !itemWasSelected ) {
-                               this.selectItem( null );
-                       }
+                       // 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.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;
+               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;
                        }
+               }
 
-                       nextItem = nextItem && nextItem.constructor.static.selectable ?
-                               nextItem : null;
-
-                       // Select the next item
-                       this.selectItem( nextItem );
+               if ( !itemWasSelected ) {
+                       this.selectItem( null );
                }
-       };
-
-       /**
-        * 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;
-}() );
+       }
+
+       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;
index 6de9c40..3d56fba 100644 (file)
-( 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;
+/**
+ * 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 || {};
+       config = config || {};
 
-               // Parent
-               RcTopSectionWidget.parent.call( this, config );
+       // Parent
+       RcTopSectionWidget.parent.call( this, config );
 
-               this.$topLinks = $topLinks;
+       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()
-               } );
+       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
+               .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' ) );
+       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' );
+       // 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,
+       // 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-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
-                                                       )
-                                       )
-                       );
+                                                                       .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 );
+       // Hack: For jumpiness reasons, this should be a sibling of -head
+       $( '.rcfilters-head' ).before( this.$top );
 
-               // Initialize top links position
-               widget.switchTopLinks( topLinksCookieValue );
-       };
+       // Initialize top links position
+       widget.switchTopLinks( topLinksCookieValue );
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( RcTopSectionWidget, OO.ui.Widget );
+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';
+/**
+ * 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' );
-       };
+       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;
-}() );
+module.exports = RcTopSectionWidget;
index 6eb0d5b..382b54c 100644 (file)
@@ -1,82 +1,80 @@
-( 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 || {};
+/**
+ * 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 );
+       // Parent
+       RclTargetPageWidget.parent.call( this, config );
 
-               this.controller = controller;
-               this.model = targetPageModel;
+       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
-               } );
+       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' } );
+       // Events
+       this.model.connect( this, { update: 'updateUiBasedOnModel' } );
 
-               this.titleSearch.$input.on( {
-                       blur: this.onLookupInputBlur.bind( this )
-               } );
+       this.titleSearch.$input.on( {
+               blur: this.onLookupInputBlur.bind( this )
+       } );
 
-               this.titleSearch.lookupMenu.connect( this, {
-                       choose: 'onLookupMenuItemChoose'
-               } );
+       this.titleSearch.lookupMenu.connect( this, {
+               choose: 'onLookupMenuItemChoose'
+       } );
 
-               // Initialize
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-rclTargetPageWidget' )
-                       .append( this.titleSearch.$element );
+       // Initialize
+       this.$element
+               .addClass( 'mw-rcfilters-ui-rclTargetPageWidget' )
+               .append( this.titleSearch.$element );
 
-               this.updateUiBasedOnModel();
-       };
+       this.updateUiBasedOnModel();
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( RclTargetPageWidget, OO.ui.Widget );
+OO.inheritClass( RclTargetPageWidget, OO.ui.Widget );
 
-       /* Methods */
+/* Methods */
 
-       /**
       * Respond to the user choosing a title
       */
-       RclTargetPageWidget.prototype.onLookupMenuItemChoose = function () {
-               this.titleSearch.$input.trigger( 'blur' );
-       };
+/**
+ * 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 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 );
-       };
+/**
+ * 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;
-}() );
+module.exports = RclTargetPageWidget;
index e2c58d0..46f2de9 100644 (file)
@@ -1,76 +1,74 @@
-( 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 || {};
+/**
+ * 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' ) )
-               } );
+       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 ) );
+       // 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.controller = controller;
+       this.model = showLinkedToModel;
 
-               this.getMenu().connect( this, { choose: 'onUserChooseItem' } );
-               this.model.connect( this, { update: 'onModelUpdate' } );
+       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();
-       };
+       // force an initial update of the component based on the state
+       this.onModelUpdate();
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( RclToOrFromWidget, OO.ui.DropdownWidget );
+OO.inheritClass( RclToOrFromWidget, OO.ui.DropdownWidget );
 
-       /* Methods */
+/* 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 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'
-               ) );
-       };
+/**
+ * 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;
-}() );
+module.exports = RclToOrFromWidget;
index d968b9e..560f3d8 100644 (file)
@@ -1,73 +1,71 @@
-( function () {
-       var RclToOrFromWidget = require( './RclToOrFromWidget.js' ),
-               RclTargetPageWidget = require( './RclTargetPageWidget.js' ),
-               RclTopSectionWidget;
+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 || {};
+/**
+ * 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 );
+       // Parent
+       RclTopSectionWidget.parent.call( this, config );
 
-               this.controller = controller;
+       this.controller = controller;
 
-               toOrFromWidget = new RclToOrFromWidget( controller, showLinkedToModel );
-               targetPage = new RclTargetPageWidget( controller, targetPageModel );
+       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(
+       // 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' )
-                                                                       .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
-                                                       )
-                                       )
-                       );
-       };
+                                                                       .addClass( 'mw-rcfilters-ui-rclTopSectionWidget-savedLinks' )
+                                                                       .append( savedLinksListWidget.$element ) :
+                                                               null
+                                               )
+                               )
+               );
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( RclTopSectionWidget, OO.ui.Widget );
+OO.inheritClass( RclTopSectionWidget, OO.ui.Widget );
 
-       module.exports = RclTopSectionWidget;
-}() );
+module.exports = RclTopSectionWidget;
index 8c3d550..1c66c6e 100644 (file)
-( 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;
+/**
+ * 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
                }
-       };
-
-       /**
-        * 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 () {
+       }, 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 );
-       };
-
-       /**
-        * 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' );
-               }
-       };
+               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;
-}() );
+module.exports = SaveFiltersPopupButtonWidget;
index ceb5ef8..4057c48 100644 (file)
-( 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' ]
-               } );
+/**
+ * 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 );
-
-               // 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
+       }
+};
+
+/**
+ * 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 );
-       };
-
-       /**
-        * 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' )
-                       );
+       }
+};
+
+/**
+ * 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' );
                }
-       };
-
-       /**
-        * Get item ID
-        *
-        * @return {string} Query identifier
-        */
-       SavedLinksListItemWidget.prototype.getID = function () {
-               return this.model.getID();
-       };
-
-       module.exports = SavedLinksListItemWidget;
-
-}() );
+               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;
index 5422daf..a29a93f 100644 (file)
-( 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;
+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
                }
-
-               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;
-}() );
+       } );
+
+       // 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;
index d66c5b5..985e2c5 100644 (file)
-( 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 );
+/**
+ * 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' ]
                }
-       };
-
-       /**
-        * 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;
-}() );
+       }, 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;
index ebd81c8..3ce63ee 100644 (file)
-( 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
-                       );
+/**
+ * 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;
        };
 
-       /* 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()
-                               } );
+       // 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.selectWidget.clearItems();
+       this.selectWidget.addItems( items );
 
-               this.selectCurrentModelItem();
-       };
+       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 ];
+/**
+ * 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() );
-               }
-       };
+       if ( selectedItem ) {
+               this.selectWidget.selectItemByData( selectedItem.getName() );
+       }
+};
 
-       module.exports = ValuePickerWidget;
-}() );
+module.exports = ValuePickerWidget;
index c00d414..e366277 100644 (file)
@@ -1,84 +1,82 @@
-( function () {
-       var GroupWidget = require( './GroupWidget.js' ),
-               ViewSwitchWidget;
+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 || {};
+/**
+ * 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 );
+       // Parent
+       ViewSwitchWidget.parent.call( this, config );
 
-               this.controller = controller;
-               this.model = model;
+       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' )
-                               } )
-                       ]
-               } );
+       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' } );
+       // 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 )
-                       );
-       };
+       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 */
+/* Initialize */
 
-       OO.inheritClass( ViewSwitchWidget, OO.ui.Widget );
+OO.inheritClass( ViewSwitchWidget, OO.ui.Widget );
 
-       /**
       * Respond to model update event
       */
-       ViewSwitchWidget.prototype.onModelUpdate = function () {
-               var currentView = this.model.getCurrentView();
+/**
+ * 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 );
-               } );
-       };
+       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() );
-       };
+/**
+ * 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;
-}() );
+module.exports = ViewSwitchWidget;
index 16c0533..7796148 100644 (file)
@@ -1,88 +1,86 @@
-( 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 || {};
+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 );
+       // 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 );
+       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 )
-                                       )
-                       );
+       $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 )
-                                       )
-                       );
+       $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' );
+       $separator = $( '<div>' )
+               .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-separator' );
 
-               this.$element
-                       .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget' )
-                       .append( $topTable, $separator, $bottomTable );
-       };
+       this.$element
+               .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget' )
+               .append( $topTable, $separator, $bottomTable );
+};
 
-       /* Initialization */
+/* Initialization */
 
-       OO.inheritClass( WatchlistTopSectionWidget, OO.ui.Widget );
+OO.inheritClass( WatchlistTopSectionWidget, OO.ui.Widget );
 
-       module.exports = WatchlistTopSectionWidget;
-}() );
+module.exports = WatchlistTopSectionWidget;
index aada50c..5f629e7 100644 (file)
                },
 
                /**
-                * Get an automatically generated random ID (persisted in sessionStorage)
+                * Retrieve a random ID persisted in sessionStorage, generating it if needed
                 *
-                * This ID is ephemeral for everyone, staying in their browser only until they
-                * close their browsing session.
+                * This ID is stored in sessionStorage and persists within a single tab,
+                * including between page views through links and form submissions,
+                * and when going forwards/backwards in browser history, and when restoring
+                * a closed tab, or restoring a closed browser session.
+                *
+                * This is different from session cookies, because it is not shared between
+                * tabs of the same browser. Two simultaneous pageviews in the same browser
+                * can have different session IDs. The ID is also not re-used when opening
+                * a new tab to a website after fully closing others.
+                *
+                * See https://phabricator.wikimedia.org/T118063#4547178 and
+                * https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage
+                * for more information.
                 *
                 * @return {string} Random session ID
                 */
index 90bcc2e..647c983 100644 (file)
@@ -57,7 +57,7 @@
 .mw-widget-dateInputWidget {
        &.oo-ui-textInputWidget {
                display: inline-block;
-               width: 21em;
+               max-width: 21em;
                // .oo-ui-inline-spacing( 0.5em ); already inherited from `.oo-ui-inputWidget`
 
                .oo-ui-labelElement-label {
index 5c574a2..0a8dfb8 100644 (file)
                         *             'dependencies': ['required.foo', 'bar.also', ...]
                         *             'group': 'somegroup', (or) null
                         *             'source': 'local', (or) 'anotherwiki'
-                        *             'skip': 'return !!window.Example', (or) null
+                        *             'skip': 'return !!window.Example', (or) null, (or) boolean result of skip
                         *             'module': export Object
                         *
                         *             // Set from execute() or mw.loader.state()
                         *             'state': 'registered', 'loaded', 'loading', 'ready', 'error', or 'missing'
                         *
                         *             // Optionally added at run-time by mw.loader.implement()
-                        *             'skipped': true
                         *             'script': closure, array of urls, or string
                         *             'style': { ... } (see #execute)
                         *             'messages': { 'key': 'value', ... }
 
                        /**
                         * @private
-                        * @param {Array} modules List of module names
+                        * @param {string[]} modules List of module names
                         * @return {string} Hash of concatenated version hashes.
                         */
                        function getCombinedVersion( modules ) {
                         * execute the module or job now.
                         *
                         * @private
-                        * @param {Array} modules Names of modules to be checked
+                        * @param {string[]} modules Names of modules to be checked
                         * @return {boolean} True if all modules are in state 'ready', false otherwise
                         */
                        function allReady( modules ) {
                         *  dependencies, such that later modules depend on earlier modules. The array
                         *  contains the module names. If the array contains already some module names,
                         *  this function appends its result to the pre-existing array.
-                        * @param {StringSet} [unresolved] Used to track the current dependency
-                        *  chain, and to report loops in the dependency graph.
-                        * @throws {Error} If any unregistered module or a dependency loop is encountered
+                        * @param {StringSet} [unresolved] Used to detect loops in the dependency graph.
+                        * @throws {Error} If an unknown module or a circular dependency is encountered
                         */
                        function sortDependencies( module, resolved, unresolved ) {
-                               var i, deps, skip;
+                               var i, skip, deps;
 
                                if ( !( module in registry ) ) {
-                                       throw new Error( 'Unknown dependency: ' + module );
+                                       throw new Error( 'Unknown module: ' + module );
                                }
 
-                               if ( registry[ module ].skip !== null ) {
+                               if ( typeof registry[ module ].skip === 'string' ) {
                                        // eslint-disable-next-line no-new-func
-                                       skip = new Function( registry[ module ].skip );
-                                       registry[ module ].skip = null;
-                                       if ( skip() ) {
-                                               registry[ module ].skipped = true;
+                                       skip = ( new Function( registry[ module ].skip )() );
+                                       registry[ module ].skip = !!skip;
+                                       if ( skip ) {
                                                registry[ module ].dependencies = [];
                                                setAndPropagate( module, 'ready' );
                                                return;
                                        }
                                }
 
-                               if ( resolved.indexOf( module ) !== -1 ) {
-                                       // Module already resolved; nothing to do
-                                       return;
-                               }
                                // Create unresolved if not passed in
                                if ( !unresolved ) {
                                        unresolved = new StringSet();
                                }
 
-                               // Add base modules
-                               if ( baseModules.indexOf( module ) === -1 ) {
-                                       for ( i = 0; i < baseModules.length; i++ ) {
-                                               if ( resolved.indexOf( baseModules[ i ] ) === -1 ) {
-                                                       resolved.push( baseModules[ i ] );
-                                               }
-                                       }
-                               }
-
-                               // Tracks down dependencies
+                               // Track down dependencies
                                deps = registry[ module ].dependencies;
                                unresolved.add( module );
                                for ( i = 0; i < deps.length; i++ ) {
                                                sortDependencies( deps[ i ], resolved, unresolved );
                                        }
                                }
+
                                resolved.push( module );
                        }
 
                         * @throws {Error} If an unregistered module or a dependency loop is encountered
                         */
                        function resolve( modules ) {
-                               var resolved = [],
+                               // Always load base modules
+                               var resolved = baseModules.slice(),
                                        i = 0;
                                for ( ; i < modules.length; i++ ) {
                                        sortDependencies( modules[ i ], resolved );
                         */
                        function resolveStubbornly( modules ) {
                                var saved,
-                                       resolved = [],
+                                       // Always load base modules
+                                       resolved = baseModules.slice(),
                                        i = 0;
                                for ( ; i < modules.length; i++ ) {
                                        saved = resolved.slice();
                                 *  "text/javascript"; if no type is provided, text/javascript is assumed.
                                 */
                                load: function ( modules, type ) {
-                                       var filtered, l;
+                                       var l;
 
                                        // Allow calling with a url or single dependency as a string
                                        if ( typeof modules === 'string' ) {
                                                modules = [ modules ];
                                        }
 
-                                       // Filter out top-level modules that are unknown or failed to load before.
-                                       filtered = modules.filter( function ( module ) {
-                                               var state = mw.loader.getState( module );
-                                               return state !== 'error' && state !== 'missing';
-                                       } );
-                                       // Resolve remaining list using the known dependency tree.
-                                       // This also filters out modules with unknown dependencies. (T36853)
-                                       filtered = resolveStubbornly( filtered );
-                                       // Some modules are not yet ready, add to module load queue.
-                                       enqueue( filtered, undefined, undefined );
+                                       // Resolve modules into flat list for internal queuing.
+                                       // This also filters out unknown modules and modules with
+                                       // unknown dependencies, allowing the rest to continue. (T36853)
+                                       enqueue( resolveStubbornly( modules ), undefined, undefined );
                                },
 
                                /**
index bebf4dc..240757c 100644 (file)
  *
  * Other browsers that pass the check are considered Grade X.
  *
- * @param {string} [str] User agent, defaults to navigator.userAgent
+ * @private
+ * @param {string} ua User agent string
  * @return {boolean} User agent is compatible with MediaWiki JS
  */
-function isCompatible( str ) {
-       var ua = str || navigator.userAgent;
+function isCompatible( ua ) {
        return !!(
                // https://caniuse.com/#feat=es5
                // https://caniuse.com/#feat=use-strict
@@ -76,7 +76,7 @@ function isCompatible( str ) {
        );
 }
 
-if ( !isCompatible() ) {
+if ( !isCompatible( navigator.userAgent ) ) {
        // Handle Grade C
        // Undo speculative Grade A <html> class. See ResourceLoaderClientHtml::getDocumentAttributes().
        document.documentElement.className = document.documentElement.className
index 1c93261..b40b769 100644 (file)
@@ -388,10 +388,10 @@ class ParserTestRunner {
                        100 => 'MemoryAlpha',
                        101 => 'MemoryAlpha_talk'
                ];
-               // Changing wgExtraNamespaces invalidates caches in MWNamespace and
-               // any live Language object, both on setup and teardown
+               // Changing wgExtraNamespaces invalidates caches in NamespaceInfo and any live Language
+               // object, both on setup and teardown
                $reset = function () {
-                       MWNamespace::clearCaches();
+                       MediaWikiServices::getInstance()->resetServiceForTesting( 'NamespaceInfo' );
                        MediaWikiServices::getInstance()->getContentLanguage()->resetNamespaces();
                };
                $setup[] = $reset;
index 5119d73..fd0cea1 100644 (file)
@@ -401,7 +401,8 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
                        self::$useTemporaryTables = !$this->getCliArg( 'use-normal-tables' );
                        self::$reuseDB = $this->getCliArg( 'reuse-db' );
 
-                       $this->db = wfGetDB( DB_MASTER );
+                       $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+                       $this->db = $lb->getConnection( DB_MASTER );
 
                        $this->checkDbIsSupported();
 
@@ -543,6 +544,24 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
                $this->tmpFiles = array_merge( $this->tmpFiles, (array)$files );
        }
 
+       // @todo Make const when we no longer support HHVM (T192166)
+       private static $namespaceAffectingSettings = [
+               'wgAllowImageMoving',
+               'wgCanonicalNamespaceNames',
+               'wgCapitalLinkOverrides',
+               'wgCapitalLinks',
+               'wgContentNamespaces',
+               'wgExtensionMessagesFiles',
+               'wgExtensionNamespaces',
+               'wgExtraNamespaces',
+               'wgExtraSignatureNamespaces',
+               'wgNamespaceContentModels',
+               'wgNamespaceProtection',
+               'wgNamespacesWithSubpages',
+               'wgNonincludableNamespaces',
+               'wgRestrictionLevels',
+       ];
+
        protected function tearDown() {
                global $wgRequest, $wgSQLMode;
 
@@ -587,8 +606,8 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
                        ini_set( $name, $value );
                }
                if (
-                       array_key_exists( 'wgExtraNamespaces', $this->mwGlobals ) ||
-                       in_array( 'wgExtraNamespaces', $this->mwGlobalsToUnset )
+                       array_intersect( self::$namespaceAffectingSettings, array_keys( $this->mwGlobals ) ) ||
+                       array_intersect( self::$namespaceAffectingSettings, $this->mwGlobalsToUnset )
                ) {
                        $this->resetNamespaces();
                }
@@ -730,7 +749,7 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
                        $GLOBALS[$key] = $value;
                }
 
-               if ( array_key_exists( 'wgExtraNamespaces', $pairs ) ) {
+               if ( array_intersect( self::$namespaceAffectingSettings, array_keys( $pairs ) ) ) {
                        $this->resetNamespaces();
                }
        }
@@ -761,14 +780,7 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
                                . 'instance has been replaced by test code.' );
                }
 
-               MWNamespace::clearCaches();
                Language::clearCaches();
-
-               // We can't have the TitleFormatter holding on to an old Language object either
-               // @todo We shouldn't need to reset all the aliases here.
-               $this->localServices->resetServiceForTesting( 'TitleFormatter' );
-               $this->localServices->resetServiceForTesting( 'TitleParser' );
-               $this->localServices->resetServiceForTesting( '_MediaWikiTitleCodec' );
        }
 
        /**
@@ -1457,12 +1469,12 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
         * @note this method only works when first called. Subsequent calls have no effect,
         * even if using different parameters.
         *
-        * @param Database $db The database connection
+        * @param IMaintainableDatabase $db The database connection
         * @param string $prefix The prefix to use for the new table set (aka schema).
         *
         * @throws MWException If the database table prefix is already $prefix
         */
-       public static function setupTestDB( Database $db, $prefix ) {
+       public static function setupTestDB( IMaintainableDatabase $db, $prefix ) {
                if ( self::$dbSetup ) {
                        return;
                }
@@ -1615,6 +1627,10 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
 
                if ( $tablesToRestore ) {
                        $this->recloneMockTables( $db, $tablesToRestore );
+
+                       // Reset the restored tables, mainly for the side effect of
+                       // re-calling $this->addCoreDBData() if necessary.
+                       $this->resetDB( $db, $tablesToRestore );
                }
        }
 
@@ -1629,6 +1645,7 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
 
                if ( $oldOverrides['alter'] || $oldOverrides['create'] || $oldOverrides['drop'] ) {
                        $this->undoSchemaOverrides( $db, $oldOverrides );
+                       unset( $db->_schemaOverrides );
                }
 
                // Determine new overrides.
@@ -1780,6 +1797,12 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
                        if ( array_intersect( $tablesUsed, $userTables ) ) {
                                $tablesUsed = array_unique( array_merge( $tablesUsed, $userTables ) );
                                TestUserRegistry::clear();
+
+                               // Reset $wgUser, which is probably 127.0.0.1, as its loaded data is probably not valid
+                               // @todo Should we start setting $wgUser to something nondeterministic
+                               //  to encourage tests to be updated to not depend on it?
+                               global $wgUser;
+                               $wgUser->clearInstanceCache( $wgUser->mFrom );
                        }
                        if ( array_intersect( $tablesUsed, $pageTables ) ) {
                                $tablesUsed = array_unique( array_merge( $tablesUsed, $pageTables ) );
diff --git a/tests/phpunit/includes/MWNamespaceTest.php b/tests/phpunit/includes/MWNamespaceTest.php
deleted file mode 100644 (file)
index c95b1eb..0000000
+++ /dev/null
@@ -1,609 +0,0 @@
-<?php
-/**
- * @author Antoine Musso
- * @copyright Copyright © 2011, Antoine Musso
- * @file
- */
-
-class MWNamespaceTest extends MediaWikiTestCase {
-
-       protected function setUp() {
-               parent::setUp();
-
-               $this->setMwGlobals( [
-                       'wgContentNamespaces' => [ NS_MAIN ],
-                       'wgNamespacesWithSubpages' => [
-                               NS_TALK => true,
-                               NS_USER => true,
-                               NS_USER_TALK => true,
-                       ],
-                       'wgCapitalLinks' => true,
-                       'wgCapitalLinkOverrides' => [],
-                       'wgNonincludableNamespaces' => [],
-               ] );
-       }
-
-       /**
-        * @todo Write more texts, handle $wgAllowImageMoving setting
-        * @covers MWNamespace::isMovable
-        */
-       public function testIsMovable() {
-               $this->assertFalse( MWNamespace::isMovable( NS_SPECIAL ) );
-       }
-
-       private function assertIsSubject( $ns ) {
-               $this->assertTrue( MWNamespace::isSubject( $ns ) );
-       }
-
-       private function assertIsNotSubject( $ns ) {
-               $this->assertFalse( MWNamespace::isSubject( $ns ) );
-       }
-
-       /**
-        * Please make sure to change testIsTalk() if you change the assertions below
-        * @covers MWNamespace::isSubject
-        */
-       public function testIsSubject() {
-               // Special namespaces
-               $this->assertIsSubject( NS_MEDIA );
-               $this->assertIsSubject( NS_SPECIAL );
-
-               // Subject pages
-               $this->assertIsSubject( NS_MAIN );
-               $this->assertIsSubject( NS_USER );
-               $this->assertIsSubject( 100 ); # user defined
-
-               // Talk pages
-               $this->assertIsNotSubject( NS_TALK );
-               $this->assertIsNotSubject( NS_USER_TALK );
-               $this->assertIsNotSubject( 101 ); # user defined
-       }
-
-       private function assertIsTalk( $ns ) {
-               $this->assertTrue( MWNamespace::isTalk( $ns ) );
-       }
-
-       private function assertIsNotTalk( $ns ) {
-               $this->assertFalse( MWNamespace::isTalk( $ns ) );
-       }
-
-       /**
-        * Reverse of testIsSubject().
-        * Please update testIsSubject() if you change assertions below
-        * @covers MWNamespace::isTalk
-        */
-       public function testIsTalk() {
-               // Special namespaces
-               $this->assertIsNotTalk( NS_MEDIA );
-               $this->assertIsNotTalk( NS_SPECIAL );
-
-               // Subject pages
-               $this->assertIsNotTalk( NS_MAIN );
-               $this->assertIsNotTalk( NS_USER );
-               $this->assertIsNotTalk( 100 ); # user defined
-
-               // Talk pages
-               $this->assertIsTalk( NS_TALK );
-               $this->assertIsTalk( NS_USER_TALK );
-               $this->assertIsTalk( 101 ); # user defined
-       }
-
-       /**
-        * @covers MWNamespace::getSubject
-        */
-       public function testGetSubject() {
-               // Special namespaces are their own subjects
-               $this->assertEquals( NS_MEDIA, MWNamespace::getSubject( NS_MEDIA ) );
-               $this->assertEquals( NS_SPECIAL, MWNamespace::getSubject( NS_SPECIAL ) );
-
-               $this->assertEquals( NS_MAIN, MWNamespace::getSubject( NS_TALK ) );
-               $this->assertEquals( NS_USER, MWNamespace::getSubject( NS_USER_TALK ) );
-       }
-
-       /**
-        * Regular getTalk() calls
-        * Namespaces without a talk page (NS_MEDIA, NS_SPECIAL) are tested in
-        * the function testGetTalkExceptions()
-        * @covers MWNamespace::getTalk
-        */
-       public function testGetTalk() {
-               $this->assertEquals( NS_TALK, MWNamespace::getTalk( NS_MAIN ) );
-               $this->assertEquals( NS_TALK, MWNamespace::getTalk( NS_TALK ) );
-               $this->assertEquals( NS_USER_TALK, MWNamespace::getTalk( NS_USER ) );
-               $this->assertEquals( NS_USER_TALK, MWNamespace::getTalk( NS_USER_TALK ) );
-       }
-
-       /**
-        * Exceptions with getTalk()
-        * NS_MEDIA does not have talk pages. MediaWiki raise an exception for them.
-        * @expectedException MWException
-        * @covers MWNamespace::getTalk
-        */
-       public function testGetTalkExceptionsForNsMedia() {
-               $this->assertNull( MWNamespace::getTalk( NS_MEDIA ) );
-       }
-
-       /**
-        * Exceptions with getTalk()
-        * NS_SPECIAL does not have talk pages. MediaWiki raise an exception for them.
-        * @expectedException MWException
-        * @covers MWNamespace::getTalk
-        */
-       public function testGetTalkExceptionsForNsSpecial() {
-               $this->assertNull( MWNamespace::getTalk( NS_SPECIAL ) );
-       }
-
-       /**
-        * Regular getAssociated() calls
-        * Namespaces without an associated page (NS_MEDIA, NS_SPECIAL) are tested in
-        * the function testGetAssociatedExceptions()
-        * @covers MWNamespace::getAssociated
-        */
-       public function testGetAssociated() {
-               $this->assertEquals( NS_TALK, MWNamespace::getAssociated( NS_MAIN ) );
-               $this->assertEquals( NS_MAIN, MWNamespace::getAssociated( NS_TALK ) );
-       }
-
-       # ## Exceptions with getAssociated()
-       # ## NS_MEDIA and NS_SPECIAL do not have talk pages. MediaWiki raises
-       # ## an exception for them.
-       /**
-        * @expectedException MWException
-        * @covers MWNamespace::getAssociated
-        */
-       public function testGetAssociatedExceptionsForNsMedia() {
-               $this->assertNull( MWNamespace::getAssociated( NS_MEDIA ) );
-       }
-
-       /**
-        * @expectedException MWException
-        * @covers MWNamespace::getAssociated
-        */
-       public function testGetAssociatedExceptionsForNsSpecial() {
-               $this->assertNull( MWNamespace::getAssociated( NS_SPECIAL ) );
-       }
-
-       /**
-        * Test MWNamespace::equals
-        * Note if we add a namespace registration system with keys like 'MAIN'
-        * we should add tests here for equivilance on things like 'MAIN' == 0
-        * and 'MAIN' == NS_MAIN.
-        * @covers MWNamespace::equals
-        */
-       public function testEquals() {
-               $this->assertTrue( MWNamespace::equals( NS_MAIN, NS_MAIN ) );
-               $this->assertTrue( MWNamespace::equals( NS_MAIN, 0 ) ); // In case we make NS_MAIN 'MAIN'
-               $this->assertTrue( MWNamespace::equals( NS_USER, NS_USER ) );
-               $this->assertTrue( MWNamespace::equals( NS_USER, 2 ) );
-               $this->assertTrue( MWNamespace::equals( NS_USER_TALK, NS_USER_TALK ) );
-               $this->assertTrue( MWNamespace::equals( NS_SPECIAL, NS_SPECIAL ) );
-               $this->assertFalse( MWNamespace::equals( NS_MAIN, NS_TALK ) );
-               $this->assertFalse( MWNamespace::equals( NS_USER, NS_USER_TALK ) );
-               $this->assertFalse( MWNamespace::equals( NS_PROJECT, NS_TEMPLATE ) );
-       }
-
-       /**
-        * @covers MWNamespace::subjectEquals
-        */
-       public function testSubjectEquals() {
-               $this->assertSameSubject( NS_MAIN, NS_MAIN );
-               $this->assertSameSubject( NS_MAIN, 0 ); // In case we make NS_MAIN 'MAIN'
-               $this->assertSameSubject( NS_USER, NS_USER );
-               $this->assertSameSubject( NS_USER, 2 );
-               $this->assertSameSubject( NS_USER_TALK, NS_USER_TALK );
-               $this->assertSameSubject( NS_SPECIAL, NS_SPECIAL );
-               $this->assertSameSubject( NS_MAIN, NS_TALK );
-               $this->assertSameSubject( NS_USER, NS_USER_TALK );
-
-               $this->assertDifferentSubject( NS_PROJECT, NS_TEMPLATE );
-               $this->assertDifferentSubject( NS_SPECIAL, NS_MAIN );
-       }
-
-       /**
-        * @covers MWNamespace::subjectEquals
-        */
-       public function testSpecialAndMediaAreDifferentSubjects() {
-               $this->assertDifferentSubject(
-                       NS_MEDIA, NS_SPECIAL,
-                       "NS_MEDIA and NS_SPECIAL are different subject namespaces"
-               );
-               $this->assertDifferentSubject(
-                       NS_SPECIAL, NS_MEDIA,
-                       "NS_SPECIAL and NS_MEDIA are different subject namespaces"
-               );
-       }
-
-       public function provideHasTalkNamespace() {
-               return [
-                       [ NS_MEDIA, false ],
-                       [ NS_SPECIAL, false ],
-
-                       [ NS_MAIN, true ],
-                       [ NS_TALK, true ],
-                       [ NS_USER, true ],
-                       [ NS_USER_TALK, true ],
-
-                       [ 100, true ],
-                       [ 101, true ],
-               ];
-       }
-
-       /**
-        * @dataProvider provideHasTalkNamespace
-        * @covers MWNamespace::hasTalkNamespace
-        *
-        * @param int $index
-        * @param bool $expected
-        */
-       public function testHasTalkNamespace( $index, $expected ) {
-               $actual = MWNamespace::hasTalkNamespace( $index );
-               $this->assertSame( $actual, $expected, "NS $index" );
-       }
-
-       /**
-        * @dataProvider provideHasTalkNamespace
-        * @covers MWNamespace::canTalk
-        *
-        * @param int $index
-        * @param bool $expected
-        */
-       public function testCanTalk( $index, $expected ) {
-               $this->hideDeprecated( 'MWNamespace::canTalk' );
-               $actual = MWNamespace::canTalk( $index );
-               $this->assertSame( $actual, $expected, "NS $index" );
-       }
-
-       private function assertIsContent( $ns ) {
-               $this->assertTrue( MWNamespace::isContent( $ns ) );
-       }
-
-       private function assertIsNotContent( $ns ) {
-               $this->assertFalse( MWNamespace::isContent( $ns ) );
-       }
-
-       /**
-        * @covers MWNamespace::isContent
-        */
-       public function testIsContent() {
-               // NS_MAIN is a content namespace per DefaultSettings.php
-               // and per function definition.
-
-               $this->assertIsContent( NS_MAIN );
-
-               // Other namespaces which are not expected to be content
-
-               $this->assertIsNotContent( NS_MEDIA );
-               $this->assertIsNotContent( NS_SPECIAL );
-               $this->assertIsNotContent( NS_TALK );
-               $this->assertIsNotContent( NS_USER );
-               $this->assertIsNotContent( NS_CATEGORY );
-               $this->assertIsNotContent( 100 );
-       }
-
-       /**
-        * Similar to testIsContent() but alters the $wgContentNamespaces
-        * global variable.
-        * @covers MWNamespace::isContent
-        */
-       public function testIsContentAdvanced() {
-               global $wgContentNamespaces;
-
-               // Test that user defined namespace #252 is not content
-               $this->assertIsNotContent( 252 );
-
-               // Bless namespace # 252 as a content namespace
-               $wgContentNamespaces[] = 252;
-
-               $this->assertIsContent( 252 );
-
-               // Makes sure NS_MAIN was not impacted
-               $this->assertIsContent( NS_MAIN );
-       }
-
-       private function assertIsWatchable( $ns ) {
-               $this->assertTrue( MWNamespace::isWatchable( $ns ) );
-       }
-
-       private function assertIsNotWatchable( $ns ) {
-               $this->assertFalse( MWNamespace::isWatchable( $ns ) );
-       }
-
-       /**
-        * @covers MWNamespace::isWatchable
-        */
-       public function testIsWatchable() {
-               // Specials namespaces are not watchable
-               $this->assertIsNotWatchable( NS_MEDIA );
-               $this->assertIsNotWatchable( NS_SPECIAL );
-
-               // Core defined namespaces are watchables
-               $this->assertIsWatchable( NS_MAIN );
-               $this->assertIsWatchable( NS_TALK );
-
-               // Additional, user defined namespaces are watchables
-               $this->assertIsWatchable( 100 );
-               $this->assertIsWatchable( 101 );
-       }
-
-       private function assertHasSubpages( $ns ) {
-               $this->assertTrue( MWNamespace::hasSubpages( $ns ) );
-       }
-
-       private function assertHasNotSubpages( $ns ) {
-               $this->assertFalse( MWNamespace::hasSubpages( $ns ) );
-       }
-
-       /**
-        * @covers MWNamespace::hasSubpages
-        */
-       public function testHasSubpages() {
-               global $wgNamespacesWithSubpages;
-
-               // Special namespaces:
-               $this->assertHasNotSubpages( NS_MEDIA );
-               $this->assertHasNotSubpages( NS_SPECIAL );
-
-               // Namespaces without subpages
-               $this->assertHasNotSubpages( NS_MAIN );
-
-               $wgNamespacesWithSubpages[NS_MAIN] = true;
-               $this->assertHasSubpages( NS_MAIN );
-
-               $wgNamespacesWithSubpages[NS_MAIN] = false;
-               $this->assertHasNotSubpages( NS_MAIN );
-
-               // Some namespaces with subpages
-               $this->assertHasSubpages( NS_TALK );
-               $this->assertHasSubpages( NS_USER );
-               $this->assertHasSubpages( NS_USER_TALK );
-       }
-
-       /**
-        * @covers MWNamespace::getContentNamespaces
-        */
-       public function testGetContentNamespaces() {
-               global $wgContentNamespaces;
-
-               $this->assertEquals(
-                       [ NS_MAIN ],
-                       MWNamespace::getContentNamespaces(),
-                       '$wgContentNamespaces is an array with only NS_MAIN by default'
-               );
-
-               # test !is_array( $wgcontentNamespaces )
-               $wgContentNamespaces = '';
-               $this->assertEquals( [ NS_MAIN ], MWNamespace::getContentNamespaces() );
-
-               $wgContentNamespaces = false;
-               $this->assertEquals( [ NS_MAIN ], MWNamespace::getContentNamespaces() );
-
-               $wgContentNamespaces = null;
-               $this->assertEquals( [ NS_MAIN ], MWNamespace::getContentNamespaces() );
-
-               $wgContentNamespaces = 5;
-               $this->assertEquals( [ NS_MAIN ], MWNamespace::getContentNamespaces() );
-
-               # test $wgContentNamespaces === []
-               $wgContentNamespaces = [];
-               $this->assertEquals( [ NS_MAIN ], MWNamespace::getContentNamespaces() );
-
-               # test !in_array( NS_MAIN, $wgContentNamespaces )
-               $wgContentNamespaces = [ NS_USER, NS_CATEGORY ];
-               $this->assertEquals(
-                       [ NS_MAIN, NS_USER, NS_CATEGORY ],
-                       MWNamespace::getContentNamespaces(),
-                       'NS_MAIN is forced in $wgContentNamespaces even if unwanted'
-               );
-
-               # test other cases, return $wgcontentNamespaces as is
-               $wgContentNamespaces = [ NS_MAIN ];
-               $this->assertEquals(
-                       [ NS_MAIN ],
-                       MWNamespace::getContentNamespaces()
-               );
-
-               $wgContentNamespaces = [ NS_MAIN, NS_USER, NS_CATEGORY ];
-               $this->assertEquals(
-                       [ NS_MAIN, NS_USER, NS_CATEGORY ],
-                       MWNamespace::getContentNamespaces()
-               );
-       }
-
-       /**
-        * @covers MWNamespace::getSubjectNamespaces
-        */
-       public function testGetSubjectNamespaces() {
-               $subjectsNS = MWNamespace::getSubjectNamespaces();
-               $this->assertContains( NS_MAIN, $subjectsNS,
-                       "Talk namespaces should have NS_MAIN" );
-               $this->assertNotContains( NS_TALK, $subjectsNS,
-                       "Talk namespaces should have NS_TALK" );
-
-               $this->assertNotContains( NS_MEDIA, $subjectsNS,
-                       "Talk namespaces should not have NS_MEDIA" );
-               $this->assertNotContains( NS_SPECIAL, $subjectsNS,
-                       "Talk namespaces should not have NS_SPECIAL" );
-       }
-
-       /**
-        * @covers MWNamespace::getTalkNamespaces
-        */
-       public function testGetTalkNamespaces() {
-               $talkNS = MWNamespace::getTalkNamespaces();
-               $this->assertContains( NS_TALK, $talkNS,
-                       "Subject namespaces should have NS_TALK" );
-               $this->assertNotContains( NS_MAIN, $talkNS,
-                       "Subject namespaces should not have NS_MAIN" );
-
-               $this->assertNotContains( NS_MEDIA, $talkNS,
-                       "Subject namespaces should not have NS_MEDIA" );
-               $this->assertNotContains( NS_SPECIAL, $talkNS,
-                       "Subject namespaces should not have NS_SPECIAL" );
-       }
-
-       private function assertIsCapitalized( $ns ) {
-               $this->assertTrue( MWNamespace::isCapitalized( $ns ) );
-       }
-
-       private function assertIsNotCapitalized( $ns ) {
-               $this->assertFalse( MWNamespace::isCapitalized( $ns ) );
-       }
-
-       /**
-        * Some namespaces are always capitalized per code definition
-        * in MWNamespace::$alwaysCapitalizedNamespaces
-        * @covers MWNamespace::isCapitalized
-        */
-       public function testIsCapitalizedHardcodedAssertions() {
-               // NS_MEDIA and NS_FILE are treated the same
-               $this->assertEquals(
-                       MWNamespace::isCapitalized( NS_MEDIA ),
-                       MWNamespace::isCapitalized( NS_FILE ),
-                       'NS_MEDIA and NS_FILE have same capitalization rendering'
-               );
-
-               // Boths are capitalized by default
-               $this->assertIsCapitalized( NS_MEDIA );
-               $this->assertIsCapitalized( NS_FILE );
-
-               // Always capitalized namespaces
-               // @see MWNamespace::$alwaysCapitalizedNamespaces
-               $this->assertIsCapitalized( NS_SPECIAL );
-               $this->assertIsCapitalized( NS_USER );
-               $this->assertIsCapitalized( NS_MEDIAWIKI );
-       }
-
-       /**
-        * Follows up for testIsCapitalizedHardcodedAssertions() but alter the
-        * global $wgCapitalLink setting to have extended coverage.
-        *
-        * MWNamespace::isCapitalized() rely on two global settings:
-        *   $wgCapitalLinkOverrides = []; by default
-        *   $wgCapitalLinks = true; by default
-        * This function test $wgCapitalLinks
-        *
-        * Global setting correctness is tested against the NS_PROJECT and
-        * NS_PROJECT_TALK namespaces since they are not hardcoded nor specials
-        * @covers MWNamespace::isCapitalized
-        */
-       public function testIsCapitalizedWithWgCapitalLinks() {
-               global $wgCapitalLinks;
-
-               $this->assertIsCapitalized( NS_PROJECT );
-               $this->assertIsCapitalized( NS_PROJECT_TALK );
-
-               $wgCapitalLinks = false;
-
-               // hardcoded namespaces (see above function) are still capitalized:
-               $this->assertIsCapitalized( NS_SPECIAL );
-               $this->assertIsCapitalized( NS_USER );
-               $this->assertIsCapitalized( NS_MEDIAWIKI );
-
-               // setting is correctly applied
-               $this->assertIsNotCapitalized( NS_PROJECT );
-               $this->assertIsNotCapitalized( NS_PROJECT_TALK );
-       }
-
-       /**
-        * Counter part for MWNamespace::testIsCapitalizedWithWgCapitalLinks() now
-        * testing the $wgCapitalLinkOverrides global.
-        *
-        * @todo split groups of assertions in autonomous testing functions
-        * @covers MWNamespace::isCapitalized
-        */
-       public function testIsCapitalizedWithWgCapitalLinkOverrides() {
-               global $wgCapitalLinkOverrides;
-
-               // Test default settings
-               $this->assertIsCapitalized( NS_PROJECT );
-               $this->assertIsCapitalized( NS_PROJECT_TALK );
-
-               // hardcoded namespaces (see above function) are capitalized:
-               $this->assertIsCapitalized( NS_SPECIAL );
-               $this->assertIsCapitalized( NS_USER );
-               $this->assertIsCapitalized( NS_MEDIAWIKI );
-
-               // Hardcoded namespaces remains capitalized
-               $wgCapitalLinkOverrides[NS_SPECIAL] = false;
-               $wgCapitalLinkOverrides[NS_USER] = false;
-               $wgCapitalLinkOverrides[NS_MEDIAWIKI] = false;
-
-               $this->assertIsCapitalized( NS_SPECIAL );
-               $this->assertIsCapitalized( NS_USER );
-               $this->assertIsCapitalized( NS_MEDIAWIKI );
-
-               $wgCapitalLinkOverrides[NS_PROJECT] = false;
-               $this->assertIsNotCapitalized( NS_PROJECT );
-
-               $wgCapitalLinkOverrides[NS_PROJECT] = true;
-               $this->assertIsCapitalized( NS_PROJECT );
-
-               unset( $wgCapitalLinkOverrides[NS_PROJECT] );
-               $this->assertIsCapitalized( NS_PROJECT );
-       }
-
-       /**
-        * @covers MWNamespace::hasGenderDistinction
-        */
-       public function testHasGenderDistinction() {
-               // Namespaces with gender distinctions
-               $this->assertTrue( MWNamespace::hasGenderDistinction( NS_USER ) );
-               $this->assertTrue( MWNamespace::hasGenderDistinction( NS_USER_TALK ) );
-
-               // Other ones, "genderless"
-               $this->assertFalse( MWNamespace::hasGenderDistinction( NS_MEDIA ) );
-               $this->assertFalse( MWNamespace::hasGenderDistinction( NS_SPECIAL ) );
-               $this->assertFalse( MWNamespace::hasGenderDistinction( NS_MAIN ) );
-               $this->assertFalse( MWNamespace::hasGenderDistinction( NS_TALK ) );
-       }
-
-       /**
-        * @covers MWNamespace::isNonincludable
-        */
-       public function testIsNonincludable() {
-               global $wgNonincludableNamespaces;
-
-               $wgNonincludableNamespaces = [ NS_USER ];
-
-               $this->assertTrue( MWNamespace::isNonincludable( NS_USER ) );
-               $this->assertFalse( MWNamespace::isNonincludable( NS_TEMPLATE ) );
-       }
-
-       private function assertSameSubject( $ns1, $ns2, $msg = '' ) {
-               $this->assertTrue( MWNamespace::subjectEquals( $ns1, $ns2 ), $msg );
-       }
-
-       private function assertDifferentSubject( $ns1, $ns2, $msg = '' ) {
-               $this->assertFalse( MWNamespace::subjectEquals( $ns1, $ns2 ), $msg );
-       }
-
-       public function provideGetCategoryLinkType() {
-               return [
-                       [ NS_MAIN, 'page' ],
-                       [ NS_TALK, 'page' ],
-                       [ NS_USER, 'page' ],
-                       [ NS_USER_TALK, 'page' ],
-
-                       [ NS_FILE, 'file' ],
-                       [ NS_FILE_TALK, 'page' ],
-
-                       [ NS_CATEGORY, 'subcat' ],
-                       [ NS_CATEGORY_TALK, 'page' ],
-
-                       [ 100, 'page' ],
-                       [ 101, 'page' ],
-               ];
-       }
-
-       /**
-        * @dataProvider provideGetCategoryLinkType
-        * @covers MWNamespace::getCategoryLinkType
-        *
-        * @param int $index
-        * @param string $expected
-        */
-       public function testGetCategoryLinkType( $index, $expected ) {
-               $actual = MWNamespace::getCategoryLinkType( $index );
-               $this->assertSame( $expected, $actual, "NS $index" );
-       }
-}
index 1cd40ed..9d6164c 100644 (file)
@@ -364,7 +364,7 @@ class MediaWikiServicesTest extends MediaWikiTestCase {
                } ) );
 
                $sortedNames = $names;
-               sort( $sortedNames );
+               natcasesort( $sortedNames );
 
                $this->assertSame( $sortedNames, $names,
                        'Please keep service getters sorted alphabetically' );
index 74e8e1b..02e06f8 100644 (file)
@@ -8,7 +8,7 @@ class ServiceWiringTest extends MediaWikiTestCase {
                global $IP;
                $services = array_keys( require "$IP/includes/ServiceWiring.php" );
                $sortedServices = $services;
-               sort( $sortedServices );
+               natcasesort( $sortedServices );
 
                $this->assertSame( $sortedServices, $services,
                        'Please keep services sorted alphabetically' );
index d3a4ed4..55f4a33 100644 (file)
@@ -590,7 +590,10 @@ class ApiQuerySiteinfoTest extends ApiTestCase {
        }
 
        public function testVariables() {
-               $this->assertSame( MagicWord::getVariableIDs(), $this->doQuery( 'variables' ) );
+               $this->assertSame(
+                       MediaWikiServices::getInstance()->getMagicWordFactory()->getVariableIDs(),
+                       $this->doQuery( 'variables' )
+               );
        }
 
        public function testProtocols() {
index b03a309..2fa662b 100644 (file)
@@ -239,7 +239,7 @@ class MessageCacheTest extends MediaWikiLangTestCase {
                $importRevision->setComment( 'Imported edit' );
                $importRevision->setTimestamp( '19991122001122' );
                $importRevision->setText( 'IMPORTED OLD TEST' );
-               $importRevision->setUsername( 'Alan Smithee' );
+               $importRevision->setUsername( 'ext>Alan Smithee' );
 
                $importer = MediaWikiServices::getInstance()->getWikiRevisionOldRevisionImporterNoUpdates();
                $importer->import( $importRevision );
index 02c25e7..7d13ac6 100644 (file)
@@ -23,7 +23,6 @@
  * @copyright © 2013 Wikimedia Foundation Inc.
  */
 
-use Wikimedia\Rdbms\Database;
 use Wikimedia\Rdbms\IDatabase;
 use Wikimedia\Rdbms\IMaintainableDatabase;
 use Wikimedia\Rdbms\LBFactory;
@@ -730,7 +729,7 @@ class LBFactoryTest extends MediaWikiTestCase {
                $factory->destroy();
        }
 
-       private function quoteTable( Database $db, $table ) {
+       private function quoteTable( IDatabase $db, $table ) {
                if ( $db->getType() === 'sqlite' ) {
                        return $table;
                } else {
index ac5fef9..5487556 100644 (file)
@@ -483,7 +483,7 @@ abstract class WikiPageDbTestBase extends MediaWikiLangTestCase {
                                'delete',
                                'delete',
                                'testing user 0 deletion',
-                               '0',
+                               null,
                                '127.0.0.1',
                                (string)$page->getTitle()->getNamespace(),
                                $page->getTitle()->getDBkey(),
diff --git a/tests/phpunit/includes/title/NamespaceInfoTest.php b/tests/phpunit/includes/title/NamespaceInfoTest.php
new file mode 100644 (file)
index 0000000..cc7df8d
--- /dev/null
@@ -0,0 +1,613 @@
+<?php
+/**
+ * @author Antoine Musso
+ * @copyright Copyright © 2011, Antoine Musso
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+
+class NamespaceInfoTest extends MediaWikiTestCase {
+
+       /** @var NamespaceInfo */
+       private $obj;
+
+       protected function setUp() {
+               parent::setUp();
+
+               $this->setMwGlobals( [
+                       'wgContentNamespaces' => [ NS_MAIN ],
+                       'wgNamespacesWithSubpages' => [
+                               NS_TALK => true,
+                               NS_USER => true,
+                               NS_USER_TALK => true,
+                       ],
+                       'wgCapitalLinks' => true,
+                       'wgCapitalLinkOverrides' => [],
+                       'wgNonincludableNamespaces' => [],
+               ] );
+
+               $this->obj = MediaWikiServices::getInstance()->getNamespaceInfo();
+       }
+
+       /**
+        * @todo Write more texts, handle $wgAllowImageMoving setting
+        * @covers NamespaceInfo::isMovable
+        */
+       public function testIsMovable() {
+               $this->assertFalse( $this->obj->isMovable( NS_SPECIAL ) );
+       }
+
+       private function assertIsSubject( $ns ) {
+               $this->assertTrue( $this->obj->isSubject( $ns ) );
+       }
+
+       private function assertIsNotSubject( $ns ) {
+               $this->assertFalse( $this->obj->isSubject( $ns ) );
+       }
+
+       /**
+        * Please make sure to change testIsTalk() if you change the assertions below
+        * @covers NamespaceInfo::isSubject
+        */
+       public function testIsSubject() {
+               // Special namespaces
+               $this->assertIsSubject( NS_MEDIA );
+               $this->assertIsSubject( NS_SPECIAL );
+
+               // Subject pages
+               $this->assertIsSubject( NS_MAIN );
+               $this->assertIsSubject( NS_USER );
+               $this->assertIsSubject( 100 ); # user defined
+
+               // Talk pages
+               $this->assertIsNotSubject( NS_TALK );
+               $this->assertIsNotSubject( NS_USER_TALK );
+               $this->assertIsNotSubject( 101 ); # user defined
+       }
+
+       private function assertIsTalk( $ns ) {
+               $this->assertTrue( $this->obj->isTalk( $ns ) );
+       }
+
+       private function assertIsNotTalk( $ns ) {
+               $this->assertFalse( $this->obj->isTalk( $ns ) );
+       }
+
+       /**
+        * Reverse of testIsSubject().
+        * Please update testIsSubject() if you change assertions below
+        * @covers NamespaceInfo::isTalk
+        */
+       public function testIsTalk() {
+               // Special namespaces
+               $this->assertIsNotTalk( NS_MEDIA );
+               $this->assertIsNotTalk( NS_SPECIAL );
+
+               // Subject pages
+               $this->assertIsNotTalk( NS_MAIN );
+               $this->assertIsNotTalk( NS_USER );
+               $this->assertIsNotTalk( 100 ); # user defined
+
+               // Talk pages
+               $this->assertIsTalk( NS_TALK );
+               $this->assertIsTalk( NS_USER_TALK );
+               $this->assertIsTalk( 101 ); # user defined
+       }
+
+       /**
+        * @covers NamespaceInfo::getSubject
+        */
+       public function testGetSubject() {
+               // Special namespaces are their own subjects
+               $this->assertEquals( NS_MEDIA, $this->obj->getSubject( NS_MEDIA ) );
+               $this->assertEquals( NS_SPECIAL, $this->obj->getSubject( NS_SPECIAL ) );
+
+               $this->assertEquals( NS_MAIN, $this->obj->getSubject( NS_TALK ) );
+               $this->assertEquals( NS_USER, $this->obj->getSubject( NS_USER_TALK ) );
+       }
+
+       /**
+        * Regular getTalk() calls
+        * Namespaces without a talk page (NS_MEDIA, NS_SPECIAL) are tested in
+        * the function testGetTalkExceptions()
+        * @covers NamespaceInfo::getTalk
+        */
+       public function testGetTalk() {
+               $this->assertEquals( NS_TALK, $this->obj->getTalk( NS_MAIN ) );
+               $this->assertEquals( NS_TALK, $this->obj->getTalk( NS_TALK ) );
+               $this->assertEquals( NS_USER_TALK, $this->obj->getTalk( NS_USER ) );
+               $this->assertEquals( NS_USER_TALK, $this->obj->getTalk( NS_USER_TALK ) );
+       }
+
+       /**
+        * Exceptions with getTalk()
+        * NS_MEDIA does not have talk pages. MediaWiki raise an exception for them.
+        * @expectedException MWException
+        * @covers NamespaceInfo::getTalk
+        */
+       public function testGetTalkExceptionsForNsMedia() {
+               $this->assertNull( $this->obj->getTalk( NS_MEDIA ) );
+       }
+
+       /**
+        * Exceptions with getTalk()
+        * NS_SPECIAL does not have talk pages. MediaWiki raise an exception for them.
+        * @expectedException MWException
+        * @covers NamespaceInfo::getTalk
+        */
+       public function testGetTalkExceptionsForNsSpecial() {
+               $this->assertNull( $this->obj->getTalk( NS_SPECIAL ) );
+       }
+
+       /**
+        * Regular getAssociated() calls
+        * Namespaces without an associated page (NS_MEDIA, NS_SPECIAL) are tested in
+        * the function testGetAssociatedExceptions()
+        * @covers NamespaceInfo::getAssociated
+        */
+       public function testGetAssociated() {
+               $this->assertEquals( NS_TALK, $this->obj->getAssociated( NS_MAIN ) );
+               $this->assertEquals( NS_MAIN, $this->obj->getAssociated( NS_TALK ) );
+       }
+
+       # ## Exceptions with getAssociated()
+       # ## NS_MEDIA and NS_SPECIAL do not have talk pages. MediaWiki raises
+       # ## an exception for them.
+       /**
+        * @expectedException MWException
+        * @covers NamespaceInfo::getAssociated
+        */
+       public function testGetAssociatedExceptionsForNsMedia() {
+               $this->assertNull( $this->obj->getAssociated( NS_MEDIA ) );
+       }
+
+       /**
+        * @expectedException MWException
+        * @covers NamespaceInfo::getAssociated
+        */
+       public function testGetAssociatedExceptionsForNsSpecial() {
+               $this->assertNull( $this->obj->getAssociated( NS_SPECIAL ) );
+       }
+
+       /**
+        * Note if we add a namespace registration system with keys like 'MAIN'
+        * we should add tests here for equivilance on things like 'MAIN' == 0
+        * and 'MAIN' == NS_MAIN.
+        * @covers NamespaceInfo::equals
+        */
+       public function testEquals() {
+               $this->assertTrue( $this->obj->equals( NS_MAIN, NS_MAIN ) );
+               $this->assertTrue( $this->obj->equals( NS_MAIN, 0 ) ); // In case we make NS_MAIN 'MAIN'
+               $this->assertTrue( $this->obj->equals( NS_USER, NS_USER ) );
+               $this->assertTrue( $this->obj->equals( NS_USER, 2 ) );
+               $this->assertTrue( $this->obj->equals( NS_USER_TALK, NS_USER_TALK ) );
+               $this->assertTrue( $this->obj->equals( NS_SPECIAL, NS_SPECIAL ) );
+               $this->assertFalse( $this->obj->equals( NS_MAIN, NS_TALK ) );
+               $this->assertFalse( $this->obj->equals( NS_USER, NS_USER_TALK ) );
+               $this->assertFalse( $this->obj->equals( NS_PROJECT, NS_TEMPLATE ) );
+       }
+
+       /**
+        * @covers NamespaceInfo::subjectEquals
+        */
+       public function testSubjectEquals() {
+               $this->assertSameSubject( NS_MAIN, NS_MAIN );
+               $this->assertSameSubject( NS_MAIN, 0 ); // In case we make NS_MAIN 'MAIN'
+               $this->assertSameSubject( NS_USER, NS_USER );
+               $this->assertSameSubject( NS_USER, 2 );
+               $this->assertSameSubject( NS_USER_TALK, NS_USER_TALK );
+               $this->assertSameSubject( NS_SPECIAL, NS_SPECIAL );
+               $this->assertSameSubject( NS_MAIN, NS_TALK );
+               $this->assertSameSubject( NS_USER, NS_USER_TALK );
+
+               $this->assertDifferentSubject( NS_PROJECT, NS_TEMPLATE );
+               $this->assertDifferentSubject( NS_SPECIAL, NS_MAIN );
+       }
+
+       /**
+        * @covers NamespaceInfo::subjectEquals
+        */
+       public function testSpecialAndMediaAreDifferentSubjects() {
+               $this->assertDifferentSubject(
+                       NS_MEDIA, NS_SPECIAL,
+                       "NS_MEDIA and NS_SPECIAL are different subject namespaces"
+               );
+               $this->assertDifferentSubject(
+                       NS_SPECIAL, NS_MEDIA,
+                       "NS_SPECIAL and NS_MEDIA are different subject namespaces"
+               );
+       }
+
+       public function provideHasTalkNamespace() {
+               return [
+                       [ NS_MEDIA, false ],
+                       [ NS_SPECIAL, false ],
+
+                       [ NS_MAIN, true ],
+                       [ NS_TALK, true ],
+                       [ NS_USER, true ],
+                       [ NS_USER_TALK, true ],
+
+                       [ 100, true ],
+                       [ 101, true ],
+               ];
+       }
+
+       /**
+        * @dataProvider provideHasTalkNamespace
+        * @covers NamespaceInfo::hasTalkNamespace
+        *
+        * @param int $index
+        * @param bool $expected
+        */
+       public function testHasTalkNamespace( $index, $expected ) {
+               $actual = $this->obj->hasTalkNamespace( $index );
+               $this->assertSame( $actual, $expected, "NS $index" );
+       }
+
+       /**
+        * @dataProvider provideHasTalkNamespace
+        * @covers MWNamespace::canTalk
+        *
+        * @param int $index
+        * @param bool $expected
+        */
+       public function testCanTalk( $index, $expected ) {
+               $this->hideDeprecated( 'MWNamespace::canTalk' );
+               $actual = MWNamespace::canTalk( $index );
+               $this->assertSame( $actual, $expected, "NS $index" );
+       }
+
+       private function assertIsContent( $ns ) {
+               $this->assertTrue( $this->obj->isContent( $ns ) );
+       }
+
+       private function assertIsNotContent( $ns ) {
+               $this->assertFalse( $this->obj->isContent( $ns ) );
+       }
+
+       /**
+        * @covers NamespaceInfo::isContent
+        */
+       public function testIsContent() {
+               // NS_MAIN is a content namespace per DefaultSettings.php
+               // and per function definition.
+
+               $this->assertIsContent( NS_MAIN );
+
+               // Other namespaces which are not expected to be content
+
+               $this->assertIsNotContent( NS_MEDIA );
+               $this->assertIsNotContent( NS_SPECIAL );
+               $this->assertIsNotContent( NS_TALK );
+               $this->assertIsNotContent( NS_USER );
+               $this->assertIsNotContent( NS_CATEGORY );
+               $this->assertIsNotContent( 100 );
+       }
+
+       /**
+        * Similar to testIsContent() but alters the $wgContentNamespaces
+        * global variable.
+        * @covers NamespaceInfo::isContent
+        */
+       public function testIsContentAdvanced() {
+               global $wgContentNamespaces;
+
+               // Test that user defined namespace #252 is not content
+               $this->assertIsNotContent( 252 );
+
+               // Bless namespace # 252 as a content namespace
+               $wgContentNamespaces[] = 252;
+
+               $this->assertIsContent( 252 );
+
+               // Makes sure NS_MAIN was not impacted
+               $this->assertIsContent( NS_MAIN );
+       }
+
+       private function assertIsWatchable( $ns ) {
+               $this->assertTrue( $this->obj->isWatchable( $ns ) );
+       }
+
+       private function assertIsNotWatchable( $ns ) {
+               $this->assertFalse( $this->obj->isWatchable( $ns ) );
+       }
+
+       /**
+        * @covers NamespaceInfo::isWatchable
+        */
+       public function testIsWatchable() {
+               // Specials namespaces are not watchable
+               $this->assertIsNotWatchable( NS_MEDIA );
+               $this->assertIsNotWatchable( NS_SPECIAL );
+
+               // Core defined namespaces are watchables
+               $this->assertIsWatchable( NS_MAIN );
+               $this->assertIsWatchable( NS_TALK );
+
+               // Additional, user defined namespaces are watchables
+               $this->assertIsWatchable( 100 );
+               $this->assertIsWatchable( 101 );
+       }
+
+       private function assertHasSubpages( $ns ) {
+               $this->assertTrue( $this->obj->hasSubpages( $ns ) );
+       }
+
+       private function assertHasNotSubpages( $ns ) {
+               $this->assertFalse( $this->obj->hasSubpages( $ns ) );
+       }
+
+       /**
+        * @covers NamespaceInfo::hasSubpages
+        */
+       public function testHasSubpages() {
+               global $wgNamespacesWithSubpages;
+
+               // Special namespaces:
+               $this->assertHasNotSubpages( NS_MEDIA );
+               $this->assertHasNotSubpages( NS_SPECIAL );
+
+               // Namespaces without subpages
+               $this->assertHasNotSubpages( NS_MAIN );
+
+               $wgNamespacesWithSubpages[NS_MAIN] = true;
+               $this->assertHasSubpages( NS_MAIN );
+
+               $wgNamespacesWithSubpages[NS_MAIN] = false;
+               $this->assertHasNotSubpages( NS_MAIN );
+
+               // Some namespaces with subpages
+               $this->assertHasSubpages( NS_TALK );
+               $this->assertHasSubpages( NS_USER );
+               $this->assertHasSubpages( NS_USER_TALK );
+       }
+
+       /**
+        * @covers NamespaceInfo::getContentNamespaces
+        */
+       public function testGetContentNamespaces() {
+               global $wgContentNamespaces;
+
+               $this->assertEquals(
+                       [ NS_MAIN ],
+                       $this->obj->getContentNamespaces(),
+                       '$wgContentNamespaces is an array with only NS_MAIN by default'
+               );
+
+               # test !is_array( $wgcontentNamespaces )
+               $wgContentNamespaces = '';
+               $this->assertEquals( [ NS_MAIN ], $this->obj->getContentNamespaces() );
+
+               $wgContentNamespaces = false;
+               $this->assertEquals( [ NS_MAIN ], $this->obj->getContentNamespaces() );
+
+               $wgContentNamespaces = null;
+               $this->assertEquals( [ NS_MAIN ], $this->obj->getContentNamespaces() );
+
+               $wgContentNamespaces = 5;
+               $this->assertEquals( [ NS_MAIN ], $this->obj->getContentNamespaces() );
+
+               # test $wgContentNamespaces === []
+               $wgContentNamespaces = [];
+               $this->assertEquals( [ NS_MAIN ], $this->obj->getContentNamespaces() );
+
+               # test !in_array( NS_MAIN, $wgContentNamespaces )
+               $wgContentNamespaces = [ NS_USER, NS_CATEGORY ];
+               $this->assertEquals(
+                       [ NS_MAIN, NS_USER, NS_CATEGORY ],
+                       $this->obj->getContentNamespaces(),
+                       'NS_MAIN is forced in $wgContentNamespaces even if unwanted'
+               );
+
+               # test other cases, return $wgcontentNamespaces as is
+               $wgContentNamespaces = [ NS_MAIN ];
+               $this->assertEquals(
+                       [ NS_MAIN ],
+                       $this->obj->getContentNamespaces()
+               );
+
+               $wgContentNamespaces = [ NS_MAIN, NS_USER, NS_CATEGORY ];
+               $this->assertEquals(
+                       [ NS_MAIN, NS_USER, NS_CATEGORY ],
+                       $this->obj->getContentNamespaces()
+               );
+       }
+
+       /**
+        * @covers NamespaceInfo::getSubjectNamespaces
+        */
+       public function testGetSubjectNamespaces() {
+               $subjectsNS = $this->obj->getSubjectNamespaces();
+               $this->assertContains( NS_MAIN, $subjectsNS,
+                       "Talk namespaces should have NS_MAIN" );
+               $this->assertNotContains( NS_TALK, $subjectsNS,
+                       "Talk namespaces should have NS_TALK" );
+
+               $this->assertNotContains( NS_MEDIA, $subjectsNS,
+                       "Talk namespaces should not have NS_MEDIA" );
+               $this->assertNotContains( NS_SPECIAL, $subjectsNS,
+                       "Talk namespaces should not have NS_SPECIAL" );
+       }
+
+       /**
+        * @covers NamespaceInfo::getTalkNamespaces
+        */
+       public function testGetTalkNamespaces() {
+               $talkNS = $this->obj->getTalkNamespaces();
+               $this->assertContains( NS_TALK, $talkNS,
+                       "Subject namespaces should have NS_TALK" );
+               $this->assertNotContains( NS_MAIN, $talkNS,
+                       "Subject namespaces should not have NS_MAIN" );
+
+               $this->assertNotContains( NS_MEDIA, $talkNS,
+                       "Subject namespaces should not have NS_MEDIA" );
+               $this->assertNotContains( NS_SPECIAL, $talkNS,
+                       "Subject namespaces should not have NS_SPECIAL" );
+       }
+
+       private function assertIsCapitalized( $ns ) {
+               $this->assertTrue( $this->obj->isCapitalized( $ns ) );
+       }
+
+       private function assertIsNotCapitalized( $ns ) {
+               $this->assertFalse( $this->obj->isCapitalized( $ns ) );
+       }
+
+       /**
+        * Some namespaces are always capitalized per code definition
+        * in NamespaceInfo::$alwaysCapitalizedNamespaces
+        * @covers NamespaceInfo::isCapitalized
+        */
+       public function testIsCapitalizedHardcodedAssertions() {
+               // NS_MEDIA and NS_FILE are treated the same
+               $this->assertEquals(
+                       $this->obj->isCapitalized( NS_MEDIA ),
+                       $this->obj->isCapitalized( NS_FILE ),
+                       'NS_MEDIA and NS_FILE have same capitalization rendering'
+               );
+
+               // Boths are capitalized by default
+               $this->assertIsCapitalized( NS_MEDIA );
+               $this->assertIsCapitalized( NS_FILE );
+
+               // Always capitalized namespaces
+               // @see NamespaceInfo::$alwaysCapitalizedNamespaces
+               $this->assertIsCapitalized( NS_SPECIAL );
+               $this->assertIsCapitalized( NS_USER );
+               $this->assertIsCapitalized( NS_MEDIAWIKI );
+       }
+
+       /**
+        * Follows up for testIsCapitalizedHardcodedAssertions() but alter the
+        * global $wgCapitalLink setting to have extended coverage.
+        *
+        * NamespaceInfo::isCapitalized() rely on two global settings:
+        *   $wgCapitalLinkOverrides = []; by default
+        *   $wgCapitalLinks = true; by default
+        * This function test $wgCapitalLinks
+        *
+        * Global setting correctness is tested against the NS_PROJECT and
+        * NS_PROJECT_TALK namespaces since they are not hardcoded nor specials
+        * @covers NamespaceInfo::isCapitalized
+        */
+       public function testIsCapitalizedWithWgCapitalLinks() {
+               $this->assertIsCapitalized( NS_PROJECT );
+               $this->assertIsCapitalized( NS_PROJECT_TALK );
+
+               $this->setMwGlobals( 'wgCapitalLinks', false );
+
+               // hardcoded namespaces (see above function) are still capitalized:
+               $this->assertIsCapitalized( NS_SPECIAL );
+               $this->assertIsCapitalized( NS_USER );
+               $this->assertIsCapitalized( NS_MEDIAWIKI );
+
+               // setting is correctly applied
+               $this->assertIsNotCapitalized( NS_PROJECT );
+               $this->assertIsNotCapitalized( NS_PROJECT_TALK );
+       }
+
+       /**
+        * Counter part for NamespaceInfo::testIsCapitalizedWithWgCapitalLinks() now
+        * testing the $wgCapitalLinkOverrides global.
+        *
+        * @todo split groups of assertions in autonomous testing functions
+        * @covers NamespaceInfo::isCapitalized
+        */
+       public function testIsCapitalizedWithWgCapitalLinkOverrides() {
+               global $wgCapitalLinkOverrides;
+
+               // Test default settings
+               $this->assertIsCapitalized( NS_PROJECT );
+               $this->assertIsCapitalized( NS_PROJECT_TALK );
+
+               // hardcoded namespaces (see above function) are capitalized:
+               $this->assertIsCapitalized( NS_SPECIAL );
+               $this->assertIsCapitalized( NS_USER );
+               $this->assertIsCapitalized( NS_MEDIAWIKI );
+
+               // Hardcoded namespaces remains capitalized
+               $wgCapitalLinkOverrides[NS_SPECIAL] = false;
+               $wgCapitalLinkOverrides[NS_USER] = false;
+               $wgCapitalLinkOverrides[NS_MEDIAWIKI] = false;
+
+               $this->assertIsCapitalized( NS_SPECIAL );
+               $this->assertIsCapitalized( NS_USER );
+               $this->assertIsCapitalized( NS_MEDIAWIKI );
+
+               $wgCapitalLinkOverrides[NS_PROJECT] = false;
+               $this->assertIsNotCapitalized( NS_PROJECT );
+
+               $wgCapitalLinkOverrides[NS_PROJECT] = true;
+               $this->assertIsCapitalized( NS_PROJECT );
+
+               unset( $wgCapitalLinkOverrides[NS_PROJECT] );
+               $this->assertIsCapitalized( NS_PROJECT );
+       }
+
+       /**
+        * @covers NamespaceInfo::hasGenderDistinction
+        */
+       public function testHasGenderDistinction() {
+               // Namespaces with gender distinctions
+               $this->assertTrue( $this->obj->hasGenderDistinction( NS_USER ) );
+               $this->assertTrue( $this->obj->hasGenderDistinction( NS_USER_TALK ) );
+
+               // Other ones, "genderless"
+               $this->assertFalse( $this->obj->hasGenderDistinction( NS_MEDIA ) );
+               $this->assertFalse( $this->obj->hasGenderDistinction( NS_SPECIAL ) );
+               $this->assertFalse( $this->obj->hasGenderDistinction( NS_MAIN ) );
+               $this->assertFalse( $this->obj->hasGenderDistinction( NS_TALK ) );
+       }
+
+       /**
+        * @covers NamespaceInfo::isNonincludable
+        */
+       public function testIsNonincludable() {
+               global $wgNonincludableNamespaces;
+
+               $wgNonincludableNamespaces = [ NS_USER ];
+
+               $this->assertTrue( $this->obj->isNonincludable( NS_USER ) );
+               $this->assertFalse( $this->obj->isNonincludable( NS_TEMPLATE ) );
+       }
+
+       private function assertSameSubject( $ns1, $ns2, $msg = '' ) {
+               $this->assertTrue( $this->obj->subjectEquals( $ns1, $ns2 ), $msg );
+       }
+
+       private function assertDifferentSubject( $ns1, $ns2, $msg = '' ) {
+               $this->assertFalse( $this->obj->subjectEquals( $ns1, $ns2 ), $msg );
+       }
+
+       public function provideGetCategoryLinkType() {
+               return [
+                       [ NS_MAIN, 'page' ],
+                       [ NS_TALK, 'page' ],
+                       [ NS_USER, 'page' ],
+                       [ NS_USER_TALK, 'page' ],
+
+                       [ NS_FILE, 'file' ],
+                       [ NS_FILE_TALK, 'page' ],
+
+                       [ NS_CATEGORY, 'subcat' ],
+                       [ NS_CATEGORY_TALK, 'page' ],
+
+                       [ 100, 'page' ],
+                       [ 101, 'page' ],
+               ];
+       }
+
+       /**
+        * @dataProvider provideGetCategoryLinkType
+        * @covers NamespaceInfo::getCategoryLinkType
+        *
+        * @param int $index
+        * @param string $expected
+        */
+       public function testGetCategoryLinkType( $index, $expected ) {
+               $actual = $this->obj->getCategoryLinkType( $index );
+               $this->assertSame( $expected, $actual, "NS $index" );
+       }
+}
index 5b4c6ef..ad33f6e 100644 (file)
@@ -238,7 +238,8 @@ class DumpAsserter {
 
                $this->assertNodeStart( "contributor" );
                $this->skipWhitespace();
-               $this->assertTextNode( "ip", false );
+               $this->assertTextNode( "username", false );
+               $this->assertTextNode( "id", false );
                $this->assertNodeEnd( "contributor" );
                $this->skipWhitespace();
 
index 26c9b92..7647915 100644 (file)
@@ -68,7 +68,7 @@ abstract class DumpTestCase extends MediaWikiLangTestCase {
        ) {
                $status = $page->doEditContent(
                        ContentHandler::makeContent( $text, $page->getTitle(), $model ),
-                       $summary
+                       $summary, 0, false, $this->getTestUser()->getUser()
                );
 
                if ( $status->isGood() ) {
index 0d4bc56..e059834 100644 (file)
@@ -561,6 +561,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase {
 
                $content = $header;
                $iterations = intval( $iterations );
+               $username = $this->getTestUser()->getUser()->getName();
+               $userid = $this->getTestUser()->getUser()->getId();
                for ( $i = 0; $i < $iterations; $i++ ) {
                        $page1 = '  <page>
     <title>BackupDumperTestP1</title>
@@ -570,7 +572,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase {
       <id>' . ( $this->revId1_1 + $i * self::$numOfRevs ) . '</id>
       <timestamp>2012-04-01T16:46:05Z</timestamp>
       <contributor>
-        <ip>127.0.0.1</ip>
+        <username>' . $username . '</username>
+        <id>' . $userid . '</id>
       </contributor>
       <comment>BackupDumperTestP1Summary1</comment>
       <model>wikitext</model>
@@ -588,7 +591,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase {
       <id>' . ( $this->revId2_1 + $i * self::$numOfRevs ) . '</id>
       <timestamp>2012-04-01T16:46:05Z</timestamp>
       <contributor>
-        <ip>127.0.0.1</ip>
+        <username>' . $username . '</username>
+        <id>' . $userid . '</id>
       </contributor>
       <comment>BackupDumperTestP2Summary1</comment>
       <model>wikitext</model>
@@ -601,7 +605,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase {
       <parentid>' . ( $this->revId2_1 + $i * self::$numOfRevs ) . '</parentid>
       <timestamp>2012-04-01T16:46:05Z</timestamp>
       <contributor>
-        <ip>127.0.0.1</ip>
+        <username>' . $username . '</username>
+        <id>' . $userid . '</id>
       </contributor>
       <comment>BackupDumperTestP2Summary2</comment>
       <model>wikitext</model>
@@ -614,7 +619,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase {
       <parentid>' . ( $this->revId2_2 + $i * self::$numOfRevs ) . '</parentid>
       <timestamp>2012-04-01T16:46:05Z</timestamp>
       <contributor>
-        <ip>127.0.0.1</ip>
+        <username>' . $username . '</username>
+        <id>' . $userid . '</id>
       </contributor>
       <comment>BackupDumperTestP2Summary3</comment>
       <model>wikitext</model>
@@ -627,7 +633,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase {
       <parentid>' . ( $this->revId2_3 + $i * self::$numOfRevs ) . '</parentid>
       <timestamp>2012-04-01T16:46:05Z</timestamp>
       <contributor>
-        <ip>127.0.0.1</ip>
+        <username>' . $username . '</username>
+        <id>' . $userid . '</id>
       </contributor>
       <comment>BackupDumperTestP2Summary4 extra</comment>
       <model>wikitext</model>
@@ -647,7 +654,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase {
       <id>' . ( $this->revId4_1 + $i * self::$numOfRevs ) . '</id>
       <timestamp>2012-04-01T16:46:05Z</timestamp>
       <contributor>
-        <ip>127.0.0.1</ip>
+        <username>' . $username . '</username>
+        <id>' . $userid . '</id>
       </contributor>
       <comment>Talk BackupDumperTestP1 Summary1</comment>
       <model>BackupTextPassTestModel</model>
index e17c78d..8b3427f 100644 (file)
                assert.deepEqual(
                        [ {
                                topic: 'resourceloader.exception',
-                               error: 'Unknown dependency: test.load.unreg',
+                               error: 'Unknown module: test.load.unreg',
                                source: 'resolve'
                        } ],
                        capture
                assert.deepEqual(
                        [ {
                                topic: 'resourceloader.exception',
-                               error: 'Unknown dependency: test.load.missingdep2',
+                               error: 'Unknown module: test.load.missingdep2',
                                source: 'resolve'
                        } ],
                        capture
index 805b793..648e52f 100644 (file)
@@ -50,7 +50,9 @@ describe( 'Rollback with confirmation', function () {
 
        it( 'should offer a way to cancel rollbacks', function () {
                HistoryPage.rollback.click();
+
                browser.pause( 300 );
+
                HistoryPage.rollbackConfirmableNo.click();
 
                browser.pause( 500 );
@@ -60,6 +62,9 @@ describe( 'Rollback with confirmation', function () {
 
        it( 'should perform rollbacks after confirming intention', function () {
                HistoryPage.rollback.click();
+
+               browser.pause( 300 );
+
                HistoryPage.rollbackConfirmableYes.click();
 
                // waitUntil indirectly asserts that the content we are looking for is present