From: jenkins-bot Date: Fri, 12 Apr 2019 15:17:23 +0000 (+0000) Subject: Merge "Fix order of @var parameter in PHP" X-Git-Tag: 1.34.0-rc.0~2033 X-Git-Url: https://git.cyclocoop.org/%242?a=commitdiff_plain;h=6497541c9c089966ce7d3b0e0abd6b68192d30d8;hp=14d9e80ba42c471f1c9e92c5cb6808b4723b5945;p=lhc%2Fweb%2Fwiklou.git Merge "Fix order of @var parameter in PHP" --- diff --git a/.fresnel.yml b/.fresnel.yml index 2f71e4ba6d..e85de79223 100644 --- a/.fresnel.yml +++ b/.fresnel.yml @@ -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: diff --git a/HISTORY b/HISTORY index a38e215771..426eb17b90 100644 --- a/HISTORY +++ b/HISTORY @@ -1,4 +1,4 @@ -Change notes from older releases. For current info see RELEASE-NOTES-1.33. +Change notes from older releases. For current info see RELEASE-NOTES-1.34. = MediaWiki 1.32 = diff --git a/RELEASE-NOTES-1.33 b/RELEASE-NOTES-1.33 index 419560d050..db5fea0c82 100644 --- a/RELEASE-NOTES-1.33 +++ b/RELEASE-NOTES-1.33 @@ -4,8 +4,8 @@ THIS IS NOT A RELEASE YET -MediaWiki 1.33 is an alpha-quality branch and is not recommended for use in -production. +MediaWiki 1.33 is a pre-release testing branch, and is not recommended for use +in production. == Upgrading notes for 1.33 == 1.33 has several database changes since 1.32, and will not work without schema @@ -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,8 @@ 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. +* ReadOnlyMode::clearCache() and ConfiguredReadOnlyMode::clearCache() have been + removed. Use MediaWikiTestCase::overrideMwServices() instead. === Deprecations in 1.33 === * The configuration option $wgUseESI has been deprecated, and is expected @@ -421,6 +429,8 @@ 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. +* ResourceLoaderContext::expandModuleNames has been deprecated. + Use ResourceLoader::expandModuleNames instead. === Other changes in 1.33 === * (T201747) Html::openElement() warns if given an element name with a space diff --git a/RELEASE-NOTES-1.34 b/RELEASE-NOTES-1.34 new file mode 100644 index 0000000000..ba4ac51a0a --- /dev/null +++ b/RELEASE-NOTES-1.34 @@ -0,0 +1,132 @@ += MediaWiki 1.34 = + +== MediaWiki 1.34.0-PRERELEASE == + +THIS IS NOT A RELEASE YET + +MediaWiki 1.34 is an alpha-quality development branch, and is not recommended +for use in production. + +== Upgrading notes for 1.34 == +1.34 has several database changes since 1.33, and will not work without schema +updates. Note that due to changes to some very large tables like the revision +table, the schema update may take quite long (minutes on a medium sized site, +many hours on a large site). + +Don't forget to always back up your database before upgrading! + +See the file UPGRADE for more detailed upgrade instructions, including +important information when upgrading from versions prior to 1.11. + +Some specific notes for MediaWiki 1.34 upgrades are below: + +* … + +For notes on 1.33.x and older releases, see HISTORY. + +=== Configuration changes for system administrators in 1.34 === +==== New configuration ==== +* … + +==== Changed configuration ==== +* … + +==== Removed configuration ==== +* … + +=== New user-facing features in 1.34 === +* … + +=== New developer features in 1.34 === +* … + +=== External library changes in 1.34 === +==== New external libraries ==== +* … + +==== Changed external libraries ==== +* Updated Mustache from 1.0.0 to v3.0.1. +* … + +==== Removed external libraries ==== +* … + +=== Bug fixes in 1.34 === +* … + +=== Action API changes in 1.34 === +* … + +=== Action API internal changes in 1.34 === +* … + +=== Languages updated in 1.34 === +MediaWiki supports over 350 languages. Many localisations are updated regularly. +Below only new and removed languages are listed, as well as changes to languages +because of Phabricator reports. + +* … + +=== Breaking changes in 1.34 === +* Preferences class, deprecated in 1.31, has been removed. +* The following parts of code, deprecated in 1.32, were removed in favor of + built-in PHP functions: + * CryptRand class + * CryptRand service + * Functions of the MWCryptRand class: singleton(), wasStrong() and generate(). +* Language::setCode, deprecated in 1.32, was removed. Use Language::factory to + create a new Language object with a different language code. +* MWNamespace::clearCaches() has been removed. So has the $rebuild parameter + to MWNamespace::getCanonicalNamespaces(), which was deprecated since 1.31. + Instead, reset services, such as by calling $this->overrideMwServices() (if + your test extends MediaWikiTestCase). Services will generally not pick up + configuration changes from after they were created, so you must reset + services after any configuration change. Even if your code works now, it is + likely to break in future versions as more code is moved to services. +* The ill-defined "DatabaseOraclePostInit" hook has been removed. + +=== Deprecations in 1.34 === +* The MWNamespace class is deprecated. Use MediaWikiServices::getNamespaceInfo. +* … + +=== Other changes in 1.34 === +* … + +== Compatibility == +MediaWiki 1.34 requires PHP 7.0.13 or later. Although HHVM 3.18.5 or later is +supported, it is generally advised to use PHP 7.0.13 or later for long term +support. + +MySQL/MariaDB is the recommended DBMS. PostgreSQL or SQLite can also be used, +but support for them is somewhat less mature. There is experimental support for +Oracle and Microsoft SQL Server. + +The supported versions are: + +* MySQL 5.5.8 or later +* PostgreSQL 9.2 or later +* SQLite 3.8.0 or later +* Oracle 9.0.1 or later +* Microsoft SQL Server 2005 (9.00.1399) + +== Online documentation == +Documentation for both end-users and site administrators is available on +MediaWiki.org, and is covered under the GNU Free Documentation License (except +for pages that explicitly state that their contents are in the public domain): + + https://www.mediawiki.org/wiki/Special:MyLanguage/Documentation + +== Mailing list == +A mailing list is available for MediaWiki user support and discussion: + + https://lists.wikimedia.org/mailman/listinfo/mediawiki-l + +A low-traffic announcements-only list is also available: + + https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce + +It's highly recommended that you sign up for one of these lists if you're +going to run a public MediaWiki, so you can be notified of security fixes. + +== IRC help == +There's usually someone online in #mediawiki on irc.freenode.net. diff --git a/autoload.php b/autoload.php index b22aeab527..a74a0b8b34 100644 --- a/autoload.php +++ b/autoload.php @@ -327,7 +327,6 @@ $wgAutoloadLocalClasses = [ 'CreditsAction' => __DIR__ . '/includes/actions/CreditsAction.php', 'CrhConverter' => __DIR__ . '/languages/classes/LanguageCrh.php', 'CryptHKDF' => __DIR__ . '/includes/libs/CryptHKDF.php', - 'CryptRand' => __DIR__ . '/includes/libs/CryptRand.php', 'CssContent' => __DIR__ . '/includes/content/CssContent.php', 'CssContentHandler' => __DIR__ . '/includes/content/CssContentHandler.php', 'CsvStatsOutput' => __DIR__ . '/maintenance/language/StatOutputs.php', @@ -364,6 +363,7 @@ $wgAutoloadLocalClasses = [ 'DatabaseUpdater' => __DIR__ . '/includes/installer/DatabaseUpdater.php', 'DateFormats' => __DIR__ . '/maintenance/language/date-formats.php', 'DateFormatter' => __DIR__ . '/includes/parser/DateFormatter.php', + 'DateFormatterFactory' => __DIR__ . '/includes/parser/DateFormatterFactory.php', 'DeadendPagesPage' => __DIR__ . '/includes/specials/SpecialDeadendpages.php', 'DeduplicateArchiveRevId' => __DIR__ . '/maintenance/deduplicateArchiveRevId.php', 'DeferrableCallback' => __DIR__ . '/includes/deferred/DeferrableCallback.php', @@ -565,6 +565,7 @@ $wgAutoloadLocalClasses = [ 'GenerateNormalizerDataMl' => __DIR__ . '/maintenance/language/generateNormalizerDataMl.php', 'GenerateSitemap' => __DIR__ . '/maintenance/generateSitemap.php', 'GenericArrayObject' => __DIR__ . '/includes/libs/GenericArrayObject.php', + 'GenericParameterJob' => __DIR__ . '/includes/jobqueue/GenericParameterJob.php', 'GetConfiguration' => __DIR__ . '/maintenance/getConfiguration.php', 'GetLagTimes' => __DIR__ . '/maintenance/getLagTimes.php', 'GetReplicaServer' => __DIR__ . '/maintenance/getReplicaServer.php', @@ -1015,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', @@ -1132,7 +1134,6 @@ $wgAutoloadLocalClasses = [ 'PostgreSqlLockManager' => __DIR__ . '/includes/libs/lockmanager/PostgreSqlLockManager.php', 'PostgresInstaller' => __DIR__ . '/includes/installer/PostgresInstaller.php', 'PostgresUpdater' => __DIR__ . '/includes/installer/PostgresUpdater.php', - 'Preferences' => __DIR__ . '/includes/Preferences.php', 'PreferencesForm' => __DIR__ . '/includes/specials/forms/PreferencesFormLegacy.php', 'PreferencesFormLegacy' => __DIR__ . '/includes/specials/forms/PreferencesFormLegacy.php', 'PreferencesFormOOUI' => __DIR__ . '/includes/specials/forms/PreferencesFormOOUI.php', @@ -1288,6 +1289,7 @@ $wgAutoloadLocalClasses = [ 'RowUpdateGenerator' => __DIR__ . '/includes/utils/RowUpdateGenerator.php', 'RunBatchedQuery' => __DIR__ . '/maintenance/runBatchedQuery.php', 'RunJobs' => __DIR__ . '/maintenance/runJobs.php', + 'RunnableJob' => __DIR__ . '/includes/jobqueue/RunnableJob.php', 'SVGMetadataExtractor' => __DIR__ . '/includes/media/SVGMetadataExtractor.php', 'SVGReader' => __DIR__ . '/includes/media/SVGMetadataExtractor.php', 'SamplingStatsdClient' => __DIR__ . '/includes/libs/stats/SamplingStatsdClient.php', diff --git a/docs/hooks.txt b/docs/hooks.txt index 21e535c95b..b2f1e816b5 100644 --- a/docs/hooks.txt +++ b/docs/hooks.txt @@ -1193,9 +1193,6 @@ a custom editor, e.g. for a special namespace, etc. $article: Article being edited $user: User performing the edit -'DatabaseOraclePostInit': Called after initialising an Oracle database -$db: the DatabaseOracle object - 'DeletedContribsPager::reallyDoQuery': Called before really executing the query for Special:DeletedContributions Similar to ContribsPager::reallyDoQuery @@ -2333,7 +2330,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) diff --git a/includes/AjaxResponse.php b/includes/AjaxResponse.php index 8f4ed40489..5f889adb86 100644 --- a/includes/AjaxResponse.php +++ b/includes/AjaxResponse.php @@ -162,7 +162,7 @@ class AjaxResponse { // For back-compat, it is supported that mResponseCode be a string like " 200 OK" // (with leading space and the status message after). Cast response code to an integer // to take advantage of PHP's conversion rules which will turn " 200 OK" into 200. - // https://secure.php.net/manual/en/language.types.string.php#language.types.string.conversion + // https://www.php.net/manual/en/language.types.string.php#language.types.string.conversion $n = intval( trim( $this->mResponseCode ) ); HttpStatus::header( $n ); } diff --git a/includes/ConfiguredReadOnlyMode.php b/includes/ConfiguredReadOnlyMode.php index 17c28ec388..7df2aed281 100644 --- a/includes/ConfiguredReadOnlyMode.php +++ b/includes/ConfiguredReadOnlyMode.php @@ -63,11 +63,4 @@ class ConfiguredReadOnlyMode { public function setReason( $msg ) { $this->overrideReason = $msg; } - - /** - * Clear the cache of the read only file - */ - public function clearCache() { - $this->fileReason = null; - } } diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index cedba704e2..828af495a4 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -72,7 +72,7 @@ $wgConfigRegistry = [ * MediaWiki version number * @since 1.2 */ -$wgVersion = '1.33.0-alpha'; +$wgVersion = '1.34.0-alpha'; /** * Name of the site. It must be changed in LocalSettings.php @@ -782,7 +782,7 @@ $wgLockManagers = []; /** * Show Exif data, on by default if available. - * Requires PHP's Exif extension: https://secure.php.net/manual/en/ref.exif.php + * Requires PHP's Exif extension: https://www.php.net/manual/en/ref.exif.php * * @note FOR WINDOWS USERS: * To enable Exif functions, add the following line to the "Windows @@ -2100,7 +2100,7 @@ $wgDBerrorLog = false; * Defaults to the wiki timezone ($wgLocaltimezone). * * A list of usable timezones can found at: - * https://secure.php.net/manual/en/timezones.php + * https://www.php.net/manual/en/timezones.php * * @par Examples: * @code @@ -3170,7 +3170,7 @@ $wgForceUIMsgAsContentMsg = []; * timezone-nameinlowercase like timezone-utc. * * A list of usable timezones can found at: - * https://secure.php.net/manual/en/timezones.php + * https://www.php.net/manual/en/timezones.php * * @par Examples: * @code @@ -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 @@ -7641,7 +7640,7 @@ $wgCategoryPagingLimit = 200; * all languages in a mediocre way. However, it is better than "uppercase". * * To use the uca-default collation, you must have PHP's intl extension - * installed. See https://secure.php.net/manual/en/intl.setup.php . The details of the + * installed. See https://www.php.net/manual/en/intl.setup.php . The details of the * resulting collation will depend on the version of ICU installed on the * server. * @@ -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 diff --git a/includes/FormOptions.php b/includes/FormOptions.php index 53ddac9b19..a6e01cc0e8 100644 --- a/includes/FormOptions.php +++ b/includes/FormOptions.php @@ -381,7 +381,7 @@ class FormOptions implements ArrayAccess { /** @name ArrayAccess functions * These functions implement the ArrayAccess PHP interface. - * @see https://secure.php.net/manual/en/class.arrayaccess.php + * @see https://www.php.net/manual/en/class.arrayaccess.php */ /* @{ */ /** diff --git a/includes/GlobalFunctions.php b/includes/GlobalFunctions.php index cdbc27a9e9..b6a1470ee9 100644 --- a/includes/GlobalFunctions.php +++ b/includes/GlobalFunctions.php @@ -2050,7 +2050,7 @@ function wfMkdirParents( $dir, $mode = null, $caller = null ) { */ function wfRecursiveRemoveDir( $dir ) { wfDebug( __FUNCTION__ . "( $dir )\n" ); - // taken from https://secure.php.net/manual/en/function.rmdir.php#98622 + // taken from https://www.php.net/manual/en/function.rmdir.php#98622 if ( is_dir( $dir ) ) { $objects = scandir( $dir ); foreach ( $objects as $object ) { diff --git a/includes/MWNamespace.php b/includes/MWNamespace.php index b40da00360..1529473a92 100644 --- a/includes/MWNamespace.php +++ b/includes/MWNamespace.php @@ -22,64 +22,9 @@ 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.34, 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 ); } } diff --git a/includes/MagicWord.php b/includes/MagicWord.php index 4420d1d327..3c77234336 100644 --- a/includes/MagicWord.php +++ b/includes/MagicWord.php @@ -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(); } diff --git a/includes/MediaWikiServices.php b/includes/MediaWikiServices.php index 6bf5d1d408..655946f51d 100644 --- a/includes/MediaWikiServices.php +++ b/includes/MediaWikiServices.php @@ -6,7 +6,7 @@ use CommentStore; use Config; use ConfigFactory; use CryptHKDF; -use CryptRand; +use DateFormatterFactory; use EventRelayerGroup; use GenderCache; use GlobalVarConfig; @@ -39,6 +39,7 @@ use MediaWiki\Linker\LinkRenderer; use MediaWiki\Linker\LinkRendererFactory; use MWException; use MimeAnalyzer; +use NamespaceInfo; use ObjectCache; use Parser; use ParserCache; @@ -516,13 +517,11 @@ class MediaWikiServices extends ServiceContainer { } /** - * @since 1.28 - * @deprecated since 1.32, use random_bytes()/random_int() - * @return CryptRand + * @since 1.33 + * @return DateFormatterFactory */ - public function getCryptRand() { - wfDeprecated( __METHOD__, '1.32' ); - return $this->getService( 'CryptRand' ); + public function getDateFormatterFactory() { + return $this->getService( 'DateFormatterFactory' ); } /** @@ -667,6 +666,14 @@ class MediaWikiServices extends ServiceContainer { return $this->getService( 'MimeAnalyzer' ); } + /** + * @since 1.34 + * @return NamespaceInfo + */ + public function getNamespaceInfo() : NamespaceInfo { + return $this->getService( 'NamespaceInfo' ); + } + /** * @since 1.32 * @return NameTableStoreFactory @@ -676,6 +683,7 @@ class MediaWikiServices extends ServiceContainer { } /** + * @since 1.32 * @return OldRevisionImporter */ public function getOldRevisionImporter() { diff --git a/includes/MovePage.php b/includes/MovePage.php index 2edd669f13..24178acdad 100644 --- a/includes/MovePage.php +++ b/includes/MovePage.php @@ -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; diff --git a/includes/OutputPage.php b/includes/OutputPage.php index cb3f1ad203..1da8ac80bc 100644 --- a/includes/OutputPage.php +++ b/includes/OutputPage.php @@ -2678,10 +2678,6 @@ class OutputPage extends ContextSource { $response->header( 'Content-language: ' . MediaWikiServices::getInstance()->getContentLanguage()->getHtmlCode() ); - if ( !$this->mArticleBodyOnly ) { - $sk = $this->getSkin(); - } - $linkHeader = $this->getLinkHeader(); if ( $linkHeader ) { $response->header( $linkHeader ); @@ -3223,7 +3219,10 @@ class OutputPage extends ContextSource { // Use an IE conditional comment to serve the script only to old IE $pieces[] = '
\n# Загалоўкі, якія будзе ігнараваць рухавік пошуку.\n# Змены набудуць моц па наступным індэксаванні старонкі.\n# Вы можаце змусіць пераіндэксаванне старонкі, зрабіўшы пустое рэдагаванне.\n# Сінтаксіс наступны:\n#   * Усё ад сімвала \"#\" да канца радка - каментарый.\n#   * Кожны непусты радок - дакладны загаловак, які трэба ігнараваць, з рэгістрам і інш.\nКрыніцы\nСпасылкі\nГл. таксама\n #
", "searchbutton": "Знайсці", @@ -394,6 +396,7 @@ "ns-specialprotected": "Не дазволена правіць старонкі ў прасторы назваў {{ns:special}}.", "titleprotected": "Назва засцерагаецца ад стварэння; ахова пастаўлена ўдзельнікам: [[User:$1|$1]].\nТлумачэнне пастаноўкі пад ахову: $2.", "filereadonlyerror": "Немагчыма змяніць файл \"$1\", таму што файлавае сховішча \"$2\" зараз у рэжыме \"толькі для чытання\".\n\nСістэмны адміністратар, які абмежаваў доступ, патлумачыў гэта так: \"$3\".", + "invalidtitle": "Няслушная назва", "invalidtitle-knownnamespace": "Недапушчальны загаловак з прасторай імёнаў \"$2\" і тэкстам \"$3\"", "invalidtitle-unknownnamespace": "Недапушчальны загаловак з невядомым лікам прасторы імён $1 і тэкстам \"$2\"", "exception-nologin": "Вы не ўвайшлі ў сістэму", @@ -571,7 +574,8 @@ "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": "Запоўніце гэту форму, каб атрымаць часовы пароль па эл.пошце.", @@ -816,7 +820,7 @@ "histfirst": "найстарэйшыя", "histlast": "найноўшыя", "historysize": "({{PLURAL:$1|1 байт|$1 байты|$1 байтаў}})", - "historyempty": "(пуста)", + "historyempty": "пуста", "history-feed-title": "Гісторыя версій", "history-feed-description": "Гісторыя версій гэтай старонкі", "history-feed-item-nocomment": "$1 на $2", @@ -938,6 +942,7 @@ "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": "Знойдзена ў тэкстах", @@ -1149,12 +1154,14 @@ "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": "Чытаць старонкі", @@ -1175,7 +1182,7 @@ "right-reupload-own": "Запісваць паўзверх існуючага файла, які ўкладвалі самі", "right-reupload-shared": "Перамагаць файлы з агульнага сховішча тутэйшымі файламі", "right-upload_by_url": "Укладваць файлы з сеціўнага адраса (URL)", - "right-purge": "Чысціць кэш пляцоўкі для старонкі без пацверджання", + "right-purge": "Ачыстка кэшу пляцоўкі для старонкі", "right-autoconfirmed": "Не падпарадкоўвацца абмежаванням хуткасці, накладзеным на IP", "right-bot": "Лічыцца аўтаматычным працэсам", "right-nominornewtalk": "Не паведамляць пра новыя паведамленні ў адказ на дробныя праўкі размоўных старонак", @@ -1246,7 +1253,7 @@ "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": "Правіць ваш спіс назірання", @@ -1347,6 +1354,7 @@ "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", @@ -1464,9 +1472,9 @@ "rcfilters-watchlist-markseen-button": "Пазначыць усе змены як прагледжаныя", "rcfilters-watchlist-edit-watchlist-button": "Рэдагаваць Ваш спіс назіраных старонак", "rcfilters-watchlist-showupdated": "Змены старонак, якія Вы не наведвалі з таго часу, як яны адбыліся, выдзелены тлустым і пазначаны маркерам.", - "rcfilters-preference-label": "Схаваць палепшаную версію «Апошніх змен»", + "rcfilters-preference-label": "Выкарыстоўваць інтэрфейс без JavaScript", "rcfilters-preference-help": "Адкатвае рэдызайн інтэрфейсу 2017 года і ўсе інструменты, дададзеныя з тых часоў.", - "rcfilters-watchlist-preference-label": "Схаваць палепшаную версію спіса назірання", + "rcfilters-watchlist-preference-label": "Выкарыстоўваць інтэрфейс без JavaScript", "rcfilters-watchlist-preference-help": "Адкатвае рэдызайн інтэрфейсу 2017 года і ўсе інструменты, дададзеныя з тых часоў.", "rcnotefrom": "Ніжэй {{PLURAL:$5|паказана змяненне|паказаны змены}} з $3, $4 (не больш за $1).", "rclistfrom": "Паказаць змены з $3 $2", @@ -1509,7 +1517,7 @@ "recentchangeslinked-feed": "Звязаныя праўкі", "recentchangeslinked-toolbox": "Звязаныя праўкі", "recentchangeslinked-title": "Змяненні, якія датычаць \"$1\"", - "recentchangeslinked-summary": "Увядзіце імя старонкі, каб убачыць змяненні на старонках, якія спасылаюцца на дадзеную старонку ці на якія яна спасылаецца. (Каб убачыць складнікі катэгорыі, увядзіце Катэгорыя:Назва катэгорыі). Старонкі, якія ўваходзяць у [[Special:Watchlist|Ваш спіс назірання]], выдзелены тлустым шрыфтам.", + "recentchangeslinked-summary": "Увядзіце імя старонкі, каб убачыць змяненні на старонках, на якія яна спасылаецца або якія спасылаюцца на яе (каб убачыць складнікі катэгорыі, увядзіце {{ns:category}}:Назва катэгорыі). Змены старонак з [[Special:Watchlist|Вашага спісу назірання]] выдзелены тлустым шрыфтам.", "recentchangeslinked-page": "Назва старонкі:", "recentchangeslinked-to": "Паказваць, замест гэтага, змяненні на старонках, што спасылаюцца сюды", "recentchanges-page-added-to-category": "[[:$1]] дададзена ў катэгорыю", @@ -1739,7 +1747,7 @@ "listfiles_size": "Памер у байтах", "listfiles_description": "Апісанне", "listfiles_count": "Версіі", - "listfiles-show-all": "Уключыць старыя версіі відарысаў", + "listfiles-show-all": "Уключыць старыя версіі файлаў", "listfiles-latestversion": "Актуальная версія", "listfiles-latestversion-yes": "Так", "listfiles-latestversion-no": "Не", @@ -1759,7 +1767,7 @@ "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|больш спасылак]] на гэты файл.", @@ -1833,20 +1841,22 @@ "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Закрэсленыя складнікі ўжо былі папраўленыя.", "double-redirect-fixed-move": "Назва [[$1]] была перанесена.\nПасля аўтаматычнага абнаўлення зараз яна перасылае да [[$2]]", - "double-redirect-fixed-maintenance": "Аўтаматычнае выпраўленне падвойнай перасылкі з [[$1]] на [[$2]] цягам тэхнічнага абслугоўвання.", + "double-redirect-fixed-maintenance": "Аўтаматычнае выпраўленне падвойнай перасылкі з [[$1]] на [[$2]] цягам тэхнічнага абслугоўвання", "double-redirect-fixer": "Выпраўляльнік перасылак", "brokenredirects": "Паламаныя перасылкі", "brokenredirectstext": "Гэтыя перасылкі паказваюць на старонкі, якіх няма:", @@ -1894,7 +1904,7 @@ "prefixindex": "Старонкі з назвамі на ўзор", "prefixindex-namespace": "Усе старонкі з прэфіксам (прастора назваў «{{ns:$1}}»)", "prefixindex-submit": "Паказаць", - "prefixindex-strip": "Прыбраць прэфікс у пераліку", + "prefixindex-strip": "Прыбраць прэфікс у выніках", "shortpages": "Старонкі малога аб'ёму", "longpages": "Старонкі вялікага аб'ёму", "deadendpages": "Старонкі без спасылак", @@ -1921,6 +1931,7 @@ "protectedtitles-submit": "Паказаць назвы", "listusers": "Усе ўдзельнікі", "listusers-editsonly": "Толькі ўдзельнікі, якія маюць праўкі", + "listusers-temporarygroupsonly": "Паказваць толькі ўдзельнікаў у тымчасовых групах удзельнікаў", "listusers-creationsort": "У парадку датаў стварэння", "listusers-desc": "Парадкаваць да памяншэння", "usereditcount": "$1 {{PLURAL:$1|праўка|праўкі|правак}}", @@ -2197,6 +2208,9 @@ "deleting-backlinks-warning": "Увага: [[Special:WhatLinksHere/{{FULLPAGENAME}}|Іншыя старонкі]] спасылаюцца на ці ўключаюць старонку, якую вы збіраецеся выдаліць.", "deleting-subpages-warning": "Увага: Старонка, якую Вы хочаце выдаліць, мае [[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|праўку|праўкі|правак}}", diff --git a/languages/i18n/bn.json b/languages/i18n/bn.json index 18327aa589..3fccc3c87c 100644 --- a/languages/i18n/bn.json +++ b/languages/i18n/bn.json @@ -86,6 +86,7 @@ "tog-norollbackdiff": "রোলব্যাকের পরে সংস্করণগুলির পার্থক্য না দেখানো হোক", "tog-useeditwarning": "কোনো সম্পাদনা পাতা ত্যাগের সময় পরিবর্তনগুলি সংরক্ষিত না হয়ে থাকলে আমাকে সাবধান করা হোক", "tog-prefershttps": "অ্যাকাউন্টে প্রবেশ করার সময় সবসময় নিরাপদ সংযোগ ব্যবহার করুন", + "tog-showrollbackconfirmation": "একটি রোলব্যাক লিঙ্ক ক্লিক করার সময় একটি নিশ্চিতকরণ বার্তা দেখান", "underline-always": "সব সময়", "underline-never": "কখনো নয়", "underline-default": "আবরণ বা ব্রাউজারে যেমনভাবে নির্দিষ্ট করা আছে", @@ -471,6 +472,7 @@ "badretype": "আপনার প্রবেশ করানো পাসওয়ার্ডটি মিলছে না।", "usernameinprogress": "এই ব্যবহারকারী নামের জন্য একটি অ্যাকাউন্ট তৈরি আগে থেকেই চলছে। দয়া করে অপেক্ষা করুন।", "userexists": "এই ব্যবহারকারী নামটি ইতমধ্যে ব্যবহার করা হয়েছে।\nঅনুগ্রহ করে অন্য নাম বেছে নিন।", + "createacct-normalization": "কারিগরি সীমাবদ্ধতার কারণে আপনার ব্যবহারকারীর নাম সামঞ্জস্য করে \"$2\" করা হবে।", "loginerror": "প্রবেশ করতে সমস্যা হয়েছে", "createacct-error": "অ্যাকাউন্ট তৈরিতে ত্রুটি", "createaccounterror": "অ্যাকাউন্ট তৈরি হয়নি: $1", @@ -3270,6 +3272,7 @@ "specialpages-group-developer": "ডেভলপারের সরঞ্জাম", "blankpage": "খালি পাতা", "intentionallyblankpage": "এই পাতাটি ইচ্ছা করে খালি রাখা হয়েছে", + "disabledspecialpage-disabled": "এই পৃষ্ঠাটি একজন সিস্টেম প্রশাসক দ্বারা নিষ্ক্রিয় করা হয়েছে।", "external_image_whitelist": " #এই লাইন ঠিক যেমন আছে<প্রাক> তেমন রাখুন
\n #রেগুলার এক্সপ্রেশনের টুকরা নীচে (শুধুমাত্র অংশ / / মধ্যে যে যায়) বসান\n#এইগুলি এক্সটার্নাল (hotlinked) ইমেজের URL-এর সাথে মেলানো হবে\n#যেগুলি মিলবে, সেগুলি চিত্র হিসাবে প্রদর্শিত হবে, অন্যথায় শুধুমাত্র ইমেজ লিঙ্ক প্রদর্শিত হবে\n#যে লাইনের প্রারম্ভে # আছে সেই লাইনগুলি মন্তব্যসমূহ হিসাবে ব্যবহার করা হয়\n#এটি কেস-অসংবেদী\n\n#এই রেখার উপরের regex টুকরা বসান. এই লাইন ঠিক যেমন আছে তেমন রাখুন
", "tags": "বৈধ পরিবর্তন ট্যাগ", "tag-filter": "[[Special:Tags|ট্যাগ]] ছাঁকনি:", diff --git a/languages/i18n/ca.json b/languages/i18n/ca.json index 7a05ff7ccc..99ee8dc3bb 100644 --- a/languages/i18n/ca.json +++ b/languages/i18n/ca.json @@ -2258,7 +2258,7 @@ "delete-confirm": "Supressió de la pàgina «$1»", "delete-legend": "Suprimeix", "historywarning": "Avís: 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", diff --git a/languages/i18n/ckb.json b/languages/i18n/ckb.json index f8e43e47ac..7050e30ef6 100644 --- a/languages/i18n/ckb.json +++ b/languages/i18n/ckb.json @@ -2776,6 +2776,7 @@ "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 کە پێشتر ڕەوانەکەر بوو", diff --git a/languages/i18n/cs.json b/languages/i18n/cs.json index a39d2bee72..fa11cb5d2d 100644 --- a/languages/i18n/cs.json +++ b/languages/i18n/cs.json @@ -836,7 +836,7 @@ "page_first": "první", "page_last": "poslední", "histlegend": "(teď) = rozdíly oproti nynější verzi, (předchozí) = rozdíly oproti předchozí verzi, m = malá editace", - "history-fieldset-title": "Hledat revize", + "history-fieldset-title": "Filtrovat revize", "history-show-deleted": "Pouze smazané", "histfirst": "nejstarší", "histlast": "nejnovější", @@ -2261,7 +2261,7 @@ "delete-confirm": "Smazání stránky „$1“", "delete-legend": "Smazat", "historywarning": "Varování: 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", diff --git a/languages/i18n/de.json b/languages/i18n/de.json index 9612ca7327..13c20eac37 100644 --- a/languages/i18n/de.json +++ b/languages/i18n/de.json @@ -890,7 +890,7 @@ "page_first": "Anfang", "page_last": "Ende", "histlegend": "Zur Anzeige der Änderungen einfach die zu vergleichenden Versionen auswählen und die Schaltfläche „{{int:compareselectedversions}}“ klicken.
\n* ({{int:cur}}) = Unterschied zur aktuellen Version, ({{int:last}}) = Unterschied zur vorherigen Version\n* Uhrzeit/Datum = Version zu dieser Zeit, Benutzername/IP-Adresse des Bearbeiters, {{int:minoreditletter}} = Kleine Änderung", - "history-fieldset-title": "Nach Versionen suchen", + "history-fieldset-title": "Versionen filtern", "history-show-deleted": "Nur gelöschte Versionen zeigen", "histfirst": "älteste", "histlast": "neueste", @@ -2006,7 +2006,7 @@ "unusedimages": "Verwaiste Dateien", "wantedcategories": "Gewünschte Kategorien", "wantedpages": "Gewünschte Seiten", - "wantedpages-summary": "Liste nicht vorhandener Seiten mit den meisten Links auf diese Seiten, ausschließlich solche, die nur Weiterleitungen haben. Für eine Liste nicht vorhandener Seiten mit Weiterleitungen, siehe [[{{#special:BrokenRedirects}}|die Liste defekter Weiterleitungen]].", + "wantedpages-summary": "Liste der am häufigsten verlinkten, aber nicht vorhandenen Seiten. Hiervon ausgenommen sind Seiten, die ausschließlich als Ziele defekter Weiterleitungen dienen. Siehe dafür [[{{#special:BrokenRedirects}}|die Liste defekter Weiterleitungen]].", "wantedpages-badtitle": "Ungültiger Titel im Ergebnis: $1", "wantedfiles": "Gewünschte Dateien", "wantedfiletext-cat": "Die folgenden Dateien werden verwendet, sind jedoch nicht vorhanden. Vorhandene Dateien aus fremden Repositorien können dennoch hier aufgelistet sein und werden durchgestrichen dargestellt. Zusätzlich werden Seiten, die nicht vorhandene Dateien enthalten, in die [[:$1]] eingeordnet.", @@ -2320,7 +2320,7 @@ "delete-confirm": "Löschen von „$1“", "delete-legend": "Löschen", "historywarning": "Achtung: 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", diff --git a/languages/i18n/diq.json b/languages/i18n/diq.json index 37caad5aea..10f58a4dc5 100644 --- a/languages/i18n/diq.json +++ b/languages/i18n/diq.json @@ -488,7 +488,7 @@ "acct_creation_throttle_hit": "Yew ten IP adresê şıma xebıtnayo u kewto no wiki, $2roco peyin de {{PLURAL:$1|1 hesabi|$1 hesaban}} vıraşto.\nxulasa ney kesê ke IP adresê şıma xebıtneni hini nêeşkeni ney ra zêdêr hesab akeri.", "emailauthenticated": "E-postay şıma $2 sehat $3 dı biya araşt", "emailnotauthenticated": "Adresa e-pota da şıma qebul nébiya.\nQandé céréna şımaré teba do nérışiyo.", - "noemailprefs": "Hesab biyo a.", + "noemailprefs": "Seba gurenayışê nê xısusiyetayew adresê e-posta cı kerê .", "emailconfirmlink": "E-postayê xo araşt kerê", "invalidemailaddress": "No format de nuştışê e-postayi qebul nêbeno. Yew formato meqbul de adresê e-posta bınuse ya zi veng bıverde.", "cannotchangeemail": "E-postay hesabi ena wiki sera nêvurneyêno.", @@ -611,8 +611,8 @@ "watchthis": "Na pele de seyr ke", "savearticle": "Pele qeyd ke", "savechanges": "Vurnayışan qeyd ke", - "publishpage": "Pelê neşr kerê", - "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...", @@ -692,7 +692,7 @@ "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": "
\n

\n'''{{int:bw-edittools-lead-in}}''' \nÄ ä Ë ë Ö ö Ü ü Ø ø Ð ð Ķ ķ Ļ ļ Ŕ ŕ Ś ś Ź ź Ĥ ĥ Ğ ğ Ħ ħ Ɛ ɛ Ɔ ɔ \n

", + "edittools": "", "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ê.", @@ -768,7 +768,7 @@ "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.
\nCetwel: ({{int:ferq}}) = ferqê versiyonê peyêni, ({{int:peyên}}) = ferqê versiyonê verêni, {{int:q}} = 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", @@ -892,7 +892,7 @@ "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", @@ -963,7 +963,7 @@ "prefs-misc": "ê bini", "prefs-resetpass": "Parola bıvurne", "prefs-changeemail": "E-postay bıvurne ya zi wedarne", - "prefs-setemail": "E-posta adresiyê xo saz kerê", + "prefs-setemail": "Adresê eposta cı kerê", "prefs-email": "Tercihê e-maili", "prefs-rendering": "Asayış", "saveprefs": "Qeyd ke", @@ -1002,7 +1002,7 @@ "email-blacklist-label": "Wa nê karberi mı rê mesac nêrışê:", "prefs-searchoptions": "Cı geyre", "prefs-namespaces": "Heruna nameyan", - "default": "qısur", + "default": "Keyfi", "prefs-files": "Dosyeyi", "prefs-custom-css": "CSSê xasi", "prefs-custom-json": "JSONo xısusi", @@ -1025,11 +1025,11 @@ "prefs-help-signature": "Mışewreyê ke pelanê werênayışi derê, gani be \"~~~~\" ra imza bıbê, no bahdo beno çerxê imza û wadeyê zemani.", "badsig": "Îmzayê tu raşt niyo.\nEtiketê HTMLî kontrol bike.", "badsiglength": "İmzaya şıma zaf derga.\nA gani be $1 {{PLURAL:$1|karakter|karakteran}} ra zêde mebo.", - "yourgender": "Şeklê xitabi?", - "gender-unknown": "Çımhal tı kerdê etiket, Nusner do çekuya Nerimaki cinsiyeti de şeno bıkar no", - "gender-male": "Perané wiki camérd deyne ezo vırnena", - "gender-female": "Perané wiki cıni deyne eza vırnena", - "prefs-help-gender": "Na tercih keyfiya.\nNa nustenek ercana qısan de qandé grameri karneyéna, na malumater herkes şeno bıvino .", + "yourgender": "Şıma yê xo seni tarif kenê?", + "gender-unknown": "Software şıma ra bahs kerdış dê, hendık cı dest ra biro cınsiyeti ya aleqeyın çekuy nêvaco.", + "gender-male": "Oyo pelanê wiki vurneno", + "gender-female": "Aya pelanê wiki vırnena.", + "prefs-help-gender": "Na eyara tercihi keyfiya.\nNa software seraniya ercê çekuyan de gorey cınsiyeta hol Gramer kar nêna. \nNê melumati herkesi rê asenê.", "email": "E-posta", "prefs-help-realname": "Nameyo raşt waştena şıma rê mendo.\nEka tu wazene ke nameyo raşt xo bide, ma nameyo raşt ti iştirakanê ti de mocnenê.", "prefs-help-email": "Dayışê adresa e-postey keyfiyo, labelê seba eyarê parola lazıma, wexto ke şıma naye xo vira kerê.", @@ -1084,7 +1084,7 @@ "userrights-conflict": "Heqan de karberi de dıbare vıcyayo! Kerem ke vurnayışane xo çımser ra ravyarne u tesdiq keri.", "group": "Grube:", "group-user": "Karberi", - "group-autoconfirmed": "Karberê ke otomatikmen biyê araşt", + "group-autoconfirmed": "Otomatik raşt bıyaye karberi", "group-bot": "Boti", "group-sysop": "İdarekari", "group-interface-admin": "İdarekarê namnişani", @@ -1092,14 +1092,14 @@ "group-suppress": "Pawıteri", "group-all": "(pêro)", "group-user-member": "{{GENDER:$1|karber}}", - "group-autoconfirmed-member": "{{GENDER:$1|Karberê ke otomatikmen biyê araşt}}", + "group-autoconfirmed-member": "{{GENDER:$1|Otomatik raşt bıyaye karberi}}", "group-bot-member": "{{GENDER:$1|bot}}", "group-sysop-member": "{{GENDER:$1|İdarekar}}", "group-interface-admin-member": "{{GENDER:$1|idarekarê namnişani}}", "group-bureaucrat-member": "{{GENDER:$1|buroqrat}}", "group-suppress-member": "{{GENDER:$1|Temaşekar}}", "grouppage-user": "{{ns:project}}:Karberi", - "grouppage-autoconfirmed": "{{ns:project}}:Karberê ke otomatikmen biyê araşt", + "grouppage-autoconfirmed": "{{ns:project}}:Otomatik raşt bıyaye karberi", "grouppage-bot": "{{ns:project}}:Boti", "grouppage-sysop": "{{ns:project}}:İdarekeri", "grouppage-interface-admin": "{{ns:project}}:İdarekarê namnişani", @@ -1896,7 +1896,7 @@ "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:", @@ -2056,7 +2056,7 @@ "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ı", @@ -2474,7 +2474,7 @@ "djvu_no_xml": "Qe DjVu nieşkenî XML fetch bikî", "thumbnail-temp-create": "İdare dosyay resimiya nêvırazêna", "thumbnail-dest-create": "Resimo werdiyo keyd nêbeno", - "thumbnail_invalid_params": "Parametreya thumbnailî raşt niyşê", + "thumbnail_invalid_params": "Parametreya xıraba resimanê werdiyan", "thumbnail_toobigimagearea": "Ebadê $1 ra gırd dosyeyi", "thumbnail_dest_directory": "Nieşkenî direktorê destinasyonî virazî", "thumbnail_image-type": "Tipê resimî kebul nibeno", @@ -2589,7 +2589,7 @@ "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ê", @@ -2603,6 +2603,8 @@ "tooltip-preferences-save": "Terciha qeyd ke", "tooltip-summary": "Xulasa kılmek bınuse", "interlanguage-link-title": "$1 - $2", + "group-autoconfirmed.css": "/* Ronışyaye CSS seba otomatik raşt bıyaye karberan rê tesir keno */", + "group-autoconfirmed.js": "/* No JavaScript teyna seba otomatik raşt bıyaye karberan rê do bar bo */", "anonymous": "{{PLURAL:$1|karberê|karberê}} anonimi yê keyepelê {{SITENAME}}i", "siteuser": "karberê {{SITENAME}}i $1", "anonuser": "karberê anonim o {{SITENAME}}i $1", @@ -2698,7 +2700,7 @@ "nextdiff": "Vurnayışo peyên →", "mediawarning": "'''Teme''': Na dosya de belkia kodê xırabıni estê.\nGurênayışê nae de, beno ke sistemê şıma zerar bıvêno.", "imagemaxsize": "Sinorê ebadê resımiyo ke pelanê şınasnayışê dosyeyan dero:", - "thumbsize": "Ebadê Thumbnaili", + "thumbsize": "Ebado werdı", "widthheight": "$1 - $2", "widthheightpage": "$1 × $2, $3 {{PLURAL:$3|pele|peli}}", "file-info": "ebatê dosyayi: $1, MIME tip: $2", @@ -2851,7 +2853,7 @@ "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)", @@ -2859,7 +2861,7 @@ "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", diff --git a/languages/i18n/en.json b/languages/i18n/en.json index 2a22fda3c9..e5b74feb05 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -848,7 +848,7 @@ "page_first": "first", "page_last": "last", "histlegend": "Diff selection: Mark the radio boxes of the revisions to compare and hit enter or the button at the bottom.
\nLegend: ({{int:cur}}) = difference with latest revision, ({{int:last}}) = difference with preceding revision, {{int:minoreditletter}} = minor edit.", - "history-fieldset-title": "Search for revisions", + "history-fieldset-title": "Filter revisions", "history-show-deleted": "Revision deleted only", "history_copyright": "-", "histfirst": "oldest", @@ -2366,7 +2366,7 @@ "delete-confirm": "Delete \"$1\"", "delete-legend": "Delete", "historywarning": "Warning: 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", diff --git a/languages/i18n/es.json b/languages/i18n/es.json index 72a383e9ab..5078817e7f 100644 --- a/languages/i18n/es.json +++ b/languages/i18n/es.json @@ -2581,6 +2581,7 @@ "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.", diff --git a/languages/i18n/exif/ast.json b/languages/i18n/exif/ast.json index ae49993736..d53895f518 100644 --- a/languages/i18n/exif/ast.json +++ b/languages/i18n/exif/ast.json @@ -2,7 +2,8 @@ "@metadata": { "authors": [ "Esbardu", - "Xuacu" + "Xuacu", + "Enolp" ] }, "exif-imagewidth": "Anchor", @@ -29,7 +30,7 @@ "exif-datetime": "Data y hora de cambiu del ficheru", "exif-imagedescription": "Títulu de la imaxe", "exif-make": "Fabricante de la cámara", - "exif-model": "Modelu de cámara", + "exif-model": "Modelu de la cámara", "exif-software": "Software usáu", "exif-artist": "Autor", "exif-copyright": "Titular del Copyright", @@ -63,6 +64,7 @@ "exif-lightsource": "Fonte de la lluz", "exif-flash": "Flax", "exif-focallength": "Llonxitú focal de la lente", + "exif-focallength-format": "$1 mm", "exif-subjectarea": "Área del suxetu", "exif-flashenergy": "Enerxía del flax", "exif-focalplanexresolution": "Resolución X del planu focal", @@ -89,7 +91,7 @@ "exif-gpsversionid": "Versión de la etiqueta GPS", "exif-gpslatituderef": "Llatitú Norte o Sur", "exif-gpslatitude": "Llatitú", - "exif-gpslongituderef": "Llonxitú Este o Oeste", + "exif-gpslongituderef": "Llonxitú este u oeste", "exif-gpslongitude": "Llonxitú", "exif-gpsaltituderef": "Referencia d'altitú", "exif-gpsaltitude": "Altitú", diff --git a/languages/i18n/fi.json b/languages/i18n/fi.json index ce46b93376..8f96bd39be 100644 --- a/languages/i18n/fi.json +++ b/languages/i18n/fi.json @@ -344,7 +344,7 @@ "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": "Ohjelmisto ei tunnista pyytämääsi toimintosivua.\n\nLuettelo toimintosivuista löytyy sivulta [[Special:SpecialPages|{{int:specialpages}}]].", "error": "Virhe", @@ -619,7 +619,7 @@ "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.", @@ -732,7 +732,7 @@ "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\nKoska sivustolla {{SITENAME}} on käytössä suodattamaton HTML-koodi, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi\n\nJos tämä on oikea muokkausyritys, yritä uudelleen. 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\nKoska {{GRAMMAR:inessive|{{SITENAME}}}} on käytössä suodattamaton HTML-koodi, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi\n\nJos tämä on oikea muokkausyritys, yritä uudelleen. 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", @@ -778,9 +778,9 @@ "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 alue ei ole tuettu|Seuraavat alueet eivät ole tuettuja}} täällä: $2.", - "edit-slots-cannot-remove": "{{PLURAL:$1|Seuraava alue vaaditaan eikä sitä|Seuraavat alueet vaaditaan eikä niitä}} voida poistaa: $2.", - "edit-slots-missing": "{{PLURAL:$1|Seuraava alue puuttuu|Seuraavat alueet puuttuvat}}: $2.", + "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.", @@ -789,7 +789,7 @@ "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.", @@ -1707,11 +1707,11 @@ "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.", @@ -2211,7 +2211,7 @@ "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", @@ -2255,12 +2255,12 @@ "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.", @@ -2280,7 +2280,7 @@ "delete-confirm": "Poista ”$1”", "delete-legend": "Sivun poisto", "historywarning": "Varoitus: Sivulla, jota olet poistamassa, on muokkaushistoriaa ja sitä on muokattu $1 {{PLURAL:$1|kerran|kertaa}}:", - "historyaction-submit": "Näytä", + "historyaction-submit": "Näytä muokkaushistoria", "confirmdeletetext": "Olet poistamassa sivun ja kaiken sen historian.\nVahvista, että olet aikeissa tehdä tämän ja että ymmärrät teon seuraukset ja teet poiston [[{{MediaWiki:Policy-url}}|käytäntöjen]] mukaisesti.", "actioncomplete": "Toiminto suoritettu", "actionfailed": "Toiminto epäonnistui", @@ -2297,12 +2297,13 @@ "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": "Varoitus: Sivulle, jota olet poistamassa, johtaa [[Special:WhatLinksHere/{{FULLPAGENAME}}|linkkejä muilta sivuilta]], taikka sivu on sisällytetty muuhun sivuun.", "deleting-subpages-warning": "Varoitus: Sivu jota olet poistamassa on [[Special:PrefixIndex/{{FULLPAGENAME}}/|{{PLURAL:$1|alasivu|$1 alasivua|51=yli 50 alasivua}}]].", "rollback": "palauta aiempaan versioon", + "rollback-confirmation-confirm": "Vahvista:", "rollback-confirmation-yes": "Palauta", "rollback-confirmation-no": "Peruuta", "rollbacklink": "palauta", @@ -2613,8 +2614,8 @@ "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.", @@ -3070,8 +3071,8 @@ "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]", @@ -3571,8 +3572,8 @@ "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": "Koska sivustolla {{SITENAME}} on käytössä suodattamaton HTML-koodi ja koska istunnon tiedot ovat kadonneet, esikatselu on piilotettu JavaScript-hyökkäyksien torjumiseksi.\n\nJos yritit esikatsella sivua, yritä uudestaan.\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": "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.\n\nJos olet oikealla asialla, [[Special:UserLogin|kirjaudu sisään]] ja yritä uudestaan.", + "expand_templates_preview_fail_html": "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.\n\nJos yritit esikatsella sivua, yritä uudestaan.\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": "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.\n\nJos olet oikealla asialla, [[Special:UserLogin|kirjaudu sisään]] ja yritä uudestaan.", "expand_templates_input_missing": "Sinun on annettava ainakin jotakin wikitekstiä syötteeksi.", "pagelanguage": "Sivun kielen vaihto", "pagelang-name": "Sivu", diff --git a/languages/i18n/fr.json b/languages/i18n/fr.json index 6826cf6571..e0d60229e4 100644 --- a/languages/i18n/fr.json +++ b/languages/i18n/fr.json @@ -973,7 +973,7 @@ "page_first": "première", "page_last": "dernière", "histlegend": "Sélection du diff : cochez les boutons radio des versions à comparer et appuyez sur entrée ou sur le bouton en bas.
\nLégende : ({{int:cur}}) = différence avec la dernière version, ({{int:last}}) = différence avec la version précédente, {{int:minoreditletter}} = modification mineure.", - "history-fieldset-title": "Rechercher des révisions", + "history-fieldset-title": "Filtrer les révisions", "history-show-deleted": "Révision supprimée uniquement", "histfirst": "les plus anciennes", "histlast": "les plus récentes", @@ -2409,7 +2409,7 @@ "delete-confirm": "Supprimer « $1 »", "delete-legend": "Supprimer", "historywarning": "Attention : 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é", diff --git a/languages/i18n/fy.json b/languages/i18n/fy.json index 95753fe65e..91a617fe05 100644 --- a/languages/i18n/fy.json +++ b/languages/i18n/fy.json @@ -153,7 +153,7 @@ "cancel": "Annulearje", "moredotdotdot": "Mear...", "mypage": "Myn side", - "mytalk": "Myn oerlis", + "mytalk": "Oerlis", "anontalk": "Oerlisside foar dit IP-adres", "navigation": "Navigaasje", "and": " en", @@ -277,13 +277,13 @@ "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": "Jo hawwe in ûnjildige bysûndere side opfrege.\n\nIn list fan jildige bysûndere siden stiet op [[Special:SpecialPages|{{int:specialpages}}]].", "error": "Flater", @@ -385,7 +385,7 @@ "noname": "Jo hawwe gjin jildige meidochnamme opjûn.", "loginsuccesstitle": "Oanmelden slagge.", "loginsuccess": "Jo binne no oanmeld op {{SITENAME}} as \"$1\".", - "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.", @@ -394,9 +394,9 @@ "password-name-match": "Jo wachtwurd moat oars wêze as jo meidochnamme.", "mailmypassword": "Nij wachtwurd", "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.", + "passwordremindertext": "Immen (mei it IP-adres $1) hat in nij\nwachtwurd oanfrege foar {{SITENAME}} ($4). Der is in tydlik wachtwurd foar\nmeidogger \"$2\" oanmakke, en dat is \"$3\". At dat jo\ndoel wie, meld jo dan no oan en kies in nij wachtwurd.\nJo tydlik wachtwurd ferrint nei {{PLURAL:$5|ien dei|$5 dagen}}.\n\nAt immen oars dy oanfraach dien hat, of at jo jo wachtwurd wer witte\nen it net mear feroarje wolle, dan kinne jo dit berjocht negearje en\njo âlde wachtwurd brûken bliuwe.", "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.", @@ -460,7 +460,7 @@ "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", @@ -519,7 +519,7 @@ "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", @@ -642,7 +642,7 @@ "mergehistory-into": "Bestimmingside:", "mergehistory-list": "Gearfoegbere bewurkingsskiednis", "mergehistory-merge": "De folgjende ferzjes fan [[:$1]] kinne gearfoege wurde nei [[:$2]].\nBrûk de kolom mei de karrûntsjes om allinne de ferzjes makke op en foar de oanjûne tiid gear te foegjen.\nTink derom it brûken fan de navigaasjeferwizings dy kolom op'e nij ynstelt.", - "mergehistory-go": "Besjen bewurkings dy't kombinearre wurde kinne", + "mergehistory-go": "Gearfoegbere bewurkings besjen", "mergehistory-submit": "Kombinearje ferzjes", "mergehistory-empty": "Gjin ferzjes kinne kombinearren wurde.", "mergehistory-done": "Kombinearjen slagge fan $3 {{PLURAL:$3|ferzje|ferzjes}} fan $1 no [[:$2]].", @@ -682,7 +682,7 @@ "nextn-title": "{{PLURAL:$1|Folgjend risseltaat|Folgjende $1 risseltaat}}", "shown-title": "Lit $1 {{PLURAL:$1|resultaat|resultaten}} de side sjen", "viewprevnext": "Besjoch ($1 {{int:pipe-separator}} $2) ($3)", - "searchmenu-exists": "Der is in side mei namme \"[[:$1]]\" yn dizze wiki", + "searchmenu-exists": "Der is in side mei de namme \"[[:$1]]\" op 'e wiki. {{PLURAL:$2|0=|Sjoch ek de oare sykresultaten.}}", "searchmenu-new": "Meitsje de side \"[[:$1]]\" op dizze wiki! {{PLURAL:$2|0=|Sjoch ek de side fûn mei jo sykopdracht.|Sjoch ek de sykresultaten dy't fûn binne.}}", "searchprofile-articles": "Ynhâldlike siden", "searchprofile-images": "Multymedia", @@ -706,7 +706,7 @@ "showingresults": "Totaal {{PLURAL:$1|1 resultaat|$1 resultaten}}, hjirûnder werjûn fan #$2 ôf.", "search-showingresults": "{{PLURAL:$4|Resultaat $1 fan $3|Resultaten $1 - $2 fan $3}}", "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", @@ -952,6 +952,19 @@ "recentchanges-legend-heading": "Leginda:", "recentchanges-legend-newpage": "{{int:recentchanges-label-newpage}}
(sjoch ek de [[Special:NewPages|list mei nije siden]])", "rcfilters-legend-heading": "List fan ôfkoartings:", + "rcfilters-search-placeholder": "Feroarings filterje (brûk it menu of sykje op filternamme)", + "rcfilters-filterlist-feedbacklink": "Lit ús hearre wat jo fan dit filterark fine", + "rcfilters-highlightbutton-title": "Resultaten aksintuearje", + "rcfilters-highlightmenu-title": "Kies in kleur", + "rcfilters-highlightmenu-help": "Kleur kieze foar it aksintuearjen fan dizze eigenskip", + "rcfilters-filter-excluded": "Utsein", + "rcfilters-exclude-button-off": "Seleksje omkeare", + "rcfilters-exclude-button-on": "Omkearde seleksje", + "rcfilters-view-tags": "Lebele bewurkings", + "rcfilters-view-namespaces-tooltip": "Resultaten op nammeromte filterje", + "rcfilters-view-tags-tooltip": "Resultaten mei bewurkingslebels filterje", + "rcfilters-view-return-to-default-tooltip": "Werom nei it filterhaadmenu", + "rcfilters-view-tags-help-icon-tooltip": "Kom mear oan 'e weet oer Lebele bewurkings", "rcnotefrom": "Hjirûnder {{PLURAL:$5|stiet de feroaring|steane de feroarings}} sûnt $3, $4 (maksimaal $1 werjûn).", "rclistfrom": "Jou nije feroarings, begjinnende mei $3 $2", "rcshowhideminor": "Lytse feroarings $1", @@ -1521,7 +1534,7 @@ "movepagetext": "Dit werneamt in side, mei alle sideskiednis.\nDe âlde titel wurdt in trochferwizing nei de nije.\nKeppelings mei de âlde side wurde net feroare;\ngean sels nei of't der dûbele of misse ferwizings binne.\nIt hinget fan jo ôf of't de siden noch keppelen binne sa't it mient wie.\n\nDe side wurdt '''net''' werneamt as der al in side mei dy namme is, útsein as it in side\nsûnder skiednis is en de side leech is of in trochferwizing is. Sa kinne jo in side\ndaalks weromneame as jo in flater meitsje, mar jo kinne in oare side net oerskriuwe.", "movepagetalktext": "As der in oerlisside by heart, dan bliuwt dy oan de side keppele, '''útsein''':\n*De nije sidenamme yn in oare nammeromte is,\n*Der keppele oan de nije namme al in net-lege oerlisside is, of\n*Jo dêr net foar kieze.\n\nIn dizze gefallen is it oan jo hoe't jo de oerlisside omneame of ynfoegje wolle.", "movenologintext": "Jo moatte [[Special:UserLogin|oanmeld]] wêze om in side wer te neamen.", - "newtitle": "As nij titel", + "newtitle": "Nije titel:", "move-watch": "Folch dizze side", "movepagebtn": "Side omneame", "pagemovedsub": "Werneamen slagge", @@ -1581,7 +1594,7 @@ "import-logentry-upload-detail": "$1 {{PLURAL:$1|ferzje|ferzjes}}", "import-logentry-interwiki-detail": "$1 {{PLURAL:$1|ferzje|ferzjes}} fan $2", "tooltip-pt-userpage": "Jo {{GENDER:|meidogger}}side", - "tooltip-pt-mytalk": "Jo oerlisside", + "tooltip-pt-mytalk": "{{GENDER:|Jo}} oerlisside", "tooltip-pt-preferences": "Myn foarkarynstellings", "tooltip-pt-watchlist": "List fan siden dy'sto besjochst op feroarings", "tooltip-pt-mycontris": "Oersjocht fan jo bydragen", @@ -1590,13 +1603,13 @@ "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-delete": "Dizze side weidwaan", "tooltip-ca-undelete": "Fuorthelle bewurkings fan dizze side weromsette", - "tooltip-ca-move": "Dizze side ferskowe", + "tooltip-ca-move": "Dizze side omneame", "tooltip-ca-watch": "Dizze side oan jo folchlist taheakje", "tooltip-ca-unwatch": "Dizze side fan jo folchlist ôfhelje", "tooltip-search": "{{SITENAME}} trochsykje", @@ -1625,8 +1638,8 @@ "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", @@ -1696,7 +1709,7 @@ "show-big-image": "Oarspronklik bestân", "show-big-image-other": "Oare {{PLURAL:$2|resolúsje|resolúsjes}}: $1.", "show-big-image-size": "$1 × $2 pixels", - "newimages": "Nije ôfbylden", + "newimages": "Galery mei nije ôfbylden", "imagelisttext": "Dit is in list fan '''$1''' {{PLURAL:$1|bestân|bestannen}}, op $2.", "newimages-summary": "Dizze bysûndere side lit de lêst opladen bestannen sjen.", "newimages-legend": "Filter", @@ -1878,16 +1891,26 @@ "tag-mw-rollback": "Weromdraaid", "tag-mw-rollback-description": "Bewurkings mei de keppeling 'weromdraaie', dy't foargeande wizigings ûngedien makke hawwe", "tag-mw-undo": "Ungedien meitsjen", + "tags-title": "Lebels", + "tags-intro": "Op dizze side steane de lebels en har betsjutting, wêrmei't de programmatuer bewurkings markearje kin.", + "tags-tag": "Lebelnamme", + "tags-display-header": "Werjefte yn feroaringslisten", + "tags-description-header": "Folsleine beskriuwing fan 'e betsjutting", "tags-source-header": "Boarne", "tags-active-header": "Aktyf?", + "tags-hitcount-header": "Lebele bewurkings", "tags-actions-header": "Aksjes", "tags-active-yes": "Ja", "tags-active-no": "Nee", + "tags-source-extension": "Definiearre yn de programmatuer", + "tags-source-manual": "Hânmjittich taheakke troch meidoggers en bots", + "tags-source-none": "Net mear yn gebrûk", "tags-edit": "bewurkje", "tags-delete": "fuortsmite", "tags-activate": "aktivearje", "tags-deactivate": "deaktivearje", "tags-hitcount": "$1 {{PLURAL:$1|bewurking|bewurkings}}", + "tags-create-tag-name": "Lebelnamme:", "tags-create-reason": "Reden:", "tags-delete-reason": "Reden:", "tags-activate-reason": "Reden:", diff --git a/languages/i18n/he.json b/languages/i18n/he.json index fa362937ee..9772a3f5f0 100644 --- a/languages/i18n/he.json +++ b/languages/i18n/he.json @@ -832,7 +832,7 @@ "page_first": "ראשון", "page_last": "אחרון", "histlegend": "בחירת גרסאות להשוואה: יש לבחור את הגרסאות שברצונך להשוות ולאחר מכן להקיש על Enter או ללחוץ על הכפתור שלמטה.
\nמקרא: ({{int:cur}}) = השוואה עם הגרסה הנוכחית, ({{int:last}}) = השוואה עם הגרסה הקודמת, {{int:minoreditletter}} = עריכה משנית.", - "history-fieldset-title": "חיפוש גרסאות", + "history-fieldset-title": "סינון גרסאות", "history-show-deleted": "גרסאות מוסתרות בלבד", "histfirst": "הישנות ביותר", "histlast": "החדשות ביותר", @@ -2261,7 +2261,7 @@ "delete-confirm": "מחיקת הדף \"$1\"", "delete-legend": "מחיקה", "historywarning": "אזהרה: לדף שעומד להימחק יש היסטוריית שינויים של {{PLURAL:$1|גרסה אחת|$1 גרסאות}}:", - "historyaction-submit": "הצגה", + "historyaction-submit": "הצגת הגרסאות", "confirmdeletetext": "ניתן להשתמש בטופס שלהלן כדי למחוק דף יחד עם כל ההיסטוריה שלו.\nנא לאשר שזה אכן מה שהתכוונת לעשות, שהתוצאות של הפעולה הזו ידועות לך, ושהמעשה מבוצע בהתאם ל[[{{MediaWiki:Policy-url}}|נוהלי האתר]].", "actioncomplete": "הפעולה בוצעה", "actionfailed": "הפעולה נכשלה", diff --git a/languages/i18n/hy.json b/languages/i18n/hy.json index 8fed360444..8c191d9c03 100644 --- a/languages/i18n/hy.json +++ b/languages/i18n/hy.json @@ -33,7 +33,8 @@ "23artashes", "Fitoschido", "Սահակ", - "ديفيد" + "ديفيد", + "Azniv Stepanian" ] }, "tog-underline": "ընդգծել հղումները՝", @@ -253,7 +254,7 @@ "mainpage": "Գլխավոր էջ", "mainpage-description": "Գլխավոր էջ", "policy-url": "Project:Կանոնակարգ", - "portal": "Խորհրդարան", + "portal": "Համայնքային պորտալ", "portal-url": "Project:Համայնքային պորտալ", "privacy": "Գաղտնիության քաղաքականություն", "privacypage": "Project:Գաղտնիության քաղաքականություն", @@ -371,6 +372,7 @@ "mypreferencesprotected": "Դուք բավարար իրավունքներ չունեք Ձեր նախընտրությունները խմբագրելու համար։", "ns-specialprotected": "«{{ns:special}}» անվանատարածքի էջերը չեն կարող խմբագրվել։", "titleprotected": "Այս անվանմամբ էջի ստեղծումը արգելվել է [[User:$1|$1]] մասնակցի կողմից։\nՏրված պատճառն է՝ $2։", + "invalidtitle": "Սխալ վերնագիր", "exception-nologin": "Չեք մտել համակարգ", "exception-nologin-text": "Խնդրում ենք, մուտք գործեք՝ այս էջը դիտելու կամ գործողությունը կատարելու համար։", "virus-badscanner": "Սխալ կարգավորւմ։ Անծանոթ վիրուսների զննիչ. ''$1''", @@ -573,6 +575,9 @@ "savechanges": "Պահպանել փոփոխությունները", "publishpage": "Ստեղծել էջը", "publishchanges": "Պահպանել", + "savearticle-start": "Հիշել էջը...", + "savechanges-start": "Հիշել փոփոխությունները...", + "publishpage-start": "Հրատարակել էջը...", "publishchanges-start": "Պահպանել…", "preview": "Նախադիտում", "showpreview": "Նախադիտել", @@ -755,6 +760,7 @@ "diff-empty": "(Տարբերություն չկա)", "diff-multi-sameuser": "(Միևնույն մասնակցի {{PLURAL:$1|մեկ միջանկյալ տարբերակ|$1 միջանկյալ տարբերակներ}} թաքցված է)", "searchresults": "Որոնման արդյունքներ", + "search-filter-title-prefix-reset": "Որոնել բոլոր էջեր", "searchresults-title": "«$1»-ի որոնման արդյունքներ", "titlematches": "Համընկած հոդվածների անվանումներ", "textmatches": "Համընկած տեքստերով էջեր", @@ -812,6 +818,7 @@ "prefs-rc": "Վերջին փոփոխություններ", "prefs-watchlist": "Հսկացանկ", "prefs-editwatchlist": "Խմբագրել հսկացանկը", + "prefs-editwatchlist-clear": "Մաքրել հսկացանկը", "prefs-watchlist-days": "Հսկացանկում ցուցադրվող օրերի թիվը՝", "prefs-watchlist-days-max": "Առավելագույնը $1 {{PLURAL:$1|օր}}", "prefs-watchlist-edits": "Ընդարձակված հսկացանկում ցուցադրվող օրերի թիվը՝", @@ -944,7 +951,9 @@ "grant-group-email": "Ուղարկել էլ. նամակ", "grant-createaccount": "Ստեղծել հաշիվներ", "grant-createeditmovepage": "Ստեղծել․ խմբագրել և տեղափոխել էջեր", + "grant-uploadfile": "Բեռնել նոր նիշքեր", "grant-basic": "Հիմնական իրավունքներ", + "grant-viewmywatchlist": "Դիտել ձեր հսկացանկը", "newuserlogpage": "Մասնակիցների գրանցման տեղեկամատյան", "newuserlogpagetext": "Սա նոր մասնակիցների գրանցման տեղեկամատյանն է.", "rightslog": "Մասնակցի իրավունքների տեղեկամատյան", @@ -952,6 +961,7 @@ "action-read": "կարդալ այս էջը", "action-edit": "խմբագրել այս էջը", "action-createpage": "Ստեղծել էջ", + "action-createtalk": "Ստեղծել քննարկման էջ", "action-createaccount": "ստեղծել այս մասնակցային հաշիվը", "action-move": "տեղափոխել այս էջը", "action-move-rootuserpages": "տեղափոխել մասնակցի էջի արմատը", @@ -1172,6 +1182,7 @@ "listfiles_size": "Չափ", "listfiles_description": "Նկարագրություն", "listfiles_count": "Տարբերակ", + "listfiles-latestversion": "Ընթացիկ տարբերակ", "listfiles-latestversion-yes": "Այո", "listfiles-latestversion-no": "Ոչ", "file-anchor-link": "Նիշք", @@ -1235,6 +1246,8 @@ "unusedtemplateswlh": "այլ հղումներ", "randompage": "Պատահական էջ", "randompage-nopages": "Այս անվանատարածքում էջեր չկան։", + "randomincategory-category": "Կատեգորիա:", + "randomincategory-legend": "Կատեգորիայի պատահական էջ", "randomincategory-submit": "Անցնել", "randomredirect": "Պատահական վերահղում", "randomredirect-nopages": "Այս անվանատարածքում վերահղումներ չկան։", @@ -1366,6 +1379,7 @@ "deletedcontributions-title": "Մասնակցի ջնջված ներդրում", "sp-deletedcontributions-contribs": "ներդրում", "linksearch": "Արտաքին հղումներ", + "linksearch-ns": "Անվանատարածք.", "linksearch-ok": "Որոնել", "linksearch-line": " \n$1-ը հղվել է $2 ից", "listusersfrom": "Ցուցադրել մասնակիցներին՝ սկսած.", @@ -1376,8 +1390,10 @@ "activeusers-noresult": "Այդպիսի մասնակիցներ չեն գտնվել։", "activeusers-submit": "Ցույց տալ ակտիվ մասնակիցներին", "listgrouprights-group": "Խումբ", + "listgrouprights-rights": "Իրավունքներ", "listgrouprights-members": "(անդամների ցանկ)", "listgrouprights-addgroup": "Ավելացնեել {{PLURAL:$2|խումբ|խմբեր}}՝ $1", + "listgrants-rights": "Իրավունքներ", "mailnologin": "Ուղարկման հասցե չկա", "mailnologintext": "Անհրաժեշտ է [[Special:UserLogin|մտնել համակարգ]] և ունենալ գործող էլ-փոստի հասցե ձեր [[Special:Preferences|նախընտրություններում]]՝ ուրիշ մասնակիցներին էլեկտրոնային նամակներ ուղարկելու համար։", "emailuser": "էլ-նամակ ուղարկել այս մասնակցին", @@ -1484,7 +1500,10 @@ "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]]» էջը", @@ -1598,6 +1617,7 @@ "sp-contributions-username": "IP-հասցե կամ մասնակցի անուն.", "sp-contributions-toponly": "Ցույց տալ միայն այն խմբագրումները, որոնք վերջին փոփոխություն են", "sp-contributions-newonly": "Ցույց տալ միայն նոր էջերի խմբագրումները", + "sp-contributions-hideminor": "Թագցնել չնչին խմբագրումներ", "sp-contributions-submit": "Որոնել", "whatlinkshere": "Այստեղ հղվող էջերը", "whatlinkshere-title": "Էջեր, որոնք հղում են դեպի «$1»", @@ -1636,6 +1656,7 @@ "ipbwatchuser": "Հսկացանկում ավելացնել մասնակցի էջն ու քննարկման էջը", "ipb-disableusertalk": "Արգելել մասնակցին խմբագրել իր քննարկման էջն արգելափակման ընթացքում", "ipb-pages-label": "Էջեր", + "ipb-namespaces-label": "Անվանատարածքներ", "badipaddress": "Սխալ IP-հասցե", "blockipsuccesssub": "Արգելափակումը կատարված է", "blockipsuccesstext": "[[Special:Contributions/$1|«$1»]] արգելափակված է։\n
Տես [[Special:BlockList|արգելափակված IP-հասցեների ցանկը]]։", @@ -1645,15 +1666,18 @@ "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", @@ -1768,6 +1792,8 @@ "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": "Նիշքի անուն․", @@ -1909,6 +1935,10 @@ "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": "Նշված է որպես ստուգված", @@ -2002,6 +2032,7 @@ "imgmultipagenext": "հաջորդ էջ →", "imgmultigo": "Անցնե՛լ", "imgmultigoto": "Անցնել $1 էջը", + "img-lang-go": "Անցնել", "ascending_abbrev": "աճմ. կարգ.", "descending_abbrev": "նվազ", "table_pager_next": "Հաջորդ էջ", @@ -2034,18 +2065,30 @@ "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": "Որոնում՝", @@ -2081,6 +2124,7 @@ "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": "Գործողություններ", @@ -2105,9 +2149,15 @@ "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", @@ -2138,6 +2188,7 @@ "feedback-close": "Արված է", "feedback-message": "Հաղորդագրություն․", "feedback-subject": "Թեմա.", + "feedback-thanks-title": "Շնորհակալություն", "searchsuggest-search": "Որոնել {{SITENAME}} կայքում", "duration-seconds": "$1 {{PLURAL:$1|վայրկյան}}", "duration-minutes": "$1 {{PLURAL:$1|րոպե}}", @@ -2149,7 +2200,13 @@ "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)", @@ -2183,5 +2240,7 @@ "date-range-from": "Սկսած՝", "date-range-to": "Մինչև՝", "randomrootpage": "Պատահական կենտրոնական էջ", - "authmanager-create-from-login": "Հաշիվ ստեղծելու համար, խնդրում ենք լրացնել ստորև դաշտերը" + "authmanager-create-from-login": "Հաշիվ ստեղծելու համար, խնդրում ենք լրացնել ստորև դաշտերը", + "authprovider-resetpass-skip-label": "Բաց թողնել", + "passwordpolicies-group": "Խումբ" } diff --git a/languages/i18n/hyw.json b/languages/i18n/hyw.json index 798836790e..9dfa8b39b8 100644 --- a/languages/i18n/hyw.json +++ b/languages/i18n/hyw.json @@ -9,6 +9,14 @@ "Kareyac" ] }, + "tog-underline": "Ընդգծել յղումները․", + "tog-hideminor": "Թաքցնել չնչին խմբագրումները վերջին փոփոխութիւններու ցանկէն", + "tog-extendwatchlist": "Տարածել հսկացանկը՝ ցոյց տալով բոլոր փոփոխութիւնները, ոչ միայն վերջինները", + "tog-numberheadings": "Ինքնահամարակալել վերնագիրները", + "tog-editondblclick": "Խմբագրել էջերը կրկնակի սեղմամբ", + "tog-watchlisthideown": "Թաքցնել իմ խմբագրումները հսկացանկէն", + "tog-watchlisthidebots": "Թաքցնել իմ խմբագրումները հսկացանկէն", + "tog-watchlisthideminor": "Թաքցնել իմ խմբագրումները հսկացանկէն", "underline-always": "Միշտ", "underline-never": "Երբեք", "editfont-serif": "Սերիֆ տառատեսակ", @@ -187,6 +195,7 @@ "badaccess-group0": "Արտունութիւն չունիք այս գործողութիւնը կատարել:", "badaccess-groups": "Տուեալ գործողութիւնը միայն $1 {{PLURAL:$2|խումբի|խումբերի}} մասնակիցները կ՛րնան կատարել։", "ok": "Լաւ", + "pagetitle": "Միացէ՛ք {{SITENAME}} նախագիծին", "retrievedfrom": "Վերցուած է «$1» էջէն", "youhavenewmessages": "{{PLURAL:$3|Դուք ունիք}} $1 ($2)։", "youhavenewmessagesfromusers": "{{PLURAL:$4|Դուք ունիք}} $1 {{PLURAL:$3|այլ մասնակից|$3 մասնակիցէն}} ($2):", @@ -234,6 +243,7 @@ "nosuchaction": "Այս գործողութիւնը չկայ", "nosuchspecialpage": "Այդպիսի յատուկ էջ չկայ", "nospecialpagetext": " Ձեր խնդրած յատուկ էջը անվաւեր է։\n\nՎաւերական յատուկ էջերու ցանկը կը գտնէք այստեղ՝ [[Special:SpecialPages|{{int:յատուկէջեր}}]].", + "error": "Սխալ", "databaseerror-query": "Հարցակէտ. $1", "databaseerror-function": "Գործառնութիւն. $1", "databaseerror-error": "Սխալ. $1", @@ -245,32 +255,54 @@ "viewsource": "Տեսնել աղբիւրը", "viewsource-title": "Տեսնել $1 էջի աղբիւրը", "viewsourcetext": "Կրնաք այս էջին աղբիւրը տեսնել ու ընդօրինակել։", - "yourname": "Գործածողի անուն.", - "userlogin-yourname": "Մասնակցային անուն", - "userlogin-yourname-ph": "Մուտքագրեցէ'ք ձեր մասնակցային անունը", - "createacct-another-username-ph": "Մուտքագրէք գործածողի անունը", - "yourpassword": "Գաղտնաբառ.", + "invalidtitle": "Անվաւեր վերնագիր", + "exception-nologin": "Դուք մուտք չէք գործած համակարգ", + "welcomeuser": "Բարի գալո՜ւստ, $1", + "welcomecreation-msg": "Ձեր հաշիւը ստեղծուած է։\nՉմոռնաք փոփոխել ձեր [[Special:Preferences|նախընտրութիւնները]]։", + "yourname": "Մասնակիցի անուն", + "userlogin-yourname": "Մասնակիցի անուն", + "userlogin-yourname-ph": "Մուտքագրեցէ'ք ձեր մասնակիցի անունը", + "createacct-another-username-ph": "Մուտքագրեցէ՛ք ձեր մասնակիցի անունը", + "yourpassword": "Անցաբառ՝", "userlogin-yourpassword": "Անցաբառ", "userlogin-yourpassword-ph": "Մուտքագրեցէ'ք ձեր անցաբառը", - "createacct-yourpassword-ph": "Մուտքագրեցէ'ք անցաբառ մը", - "yourpasswordagain": "Նորէն մուտքագրէք գաղտնաբառը", - "createacct-yourpasswordagain": "Հաստատեցէ'ք անցաբառը", - "createacct-yourpasswordagain-ph": "Դարձեալ մուտքագրեցէ'ք անցաբառը", + "createacct-yourpassword-ph": "Մուտքագրեցէ՛ք անցաբառ մը", + "yourpasswordagain": "Դարձեալ մուտքագրեցէ՛ք անցաբառը", + "createacct-yourpasswordagain": "Հաստատեցէ՛ք անցաբառը", + "createacct-yourpasswordagain-ph": "Դարձեալ մուտքագրեցէ՛ք անցաբառը", "userlogin-remembermypassword": "Մնալ համակարգին մէջ", + "cannotloginnow-title": "Այժմ դուք չէք կրնար մուտք գործել", + "cannotcreateaccount-title": "Չէք կրնար հաշիւներ ստեղծել", + "password-change-forbidden": "Այս Ուիքիին մէջ դուք չէք կրնար անցաբառեր փոխել։", "login": "Մուտք գործել", + "login-security": "Հաստատեցէէ՛ք Ձեր ինքնութիւնը", + "nav-login-createaccount": "Մուտք գործել/Հաշիւ ստեղծել", + "logout": "Դուրս գալ", + "userlogout": "Դուրս գալ", + "notloggedin": "Դուք մուտք չէք գործած համակարգ", "userlogin-noaccount": "Հաշիւ չունի՞ք։", "userlogin-joinproject": "Միացէ՛ք {{SITENAME}} նախագիծին", "createaccount": "Հաշիւ ստեղծել", "userlogin-resetpassword-link": "Անցաբառը մոռցա՞ծ էք", "userlogin-helplink2": "Մուտք գործելու օգնութիւն", - "createacct-emailoptional": "Իմակահասցէ (ոչ պարտադիր)", - "createacct-email-ph": "Մուտքագրեցէ՛ք ձեր իմակահասցէն", + "userlogin-createanother": "Այլ հաշիւ ստեղծել", + "createacct-emailrequired": "Ելեկտրոնային հասցէ", + "createacct-emailoptional": "Ելեկտրոնային հասցէ (ոչ պարտադիր)", + "createacct-email-ph": "Մուտքագրեցէ՛ք ձեր ելեկտրոնային հասցէն", + "createacct-another-email-ph": "Մուտքագրեցէ՛ք ձեր ելեկտրոնային հասցէն", + "createacct-realname": "Իրական անուն (ոչ պարտադիր)", "createacct-reason": "Պատճառ", "createacct-submit": "Ստեղծել ձեր հաշիւը", + "createacct-another-submit": "Հաշիւ ստեղծել", + "createacct-continue-submit": "Շարունակեցէ՛ք հաշիւի ստեղծումը", + "createacct-another-continue-submit": "Շարունակեցէ՛ք հաշիւի ստեղծումը", "createacct-benefit-heading": "{{SITENAME}}՝ ստեղծուած է ձեզի պէս մարդոց կողմէ։", "createacct-benefit-body1": "{{PLURAL:$1|խմբագրում}}", "createacct-benefit-body2": "$1 {{PLURAL:$1|էջ}}", "createacct-benefit-body3": "վերջին {{PLURAL:$1|մասնակից}}", + "badretype": "Ձեր նշած անցաբառերը իրարու չեն համապատասխաներ։", + "loginerror": "Մուտքի սխալ", + "createacct-error": "Հաշուի ստեղծման սխալ", "loginsuccesstitle": "Բարեյաջող մուտք", "loginsuccess": "'''Դուք մուտք գործեցիք {{SITENAME}}, իբր \"$1\"։'''", "nouserspecified": "Հարկաւոր է նշել մասնակցին անունը։", @@ -293,20 +325,37 @@ "pt-userlogout": "Դուրս գալ", "php-mail-error-unknown": "Անյայտ սխալ PHP-ի mail() կախարկութեան մէջ:", "changepassword": "Գաղտնաբառը փոխել", - "oldpassword": "Հին գաղտնաբառը.", - "newpassword": "Նոր գաղտնաբառը.", + "resetpass_header": "Փոխել հաշիւի անցաբառը", + "oldpassword": "Հին անցաբառը.", + "newpassword": "Նոր անցաբառը.", "retypenew": "Նորէն մուտքագրէք գաղտնաբառը", - "changepassword-success": "Ձեր գաղտնաբառը փոխուեցաւ։", + "resetpass_submit": "Հաստատեցէ՛ք անցաբառը եւ մուտք գործեցէ՛ք համակարգ", + "changepassword-success": "Ձեր անցաբառը փոխուեցաւ։", + "botpasswords-label-appid": "Մեքենայիկի անուն՝", "botpasswords-label-create": "Ստեղծել", "botpasswords-label-cancel": "Չեղարկել", "botpasswords-label-delete": "Ջնջել", "botpasswords-label-resetpassword": "Վերականգնել գաղտնաբառը", + "botpasswords-label-grants-column": "Արտօնութիւնը տրուած է", + "botpasswords-bad-appid": "\"$1\" մեքենայիկին անունը չէ վաւերացուած:", + "botpasswords-created-title": "Մեքենայիկին անցաբառը ստեղծուեցաւ", + "botpasswords-deleted-title": "Մեքենայիկին անցաբառը ջնջուեցաւ", + "resetpass_forbidden": "Անցաբառերը չեն կրնար փոխել", + "resetpass-submit-loggedin": "Անցաբառը փոխել", "resetpass-submit-cancel": "Չեղարկել", "resetpass-temp-password": "Ժամանակաւոր գաղտնաբառ.", "passwordreset": "Վերականգնել անցաբառը", + "passwordreset-username": "Մասնակիցի անուն՝", "passwordreset-domain": "Համակարգիչի պետութիւն.", "passwordreset-email": "Էլ-նամակաի հասցէն.", "passwordreset-emailtitle": "{{SITENAME}} հաշիւի մանրամասները", + "passwordreset-invalidemail": "Անվաւեր ելեկտրոնային հասցէ", + "changeemail": "Փոխել կամ հանել ելեկտրոնային հասցէն", + "changeemail-oldemail": "Այժմու ելեկտրոնային հասցէ․", + "changeemail-newemail": "Նոր ելեկտրոնային հասցէ․", + "changeemail-none": "(ոչ մէկ)", + "changeemail-password": "Ձեր {{SITENAME}} անցաբառը՝", + "changeemail-submit": "Փոխել ելեկտրոնային հասցէն", "bold_sample": "Շեշտուած տառերով գրութիւն", "bold_tip": "Շեշտուած տառերով գրութիւն", "italic_sample": "Շեղատառ գրութիւն", @@ -319,7 +368,9 @@ "headline_tip": "Երկրորդ մակարդակի վերնագիր", "nowiki_sample": "Մուտքագրեցէ՛ք չձեւաւորուած գրութիւնը այստեղ", "nowiki_tip": "Անտեսել ուիքի ձեւաւորումը", + "image_sample": "Օրինակ.jpg", "image_tip": "Ներփակ նիշք", + "media_sample": "Օրինակ.jpg", "media_tip": "Նիշքին յղումը", "sig_tip": "Ձեր ստորագրութիւնը ժամակնիքով", "hr_tip": "Հորիզոնական գիծ (գործածել խնայողաբար)", @@ -337,9 +388,12 @@ "preview": "Կանխաստուգել", "showpreview": "Կանխաստուգել", "showdiff": "Ցուցնել փոփոխութիւնները", - "anoneditwarning": "Զգուշացում։ Մուտք գործած չէք համակարգ։ Որեւէ խմբագրումի պարագային ձեր IP հասցէն տեսանելի կը դառնայ բոլորին։ Եթե [$1 մուտք գործէք] կամ [$2 ստեղծէք մասնակցային հաշիւ], ձեր կատարած խմբագրումները կը կապուին ձեր մասնակցային անունին հետ, ինչպէս նաեւ կ՚ունենաք այլ առաւելութիւններ։", + "anoneditwarning": "Զգուշացում։ Մուտք գործած չէք համակարգ։ Որեւէ խմբագրումի պարագային ձեր IP հասցէն տեսանելի կը դառնայ բոլորին։ Եթէ [$1 մուտք գործէք] կամ [$2 ստեղծէք մասնակիցի հաշիւ], ձեր կատարած խմբագրումները կը կապուին ձեր մասնակիցի անունին հետ, ինչպէս նաեւ կ՚ունենաք այլ առաւելութիւններ։", + "blockedtitle": "Մասնակիցը արգելափակուած է", "blockedtext": "Ձեր մասնակցային անոիւնը կամ IP հասցէն արգելակուած է։\n\nԱրգելակումը կատարուած է $1ի կողմէ.\nՊարտճառը՝ $2.\n\n* Արգելակման սկիբժ՝ $8\n* Արգելակման աւարտ՝ $6\n* արգելակուած առարկայ՝ $7\n\nԿրնաք կապուիլ $1ի կամ այլ անդատներու հետ [[{{MediaWiki:Grouppage-sysop}}|վարիչ]] արգելակման մասին զրուցելու համար.\nՉէք կրնար օգտագործել \"{{int:emailuser}}\" հնարաւորութիւնը բացի եթէ նշած էք իմակի վաւերական հասցէ մը ձեր [[Special:Preferences|account preferences]] եւ արգելակուած չէ վեր անոր օգտագործումը.\nՁեր ընթացիկ IP հասցէն է $3, եւ արգելակման ինքնութեան համարն է #$5.\nԿը շնդրենք որ այս մանրամասնութիւնները նշէք ձեր բոլոր թղթակցութիւններուն մէջ։", - "loginreqlink": "մուտք գործել", + "loginreqtitle": "Կը խնդրուի մուտք գործել համակարգ", + "loginreqlink": "Մուտք գործել", + "newarticle": "(Նոր)", "newarticletext": "Դուք յղուած էք տակաւին գոյութիւն չունեցող էջի մը։\nԷջը ստեղծելու համար, մեքենագրեցէք ներքեւի տուփիկին մէջ (յաւելեալ տեղեկութեանց համար տե՛ս [$1 օգնութեան ցուցմունքներու էջը])։\nԵթէ սխալմամբ հոս հասած էք, սեղմել դիտարկիչի ետ կոճակը։", "anontalkpagetext": " Այս էջը առայժմ հաշիւ չստեղծած, կամ հաշիւ չօգտագործող, անանուն մասնակիցներու քննարկման էջն է։\nՈւրեմն որպէս ինքնութիւն ստիպուած ենք օգտագործել անոնց IP հասցէն։\nԱյսպիսի IP հասցէ կրնան ունենալ մէկէ աւելի մասնակիցներ։\nԵթէ դուք անանուն մասնակից էք եւ կը խորհիք որ անկապ դիտողութիւններու թիրախ դարձած էք, կը խնդրուի [[Special:CreateAccount|Հաշիւ ստեղծել]] կամ [[Special:UserLogin|մուտք գործել]] խուսափելու համար ապագային այլ անդանուն մասնակիցներու հետ շփոթուելու հնարաւորութենէն։", "noarticletext": "Ներկայիս այս էջին վրայ որեւէ գրութիւն չկայ։\nԴուք կրնաք [[Special:Search/{{PAGENAME}}|որոնել այս անուանումը]] այլ էջերու մէջ, [{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} որոնել համապատասխան տեղեկամատեանները] կամ [{{fullurl:{{FULLPAGENAME}}|action=edit}} ստեղծել նոր էջ այս անուանումով]։", @@ -351,6 +405,8 @@ "editing": "Կը խմբագրուի՝ $1 էջը", "creating": "«$1» էջի ստեղծում", "editingsection": "$1 բաժինի խմբագրում", + "yourtext": "Ձեր գրութիւնը", + "storedversion": "Պահուած տարբերակ", "yourdiff": "Տարբերութիւններ", "templatesused": "Այս էջին մէջ օգտագործուած {{PLURAL:$1|կաղապարը|կաղապարները}}.", "templatesusedpreview": "{{PLURAL:$1|Կաղապար}} օգտագործուած այս կանխաստուգումին մէջ՝", @@ -362,6 +418,9 @@ "recreate-moveddeleted-warn": "Զգուշացում. Նախապէս ջնջուած էջ մը պիտի վերստեղծուի։\n\nԿը խնդրուի մտածել այս էջի խմբագրման նպատակայարմարութեան մասին։ \nՁեր դիւրութեան համար ներքեւ կը գտնէք այս էջի ջնջումին և տեղափոխումին տեղեկատետրերը։", "moveddeleted-notice": "Այս էջը ջնջուած է։\nԷջին ջնջումի, պահպանումի եւ փոխադրումի տեղեկատետրը տրամադրելի է ներքեւ որպէս տեղեկութիւն։", "edit-conflict": "Խմբագրման ընհարում։", + "postedit-confirmation-created": "Էջը ստեղծուած է։", + "postedit-confirmation-saved": "Ձեր խմբագրումը պահուած է:", + "postedit-confirmation-published": "Ձեր խմբագրումը հրատարակուած է:", "slot-name-main": "Գլխաւոր", "content-model-wikitext": "ուիքիթէքսթ", "content-model-text": "պարզ բնաբան", @@ -392,9 +451,12 @@ "rev-delundel": "ցուցնել/թաքցնել", "rev-showdeleted": "Ցուցադրել", "revdelete-show-file-submit": "Այո", + "revdelete-hide-image": "Թաքցնել նիշքին բովանդակութիւնը", + "revdelete-radio-unset": "Տեսանելի", "revdelete-log": "Պատճառ.", "pagehist": "Էջի պատմութիւն", "revdelete-reasonotherlist": "Ուրիշ պատճառ.", + "revdelete-edit-reasonlist": "Խմբագրել ջնջման պատճառները", "mergehistory-reason": "Պատճառ.", "mergelog": "Ձուլման տեղեկատետր", "history-title": "«$1»ի վերանայումներու ցուցակ", @@ -407,6 +469,7 @@ "diff-multi-otherusers": "(նոյն մասնակիցի ցոյց չտրուած {{PLURAL:$1|մէկ միջանկեալ վերանայում|$2 միջանկեալ վերանայումներ}})", "searchresults": "Որոնման արդիւնքներ", "searchresults-title": "«$1»-ի որոնման արդիւնքները", + "notextmatches": "Չկան համապատասխան գրութիւնով էջեր", "prevn": "նախորդ {{PLURAL:$1|$1}}", "nextn": "յաջորդ {{PLURAL:$1|$1}}", "prev-page": "նախորդ էջ", @@ -441,10 +504,12 @@ "search-nonefound": "Որոնումին համապատասխանող արդիւնքներ չգտնուեցան", "powersearch-toggleall": "Բոլոր", "powersearch-togglenone": "Ոչ մէկ", + "search-external": "Արտաքին որոնում", "preferences": "Նախընտրութիւններ", "mypreferences": "Նախընտրութիւններ", "skin-preview": "Նախադիտել", "prefs-watchlist": "Հսկողութեան ցանկ", + "prefs-editwatchlist-clear": "Մաքրել հսկողութեան ցանկը", "saveprefs": "Յիշել", "searchresultshead": "Որոնել", "stub-threshold-sample-link": "օրինակ", @@ -487,6 +552,7 @@ "recentchanges-label-plusminus": "Էջին ծաւալը փոխուեցաւ այսքան պայթով", "recentchanges-legend-heading": "Ծանօթ.՝", "recentchanges-legend-newpage": "{{int:recentchanges-label-newpage}} (տե՛ս նաեւ՝ [[Special:NewPages|նոր էջերու ցանկ]])", + "rcfilters-activefilters": "Աշխոյժ զտիչներ", "rcnotefrom": "Ներքեւ {{PLURAL:$5|փոփոխութիւնն է|փոփոխութիւններն են}} սկսեալ $3, $4 (մինչեւ $1 ցոյց տրուած).", "rclistfrom": "Ցոյց տալ նոր փոփոխութիւնները սկսած $3 $2", "rcshowhideminor": "$1 չնչին խմբագրումներ", @@ -760,6 +826,8 @@ "imgmultipagenext": "յաջորդ էջը →", "imgmultigo": "Անցնի՛լ", "imgmultigoto": "Անցնիլ $1 էջը", + "watchlistedit-clear-title": "Մաքրել հսկողութեան ցանկը", + "watchlistedit-clear-legend": "Մաքրել հսկողութեան ցանկը", "watchlisttools-clear": "Մաքրել հսկողութեան ցանկը", "watchlisttools-view": "Ցուցադրել փոփոխութիւնները", "watchlisttools-edit": "Տեսնել եւ խմբագրել հսկողութեան ցանկը", @@ -792,6 +860,7 @@ "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": "Պատահական արմատ էջ" diff --git a/languages/i18n/ia.json b/languages/i18n/ia.json index 15d49ef343..e748e28acf 100644 --- a/languages/i18n/ia.json +++ b/languages/i18n/ia.json @@ -810,7 +810,7 @@ "page_first": "prime", "page_last": "ultime", "histlegend": "Pro comparar duo versiones: marca lor circulos correspondente, e preme Enter o clicca le button in basso.
\nLegenda: '''({{int:cur}})''' = comparar con le version actual,\n'''({{int:last}})''' = comparar con le version precedente, '''{{int:minoreditletter}}''' = modification minor.", - "history-fieldset-title": "Cercar versiones", + "history-fieldset-title": "Filtrar versiones", "history-show-deleted": "Solmente versiones delite", "histfirst": "le plus ancian", "histlast": "le plus nove", @@ -2234,7 +2234,7 @@ "delete-confirm": "Deler \"$1\"", "delete-legend": "Deler", "historywarning": "Attention: Le pagina que tu vole deler ha un historia de circa $1 {{PLURAL:$1|version|versiones}}:", - "historyaction-submit": "Monstrar", + "historyaction-submit": "Monstrar versiones", "confirmdeletetext": "Tu va deler un pagina con tote su historia.\nPer favor confirma que tu ha le intention de facer isto, que tu comprende le consequentias, e que tu face isto in accordo con [[{{MediaWiki:Policy-url}}|le politicas]].", "actioncomplete": "Action complete", "actionfailed": "Action fallite", diff --git a/languages/i18n/id.json b/languages/i18n/id.json index 817d403f0f..95b8012576 100644 --- a/languages/i18n/id.json +++ b/languages/i18n/id.json @@ -2320,6 +2320,9 @@ "deleting-backlinks-warning": "Peringatan: [[Special:WhatLinksHere/{{FULLPAGENAME}}|Halaman lain]] mengarah atau memiliki transklusi ke halaman yang akan Anda hapus.", "deleting-subpages-warning": "Peringatan: Halaman yang akan Anda hapus memiliki [[Special:PrefixIndex/{{FULLPAGENAME}}/|{{PLURAL:$1|subhalaman|$1 subhalaman|51=lebih dari 50 subhalaman}}]].", "rollback": "Kembalikan suntingan", + "rollback-confirmation-confirm": "Silakan konfirmasi:", + "rollback-confirmation-yes": "Kembalikan", + "rollback-confirmation-no": "Batal", "rollbacklink": "kembalikan", "rollbacklinkcount": "kembalikan $1 {{PLURAL:$1|suntingan}}", "rollbacklinkcount-morethan": "kembalikan lebih dari $1 {{PLURAL:$1|suntingan|suntingan}}", diff --git a/languages/i18n/io.json b/languages/i18n/io.json index 215df6739b..623c04a845 100644 --- a/languages/i18n/io.json +++ b/languages/i18n/io.json @@ -194,7 +194,7 @@ "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", @@ -998,6 +998,7 @@ "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", @@ -1543,6 +1544,7 @@ "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", @@ -1699,6 +1701,7 @@ "blocklog-showlog": "Ica uzero ja blokusesis antee.\nInfre esas l'informo pri la blokuso, por vua konoco:", "blocklogentry": "blokusis [[$1]] dum periodo di $2 $3", "reblock-logentry": "modifikis la tempo di blokuso [[$1]] por durado di $2 $3", + "blocklogtext": "Ca esas protokolo pri agadi di blokuso o desblokuso.\nL'adresi IP automatale blokusata ne montresas che la listo.\nVidez la [[Special:BlockList|listo pri blokuso]] por la nuna listo pri blokusi e proskriptadi.", "unblocklogentry": "desblokusis \"$1\"", "block-log-flags-anononly": "nur anonima uzeri", "block-log-flags-nocreate": "ne povas krear konto", @@ -1750,6 +1753,7 @@ "delete_and_move_reason": "Efacita por permisar la chanjo di la nomo di la pagino \"[[$1]]\"", "move-leave-redirect": "Mantenez ridirektilo inter la du", "export": "Exportacar pagini", + "exporttext": "Vu povas exportacar la texto e historio pri redakto de ula pagino o grupo di pagini envelopata en ula XML.\nCa povas importacar por altra wiki tra l'uzo di MediaWiki, per la [[Special:Import|pagino di importaco]].\n\nPor exportar pagini, mencionez la titulo en la texto-buxo adinfre, un titulo per singla lineo, e selektez se vu deziras vidar la nuna ed anke l'antea redakturi (singla antea revizo en singla lineo), o nur vidar la nuna revizo e l'informi pri ol.\n\nIn the latter case you can also use a link, for example [[{{#Special:Export}}/{{MediaWiki:Mainpage}}]] for the page \"[[{{MediaWiki:Mainpage}}]]\".", "exportcuronly": "On inkluzas nur la nuna revizo, ne la kompleta versionaro", "export-addcattext": "Adjuntar pagini ek kategorio:", "export-addcat": "Adjuntar", @@ -1795,7 +1799,7 @@ "tooltip-n-recentchanges": "Listo di recenta chanji en la wiki.", "tooltip-n-randompage": "Vizitez hazarda pagino", "tooltip-n-help": "La loko por trovar ulo.", - "tooltip-t-whatlinkshere": "Listo di omna Wikipagini qui ligesas adhike", + "tooltip-t-whatlinkshere": "Listo pri omna Wikipagini qui ligesas adhike", "tooltip-t-recentchangeslinked": "Recenta chanji di pagini ligita de ca pagino", "tooltip-feed-rss": "RSS provizero por ica pagino", "tooltip-feed-atom": "Atom provizero por ica pagino", @@ -2070,7 +2074,9 @@ "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", diff --git a/languages/i18n/it.json b/languages/i18n/it.json index 54c57885e2..01dcb641a2 100644 --- a/languages/i18n/it.json +++ b/languages/i18n/it.json @@ -277,6 +277,7 @@ "returnto": "Torna a $1.", "tagline": "Da {{SITENAME}}.", "help": "Aiuto", + "help-mediawiki": "Aiuto su MediaWiki", "search": "Ricerca", "search-ignored-headings": " #
\n# Elenco delle intestazioni che saranno ignorate dalla ricerca.\n# Le modifiche a questa pagina saranno effettive non appena la pagina sarà indicizzata.\n# Puoi forzare la re-indicizzazione di una pagina effettuando una modifica nulla.\n# La sintassi è la seguente:\n#   * Tutto dal carattere \"#\" alla fine della riga è un commento\n#   * Tutte le righe non vuote sono le intestazioni esatte da ignorare, maiuscolo/minuscolo e tutto\nNote\nVoci correlate\nCollegamenti esterni\n #
", "searchbutton": "Ricerca", @@ -652,6 +653,8 @@ "botpasswords-restriction-failed": "Le restrizioni di password bot impediscono questo accesso.", "botpasswords-invalid-name": "Il nome utente indicato non contiene il separatore per password bot (\"$1\").", "botpasswords-not-exist": "L'utente \"$1\" non dispone di una password bot chiamata \"$2\".", + "botpasswords-needs-reset": "La password per il bot di nome \"$2\" {{GENDER:$1|dell'utente}} \"$1\" deve essere reimpostata.", + "botpasswords-locked": "Non puoi accedere con una password bot poiché la tua utenza è bloccata.", "resetpass_forbidden": "Non è possibile modificare le password", "resetpass_forbidden-reason": "Non è possibile modificare le password: $1", "resetpass-no-info": "Devi aver effettuato l'accesso per accedere a questa pagina direttamente.", @@ -771,8 +774,9 @@ "userpage-userdoesnotexist-view": "L'utenza \"$1\" non è registrata.", "blocked-notice-logextract": "Questo utente è attualmente bloccato.\nL'ultimo elemento del registro dei blocchi è riportato di seguito per informazione:", "clearyourcache": "Nota: dopo aver salvato, potrebbe essere necessario pulire la cache del proprio browser per vedere i cambiamenti. \n*Firefox / Safari: tenere premuto il tasto delle maiuscole Shift e fare clic su Ricarica, oppure premere Ctrl-F5 o Ctrl-R (⌘-R su Mac)\n*Google Chrome: premere Ctrl-Shift-R (⌘-Shift-R su un Mac)\n*Internet Explorer: tenere premuto il tasto Ctrl e fare clic su Aggiorna, oppure premere Ctrl-F5\n* Opera: Vai nel Menu → Impostazioni (Opera → Preferenze su un Mac) e poi in Privacy & sicurezza → Pulisci dati del browser → Immagini e file nella cache.", - "usercssyoucanpreview": "'''Suggerimento:''' usa il pulsante 'Visualizza anteprima' per provare il tuo nuovo CSS prima di salvarlo.", - "userjsyoucanpreview": "'''Suggerimento:''' usa il pulsante 'Visualizza anteprima' per provare il tuo nuovo JavaScript prima di salvarlo.", + "usercssyoucanpreview": "Suggerimento: usa il pulsante \"{{int:showpreview}}\" per provare il tuo nuovo CSS prima di salvarlo.", + "userjsonyoucanpreview": "Suggerimento: usa il pulsante \"{{int:showpreview}}\" per provare il tuo nuovo JSON prima di salvarlo.", + "userjsyoucanpreview": "Suggerimento: usa il pulsante \"{{int:showpreview}}\" per provare il tuo nuovo JavaScript prima di salvarlo.", "usercsspreview": "'''Questa è solo un'anteprima del proprio CSS personale. Le modifiche non sono ancora state salvate!'''", "userjsonpreview": "Questa è solo un'anteprima per provare il proprio JSON personale; le modifiche non sono ancora state salvate!", "userjspreview": "'''Questa è solo un'anteprima per provare il proprio JavaScript personale; le modifiche non sono ancora state salvate!'''", @@ -902,7 +906,7 @@ "page_first": "prima", "page_last": "ultima", "histlegend": "Confronto tra versioni: selezionare le caselle corrispondenti alle versioni desiderate e premere Invio o il pulsante in basso.\n\nLegenda: '''({{int:cur}})''' = differenze con la versione attuale, '''({{int:last}})''' = differenze con la versione precedente, '''{{int:minoreditletter}}''' = modifica minore", - "history-fieldset-title": "Ricerca per versioni", + "history-fieldset-title": "Filtra versioni", "history-show-deleted": "Solo versioni cancellate", "histfirst": "prima", "histlast": "ultima", @@ -1300,6 +1304,9 @@ "right-editusercss": "Modifica i file CSS di altri utenti", "right-edituserjson": "Modifica i file JSON di altri utenti", "right-edituserjs": "Modifica i file JS di altri utenti", + "right-editsitecss": "Modifica CSS del sito", + "right-editsitejson": "Modifica JSON del sito", + "right-editsitejs": "Modifica JavaScript del sito", "right-editmyusercss": "Modifica il file CSS del proprio utente", "right-editmyuserjson": "Modifica il file JSON del proprio utente", "right-editmyuserjs": "Modifica il file JavaScript del proprio utente", @@ -1444,6 +1451,7 @@ "rcfilters-activefilters-hide": "Nascondi", "rcfilters-activefilters-show": "Mostra", "rcfilters-activefilters-hide-tooltip": "Nascondi area dei filtri attivi", + "rcfilters-activefilters-show-tooltip": "Mostra area dei filtri attivi", "rcfilters-advancedfilters": "Filtri avanzati", "rcfilters-limit-title": "Risultati da mostrare", "rcfilters-limit-and-date-label": "$1 {{PLURAL:$1|modifica|modifiche}}, $2", @@ -2305,7 +2313,7 @@ "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", diff --git a/languages/i18n/ja.json b/languages/i18n/ja.json index 5e81e2852f..6f86888026 100644 --- a/languages/i18n/ja.json +++ b/languages/i18n/ja.json @@ -2334,7 +2334,7 @@ "delete-confirm": "「$1」の削除", "delete-legend": "削除", "historywarning": "警告: 削除しようとしているページには、$1版の履歴があります:", - "historyaction-submit": "表示", + "historyaction-submit": "更新の表示", "confirmdeletetext": "ページをすべての履歴とともに削除しようとしています。\n本当にこの操作を行いたいか、操作の結果を理解しているか、およびこの操作が[[{{MediaWiki:Policy-url}}|方針]]に従っているかどうか、確認してください。", "actioncomplete": "操作を完了しました", "actionfailed": "操作に失敗しました", diff --git a/languages/i18n/ko.json b/languages/i18n/ko.json index 3299d243b1..0fc84352b6 100644 --- a/languages/i18n/ko.json +++ b/languages/i18n/ko.json @@ -865,7 +865,7 @@ "page_first": "처음", "page_last": "마지막", "histlegend": "차이 선택: 비교하려는 판의 라디오 상자를 선택한 다음 엔터나 아래의 버튼을 누르세요.
\n설명: ({{int:cur}}) = 최신 판과 비교, ({{int:last}}) = 이전 판과 비교, {{int:minoreditletter}}= 사소한 편집", - "history-fieldset-title": "특정판 검색", + "history-fieldset-title": "특정판 필터링", "history-show-deleted": "특정판이 삭제된 것만", "histfirst": "오래됨", "histlast": "최신", @@ -2292,7 +2292,7 @@ "delete-confirm": "\"$1\" 삭제", "delete-legend": "삭제", "historywarning": "경고: 삭제하려고 하는 문서에 {{PLURAL:$1|판}} $1개의 역사가 있습니다:", - "historyaction-submit": "보이기", + "historyaction-submit": "판 보이기", "confirmdeletetext": "문서와 문서 역사를 삭제하려고 합니다.\n삭제하려는 문서가 맞는지, 이 문서를 삭제하는 것이 [[{{MediaWiki:Policy-url}}|정책]]에 맞는 행동인지를 확인해 주세요.", "actioncomplete": "동작 완료", "actionfailed": "명령 실패", diff --git a/languages/i18n/kum.json b/languages/i18n/kum.json index 207d587303..848847b7ef 100644 --- a/languages/i18n/kum.json +++ b/languages/i18n/kum.json @@ -332,7 +332,7 @@ "page_first": "биринчи", "page_last": "ахырынчы", "histlegend": "Тюзлевлерден сайлав: тенглешдирмеге сюеген бары тюрлерин белгилеп '''{{int:compare-submit}}''' бас.
\nАчыкълавлар: '''({{int:cur}})''' — гьалиги тюрюнден башгъалыкъ; '''({{int:last}})''' — алдагъы тюрюнден башгъалыкъ; '''{{int:minoreditletter}}''' — увакъ тюзелтив.", - "history-fieldset-title": "Тюзлевлеге излев", + "history-fieldset-title": "Тюзлев сюзгюч", "histfirst": "инг эсгилер", "histlast": "инг янгылар", "historyempty": "бош", diff --git a/languages/i18n/lb.json b/languages/i18n/lb.json index f3425cadd3..d4b3e7ff6c 100644 --- a/languages/i18n/lb.json +++ b/languages/i18n/lb.json @@ -2087,7 +2087,7 @@ "delete-confirm": "Läsche vu(n) \"$1\"", "delete-legend": "Läschen", "historywarning": "Opgepasst: 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", @@ -2107,6 +2107,7 @@ "deleting-backlinks-warning": "Opgepasst: [[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": "Opgepasst: 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", diff --git a/languages/i18n/lrc.json b/languages/i18n/lrc.json index 7458dea35a..31b169e80e 100644 --- a/languages/i18n/lrc.json +++ b/languages/i18n/lrc.json @@ -76,12 +76,12 @@ "thu": "پٱن شٱمٱ", "fri": "جۏمٱ", "sat": "شٱمٱ", - "january": "جانڤیٱ", + "january": "ژانڤیٱ", "february": "فڤریٱ", "march": "مارس", "april": "آڤریل", "may_long": "ماٛی", - "june": "جوئٱن", + "june": "ژوئٱن", "july": "جۊلای", "august": "آگوست", "september": "سپتامر", @@ -100,12 +100,12 @@ "october-gen": "اوکتوبر", "november-gen": "نوڤامر", "december-gen": "دسامر", - "jan": "جانڤیٱ", + "jan": "ژانڤیٱ", "feb": "فڤریٱ", "mar": "مارس", "apr": "آڤریل", "may": "ماٛی", - "jun": "جوئٱن", + "jun": "ژوئٱن", "jul": "جۊلای", "aug": "آگوست", "sep": "سپتامر", @@ -169,7 +169,7 @@ "history": "ڤیرگار بٱلگٱ", "history_short": "ڤیرگار", "updatedmarker": "د آخئری دییئن مئنە ڤئ هنگوم کو", - "printableversion": "نۏسخٱ پلا بینی", + "printableversion": "نۏسخٱ پلا بیئنی", "permalink": "هوم پاٛڤٱن هٱمیشاٛیی", "print": "چاپ گئرئتئن", "view": "دیئن", @@ -214,12 +214,12 @@ "pool-errorunknown": "خأطا نادیار", "pool-servererror": "پوٙل ئشمار خئذمأتگە د دأسرئس نی($1).", "poolcounter-usage-error": "خأطا ڤئ کار گئرئتئن:$1", - "aboutsite": "دبارٱ {{SITENAME}}", - "aboutpage": "Project:دبارٱ", + "aboutsite": "دٱربارٱ {{SITENAME}}", + "aboutpage": "Project:دٱربارٱ", "copyright": "مینۊنٱیا هان د دٱسرس $1 مٱر یٱ کاٛ ڤ یاٛ گاٛل شیڤاٛ هٱنی نیسٱنٱ بۊٱ.", "copyrightpage": "{{ns:project}}:کوپی رایت", - "currentevents": "روخ ڤنؽا ایسنی", - "currentevents-url": "Project:روخ ڤنؽا ایسنی", + "currentevents": "روخ ڤٱنؽا ایسنی", + "currentevents-url": "Project:روخ ڤٱنؽا ایسنی", "disclaimers": "تیٱپۊشکاریٛا", "disclaimerpage": "پروژٱ: تیٱپۊشی کردن همٱگیر", "edithelp": "هومياری سی ڤیرایش", @@ -620,7 +620,7 @@ "templatesused": "{{PLURAL:$1|چۊئٱ|چۊئٱ یا}} ڤ کار گرتٱ بیٱ د اؽ بٱلگٱ:", "templatesusedpreview": "{{PLURAL:$1| چۊئٱ|چۊئٱ یا}} ڤ کار گرتٱ بیٱ د پیش ساٛلٛ :", "templatesusedsection": "{{PLURAL:$1|چۊئٱ|چۊئٱ یا}} ڤ کار گرتٱ بیٱ د اؽ بٱلگٱ:", - "template-protected": "(پٱر ۉ پیم بیٱ)", + "template-protected": "(پر ۉ پیم بیٱ)", "template-semiprotected": "(نسم ۉ نیمٱ پٱر ۉ پیم بیٱ)", "hiddencategories": "اؽ بٱلگٱ یٱکؽ د ٱندومؽا ٱ {{PLURAL:$1|1 hidden category|$1 hidden categories}} :", "edittools-upload": "-", @@ -826,17 +826,17 @@ "next-page": "بلگه نهایی", "prevn-title": "زیتر $1 {{PLURAL:$1|نٱتیجٱ|نٱتيجٱيا}}", "nextn-title": "دمایی $1 {{PLURAL:$1|نتيجٱ|نتيجيا}}", - "shown-title": "نشوݩ داٛین $1 {{PLURAL:$1|نتیجٱ|نتیجٱیا}} سی هار بٱلگٱ", + "shown-title": "نشوݩ داٛین $1 {{PLURAL:$1|نتیجٱ|نتیجٱیا}} سی هٱر بٱلگٱ", "viewprevnext": "ديین ($1 {{int:pipe-separator}} $2) ($3)", "searchmenu-exists": "'''ایچاْ بٱلگاٛیؽ هؽ ڤ نوم\"[[:$1]]\" کاْ ها د اؽ ڤیکی'''", "searchmenu-new": "'''اؽ بٱلگٱ ناْ دۏرس كو \"[[:$1]]\" د اؽ ڤیکی!'''", "searchprofile-articles": "بٱلگٱيا مؽنونٱ دار", - "searchprofile-images": "ڤارسگرؽا خلکمن", + "searchprofile-images": "ڤارسگرؽا خلکمٱن", "searchprofile-everything": "همٱ چی", "searchprofile-advanced": "پیشکردٱ", "searchprofile-articles-tooltip": "بٱگٱرد مؽن $1", - "searchprofile-images-tooltip": "جانؽایاناْ پاٛ جۊری کو", - "searchprofile-everything-tooltip": "همٱ مؽنونٱیا ناْ پۊ جۊری كو (شاملاْ بٱلگٱيا چٱک چنٱ)", + "searchprofile-images-tooltip": "جانؽایا ناْ پاٛ جۊری کو", + "searchprofile-everything-tooltip": "همٱ مؽنونٱیا ناْ پاٛ جۊری كو (شامل بٱلگٱيا چٱک چنٱ)", "searchprofile-advanced-tooltip": "نوم جايا نوم دؽار بٱگٱرد", "search-result-size": "$1 ({{PLURAL:$2|1 کلٱمٱ|$2 کلٱمٱیا}})", "search-result-category-size": "{{PLURAL:$1|1 ٱندوم|$1 ٱندومؽا}} ({{PLURAL:$2|1 زؽردٱسٱ|$2 زؽردٱسٱیا}}، {{PLURAL:$3|1 جانؽا|$3 جانؽایا}}", @@ -1406,7 +1406,7 @@ "filehist-revert": "لٛرنیئن", "filehist-current": "تازٱ با", "filehist-datetime": "ڤيرگار/ڤٱخت", - "filehist-thumb": "عٱسک کوچک بیٱ", + "filehist-thumb": "عٱسگ کوچک بیٱ", "filehist-thumbtext": "كوچک کردن سی نۏسخٱ چی $1", "filehist-nothumb": "بؽ بٱن کلٛکی", "filehist-user": "کاریار", @@ -1901,7 +1901,7 @@ "undelete-show-file-confirm": "آیا یه دل بئیته که میهایت یه گل نسقه پاکسا بیه د جانیا \"$1\" که ها د ویرگار $2 ساعت $3 نه سیل بکیت؟", "undelete-show-file-submit": "هأری", "namespace": "نوم جا", - "invert": "گلٛٱڤرچی بیئن بٱرعٱسک بۊئٱ", + "invert": "گلٛٱڤرچی بیئن بٱرعٱسگ بۊئٱ", "tooltip-invert": "د ری اؽ جٱڤٱ بٱپۊرنؽت ۉ آلشتؽایؽ ناْ کاْ د مؽنجا نوم ڤرگٱ اْنتخاو بیٱ ٱنجوم بینٱ قایم بٱکؽت(ۉ ٱر نوم ڤرگٱ شریکی ڤارسی بیٱ)", "tooltip-whatlinkshere-invert": "ای جعون نه سی نهو کردن هوم پیوند بلگه یایی که نوم جاشو انتخاو بیه، انتخاو بکیت.", "namespace_association": "نوم جایا یٱکاگرتٱ", @@ -2016,7 +2016,7 @@ "blocklist-nousertalk": "نبوئه بلگه چک چنه خوتونه ویرایشت بکید", "ipblocklist-empty": "جاگه نوم گه حالیه", "ipblocklist-no-results": "دسرسی نوم کاریاری یا تیرنشون آی پی حاسته بیه نهاگری نبیه.", - "blocklink": "نوئاگری بۊئٱ", + "blocklink": "نوئاگیری بۊئٱ", "unblocklink": "بی قطی", "change-blocklink": "اجازه نديئن سی آلشت", "contribslink": "هومیاریٛا", @@ -2249,13 +2249,13 @@ "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": "یاٛ جاگٱ سی فٱمسن", @@ -2290,7 +2290,7 @@ "tooltip-watchlistedit-raw-submit": "وه هنگوم سازی سیل برگ", "tooltip-recreate": "د نو راس کردن بلگه بی یه که و پاکساگری دماتر وه سیل بکیم", "tooltip-upload": "شرو د سوار کرد", - "tooltip-rollback": "\"ڤرگٱشتن\" لٛرسن د هال و بال ٱڤٱل سی اؽ بٱلگٱ سی یٱ کاْ هومیاری نؽایی بؽتر کاری بیٱ ڤا یاٛ پۊرنین.", + "tooltip-rollback": "\"ڤرگٱشتن\" لٛرسن د هال و بار ٱڤٱل سی اؽ بٱلگٱ سی یٱ کاْ هومیاری نؽایی بؽتر کاری بیٱ ڤا یاٛ پۊرنیئن.", "tooltip-undo": "ٱنجوم نٱگرتن اؽ ڤیرایش. ڤرگٱرد ۉ هٱمٱ فورمؽا ڤیرایشؽاناْ د هالٱت پیش ساٛلٛ ڤاکو.یٱ اْجازٱ ماٛئٱ سی اْزاف کردن یاٛ دٱلٛیلٛ د چکسٱ.", "tooltip-preferences-save": "اولويتيا نه ذخيره بكيد", "tooltip-summary": "ياٛ چكسٱ کوچک ڤارد بٱکؽت", diff --git a/languages/i18n/lt.json b/languages/i18n/lt.json index 4daa9bad4f..72d86c1cf4 100644 --- a/languages/i18n/lt.json +++ b/languages/i18n/lt.json @@ -2319,6 +2319,7 @@ "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", diff --git a/languages/i18n/lv.json b/languages/i18n/lv.json index aac9a3d740..6d328e6001 100644 --- a/languages/i18n/lv.json +++ b/languages/i18n/lv.json @@ -727,7 +727,7 @@ "histfirst": "Senākās", "histlast": "Jaunākās", "historysize": "({{PLURAL:$1|$1 baiti|1 baits|$1 baiti}})", - "historyempty": "(tukša)", + "historyempty": "tukša", "history-feed-title": "Versiju hronoloģija", "history-feed-description": "Šīs wiki lapas versiju hronoloģija", "history-feed-item-nocomment": "$1 : $2", @@ -1854,7 +1854,7 @@ "delete-confirm": "Dzēst \"$1\"", "delete-legend": "Dzēšana", "historywarning": "'''Brīdinājums:''' Lapai, ko tu gatavojies dzēst, ir vēsture ar aptuveni $1 {{PLURAL:$1|versijām|versiju|versijām}}:", - "historyaction-submit": "Rādīt", + "historyaction-submit": "Rādīt versijas", "confirmdeletetext": "Tu tūlīt no datubāzes dzēsīsi lapu vai attēlu, kā arī to iepriekšējās versijas. Lūdzu, apstiprini, ka tu tiešām to vēlies darīt, ka tu apzinies sekas un ka tu to dari saskaņā ar [[{{MediaWiki:Policy-url}}|vadlīnijām]].", "actioncomplete": "Darbība pabeigta", "actionfailed": "Darbība neizdevās", diff --git a/languages/i18n/mk.json b/languages/i18n/mk.json index 00ec23e19b..5c127b9e06 100644 --- a/languages/i18n/mk.json +++ b/languages/i18n/mk.json @@ -45,7 +45,7 @@ "tog-watchrollback": "Додај ги страниците сум ги отповикал во набљудувани", "tog-minordefault": "Обележувај ги сите уредувања како ситни по основно", "tog-previewontop": "Прикажи го прегледот пред кутијата за уредување", - "tog-previewonfirst": "Прикажи преглед на првото уредување", + "tog-previewonfirst": "Прикажи преглед при првото уредување", "tog-enotifwatchlistpages": "Испраќај ми е-пошта при промена на страница или податотека од мојот список на набљудувања", "tog-enotifusertalkpages": "Испраќај ми е-пошта при промена на мојата разговорна страница", "tog-enotifminoredits": "Испраќај ми е-пошта и за ситни промени во страниците и податотеките", @@ -505,7 +505,7 @@ "accountcreatedtext": "Корисничката сметка за [[{{ns:User}}:$1|$1]] ([[{{ns:User talk}}:$1|разговор]]) е направена.", "createaccount-title": "Создавање на сметка за {{SITENAME}}", "createaccount-text": "Некој направил сметка со вашата е-поштенска адреса на {{SITENAME}} ($4) со име „$2“ и лозинка „$3“.\nБи требало сега да се пријавите и да ја промените вашата лозинка.\n\nМожете да ја занемарите оваа порака ако сметката била направена по грешка.", - "login-throttled": "Имате премногу обиди за најава за кратко време.\nПочекајте $1 пред да се обидете повторно.", + "login-throttled": "Направивте премногу обиди за најава.\nПочекајте $1 пред да се обидете повторно.", "login-abort-generic": "Најавата е неуспешна — Откажано", "login-migrated-generic": "Вашата сметка е пренесена и корисничкото име веќе не постои на ова вики.", "loginlanguagelabel": "Јазик: $1", @@ -823,7 +823,7 @@ "page_first": "прв", "page_last": "последен", "histlegend": "Разлика помеѓу преработките: Означете ги преработките што сакате да ги споредите и притиснете на Enter или копчето на дното од страницата.
\nЛегенда: '''({{int:cur}})''' = разлика со последна преработка, '''({{int:last}})''' = разлика со претходна преработка, '''{{int:minoreditletter}}''' = ситна промена.", - "history-fieldset-title": "Пребарај преработки", + "history-fieldset-title": "Филтрирај преработки", "history-show-deleted": "Само избришани преработки", "histfirst": "најстари", "histlast": "најнови", @@ -880,7 +880,7 @@ "revdelete-suppress": "Притајувај податоци и од администраторите", "revdelete-unsuppress": "Отстрани ограничувања на обновени преработки", "revdelete-log": "Причина:", - "revdelete-submit": "Примени врз {{PLURAL:$1|одбрана преработка|одбрани преработки}}", + "revdelete-submit": "Примени врз {{PLURAL:$1|избраната преработка|избраните преработки}}", "revdelete-success": "Видливоста на преработката е изменета.", "revdelete-failure": "'''Видливоста на преработката не можеше да се измени:'''\n$1", "logdelete-success": "Дневникот на видливост е поставен.", @@ -1075,7 +1075,7 @@ "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|Корисничко име}}:", @@ -1268,7 +1268,7 @@ "grant-delete": "Бришење страници, преработки и дневнички записи", "grant-editinterface": "Уредување на именскиот простор „МедијаВики“ и JSON за цело вики/за корисник", "grant-editmycssjs": "Уредување на вашиот кориснички CSS/JSON/JavaScript", - "grant-editmyoptions": "Уредување на вашите кориснички нагодувања и поставеноста на JSON", + "grant-editmyoptions": "Уредување на вашите кориснички нагодувања и JSON-конфигурацијата", "grant-editmywatchlist": "Уредување на вашите набљудувани", "grant-editsiteconfig": "Уредување на CSS/JS за цело вики и за корисник", "grant-editpage": "Менување постоечки страници", @@ -1397,7 +1397,7 @@ "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": "Филтри", @@ -1935,7 +1935,7 @@ "uncategorizedtemplates": "Некатегоризирани преуредувања", "uncategorized-categories-exceptionlist": " # Содржи список на категории кои не треба да се споменуваат во Special:UncategorizedCategories. По една во секој нов ред што почнува со „*“. Редовите што почнуваат со друг знак (заклучно со празни места) ќе се занемарат. Користете „#“ за прибелешки.", "unusedcategories": "Неискористени категории", - "unusedimages": "Неискористени слики", + "unusedimages": "Неискористени податотеки", "wantedcategories": "Потребни категории", "wantedpages": "Потребни страници", "wantedpages-summary": "Список на непостоечки страници со највеќе врски што водат до нив, исклучувајќи страниците до кои водат само пренасочувања. Список на непостоечки страници до кои водат пренасочувања ќе најдете на [[{{#special:BrokenRedirects}}|списокот на прекинати пренасочувања]].", @@ -2208,10 +2208,10 @@ "notanarticle": "Не е статија", "notvisiblerev": "Преработката била избришана", "watchlist-details": "Во вашите набљудувани имате {{PLURAL:$1|$1 страница|$1 страници}} (не броејќи ги разговорните страници).", - "wlheader-enotif": "Известувањето по е-пошта е вклучено.", + "wlheader-enotif": "Известувањето по е-пошта е овозможено.", "wlheader-showupdated": "Страниците кои се променети од вашата последна посета се прикажани со задебелени букви.", "wlnote": "Подолу {{PLURAL:$1|е прикажана последната промена|се прикажани последните $1 промени}} во {{PLURAL:$2|последниов час|последниве $2 часа}}, заклучно со $3, $4 ч.", - "wlshowlast": "Прикажи ги последните $1 часа, $2 дена,", + "wlshowlast": "Прикажи ги последните $1 часа, $2 дена", "watchlist-hide": "Скриј", "watchlist-submit": "Прикажи", "wlshowtime": "Период за приказ:", @@ -2253,7 +2253,7 @@ "delete-confirm": "Бришење на „$1“", "delete-legend": "Бришење", "historywarning": "Предупредување: Страницата што сакате да ја избришете има историја со {{PLURAL:$1|една преработка|$1 преработки}}:", - "historyaction-submit": "Прикажи", + "historyaction-submit": "Прикажи преработки", "confirmdeletetext": "На пат сте трајно да избришете страница заедно со нејзината историја.\nПотврдете дека имате намера да го направите ова, дека ги разбирате последиците од тоа и дека го правите во согласност со [[{{MediaWiki:Policy-url}}|правилата]].", "actioncomplete": "Дејството е извршено", "actionfailed": "Неуспешно дејство", @@ -2936,7 +2936,7 @@ "pageinfo-category-files": "Број на податотеки", "pageinfo-user-id": "Корисничка назнака", "pageinfo-file-hash": "Тарабна вредност", - "pageinfo-view-protect-log": "Погл. го дневникот на заштити за страницава.", + "pageinfo-view-protect-log": "Преглед на дневникот на заштити за страницава.", "markaspatrolleddiff": "Означи како проверена верзија", "markaspatrolledtext": "Означи ја верзијата како проверена", "markaspatrolledtext-file": "Означи ја верзијава како испатролирана", @@ -3611,7 +3611,7 @@ "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|милениум|милениуми}}", diff --git a/languages/i18n/my.json b/languages/i18n/my.json index 50dfe18052..6d8f5e6f43 100644 --- a/languages/i18n/my.json +++ b/languages/i18n/my.json @@ -608,6 +608,7 @@ "copyrightwarning": "{{SITENAME}} တွင် ရေးသားမှုအားလုံးကို $2 အောက်တွင် ဖြန့်ဝေရန် ဆုံးဖြတ်ပြီး ဖြစ်သည်ကို ကျေးဇူးပြု၍ သတိပြုပါ။။ (အသေးစိတ်ကို $1 တွင်ကြည့်ပါ။)\nအကယ်၍ သင့်ရေးသားချက်များကို အညှာအတာမရှိ တည်းဖြတ်ခံရခြင်း၊ စိတ်တိုင်းကျ ဖြန့်ဝေခံရခြင်းတို့ကို အလိုမရှိပါက ဤနေရာတွင် မတင်ပါနှင့်။
\nသင်သည် ဤဆောင်းပါးကို သင်ကိုယ်တိုင်ရေးသားခြင်း၊ သို့မဟုတ် အများပြည်သူဆိုင်ရာဒိုမိန်းများ၊ ယင်းကဲ့သို့ လွတ်လပ်သည့် ရင်းမြစ်မှ ကူးယူထားခြင်း ဖြစ်ကြောင်းလည်း ဝန်ခံ ကတိပြုပါသည်။\nမူပိုင်ခွင့်ရှိသော စာ၊ပုံများကို ခွင့်ပြုချက်မရှိဘဲ မတင်ပါနှင့်။", "copyrightwarning2": "{{SITENAME}} တွင် ရေးသားမှုအားလုံးသည် အခြားပုံပိုးသူများ၏ တည်းဖြတ်၊ ပြောင်းလဲ၊ ဖယ်ရှားခံရနိုင်သည်ကို သတိပြုပါ။\nအကယ်၍ သင့်ရေးသားချက်များကို အညှာအတာမရှိ တည်းဖြတ်ခံရခြင်း၊ စိတ်တိုင်းကျ ဖြန့်ဝေခံရခြင်းတို့ကို အလိုမရှိပါက ဤနေရာတွင် မတင်ပါနှင့်။
\nသင်သည် ဤဆောင်းပါးကို သင်ကိုယ်တိုင်ရေးသားခြင်း၊ သို့မဟုတ် အများပြည်သူဆိုင်ရာဒိုမိန်းများ၊ ယင်းကဲ့သို့ လွတ်လပ်သည့် ရင်းမြစ်မှ ကူးယူထားခြင်း ဖြစ်ကြောင်းလည်း ဝန်ခံ ကတိပြုပါသည် (အသေးစိတ်ကို $1 တွင်ကြည့်ပါ)။\nမူပိုင်ခွင့်ရှိသော စာ၊ပုံများကို ခွင့်ပြုချက်မရှိဘဲ မတင်ပါနှင့်။", "editpage-cannot-use-custom-model": "ဤစာမျက်နှာ၏ မာတိကာမော်ဒယ်ကို မပြောင်းလဲနိုင်ခဲ့ပါ။", + "readonlywarning": "သတိပေးချက်: ပြုပြင်ထိန်းသိမ်းရေးအတွက် ဒေတာဘေ့စ်ကို ပိတ်ထားလိုက်ပါပြီ၊ ယခုလက်ရှိ တည်းဖြတ်နိုင်တာ့မည် မဟုတ်ပါ။\n\n\nယင်းအားပိတ်ခဲ့သည့် စနစ်အက်ဒမင်က ဤရှင်းပြချက်ကို ပေးထားပါသည်: $1", "protectedpagewarning": "သတိပေးချက်။ ဤစာမျက်နှာအား စီမံခန့်ခွဲသူအဆင့်ရှိသူများသာ ပြင်ဆင်နိုင်ရန် ကာကွယ်ထားသည်။\nနောက်ဆုံးမှတ်တမ်းအား ကိုးကားနိုင်ရန် အောက်တွင် ဖော်ပြထားသည်။", "semiprotectedpagewarning": "မှတ်ချက်။ ဤစာမျက်နှာအား အလိုအလျောက် အတည်ပြုထားသော အသုံးပြုသူအဆင့်ရှိသူများသာ တည်းဖြတ်နိုင်ရန် ကာကွယ်ထားသည်။\nနောက်ဆုံးမှတ်တမ်းအား ကိုးကားနိုင်ရန် အောက်တွင် ဖော်ပြထားသည်။", "titleprotectedwarning": "သတိပေးချက်။ ဤစာမျက်နှာကို ကာကွယ်ထားပြီး ဖန်တီးနိုင်ရန်အတွက် [[Special:ListGroupRights|အထူး အခွင့်အရေးများ]]ရှိထားရန် လိုအပ်သည်။\nနောက်ဆုံးမှတ်တမ်းအား ကိုးကားနိုင်ရန် အောက်တွင် ဖော်ပြထားသည်။", diff --git a/languages/i18n/nl.json b/languages/i18n/nl.json index dfaf649cd8..489fba65df 100644 --- a/languages/i18n/nl.json +++ b/languages/i18n/nl.json @@ -749,7 +749,7 @@ "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": "Opmerking: nadat u de wijzigingen hebt opgeslagen is het wellicht nodig uw browsercache te legen.\n* Firefox / Safari: houd Shift ingedrukt terwijl u op Vernieuwen klikt of druk op Ctrl-F5 of Ctrl-R (⌘-Shift-R op een Mac)\n* Google Chrome: druk op Ctrl-Shift-R (⌘-Shift-R op een Mac)\n* Internet Explorer: houd Ctrl ingedrukt terwijl u op Vernieuwen klikt of druk op Ctrl-F5\n* '''Opera:''' ga naar Menu → Instellingen (Opera → Voorkeuren op een Mac) en daarna naar Privacy & beveiliging → Browsegegevens wissen... → Tijdelijk opgeslgen afbeeldingen en bestanden.", + "clearyourcache": "Opmerking: nadat u de wijzigingen hebt opgeslagen is het wellicht nodig uw browsercache te legen.\n* Firefox / Safari: houd Shift ingedrukt terwijl u op Vernieuwen klikt of druk op Ctrl-F5 of Ctrl-R (⌘-Shift-R op een Mac)\n* Google Chrome: druk op Ctrl-Shift-R (⌘-Shift-R op een Mac)\n* Internet Explorer: houd Ctrl ingedrukt terwijl u op Vernieuwen klikt of druk op Ctrl-F5\n* '''Opera:''' ga naar Menu → Instellingen (Opera → Voorkeuren op een Mac) en daarna naar Privacy & beveiliging → Browsegegevens wissen... → Tijdelijk opgeslagen afbeeldingen en bestanden.", "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.", @@ -2314,7 +2314,7 @@ "delete-confirm": "\"$1\" verwijderen", "delete-legend": "Verwijderen", "historywarning": "Waarschuwing: 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", diff --git a/languages/i18n/nn.json b/languages/i18n/nn.json index 0af2815ea9..6e14ad1bd3 100644 --- a/languages/i18n/nn.json +++ b/languages/i18n/nn.json @@ -2140,6 +2140,7 @@ "ipb-unblock": "Opphev blokkeringa av eit brukarnamn eller ei IP-adresse", "ipb-blocklist": "Vis gjeldande blokkeringar", "ipb-blocklist-contribs": "Bidrag frå $1", + "block-actions": "Handlingar som skal blokkerast:", "block-expiry": "Opphøyrstid:", "unblockip": "Opphev blokkering", "unblockiptext": "Bruk skjemaet nedanfor for å oppheve blokkeringa av ein tidlegare blokkert brukar.", diff --git a/languages/i18n/pl.json b/languages/i18n/pl.json index aa6717e83b..a862c18c95 100644 --- a/languages/i18n/pl.json +++ b/languages/i18n/pl.json @@ -895,7 +895,7 @@ "page_first": "początek", "page_last": "koniec", "histlegend": "Wybór porównania – zaznacz kropeczkami dwie wersje do porównania i wciśnij enter lub przycisk ''Porównaj wybrane wersje''.
\nLegenda: (bież.) – pokaż zmiany od tej wersji do bieżącej,\n(poprz.) – pokaż zmiany od wersji poprzedzającej, m – mała (drobna) zmiana", - "history-fieldset-title": "Szukaj wersji", + "history-fieldset-title": "Filtruj wersje", "history-show-deleted": "Tylko usunięte edycje", "histfirst": "od najstarszych", "histlast": "od najnowszych", @@ -2320,7 +2320,7 @@ "delete-confirm": "Usuwanie „$1”", "delete-legend": "Usuń", "historywarning": "Uwaga: Strona, którą chcesz usunąć, ma {{PLURAL:$1|jedną starszą wersję|$1 starsze wersje|$1 starszych wersji}}:", - "historyaction-submit": "Pokaż", + "historyaction-submit": "Pokaż wersje", "confirmdeletetext": "Zamierzasz usunąć stronę razem z całą dotyczącą jej historią.\nUpewnij się, czy na pewno chcesz to zrobić, że rozumiesz konsekwencje i że robisz to w zgodzie z [[{{MediaWiki:Policy-url}}|zasadami]].", "actioncomplete": "Operacja wykonana", "actionfailed": "Operacja się nie powiodła", diff --git a/languages/i18n/pt-br.json b/languages/i18n/pt-br.json index f9ab2225e0..3d758592b1 100644 --- a/languages/i18n/pt-br.json +++ b/languages/i18n/pt-br.json @@ -920,7 +920,7 @@ "page_first": "primeira", "page_last": "última", "histlegend": "Como selecionar: marque as caixas de seleção das versões que deseja comparar e pressione enter ou clique no botão na parte inferior do formulário.
\nLegenda: ({{int:cur}}) = diferenças em relação a última versão, ({{int:last}}) = diferenças em relação a versão anterior, {{int:minoreditletter}} = edição menor.", - "history-fieldset-title": "Pesquisar revisões", + "history-fieldset-title": "Filtrar revisões", "history-show-deleted": "Apenas as revisões excluídas", "histfirst": "Mais antigas", "histlast": "Mais novas", @@ -2361,7 +2361,7 @@ "delete-confirm": "Eliminar \"$1\"", "delete-legend": "Eliminar", "historywarning": "Aviso: A página que está prestes a eliminar tem um histórico com aproximadamente $1 {{PLURAL:$1|revisão|revisões}}:", - "historyaction-submit": "Exibir", + "historyaction-submit": "Mostrar revisões", "confirmdeletetext": "Encontra-se prestes a eliminar uma página juntamente com todo o seu histórico.\nPor favor, confirme que possui a intenção de fazer isto, que compreende as consequências e que encontra-se a fazer isto de acordo com as [[{{MediaWiki:Policy-url}}|políticas]] do projeto.", "actioncomplete": "Ação efetuada com sucesso", "actionfailed": "Falha na ação", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index e45dda0e92..a22fac3d9f 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -1054,7 +1054,7 @@ "page_first": "This is part of the navigation message on the top and bottom of Special pages which are lists of things in alphabetical order, e.g. the '[[Special:Categories|Categories]]' special page. It is followed by the message {{msg-mw|Viewprevnext}}.\n{{Identical|First}}", "page_last": "This is part of the navigation message on the top and bottom of Special pages which are lists of things in alphabetical order, e.g. the '[[Special:Categories|Categories]]' special page. It is followed by the message {{msg-mw|Viewprevnext}}.\n\n{{Identical|Last}}", "histlegend": "Text in history page.\n\nSee also:\n* {{msg-mw|Cur}}\n* {{msg-mw|Last}}\n* {{msg-mw|Minoreditletter}}", - "history-fieldset-title": "Fieldset label in the edit history pages.", + "history-fieldset-title": "Form legend label in the edit history page.", "history-show-deleted": "CheckBox to show only per [[mw:Manual:RevisionDelete|RevisionDelete]] deleted versions.\n\nUsed in History and [[Special:Contributions]].", "history_copyright": "{{notranslate}}", "histfirst": "This is part of the navigation message on the top and bottom of Page History pages which are lists of things in date order, e.g. [{{canonicalurl:Support|action=history}} Page History of Support].\n\nIt is followed by the message {{msg-mw|Viewprevnext}}.\n{{Identical|Oldest}}", @@ -2572,7 +2572,7 @@ "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}}", @@ -4179,15 +4179,15 @@ "mediastatistics-header-3d": "Header on [[Special:MediaStatistics]] for file types that are in the 3D category. Includes STL files.", "mediastatistics-header-total": "Header on [[Special:MediaStatistics]] for a summary of all file types.", "json-warn-trailing-comma": "A warning message notifying that JSON text was automatically corrected by removing erroneous commas.\n\nParameters:\n* $1 - number of commas that were removed\n{{Related|Json-error}}", - "json-error-unknown": "User error message when there’s an unknown error.\n\nThis error is shown if we received an unexpected value from PHP. See https://secure.php.net/manual/en/function.json-last-error.php\n\nParameters:\n* $1 - integer error code\n{{Related|Json-error}}", - "json-error-depth": "User error message when the maximum stack depth is exceeded.\nSee https://secure.php.net/manual/en/function.json-last-error.php\n{{Related|Json-error}}", - "json-error-state-mismatch": "User error message when underflow or the modes mismatch.\n\n'''Underflow''': A data-processing error arising when the absolute value of a computed quantity is smaller than the limits of precision of the computing device, retaining at least one significant digit.\n\nSee https://secure.php.net/manual/en/function.json-last-error.php\n{{Related|Json-error}}", - "json-error-ctrl-char": "User error message when an unexpected control character has been found.\nSee https://secure.php.net/manual/en/function.json-last-error.php\n{{Related|Json-error}}", - "json-error-syntax": "User error message when there is a syntax error; a malformed JSON.\nSee https://secure.php.net/manual/en/function.json-last-error.php\n{{Related|Json-error}}\n{{Identical|Syntax error}}", - "json-error-utf8": "User error message when there are malformed UTF-8 characters, possibly incorrectly encoded.\nSee https://secure.php.net/manual/en/function.json-last-error.php\n{{Related|Json-error}}", - "json-error-recursion": "PHP JSON encoding/decoding error. See https://secure.php.net/manual/en/function.json-last-error.php\n{{Related|Json-error}}", - "json-error-inf-or-nan": "PHP JSON encoding/decoding error. See https://secure.php.net/manual/en/function.json-last-error.php\n{{Related|Json-error}}", - "json-error-unsupported-type": "PHP JSON encoding/decoding error. See https://secure.php.net/manual/en/function.json-last-error.php\n{{Related|Json-error}}", + "json-error-unknown": "User error message when there’s an unknown error.\n\nThis error is shown if we received an unexpected value from PHP. See https://www.php.net/manual/en/function.json-last-error.php\n\nParameters:\n* $1 - integer error code\n{{Related|Json-error}}", + "json-error-depth": "User error message when the maximum stack depth is exceeded.\nSee https://www.php.net/manual/en/function.json-last-error.php\n{{Related|Json-error}}", + "json-error-state-mismatch": "User error message when underflow or the modes mismatch.\n\n'''Underflow''': A data-processing error arising when the absolute value of a computed quantity is smaller than the limits of precision of the computing device, retaining at least one significant digit.\n\nSee https://www.php.net/manual/en/function.json-last-error.php\n{{Related|Json-error}}", + "json-error-ctrl-char": "User error message when an unexpected control character has been found.\nSee https://www.php.net/manual/en/function.json-last-error.php\n{{Related|Json-error}}", + "json-error-syntax": "User error message when there is a syntax error; a malformed JSON.\nSee https://www.php.net/manual/en/function.json-last-error.php\n{{Related|Json-error}}\n{{Identical|Syntax error}}", + "json-error-utf8": "User error message when there are malformed UTF-8 characters, possibly incorrectly encoded.\nSee https://www.php.net/manual/en/function.json-last-error.php\n{{Related|Json-error}}", + "json-error-recursion": "PHP JSON encoding/decoding error. See https://www.php.net/manual/en/function.json-last-error.php\n{{Related|Json-error}}", + "json-error-inf-or-nan": "PHP JSON encoding/decoding error. See https://www.php.net/manual/en/function.json-last-error.php\n{{Related|Json-error}}", + "json-error-unsupported-type": "PHP JSON encoding/decoding error. See https://www.php.net/manual/en/function.json-last-error.php\n{{Related|Json-error}}", "headline-anchor-title": "Title tooltip for the section anchor symbol, which is a link to the current section. Can be interpreted both as a noun (\"this is a link\") or as a verb (\"use this to link\").", "special-characters-group-latin": "This is the name of a script, or alphabet, not a language.", "special-characters-group-latinextended": "The name of the Latin Extended character set.", diff --git a/languages/i18n/ro.json b/languages/i18n/ro.json index 28bfd42a91..99f2a77a17 100644 --- a/languages/i18n/ro.json +++ b/languages/i18n/ro.json @@ -2018,8 +2018,8 @@ "querypage-disabled": "Această pagină specială este dezactivată din motive de performanță.", "apihelp": "Ajutor API", "apihelp-no-such-module": "Modulul „$1” nu a fost găsit.", - "apisandbox": "Cutia cu nisip pentru API", - "apisandbox-jsonly": "Este nevoie de JavaScript pentru a folosi Cutia cu nisip pentru API.", + "apisandbox": "Pagina de teste pentru API", + "apisandbox-jsonly": "Este nevoie de JavaScript pentru a folosi pagina de teste pentru API.", "apisandbox-api-disabled": "API este dezactivat pe acest site.", "apisandbox-intro": "Folosiți această pagină pentru a experimenta cu API-ul MediaWiki. Citiți [[mw:API:Main page|documentația API-ului]] pentru mai multe detalii de utilizare. Exemplu: [https://www.mediawiki.org/wiki/API#A_simple_example obțineți conținutul paginii principale]. Selectați o acțiune pentru a vedea mai multe exemple.", "apisandbox-submit": "Efectuați cererea", @@ -2044,7 +2044,7 @@ "apisandbox-sending-request": "Se trimite solicitarea API...", "apisandbox-loading-results": "Se obțin rezultatele API...", "apisandbox-results-error": "A apărut o eroare la încărcarea răspunsului solicitării API: $1.", - "apisandbox-results-login-suppressed": "Această cerere a fost procesată ca venind din partea unui utilizator neautentificat deoarece poate fi folosită pentru a evita verificările cu privire la originea comună făcute de browser. Metoda automată de administrare a token-urilor din groapa cu nisip pentru APU nu funcționează corect cu aceste cereri, vă rugăm să le completați manual.", + "apisandbox-results-login-suppressed": "Această cerere a fost procesată ca venind din partea unui utilizator neautentificat deoarece poate fi folosită pentru a evita verificările cu privire la originea comună făcute de browser. Metoda automată de administrare a token-urilor din pagina de teste pentru API nu funcționează corect cu aceste cereri, vă rugăm să le completați manual.", "apisandbox-request-selectformat-label": "Afișați datele solicitate ca:", "apisandbox-request-format-url-label": "Șir de interogări URL", "apisandbox-request-url-label": "URL cerere:", diff --git a/languages/i18n/roa-tara.json b/languages/i18n/roa-tara.json index 7c601f414e..534d0a4a3b 100644 --- a/languages/i18n/roa-tara.json +++ b/languages/i18n/roa-tara.json @@ -58,6 +58,7 @@ "tog-norollbackdiff": "Non sce penzanne a le differenze apprisse l'esecuzione de 'nu rollback", "tog-useeditwarning": "Avvisave quanne jie lasse 'na pàgene cangiate senze ca agghie sarvate le cangiaminde", "tog-prefershttps": "Ause sembre 'na connessione secure quanne trase", + "tog-showrollbackconfirmation": "Fà 'ndrucà 'na richieste de conferme quanne ste cazze sus a 'nu collegamende de annullamende", "underline-always": "Sembre", "underline-never": "Maje", "underline-default": "Valore de default d'u browser o scheme", @@ -448,6 +449,7 @@ "badretype": "Le passuord ca è scritte non ge sonde uguale.", "usernameinprogress": "'Na ccrejazzione de 'nu cunde pe stu nome utende ste già in esecuzione.\nPe piacere, aspitte.", "userexists": "'U nome de l'utende ca è scritte jè già ausate.\nPe piacere scacchiane n'otre.", + "createacct-normalization": "'U nome utende tune avène corrette jndr'à \"$2\" purcé stonne de le restriziune tecniche.", "loginerror": "Errore de collegamende", "createacct-error": "Errore sus 'a ccrejazione d'u cunde", "createaccounterror": "Non ge puè ccrejà 'u cunde utende: $1", @@ -794,7 +796,7 @@ "page_first": "prime", "page_last": "urteme", "histlegend": "Differenze de selezione: signe le radio box de le versiune ca vuè cu combronde e cazze ''invio'' o 'u buttone ca ste sotte.
\nLeggenda: (cur) = differenze cu 'a versiona corrende,\n(last) = differenze ca 'a versione precedende, M = cangiaminde stuédeche.", - "history-fieldset-title": "cirche pe revisiune", + "history-fieldset-title": "Filtre le revisiune", "history-show-deleted": "Sule le revisiune scangellate", "histfirst": "Prime", "histlast": "Urteme", @@ -2104,7 +2106,7 @@ "delete-confirm": "Scangille \"$1\"", "delete-legend": "Scangille", "historywarning": "Vide Bbuene: 'A pàgene ca ste scangille tène 'na storie de cangiaminde cu cchiù o mene $1 {{PLURAL:$1|revisione|revisiune}}:", - "historyaction-submit": "Fà 'ndrucà", + "historyaction-submit": "Fà 'ndrucà le revisiune", "confirmdeletetext": "Vide Bbuene, vide ca ste scangille 'na pàgene ca tène pure nu sbuenne de cangiaminde.\nConferme quidde ca ste face, ce si sicure ca è capite quidde ca ste cumbine e ce è corrette rispette a [[{{MediaWiki:Policy-url}}|le regole de scangellazione]], ce no statte quiete.", "actioncomplete": "Aziona Combletete", "actionfailed": "Aziona fallite", diff --git a/languages/i18n/ru.json b/languages/i18n/ru.json index d16e9aae36..a7d26b13ff 100644 --- a/languages/i18n/ru.json +++ b/languages/i18n/ru.json @@ -185,6 +185,7 @@ "tog-norollbackdiff": "Не показывать разницу версий после выполнения отката", "tog-useeditwarning": "Предупреждать, когда я покидаю страницу с несохранёнными изменениями", "tog-prefershttps": "Всегда использовать защищённое соединение после представления системе", + "tog-showrollbackconfirmation": "Показывать подтверждение при нажатии ссылки для отката", "underline-always": "Всегда", "underline-never": "Никогда", "underline-default": "Использовать настройки браузера", @@ -943,7 +944,7 @@ "histfirst": "старейшие", "histlast": "новейшие", "historysize": "($1 {{PLURAL:$1|байт|байта|байт}})", - "historyempty": "(пусто)", + "historyempty": "пусто", "history-feed-title": "История изменений", "history-feed-description": "История изменений этой страницы в вики", "history-feed-item-nocomment": "$1 в $2", @@ -1306,7 +1307,7 @@ "right-reupload-own": "Перезапись файлов, загруженных тем же участником", "right-reupload-shared": "Замена файлов из общих хранилищ локальными", "right-upload_by_url": "Загрузка файлов с адреса URL", - "right-purge": "Очистка кэша страниц без подтверждения", + "right-purge": "Очистка кэша страницы", "right-autoconfirmed": "Обход ограничений скорости на IP-адрес", "right-bot": "Автоматический процесс", "right-nominornewtalk": "Малые правки на страницах обсуждений участников не создают для них уведомление о новом сообщении", @@ -2601,6 +2602,8 @@ "ipb-confirm": "Подтвердить блокировку", "ipb-sitewide": "Во всём проекте", "ipb-partial": "Частичная", + "ipb-sitewide-help": "Каждая страница вики и все другие действия вклада.", + "ipb-partial-help": "Конкретные страницы или пространства имён.", "ipb-pages-label": "Страницы", "ipb-namespaces-label": "Пространства имён", "badipaddress": "IP-адрес записан в неправильном формате, или участника с таким именем не существует.", @@ -3187,6 +3190,7 @@ "confirm-unwatch-top": "Удалить эту страницу из вашего списка наблюдения?", "confirm-rollback-button": "ОК", "confirm-rollback-top": "Откатить правки на этой странице?", + "confirm-rollback-bottom": "Это действие немедленно откатит выбранные изменения этой страницы.", "confirm-mcrrestore-title": "Восстановить версию", "confirm-mcrundo-title": "Отменить изменение", "mcrundofailed": "Отменить не удалось", @@ -3642,7 +3646,7 @@ "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 загрузил{{GENDER:$2||а}} $3", + "logentry-upload-revert": "$1 откатил{{GENDER:$2||а}} $3 к старой версии", "log-name-managetags": "Журнал управления метками", "log-description-managetags": "На этой странице перечислены задачи, связанные с управлением [[Special:Tags|метками]]. Журнал содержит только действия, выполненные администратором вручную. Метки могут быть созданы или удалены с помощью программного обеспечения вики без добавления записей в этот журнал.", "logentry-managetags-create": "$1 создал{{GENDER:$2||а}} метку «$4»", @@ -3882,6 +3886,7 @@ "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": "Предоставленные учётные данные не связаны ни с одним участником этой вики.", @@ -3985,6 +3990,7 @@ "passwordpolicies-policy-passwordcannotbepopular": "Пароль не может соответствовать {{PLURAL:$1|самому часто используемому паролю|какому-либо из $1 самых часто используемых паролей}}", "passwordpolicies-policy-passwordnotinlargeblacklist": "Пароль не может соответствовать какому-либо из 100 000 самых часто используемых паролей.", "passwordpolicies-policyflag-forcechange": "необходимо изменить при входе", + "passwordpolicies-policyflag-suggestchangeonlogin": "предложить изменение при входе", "easydeflate-invaliddeflate": "Предоставленное содержимое не спущено надлежащим образом", "unprotected-js": "По соображениям безопасности JavaScript нельзя загружать с незащищённых страниц. Пожалуйста, создавайте скрипты только в пространстве имён MediaWiki: или как подстраницы участника." } diff --git a/languages/i18n/sh.json b/languages/i18n/sh.json index 277dd778a5..a5e2487f63 100644 --- a/languages/i18n/sh.json +++ b/languages/i18n/sh.json @@ -804,7 +804,7 @@ "histfirst": "najstarije", "histlast": "najnovije", "historysize": "({{PLURAL:$1|1 bajt|$1 bajta|$1 bajtova}})", - "historyempty": "(prazno)", + "historyempty": "prazno", "history-feed-title": "Historija izmjena", "history-feed-description": "Historija promjena ove stranice na wikiju", "history-feed-item-nocomment": "$1 u $2", @@ -1165,7 +1165,7 @@ "right-reupload-own": "Postavljanje nove verzije datoteke koju je postavio korisnik", "right-reupload-shared": "Postavljanje novih lokalnih verzija datoteka identičnih onima u zajedničkoj ostavi", "right-upload_by_url": "Postavljanje datoteke sa URL adrese", - "right-purge": "Osvježavanje keša za stranice bez potvrde", + "right-purge": "Osvježavanje keša stranice", "right-autoconfirmed": "Izbjegavanje ograničenja stopa temeljenih na IP-u", "right-bot": "Postavljen kao automatski proces", "right-nominornewtalk": "Male izmjene na stranicama za razgovor ne uzrokuju obavještenje o novim porukama", @@ -1199,6 +1199,7 @@ "right-editsitejson": "Uređivanje JSON-a za cijelo wiki", "right-editsitejs": "Uređivanje JavaScripta za cijelo wiki", "right-editmyusercss": "Uredite svoje vlastite CSS datoteke", + "right-editmyuserjson": "Uređivanje vlastitih JSON datoteka", "right-editmyuserjs": "Uredite vlastite korisničke JavaScript datoteke", "right-viewmywatchlist": "Pregled vlastitog popisa praćenih stranica", "right-editmywatchlist": "Uređivanje vlastitih praćenih. Obratite pažnju da će neke akcije dodati stranice čak bez ovog prava.", @@ -1240,7 +1241,7 @@ "grant-delete": "Brisanje stranica, izmjena i unosa u zapisnicima", "grant-editinterface": "Uređivanje imenskog prostora \"MediaWiki\" i JSON za cijelo wiki/za korisnika", "grant-editmycssjs": "Uređivanje Vašeg korisničkog CSS/JSON/JavaScripta", - "grant-editmyoptions": "Uređivanje vaših korisničkih podešavanja i postavljenosti JSON-a", + "grant-editmyoptions": "Uređivanje vaših korisničkih podešavanja i JSON konfiguracije", "grant-editmywatchlist": "Uređivanje Vaših praćenih", "grant-editsiteconfig": "Uređivanje CSS/JS za cijelo wiki i za korisnika", "grant-editpage": "Uređivanje postojećih stranica", @@ -2836,7 +2837,7 @@ "pageinfo-category-files": "Broj datoteka", "pageinfo-user-id": "Korisnička naznaka", "pageinfo-file-hash": "Tarabna vrijednost", - "pageinfo-view-protect-log": "Pogl. evidenciju zaštite ove stranice.", + "pageinfo-view-protect-log": "Vidi evidenciju zaštićivanja ove stranice.", "markaspatrolleddiff": "Označi kao patrolirano", "markaspatrolledtext": "Označi ovu stranicu kao patroliranu", "markaspatrolledtext-file": "Označi ovu verziju kao ispatroliranu", diff --git a/languages/i18n/sl.json b/languages/i18n/sl.json index 7bcf82a4bb..41224ce0ff 100644 --- a/languages/i18n/sl.json +++ b/languages/i18n/sl.json @@ -2236,7 +2236,7 @@ "delete-confirm": "Brisanje »$1«", "delete-legend": "Izbriši", "historywarning": "Opozorilo: Stran, ki jo nameravate izbrisati, ima zgodovino s približno $1 {{PLURAL:$1|redakcijo|redakcijama|redakcijami}}:", - "historyaction-submit": "Prikaži", + "historyaction-submit": "Pokaži redakcije", "confirmdeletetext": "Iz zbirke podatkov boste izbrisali stran ali sliko skupaj z vso njeno zgodovino.\nProsimo, '''potrdite''', da to resnično želite, da razumete posledice dejanja in da se ravnate po [[{{MediaWiki:Policy-url}}|pravilih]].", "actioncomplete": "Poseg je končan", "actionfailed": "Dejanje spodletelo", diff --git a/languages/i18n/sr-ec.json b/languages/i18n/sr-ec.json index b03fd93d1f..fe737be72b 100644 --- a/languages/i18n/sr-ec.json +++ b/languages/i18n/sr-ec.json @@ -181,7 +181,7 @@ "newwindow": "(отвара се у новом прозору)", "cancel": "Откажи", "moredotdotdot": "Више…", - "morenotlisted": "Ова листа можда није потпуна.", + "morenotlisted": "Овај списак можда није потпун.", "mypage": "Страница", "mytalk": "Разговор", "anontalk": "Разговор", @@ -314,8 +314,8 @@ "feed-atom": "Atom", "feed-rss": "RSS", "red-link-title": "$1 (страница не постоји)", - "sort-descending": "Сортирај опадајуће", - "sort-ascending": "Сортирај растуће", + "sort-descending": "Поређај опадајуће", + "sort-ascending": "Поређај растуће", "nstab-main": "Страница", "nstab-user": "{{GENDER:{{BASEPAGENAME}}|Корисник|Корисница}}", "nstab-media": "Медији", @@ -330,7 +330,7 @@ "nosuchaction": "Нема такве радње", "nosuchactiontext": "Радња која је наведена у УРЛ-у није важећа.\nМожда сте погрешно откуцали УРЛ или сте следили покварену везу.\nОво такође може да указује на грешку у софтверу који користи {{SITENAME}}.", "nosuchspecialpage": "Нема такве посебне странице", - "nospecialpagetext": "Захтевали сте неважећу посебну страницу.\n\nЛиста важећих посебних страница може се пронађи на „[[Special:SpecialPages|{{int:specialpages}}]]”.", + "nospecialpagetext": "Тражили сте неважећу посебну страницу.\n\nСписак важећих може се пронађи на „[[Special:SpecialPages|{{int:specialpages}}]]”.", "error": "Грешка", "databaseerror": "Грешка у бази података", "databaseerror-text": "Дошло је до грешке у упиту базе података. \nОво може да указује на грешку у софтверу.", @@ -905,7 +905,7 @@ "revdelete-edit-reasonlist": "Уреди разлоге за брисање", "revdelete-offender": "Аутор измене:", "suppressionlog": "Дневник сакривања", - "suppressionlogtext": "Испод се налази листа брисања и блокирања који укључује садржај сакривен од администратора. Погледајте [[Special:BlockList|листу блокирања]] за списак тренутних операција забрана и блокирања.", + "suppressionlogtext": "Испод се налази списак брисања и блокирања који укључује садржај сакривен од администратора. Погледајте [[Special:BlockList|списак блокирања]] за списак текућих забрана и блокирања.", "mergehistory": "Обједињавање историја странице", "mergehistory-header": "Ова страница вам допушта да обједините историју измена неке изворне странице у новију страницу.\nУверите се да ће ова промена оставити непромењен садржај историје странице.", "mergehistory-box": "Обједини измене двеју страница:", @@ -937,7 +937,7 @@ "mergehistory-revisionrow": "$1 ($2) $3 . . $4 $5 $6", "mergelog": "Дневник обједињавања", "revertmerge": "растави", - "mergelogpagetext": "Испод се налази листа најновијих обједињавања историја једне странице у другу.", + "mergelogpagetext": "Испод се налази списак најновијих обједињавања историја једне странице у другу.", "history-title": "Историја измена странице „$1”", "difference-title": "Разлика између измена на страници „$1”", "difference-title-multipage": "Разлика између страница „$1“ и „$2“", @@ -1569,7 +1569,7 @@ "upload-preferred": "Препоручени {{PLURAL:$2|тип|типови}} датотека: $1.", "upload-prohibited": "Забрањени {{PLURAL:$2|тип|типови}} датотека: $1.", "uploadlogpage": "Дневник отпремања", - "uploadlogpagetext": "Испод се налази листа најновијих отпремања.\nПогледајте [[Special:NewFiles|галерију нових датотека]] за визуелнији преглед.", + "uploadlogpagetext": "Испод се налази списак најновијих отпремања.\nПогледајте [[Special:NewFiles|галерију нових датотека]] за визуелнији преглед.", "filename": "Назив датотеке", "filedesc": "Опис измене", "fileuploadsummary": "Опис измене:", @@ -1778,7 +1778,7 @@ "listfiles_search_for": "Претражи име медија:", "listfiles-userdoesnotexist": "Кориснички налог „$1“ није отворен.", "imgfile": "датотека", - "listfiles": "Листа датотека", + "listfiles": "Списак датотека", "listfiles_thumb": "Сличица", "listfiles_date": "Датум", "listfiles_name": "Назив", @@ -1807,7 +1807,7 @@ "filehist-comment": "Коментар", "imagelinks": "Употреба датотеке", "linkstoimage": "{{PLURAL:$1|Следећа страница користи|$1 следеће странице користе|$1 следећих страница користи}} ову датотеку:", - "linkstoimage-more": "Више од $1 {{PLURAL:$1|страница користи|странице користе|страница користи}} ову датотеку.\nСледећа листа приказује {{PLURAL:$1|прву страницу која користи|прве $1 странице које користе|првих $1 страница које користе}} само ову датотеку.\nДоступна је и [[Special:WhatLinksHere/$2|потпуна листа]].", + "linkstoimage-more": "Више од $1 {{PLURAL:$1|страница користи|странице користе|страница користи}} ову датотеку.\nСледећи списак приказује {{PLURAL:$1|прву страницу која користи|прве $1 странице које користе|првих $1 страница које користе}} само ову датотеку.\nДоступан је и [[Special:WhatLinksHere/$2|потпуни списак]].", "nolinkstoimage": "Нема страница које користе ову датотеку.", "morelinkstoimage": "Погледајте [[Special:WhatLinksHere/$1|више веза]] до ове датотеке.", "linkstoimage-redirect": "$1 (преусмерење датотеке) $2", @@ -1855,9 +1855,9 @@ "mimetype": "МИМЕ врста:", "download": "преузми", "unwatchedpages": "Ненадгледане странице", - "listredirects": "Листа преусмерења", - "listduplicatedfiles": "Листа датотека са дупликатима", - "listduplicatedfiles-summary": "Ово је листа датотека чија је најновија верзија дупликат неких других датотека. Само локалне датотеке су приказане.", + "listredirects": "Списак преусмерења", + "listduplicatedfiles": "Списак датотека са дупликатима", + "listduplicatedfiles-summary": "Ово је списак датотека чија је најновија верзија дупликат неких других датотека. Само локалне датотеке су приказане.", "listduplicatedfiles-entry": "[[:File:$1|$1]] има [[$3|{{PLURAL:$2|један дупликат|$2 дупликата}}]].", "unusedtemplates": "Некоришћени шаблони", "unusedtemplatestext": "Ова страница наводи све странице у именском простору {{ns:template}} које нису укључене ни на једној другој страници.\nПре брисања проверите да ли друге странице воде до тих шаблона.", @@ -1891,7 +1891,7 @@ "pageswithprop-text": "Ова страна излистава стране које имају одређену особину", "pageswithprop-prop": "Име особине:", "pageswithprop-reverse": "Поређај у супротном редоследу", - "pageswithprop-sortbyvalue": "Сортирај по вредности својства", + "pageswithprop-sortbyvalue": "Поређај по вредности својства", "pageswithprop-submit": "Иди", "pageswithprop-prophidden-long": "сакривено дуго текстуално својство ($1)", "pageswithprop-prophidden-binary": "сакривено дуго бинарно својство ($1)", @@ -1929,7 +1929,7 @@ "unusedimages": "Некоришћене датотеке", "wantedcategories": "Тражене категорије", "wantedpages": "Тражене странице", - "wantedpages-summary": "Листа непостојећих страница са највише веза на њих, на овом списку се не налазе странице до којих воде преусмерења. За списак покварених преусмерења, погледајте [[{{#special:BrokenRedirects}}|листу покварених преусмерења]].", + "wantedpages-summary": "Списак непостојећих страница са највише веза на њих, на овом списку се не налазе странице до којих воде преусмерења. За списак покварених преусмерења, погледајте [[{{#special:BrokenRedirects}}|списак покварених преусмерења]].", "wantedpages-badtitle": "Невалидан наслов у скупу резултата: $1", "wantedfiles": "Тражене датотеке", "wantedfiletext-cat": "Следеће датотеке се користе, али не постоје. Датотеке из других ризница могу бити наведене иако не постоје. Такве датотеке ће бити поништене са списка. Поред тога, странице које садрже непостојеће датотеке се налазе [[:$1|овде]].", @@ -1971,10 +1971,10 @@ "protectedtitles-summary": "Ова страница наводи наслове који су тренутно заштићени од прављења. За листу постојећих страница које су заштићене, погледајте [[{{#special:ProtectedPages}}|{{int:protectedpages}}]].", "protectedtitlesempty": "Нема заштићених наслова с овим параметрима.", "protectedtitles-submit": "Прикажи наслове", - "listusers": "Листа корисника", + "listusers": "Списак корисника", "listusers-editsonly": "Прикажи само кориснике који су уређивали", "listusers-temporarygroupsonly": "Прикажи само кориснике у привременим корисничким групама", - "listusers-creationsort": "Сортирај по датуму прављења", + "listusers-creationsort": "Поређај по датуму отварања налога", "listusers-desc": "Поређај у опадајућем редоследу", "usereditcount": "$1 {{PLURAL:$1|измена|измене|измена}}", "usercreated": "{{GENDER:$3|је направио|је направила|је направио}} дана $1 у $2", @@ -2038,7 +2038,7 @@ "booksources-search-legend": "Претражи штампане изворе", "booksources-isbn": "ISBN:", "booksources-search": "Претражи", - "booksources-text": "Испод се налази листа веза на друге сајтове који се баве продајом нових и половних књига, а који би могли имати додатне податке о књигама које тражите:", + "booksources-text": "Испод се налази списак веза на друге сајтове који се баве продајом нових и половних књига, а који би могли имати додатне податке о књигама које тражите:", "booksources-invalid-isbn": "Наведени ISBN број није валидан. Проверите да није дошло до грешке при копирању из првобитног извора.", "magiclink-tracking-rfc": "Странице са чаробним RFC везама", "magiclink-tracking-pmid": "Странице са чаробним PMID везама", @@ -2093,8 +2093,8 @@ "listusers-submit": "Прикажи", "listusers-noresult": "Корисник није пронађен.", "listusers-blocked": "({{GENDER:$1|блокиран|блокирана|блокиран}})", - "activeusers": "Листа активних корисника", - "activeusers-intro": "Ово је листа корисника који су били активни {{PLURAL:$1|1=претходни дан|у последња $1 дана|у последњих $1 дана}}.", + "activeusers": "Списак активних корисника", + "activeusers-intro": "Ово је списак корисника који су били активни {{PLURAL:$1|1=претходни дан|у последња $1 дана|у последњих $1 дана}}.", "activeusers-count": "$1 {{PLURAL:$1|радња|радње|радњи}} {{PLURAL:$3|претходни дан|у последња $3 дана|у последњих $3 дана}}", "activeusers-from": "Прикажи кориснике почев од:", "activeusers-groups": "Прикажи кориснике који су чланови група:", @@ -2239,10 +2239,10 @@ "actionfailed": "Радња није успела", "deletedtext": "Страница „$1“ је избрисана.\nПогледајте $2 за запис недавних брисања.", "dellogpage": "Дневник брисања", - "dellogpagetext": "Испод се налази листа најновијих брисања.", + "dellogpagetext": "Испод се налази списак најновијих брисања.", "deletionlog": "дневник брисања", "log-name-create": "Дневник прављења страница", - "log-description-create": "Испод се налази листа најновијих прављења страница.", + "log-description-create": "Испод се налази списак најновијих прављења страница.", "logentry-create-create": "$1 је {{GENDER:$2|направио|направила}} страницу $3", "reverted": "Враћено на ранију измену", "deletecomment": "Разлог:", @@ -2290,7 +2290,7 @@ "logentry-contentmodel-change-revertlink": "врати", "logentry-contentmodel-change-revert": "врати", "protectlogpage": "Дневник заштите", - "protectlogtext": "Испод се налази листа промена заштите страница.\nПогледајте [[Special:ProtectedPages|листу заштићених страница]] за тренутно оперативне заштите страница.", + "protectlogtext": "Испод се налази списак промена заштите страница.\nПогледајте [[Special:ProtectedPages|списак заштићених страница]] за текуће заштите страница.", "protectedarticle": "је {{GENDER:|заштитио|заштитила}} страницу „[[$1]]“", "modifiedarticleprotection": "је {{GENDER:|променио|променила}} ниво заштите странице „[[$1]]“", "unprotectedarticle": "је скинуо заштиту са странице „[[$1]]“", @@ -2493,10 +2493,10 @@ "blocklist": "Блокирани корисници", "autoblocklist": "Аутоблокови", "autoblocklist-submit": "Претражи", - "autoblocklist-legend": "Листа аутоблокирања", + "autoblocklist-legend": "Списак аутоблокирања", "autoblocklist-localblocks": "{{PLURAL:$1|Локални аутоблок|Локални аутоблокови}}", "autoblocklist-total-autoblocks": "Укупно аутоблокова: $1", - "autoblocklist-empty": "Листа аутоблокирања је празна.", + "autoblocklist-empty": "Списак аутоблокирања је празан.", "autoblocklist-otherblocks": "{{PLURAL:$1|Други аутоблок|Други аутоблокови}}", "ipblocklist": "Блокирани корисници", "ipblocklist-legend": "Проналажење блокираног корисника", @@ -2524,7 +2524,7 @@ "blocklist-editing-sitewide": "уређивање (на целом сајту)", "blocklist-editing-page": "странице", "blocklist-editing-ns": "именски простори", - "ipblocklist-empty": "Листа блокирања је празна.", + "ipblocklist-empty": "Списак блокирања је празан.", "ipblocklist-no-results": "Тражена IP адреса или корисничко име није блокирано.", "blocklink": "блокирај", "unblocklink": "деблокирај", @@ -2620,7 +2620,7 @@ "movepage-page-unmoved": "Страница $1 не може да се премести на $2.", "movepage-max-pages": "Највише $1 {{PLURAL:$1|страница је премештена|странице су премештене|страница је премештено}} и више не може да буде аутоматски премештено.", "movelogpage": "Дневник премештања", - "movelogpagetext": "Испод се налази листа свих премештања страница.", + "movelogpagetext": "Испод се налази списак свих премештања страница.", "movesubpage": "{{PLURAL:$1|Подстраница|Подстранице}}", "movesubpagetext": "Ова страница има $1 {{PLURAL:$1|подстраницу приказану|подстранице приказане|подстраница приказаних}} испод.", "movenosubpage": "Ова страница нема подстрана.", @@ -2665,7 +2665,7 @@ "allmessagesname": "Назив", "allmessagesdefault": "Подразумевани текст", "allmessagescurrent": "Актуелни текст поруке", - "allmessagestext": "Ово је листа системских порука доступних у именском простору „Медијавики”.\nПосетите [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation локализацију Медијавикија] и [https://translatewiki.net translatewiki.net] ако желите да допринесете општој локализацији Медијавикија.", + "allmessagestext": "Ово је списак системских порука доступних у именском простору „Медијавики”.\nПосетите [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation локализацију Медијавикија] и [https://translatewiki.net translatewiki.net] ако желите да допринесете општој локализацији Медијавикија.", "allmessagesnotsupportedDB": "Ова страница не може да се користи јер је '''$wgUseDatabaseMessages''' онемогућен.", "allmessages-filter-legend": "Филтер", "allmessages-filter": "Филтрирај по стању:", @@ -2751,9 +2751,9 @@ "tooltip-pt-mytalk": "{{GENDER:|Ваша}} страница за разговор", "tooltip-pt-anontalk": "Дискусија о изменама са ове IP адресе", "tooltip-pt-preferences": "{{GENDER:|Ваша}} подешавања", - "tooltip-pt-watchlist": "Листа страница чије промене надгледате", - "tooltip-pt-mycontris": "Листа {{GENDER:|ваших}} доприноса", - "tooltip-pt-anoncontribs": "Листа измена направљених са ове IP адресе", + "tooltip-pt-watchlist": "Списак страница чије промене надгледате", + "tooltip-pt-mycontris": "Списак {{GENDER:|ваших}} доприноса", + "tooltip-pt-anoncontribs": "Списак измена направљених са ове IP адресе", "tooltip-pt-login": "Предлажемо вам да се пријавите, иако то није обавезно", "tooltip-pt-login-private": "Морате да се пријавите да бисте користили овај Вики", "tooltip-pt-logout": "Одјавите се", @@ -2778,18 +2778,18 @@ "tooltip-n-mainpage-description": "Посетите главну страну", "tooltip-n-portal": "О пројекту, шта можете да радите и где да пронађете ствари", "tooltip-n-currentevents": "Пронађите информације о актуелностима", - "tooltip-n-recentchanges": "Листа недавних промена на викију", + "tooltip-n-recentchanges": "Списак недавних промена на викију", "tooltip-n-randompage": "Учитајте случајну страницу", "tooltip-n-help": "Место где можете да се информишете", - "tooltip-t-whatlinkshere": "Листа свих вики страница које воде овде", + "tooltip-t-whatlinkshere": "Списак свих вики страница које воде овде", "tooltip-t-recentchangeslinked": "Недавне промене на страницама које воде на ову страницу", "tooltip-feed-rss": "RSS фид за ову страницу", "tooltip-feed-atom": "Atom фид за ову страницу", - "tooltip-t-contributions": "Листа доприноса {{GENDER:$1|овог корисника|ове кориснице|овог корисника}}", + "tooltip-t-contributions": "Списак доприноса {{GENDER:$1|овог корисника|ове кориснице|овог корисника}}", "tooltip-t-emailuser": "Пошаљите е-поруку {{GENDER:$1|овом кориснику|овој корисници|кориснику/ци}}", "tooltip-t-info": "Више информација о овој страници", "tooltip-t-upload": "Отпремите датотеке", - "tooltip-t-specialpages": "Листа свих посебних страница", + "tooltip-t-specialpages": "Списак свих посебних страница", "tooltip-t-print": "Верзија ове странице за штампање", "tooltip-t-permalink": "Трајна веза ка овој измени странице", "tooltip-ca-nstab-main": "Погледајте страницу са садржајем", @@ -2859,7 +2859,7 @@ "pageinfo-header-restrictions": "Заштита странице", "pageinfo-header-properties": "Својства странице", "pageinfo-display-title": "Наслов за приказ", - "pageinfo-default-sort": "Подразумевани кључ сортирања", + "pageinfo-default-sort": "Подразумевани кључ ређања", "pageinfo-length": "Дужина странице (у бајтовима)", "pageinfo-namespace": "Именски простор", "pageinfo-article-id": "ID странице", @@ -2956,7 +2956,7 @@ "file-no-thumb-animation": "Напомена: Због техничких ограничења, сличице ове датотеке неће да се анимирају.", "file-no-thumb-animation-gif": "'''Напомена: због техничких ограничења, минијатуре GIF слика високе резолуције као што је ова неће се анимирати.'''", "newimages": "Галерија нових датотека", - "imagelisttext": "Испод се налази списак од $1 {{PLURAL:$1|датотеке|датотеке|датотека}} сортираних $2.", + "imagelisttext": "Испод се налази списак од $1 {{PLURAL:$1|датотеке|датотеке|датотека}} поређаних $2.", "newimages-summary": "Ова посебна страница приказује последње отпремљене датотеке.", "newimages-legend": "Филтер", "newimages-label": "Назив датотеке (или њен део):", @@ -3854,7 +3854,7 @@ "pagedata-bad-title": "Невалидан наслов: $1.", "unregistered-user-config": "Из безбедоносних разлога, јаваскрипт, Це-Ес-Ес и ЈСОН корисничке подстранице не могу бити учитане за нерегистроване кориснике.", "passwordpolicies": "Правила за лозинке", - "passwordpolicies-summary": "Ово је листа делотворних смерница за лозинке за корисничке групе дефинисане на овом викију.", + "passwordpolicies-summary": "Ово је списак делотворних смерница за лозинке за корисничке групе дефинисане на овом викију.", "passwordpolicies-group": "Група", "passwordpolicies-policies": "Правила", "passwordpolicies-policy-display": "$1 ($2)", diff --git a/maintenance/dev/includes/router.php b/maintenance/dev/includes/router.php index 1f34b5aae5..d767df0d8b 100644 --- a/maintenance/dev/includes/router.php +++ b/maintenance/dev/includes/router.php @@ -1,7 +1,7 @@ fatalError( "You must enable zlib support in PHP to compress old revisions!\n" . - "Please see https://secure.php.net/manual/en/ref.zlib.php\n" ); + "Please see https://www.php.net/manual/en/ref.zlib.php\n" ); } $type = $this->getOption( 'type', 'concat' ); diff --git a/maintenance/updateCollation.php b/maintenance/updateCollation.php index ab40e4863e..ebace75eab 100644 --- a/maintenance/updateCollation.php +++ b/maintenance/updateCollation.php @@ -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 ) { diff --git a/resources/Resources.php b/resources/Resources.php index cde47217b6..174c7d9bf3 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -1435,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' ], ], @@ -2099,7 +2100,15 @@ return [ ], ], 'mediawiki.special.block' => [ - 'scripts' => 'resources/src/mediawiki.special.block.js', + 'localBasePath' => "$IP/resources/src", + 'remoteBasePath' => "$wgResourceBasePath/resources/src", + 'packageFiles' => [ + 'mediawiki.special.block.js', + [ 'name' => 'config.json', 'config' => [ + 'EnablePartialBlocks', + 'BlockAllowsUTEdit', + ] ], + ], 'dependencies' => [ 'oojs-ui-core', 'oojs-ui.styles.icons-editing-core', diff --git a/resources/lib/foreign-resources.yaml b/resources/lib/foreign-resources.yaml index 651acc81a0..dc7379ad44 100644 --- a/resources/lib/foreign-resources.yaml +++ b/resources/lib/foreign-resources.yaml @@ -192,11 +192,11 @@ mustache: type: multi-file files: mustache.js: - src: https://raw.githubusercontent.com/janl/mustache.js/v1.0.0/mustache.js - integrity: sha384-k2UYqmzoiq/qgIzZvcYBxbXQW4YdPAsXDOTkHTGb9TCZ9sjCkyT4TlaUN0wQRkql + src: https://raw.githubusercontent.com/janl/mustache.js/v3.0.1/mustache.js + integrity: sha384-YjAj6Nll7fkEWzxTlE9o3NWC9qdZt1Upat6Afjib9eLs8lTODpSKEBHeXq8o/VUH LICENSE: - src: https://raw.githubusercontent.com/janl/mustache.js/v1.0.0/LICENSE - integrity: sha384-MYVwXwula9+YkyXexOJVZ0v0DaVvG22uX57mNq5Di+7u8OH9EG9q3yuXkp1Iehiq + src: https://raw.githubusercontent.com/janl/mustache.js/v3.0.1/LICENSE + integrity: sha384-j2EDj6YtCRgFvYDtzo6pXzbskIj/K1Yg65BL0j3/L6UIHxbMtRMJwC/W+XoYx0FZ oojs: type: tar diff --git a/resources/lib/mustache/LICENSE b/resources/lib/mustache/LICENSE index aa1b831603..4df7d1aae6 100644 --- a/resources/lib/mustache/LICENSE +++ b/resources/lib/mustache/LICENSE @@ -2,6 +2,7 @@ The MIT License Copyright (c) 2009 Chris Wanstrath (Ruby) Copyright (c) 2010-2014 Jan Lehnardt (JavaScript) +Copyright (c) 2010-2015 The mustache.js community Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/resources/lib/mustache/mustache.js b/resources/lib/mustache/mustache.js index c7ffbef007..8ec1b44cca 100644 --- a/resources/lib/mustache/mustache.js +++ b/resources/lib/mustache/mustache.js @@ -3,54 +3,86 @@ * http://github.com/janl/mustache.js */ -/*global define: false*/ +/*global define: false Mustache: true*/ -(function (global, factory) { - if (typeof exports === "object" && exports) { +(function defineMustache (global, factory) { + if (typeof exports === 'object' && exports && typeof exports.nodeName !== 'string') { factory(exports); // CommonJS - } else if (typeof define === "function" && define.amd) { + } else if (typeof define === 'function' && define.amd) { define(['exports'], factory); // AMD } else { - factory(global.Mustache = {}); // - // - // results in '<script>' and '</script>' - // (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 ); } - // ... 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: + // + // + // + // results in '<script>' and '</script>' + // (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( '' ), - // We use a greedy non-backtracking parser, so we must ensure here that we don't take too much - makeRegexParser( /^.*?(?=<\/nowiki>)/ ), - makeStringParser( '' ) - ] ); - if ( parsedResult !== null ) { - plainText = parsedResult[ 1 ]; - result = [ 'CONCAT' ].concat( plainText ); - } + return result; + } - return result; + // ... tag. The tags are stripped and the contents are returned unparsed. + function nowiki() { + var parsedResult, plainText, + result = null; + + parsedResult = sequence( [ + makeStringParser( '' ), + // We use a greedy non-backtracking parser, so we must ensure here that we don't take too much + makeRegexParser( /^.*?(?=<\/nowiki>)/ ), + makeStringParser( '' ) + ] ); + 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 = $( '' ).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 = $( '' ).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 = $( '' ).attr( { + title: page, + href: url + } ); + return appendWithoutParsing( $el, anchor ); + }, - $el = $( '' ).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 = $( '' ); + 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 = $( '' ); - 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 = $( '
' ); - 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 = $( '
' ); + return function () { + return $wrapper.msg( this.key, this.parameters ).contents().detach(); + }; }() ); diff --git a/resources/src/mediawiki.legacy/shared.css b/resources/src/mediawiki.legacy/shared.css index 5f20cf9921..a63c5c65e7 100644 --- a/resources/src/mediawiki.legacy/shared.css +++ b/resources/src/mediawiki.legacy/shared.css @@ -462,7 +462,7 @@ a.new { } .mw-datatable th { - background-color: #ddf; + background-color: #eaeeff; } .mw-datatable td { diff --git a/resources/src/mediawiki.less/mediawiki.mixins.less b/resources/src/mediawiki.less/mediawiki.mixins.less index 4c5b4a742f..4684ea27cd 100644 --- a/resources/src/mediawiki.less/mediawiki.mixins.less +++ b/resources/src/mediawiki.less/mediawiki.mixins.less @@ -40,12 +40,6 @@ 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 index 0000000000..ad8dbb3e85 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "parserOptions": { + "sourceType": "module" + } +} diff --git a/resources/src/mediawiki.rcfilters/Controller.js b/resources/src/mediawiki.rcfilters/Controller.js index ce5d4071c2..97b73ae2bd 100644 --- a/resources/src/mediawiki.rcfilters/Controller.js +++ b/resources/src/mediawiki.rcfilters/Controller.js @@ -1,1230 +1,1226 @@ -( 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.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 = $( '
' ).append( $( $.parseHTML( - data ? data.content : '' - ) ) ); - return this._extractChangesListInfo( $parsed, data.status ); - }.bind( this ) - ); - }; + $parsed = $( '
' ).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; diff --git a/resources/src/mediawiki.rcfilters/HighlightColors.js b/resources/src/mediawiki.rcfilters/HighlightColors.js index a4ef73bbf0..42bfae6a36 100644 --- a/resources/src/mediawiki.rcfilters/HighlightColors.js +++ b/resources/src/mediawiki.rcfilters/HighlightColors.js @@ -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; diff --git a/resources/src/mediawiki.rcfilters/UriProcessor.js b/resources/src/mediawiki.rcfilters/UriProcessor.js index 37874d5e5f..3b69654bf7 100644 --- a/resources/src/mediawiki.rcfilters/UriProcessor.js +++ b/resources/src/mediawiki.rcfilters/UriProcessor.js @@ -1,296 +1,294 @@ -( 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; diff --git a/resources/src/mediawiki.rcfilters/dm/ChangesListViewModel.js b/resources/src/mediawiki.rcfilters/dm/ChangesListViewModel.js index 64d2e79cf7..70677b91d4 100644 --- a/resources/src/mediawiki.rcfilters/dm/ChangesListViewModel.js +++ b/resources/src/mediawiki.rcfilters/dm/ChangesListViewModel.js @@ -1,169 +1,167 @@ -( 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; diff --git a/resources/src/mediawiki.rcfilters/dm/FilterGroup.js b/resources/src/mediawiki.rcfilters/dm/FilterGroup.js index db504b5937..d1f700cc1c 100644 --- a/resources/src/mediawiki.rcfilters/dm/FilterGroup.js +++ b/resources/src/mediawiki.rcfilters/dm/FilterGroup.js @@ -1,994 +1,967 @@ -( 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; +}; + +/** + * 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; +}; + +/** + * 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 +}; + +/** + * 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 ) { - // We must store all parameter values as strings '0' or '1' - if ( model.getType() === 'send_unselected_if_any' ) { - result[ filterParamNames[ name ] ] = areAnySelected ? - String( Number( !value ) ) : - '0'; - } else if ( model.getType() === 'boolean' ) { - // Representation is straight-forward and direct from - // the parameter value to the filter state - result[ filterParamNames[ name ] ] = String( Number( !!value ) ); - } else if ( model.getType() === 'any_value' ) { - result[ filterParamNames[ name ] ] = value; - } - } ); - } else if ( this.getType() === 'string_options' ) { - values = []; - - // eslint-disable-next-line 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 ); - } - - 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 ); + 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; + } } - 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; - } ); + if ( filterRepresentation[ item.getName() ] ) { + areAnySelected = true; } - - // 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. + * + * @return {string} Group prefix + */ +FilterGroup.prototype.getNamePrefix = function () { + return this.getName() + '__'; +}; + +/** + * Get a filter name with the prefix used for the filter names inside this group. + * + * @param {string} name Filter name to prefix + * @return {string} Group prefix + */ +FilterGroup.prototype.getPrefixedName = function ( name ) { + return this.getNamePrefix() + name; +}; + +/** + * Get group's title + * + * @return {string} Title + */ +FilterGroup.prototype.getTitle = function () { + return this.title; +}; + +/** + * Get group's values separator + * + * @return {string} Values separator + */ +FilterGroup.prototype.getSeparator = function () { + return this.separator; +}; + +/** + * Check whether the group is defined as full coverage + * + * @return {boolean} Group is full coverage + */ +FilterGroup.prototype.isFullCoverage = function () { + return this.fullCoverage; +}; + +/** + * Check whether the group is defined as sticky default + * + * @return {boolean} Group is sticky default + */ +FilterGroup.prototype.isSticky = function () { + return this.sticky; +}; + +/** + * Normalize a value given to this group. This is mostly for correcting + * arbitrary values for 'single option' groups, given by the user settings + * or the URL that can go outside the limits that are allowed. + * + * @param {string} value Given value + * @return {string} Corrected value + */ +FilterGroup.prototype.normalizeArbitraryValue = function ( value ) { + if ( + this.getType() === 'single_option' && + this.isAllowArbitrary() + ) { + if ( + this.getMaxValue() !== null && + value > this.getMaxValue() + ) { + // Change the value to the actual max value + return String( this.getMaxValue() ); + } else if ( + this.getMinValue() !== null && + value < this.getMinValue() + ) { + // Change the value to the actual min value + return String( this.getMinValue() ); + } + } + + return value; +}; + +/** + * Toggle the visibility of this group + * + * @param {boolean} [isVisible] Item is visible + */ +FilterGroup.prototype.toggleVisible = function ( isVisible ) { + isVisible = isVisible === undefined ? !this.visible : isVisible; + + if ( this.visible !== isVisible ) { + this.visible = isVisible; + this.emit( 'update' ); + } +}; + +/** + * Check whether the group is visible + * + * @return {boolean} Group is visible + */ +FilterGroup.prototype.isVisible = function () { + return this.visible; +}; + +/** + * Set the visibility of the items under this group by the given items array + * + * @param {mw.rcfilters.dm.ItemModel[]} visibleItems An array of visible items + */ +FilterGroup.prototype.setVisibleItems = function ( visibleItems ) { + this.getItems().forEach( function ( itemModel ) { + itemModel.toggleVisible( visibleItems.indexOf( itemModel ) !== -1 ); + } ); +}; + +module.exports = FilterGroup; diff --git a/resources/src/mediawiki.rcfilters/dm/FilterItem.js b/resources/src/mediawiki.rcfilters/dm/FilterItem.js index 1138c4e6f8..50057af650 100644 --- a/resources/src/mediawiki.rcfilters/dm/FilterItem.js +++ b/resources/src/mediawiki.rcfilters/dm/FilterItem.js @@ -1,406 +1,402 @@ -( 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 + * + * @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; diff --git a/resources/src/mediawiki.rcfilters/dm/FiltersViewModel.js b/resources/src/mediawiki.rcfilters/dm/FiltersViewModel.js index d1b9f7a973..4b219de1f8 100644 --- a/resources/src/mediawiki.rcfilters/dm/FiltersViewModel.js +++ b/resources/src/mediawiki.rcfilters/dm/FiltersViewModel.js @@ -1,1044 +1,1064 @@ -( 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 `_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 `_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' && @@ -1050,250 +1070,227 @@ 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; diff --git a/resources/src/mediawiki.rcfilters/dm/ItemModel.js b/resources/src/mediawiki.rcfilters/dm/ItemModel.js index 2dc578e054..ae8ac5fdb7 100644 --- a/resources/src/mediawiki.rcfilters/dm/ItemModel.js +++ b/resources/src/mediawiki.rcfilters/dm/ItemModel.js @@ -1,276 +1,274 @@ -( 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; diff --git a/resources/src/mediawiki.rcfilters/dm/SavedQueriesModel.js b/resources/src/mediawiki.rcfilters/dm/SavedQueriesModel.js index aa407b91ee..19de282e39 100644 --- a/resources/src/mediawiki.rcfilters/dm/SavedQueriesModel.js +++ b/resources/src/mediawiki.rcfilters/dm/SavedQueriesModel.js @@ -1,415 +1,413 @@ -( 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; diff --git a/resources/src/mediawiki.rcfilters/dm/SavedQueryItemModel.js b/resources/src/mediawiki.rcfilters/dm/SavedQueryItemModel.js index 1774391690..27e93e3071 100644 --- a/resources/src/mediawiki.rcfilters/dm/SavedQueryItemModel.js +++ b/resources/src/mediawiki.rcfilters/dm/SavedQueryItemModel.js @@ -1,127 +1,125 @@ -( 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; diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js index a69dc55051..4e5e0fe721 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js @@ -1,162 +1,158 @@ /*! * 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; diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.js index b32fb38dbf..5bf991680b 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.js @@ -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; diff --git a/resources/src/mediawiki.rcfilters/ui/ChangesLimitAndDateButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/ChangesLimitAndDateButtonWidget.js index 23b05e812b..ac5bbae78e 100644 --- a/resources/src/mediawiki.rcfilters/ui/ChangesLimitAndDateButtonWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/ChangesLimitAndDateButtonWidget.js @@ -1,174 +1,169 @@ -( 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: $( '
' ).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: $( '
' ).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 + */ +ChangesLimitAndDateButtonWidget.prototype.updateButtonLabel = function () { + var message, + limit = this.limitGroupModel.findSelectedItems()[ 0 ], + label = limit && limit.getLabel(), + days = this.daysGroupModel.findSelectedItems()[ 0 ], + daysParamName = Number( days.getParamName() ) < 1 ? + 'rcfilters-days-show-hours' : + 'rcfilters-days-show-days'; + + // Update the label + if ( label && days ) { + message = mw.msg( 'rcfilters-limit-and-date-label', label, + mw.msg( daysParamName, days.getLabel() ) + ); + this.button.setLabel( message ); + } +}; + +module.exports = ChangesLimitAndDateButtonWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js b/resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js index d78c42b633..a0c0d80c5d 100644 --- a/resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js @@ -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; diff --git a/resources/src/mediawiki.rcfilters/ui/ChangesListWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/ChangesListWrapperWidget.js index ba7f4d11a6..78cd8f4e62 100644 --- a/resources/src/mediawiki.rcfilters/ui/ChangesListWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/ChangesListWrapperWidget.js @@ -1,388 +1,369 @@ -( 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; + + // 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 ); + +/** + * 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 = $( '
' ) + .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( + $( '
' ) + .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-conflict' ) + .text( mw.message( 'rcfilters-noresults-conflict' ).text() ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-message' ) + .text( mw.message( conflictItem.getCurrentConflictResultMessage() ).text() ) + ); } else { - this.clearHighlight(); - } - }; + $message + .append( + $( '
' ) + .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 = $( '
' ) - .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( - $( '
' ) - .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-conflict' ) - .text( mw.message( 'rcfilters-noresults-conflict' ).text() ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-message' ) - .text( mw.message( conflictItem.getCurrentConflictResultMessage() ).text() ) - ); - } else { - $message - .append( - $( '
' ) - .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( $( '
' ).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( $( '
' ).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 = $( '
' ) - .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 = $( '
' ) + .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; diff --git a/resources/src/mediawiki.rcfilters/ui/CheckboxInputWidget.js b/resources/src/mediawiki.rcfilters/ui/CheckboxInputWidget.js index 490d54e460..b6e21cf2a0 100644 --- a/resources/src/mediawiki.rcfilters/ui/CheckboxInputWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/CheckboxInputWidget.js @@ -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; diff --git a/resources/src/mediawiki.rcfilters/ui/DatePopupWidget.js b/resources/src/mediawiki.rcfilters/ui/DatePopupWidget.js index 1ac0d495b3..226821cfa7 100644 --- a/resources/src/mediawiki.rcfilters/ui/DatePopupWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/DatePopupWidget.js @@ -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; diff --git a/resources/src/mediawiki.rcfilters/ui/FilterItemHighlightButton.js b/resources/src/mediawiki.rcfilters/ui/FilterItemHighlightButton.js index 13277555b7..fb591d0028 100644 --- a/resources/src/mediawiki.rcfilters/ui/FilterItemHighlightButton.js +++ b/resources/src/mediawiki.rcfilters/ui/FilterItemHighlightButton.js @@ -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; diff --git a/resources/src/mediawiki.rcfilters/ui/FilterMenuHeaderWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterMenuHeaderWidget.js index 13963413f7..3735af26b1 100644 --- a/resources/src/mediawiki.rcfilters/ui/FilterMenuHeaderWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/FilterMenuHeaderWidget.js @@ -1,184 +1,182 @@ -( 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: $( '
' ) - .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( - $( '
' ) - .addClass( 'mw-rcfilters-ui-table' ) - .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-back' ) - .append( this.backButton.$element ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-title' ) - .append( this.$label, this.helpIcon.$element ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-invert' ) - .append( this.invertNamespacesButton.$element ), - $( '
' ) - .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: $( '
' ) + .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( + $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-back' ) + .append( this.backButton.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-title' ) + .append( this.$label, this.helpIcon.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-invert' ) + .append( this.invertNamespacesButton.$element ), + $( '
' ) + .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; diff --git a/resources/src/mediawiki.rcfilters/ui/FilterMenuOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterMenuOptionWidget.js index 4080f4d645..b4b0e9dc95 100644 --- a/resources/src/mediawiki.rcfilters/ui/FilterMenuOptionWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/FilterMenuOptionWidget.js @@ -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; diff --git a/resources/src/mediawiki.rcfilters/ui/FilterMenuSectionOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterMenuSectionOptionWidget.js index 5b9e3590fe..abcce813a0 100644 --- a/resources/src/mediawiki.rcfilters/ui/FilterMenuSectionOptionWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/FilterMenuSectionOptionWidget.js @@ -1,127 +1,124 @@ -( 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 = $( '
' ) - .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header' ), - $popupContent = $( '
' ) - .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: $( '
' ) - .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 = $( '
' ) + .addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header' ), + $popupContent = $( '
' ) + .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: $( '
' ) + .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; diff --git a/resources/src/mediawiki.rcfilters/ui/FilterTagItemWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterTagItemWidget.js index bda898b49d..98eea71832 100644 --- a/resources/src/mediawiki.rcfilters/ui/FilterTagItemWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/FilterTagItemWidget.js @@ -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; diff --git a/resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js index dc6fc12e1d..085e22b0ac 100644 --- a/resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js @@ -1,777 +1,775 @@ -( 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 = $( '
' ) - .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: $( '
' ) - .append( new ViewSwitchWidget( this.controller, this.model ).$element ), - views: [ 'default' ] - }, - { - name: 'feedback', - // Feedback footer, appears on all views - $element: $( '
' ) - .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 = $( '
' ) + .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: $( '
' ) + .append( new ViewSwitchWidget( this.controller, this.model ).$element ), + views: [ 'default' ] + }, { - $overlay: this.$overlay + name: 'feedback', + // Feedback footer, appears on all views + $element: $( '
' ) + .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 = $( '
' ) - .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( - $( '
' ) - .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( - $( '
' ) - .addClass( 'mw-rcfilters-ui-table' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' ) - .append( this.input.$element ), - $( '
' ) - .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 = $( '
' ) + .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( $( '
' ) .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( - $( '
' ) - .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-title' ) - .append( title.$element ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-queryName' ) - .append( this.savedQueryTitle.$element ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-hideshow' ) - .append( - this.hideShowButton.$element - ) - ), - $( '
' ) - .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( + $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' ) + .append( this.input.$element ), + $( '
' ) + .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( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' ) + .append( this.resetButton.$element ) + ); + + // Build the content + $contentWrapper.append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-title' ) + .append( title.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-queryName' ) + .append( this.savedQueryTitle.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-hideshow' ) + .append( + this.hideShowButton.$element + ) + ), + $( '
' ) + .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; diff --git a/resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js index cb297f68ea..ce9656eb27 100644 --- a/resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js @@ -1,139 +1,137 @@ -( 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 = $( '
' ) + .addClass( 'mw-rcfilters-ui-filterWrapperWidget-top' ); + + $bottom = $( '
' ) + .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 = $( '
' ) - .addClass( 'mw-rcfilters-ui-filterWrapperWidget-top' ); - - $bottom = $( '
' ) - .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; diff --git a/resources/src/mediawiki.rcfilters/ui/FormWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/FormWrapperWidget.js index dbf177612f..7d69fb6be1 100644 --- a/resources/src/mediawiki.rcfilters/ui/FormWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/FormWrapperWidget.js @@ -1,176 +1,174 @@ -( 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
s, which are inside rcshowhide - // If we still have content in rcshowhide, the
s are - // gone. Instead, the CSS now has a rule to mark all 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
s, which are inside rcshowhide + // If we still have content in rcshowhide, the
s are + // gone. Instead, the CSS now has a rule to mark all s + // inside .rcshowhide with display:block; to simulate newlines + // where they're actually needed. + this.$element.find( 'br' ).detach(); + if ( !this.$element.find( '.rcshowhide' ).contents().length ) { + this.$element.find( '.rcshowhide' ).detach(); + } + + if ( this.$element.find( '.cloption' ).text().trim() === '' ) { + this.$element.find( '.cloption-submit' ).detach(); + } + + this.$element.find( + '.rclistfrom, .rcnotefrom, .rcoptions-listfromreset' + ).detach(); + + // Get rid of the legend + this.$element.find( 'legend' ).detach(); + + // Check if the element is essentially empty, and detach it if it is + if ( !this.$element.text().trim().length ) { + this.$element.detach(); + } +}; + +module.exports = FormWrapperWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/GroupWidget.js b/resources/src/mediawiki.rcfilters/ui/GroupWidget.js index 73b874cbe4..12d53bb74b 100644 --- a/resources/src/mediawiki.rcfilters/ui/GroupWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/GroupWidget.js @@ -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 + * @cfg {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; diff --git a/resources/src/mediawiki.rcfilters/ui/HighlightColorPickerWidget.js b/resources/src/mediawiki.rcfilters/ui/HighlightColorPickerWidget.js index cb5f8eb844..082b65b1a1 100644 --- a/resources/src/mediawiki.rcfilters/ui/HighlightColorPickerWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/HighlightColorPickerWidget.js @@ -1,125 +1,123 @@ -( 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; diff --git a/resources/src/mediawiki.rcfilters/ui/HighlightPopupWidget.js b/resources/src/mediawiki.rcfilters/ui/HighlightPopupWidget.js index 4c467dfae2..5a6901338f 100644 --- a/resources/src/mediawiki.rcfilters/ui/HighlightPopupWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/HighlightPopupWidget.js @@ -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; diff --git a/resources/src/mediawiki.rcfilters/ui/ItemMenuOptionWidget.js b/resources/src/mediawiki.rcfilters/ui/ItemMenuOptionWidget.js index 56ed628f5d..710bd653eb 100644 --- a/resources/src/mediawiki.rcfilters/ui/ItemMenuOptionWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/ItemMenuOptionWidget.js @@ -1,172 +1,169 @@ -( 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 = $( '
' ) - .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 = $( '
' ) + .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( + $( '
' ) + .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-title' ) + .append( $( '' ).append( this.$label ) ) + ); + if ( this.itemModel.getDescription() ) { $label.append( $( '
' ) - .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-title' ) - .append( $( '' ).append( this.$label ) ) + .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-desc' ) + .append( $( '' ).text( this.itemModel.getDescription() ) ) ); - if ( this.itemModel.getDescription() ) { - $label.append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-desc' ) - .append( $( '' ).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( + $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox' ) + .append( layout.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-excludeLabel' ) + .append( this.excludeLabel.$element ), + $( '
' ) + .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( - $( '
' ) - .addClass( 'mw-rcfilters-ui-table' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox' ) - .append( layout.$element ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-excludeLabel' ) - .append( this.excludeLabel.$element ), - $( '
' ) - .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; diff --git a/resources/src/mediawiki.rcfilters/ui/LiveUpdateButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/LiveUpdateButtonWidget.js index 3ccb6e25dd..04289c744e 100644 --- a/resources/src/mediawiki.rcfilters/ui/LiveUpdateButtonWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/LiveUpdateButtonWidget.js @@ -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; diff --git a/resources/src/mediawiki.rcfilters/ui/MainWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/MainWrapperWidget.js index bc1cac8d69..31edb77b64 100644 --- a/resources/src/mediawiki.rcfilters/ui/MainWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/MainWrapperWidget.js @@ -1,142 +1,140 @@ -( 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 = $( '
' ).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 = $( '
' ).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; diff --git a/resources/src/mediawiki.rcfilters/ui/MarkSeenButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/MarkSeenButtonWidget.js index 3914337c1a..c7fa334879 100644 --- a/resources/src/mediawiki.rcfilters/ui/MarkSeenButtonWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/MarkSeenButtonWidget.js @@ -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; diff --git a/resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js b/resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js index 864d0cf80f..1e7502018d 100644 --- a/resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js @@ -1,368 +1,366 @@ -( 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 = $( '
' ).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( - $( '
' ) - .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 = $( '
' ).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( + $( '
' ) + .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: $( '
' ) - .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: $( '
' ) + .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; diff --git a/resources/src/mediawiki.rcfilters/ui/RcTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/RcTopSectionWidget.js index 6de9c401a6..3d56fba648 100644 --- a/resources/src/mediawiki.rcfilters/ui/RcTopSectionWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/RcTopSectionWidget.js @@ -1,116 +1,114 @@ -( 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: $( '' ).append( mw.message( 'rcfilters-other-review-tools' ).parse() ).contents() - } ); + toplinksTitle = new OO.ui.ButtonWidget( { + framed: false, + indicator: topLinksCookieValue === 'collapsed' ? 'down' : 'up', + flags: [ 'progressive' ], + label: $( '' ).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 = $( '
' ) - .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-top' ); - this.$tableTopLinks = $( '
' ) - .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 = $( '
' ) + .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-top' ); + this.$tableTopLinks = $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-table' ); - // Initialize - this.$element - .addClass( 'mw-rcfilters-ui-rcTopSectionWidget' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-table' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .append( - this.$tableTopLinks, + // Initialize + this.$element + .addClass( 'mw-rcfilters-ui-rcTopSectionWidget' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + this.$tableTopLinks, + $( '
' ) + .addClass( 'mw-rcfilters-ui-table-placeholder' ) + .addClass( 'mw-rcfilters-ui-cell' ), + !mw.user.isAnon() ? $( '
' ) - .addClass( 'mw-rcfilters-ui-table-placeholder' ) - .addClass( 'mw-rcfilters-ui-cell' ), - !mw.user.isAnon() ? - $( '
' ) - .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; diff --git a/resources/src/mediawiki.rcfilters/ui/RclTargetPageWidget.js b/resources/src/mediawiki.rcfilters/ui/RclTargetPageWidget.js index 6eb0d5b252..382b54c8e5 100644 --- a/resources/src/mediawiki.rcfilters/ui/RclTargetPageWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/RclTargetPageWidget.js @@ -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; diff --git a/resources/src/mediawiki.rcfilters/ui/RclToOrFromWidget.js b/resources/src/mediawiki.rcfilters/ui/RclToOrFromWidget.js index e2c58d036a..46f2de92df 100644 --- a/resources/src/mediawiki.rcfilters/ui/RclToOrFromWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/RclToOrFromWidget.js @@ -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; diff --git a/resources/src/mediawiki.rcfilters/ui/RclTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/RclTopSectionWidget.js index d968b9e735..560f3d8b33 100644 --- a/resources/src/mediawiki.rcfilters/ui/RclTopSectionWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/RclTopSectionWidget.js @@ -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( - $( '
' ) - .addClass( 'mw-rcfilters-ui-table' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .append( + // Initialize + this.$element + .addClass( 'mw-rcfilters-ui-rclTopSectionWidget' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .append( toOrFromWidget.$element ) + ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .append( targetPage.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-table-placeholder' ) + .addClass( 'mw-rcfilters-ui-cell' ), + !mw.user.isAnon() ? $( '
' ) .addClass( 'mw-rcfilters-ui-cell' ) - .append( toOrFromWidget.$element ) - ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .append( targetPage.$element ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-table-placeholder' ) - .addClass( 'mw-rcfilters-ui-cell' ), - !mw.user.isAnon() ? - $( '
' ) - .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; diff --git a/resources/src/mediawiki.rcfilters/ui/SaveFiltersPopupButtonWidget.js b/resources/src/mediawiki.rcfilters/ui/SaveFiltersPopupButtonWidget.js index 8c3d5508a4..1c66c6e7f0 100644 --- a/resources/src/mediawiki.rcfilters/ui/SaveFiltersPopupButtonWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/SaveFiltersPopupButtonWidget.js @@ -1,191 +1,189 @@ -( 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 = $( '
' ); - - 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( - $( '
' ) - .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-layout' ) - .append( layout.$element ), - $( '
' ) - .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-options' ) - .append( checkBoxLayout.$element ), - $( '
' ) - .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 = $( '
' ); + + 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( + $( '
' ) + .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-layout' ) + .append( layout.$element ), + $( '
' ) + .addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-options' ) + .append( checkBoxLayout.$element ), + $( '
' ) + .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; diff --git a/resources/src/mediawiki.rcfilters/ui/SavedLinksListItemWidget.js b/resources/src/mediawiki.rcfilters/ui/SavedLinksListItemWidget.js index ceb5ef87cb..806b9a3b13 100644 --- a/resources/src/mediawiki.rcfilters/ui/SavedLinksListItemWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/SavedLinksListItemWidget.js @@ -1,333 +1,329 @@ -( 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( + $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + $( '
' ) + .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 + ), + $( '
' ) + .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( - $( '
' ) - .addClass( 'mw-rcfilters-ui-table' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .append( - $( '
' ) - .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 - ), - $( '
' ) - .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 + * + * @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; diff --git a/resources/src/mediawiki.rcfilters/ui/SavedLinksListWidget.js b/resources/src/mediawiki.rcfilters/ui/SavedLinksListWidget.js index 5422daf683..a29a93fdab 100644 --- a/resources/src/mediawiki.rcfilters/ui/SavedLinksListWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/SavedLinksListWidget.js @@ -1,159 +1,157 @@ -( 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 = $( '
' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-title' ) - .text( mw.msg( 'rcfilters-quickfilters-placeholder-title' ) ), - $( '
' ) - .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 = $( '
' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-title' ) + .text( mw.msg( 'rcfilters-quickfilters-placeholder-title' ) ), + $( '
' ) + .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; diff --git a/resources/src/mediawiki.rcfilters/ui/TagItemWidget.js b/resources/src/mediawiki.rcfilters/ui/TagItemWidget.js index d66c5b588d..985e2c5002 100644 --- a/resources/src/mediawiki.rcfilters/ui/TagItemWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/TagItemWidget.js @@ -1,225 +1,223 @@ -( 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: $( '
' ) - .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 = $( '
' ) - .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( $( '
' ).append( - $( '' ).html( - mw.message( labelMsg, mw.html.escape( this.itemModel.getLabel() ) ).parse() - ) - ).contents() ); - } else { - this.setLabel( - $( '' ).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: $( '
' ) + .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 = $( '
' ) + .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( $( '
' ).append( + $( '' ).html( + mw.message( labelMsg, mw.html.escape( this.itemModel.getLabel() ) ).parse() + ) + ).contents() ); + } else { + this.setLabel( + $( '' ).append( + this.itemModel.getLabel() + ) + ); + } + + this.setCurrentMuteState(); + this.setHighlightColor(); +}; + +/** + * Set the current highlight color for this item + */ +TagItemWidget.prototype.setHighlightColor = function () { + var selectedColor = this.filtersViewModel.isHighlightEnabled() && this.itemModel.isHighlighted ? + this.itemModel.getHighlightColor() : + null; + + this.$highlight + .attr( 'data-color', selectedColor ) + .toggleClass( + 'mw-rcfilters-ui-tagItemWidget-highlight-highlighted', + !!selectedColor + ); +}; + +/** + * Set the current mute state for this item + */ +TagItemWidget.prototype.setCurrentMuteState = function () {}; + +/** + * Respond to mouse enter event + */ +TagItemWidget.prototype.onMouseEnter = function () { + var labelText = this.itemModel.getStateMessage(); + + if ( labelText ) { + this.popupLabel.setLabel( labelText ); + + // Set timeout for the popup to show + this.popupTimeoutShow = setTimeout( function () { + this.popup.toggle( true ); + }.bind( this ), 500 ); + + // Cancel the hide timeout + clearTimeout( this.popupTimeoutHide ); + this.popupTimeoutHide = null; + } +}; + +/** + * Respond to mouse leave event + */ +TagItemWidget.prototype.onMouseLeave = function () { + this.popupTimeoutHide = setTimeout( function () { + this.popup.toggle( false ); + }.bind( this ), 250 ); + + // Clear the show timeout + clearTimeout( this.popupTimeoutShow ); + this.popupTimeoutShow = null; +}; + +/** + * Set selected state on this widget + * + * @param {boolean} [isSelected] Widget is selected + */ +TagItemWidget.prototype.toggleSelected = function ( isSelected ) { + isSelected = isSelected !== undefined ? isSelected : !this.selected; + + if ( this.selected !== isSelected ) { + this.selected = isSelected; + + this.$element.toggleClass( 'mw-rcfilters-ui-tagItemWidget-selected', this.selected ); + } +}; + +/** + * Get the selected state of this widget + * + * @return {boolean} Tag is selected + */ +TagItemWidget.prototype.isSelected = function () { + return this.selected; +}; + +/** + * Get item name + * + * @return {string} Filter name + */ +TagItemWidget.prototype.getName = function () { + return this.itemModel.getName(); +}; + +/** + * Get item model + * + * @return {string} Filter model + */ +TagItemWidget.prototype.getModel = function () { + return this.itemModel; +}; + +/** + * Get item view + * + * @return {string} Filter view + */ +TagItemWidget.prototype.getView = function () { + return this.itemModel.getGroupModel().getView(); +}; + +/** + * Remove and destroy external elements of this widget + */ +TagItemWidget.prototype.destroy = function () { + // Destroy the popup + this.popup.$element.detach(); + + // Disconnect events + this.itemModel.disconnect( this ); + this.closeButton.disconnect( this ); +}; + +module.exports = TagItemWidget; diff --git a/resources/src/mediawiki.rcfilters/ui/ValuePickerWidget.js b/resources/src/mediawiki.rcfilters/ui/ValuePickerWidget.js index ebd81c8358..3ce63eee11 100644 --- a/resources/src/mediawiki.rcfilters/ui/ValuePickerWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/ValuePickerWidget.js @@ -1,114 +1,112 @@ -( 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; diff --git a/resources/src/mediawiki.rcfilters/ui/ViewSwitchWidget.js b/resources/src/mediawiki.rcfilters/ui/ViewSwitchWidget.js index c00d4145c5..e366277ca1 100644 --- a/resources/src/mediawiki.rcfilters/ui/ViewSwitchWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/ViewSwitchWidget.js @@ -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, - $( '
' ) - .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, + $( '
' ) + .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; diff --git a/resources/src/mediawiki.rcfilters/ui/WatchlistTopSectionWidget.js b/resources/src/mediawiki.rcfilters/ui/WatchlistTopSectionWidget.js index 16c0533d8b..7796148afe 100644 --- a/resources/src/mediawiki.rcfilters/ui/WatchlistTopSectionWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/WatchlistTopSectionWidget.js @@ -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 = $( '
' ) - .addClass( 'mw-rcfilters-ui-table' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-watchlistDetails' ) - .append( $watchlistDetails ) - ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-editWatchlistButton' ) - .append( editWatchlistButton.$element ) - ) - ); + $topTable = $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-watchlistDetails' ) + .append( $watchlistDetails ) + ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-editWatchlistButton' ) + .append( editWatchlistButton.$element ) + ) + ); - $bottomTable = $( '
' ) - .addClass( 'mw-rcfilters-ui-table' ) - .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinksTable' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .append( markSeenButton.$element ) - ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinks' ) - .append( savedLinksListWidget.$element ) - ) - ); + $bottomTable = $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinksTable' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .append( markSeenButton.$element ) + ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinks' ) + .append( savedLinksListWidget.$element ) + ) + ); - $separator = $( '
' ) - .addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-separator' ); + $separator = $( '
' ) + .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; diff --git a/resources/src/mediawiki.special.block.js b/resources/src/mediawiki.special.block.js index b46df8575b..58657dbd3e 100644 --- a/resources/src/mediawiki.special.block.js +++ b/resources/src/mediawiki.special.block.js @@ -11,23 +11,10 @@ } $( function () { - // This code is also loaded on the "block succeeded" page where there is no form, - // so username and expiry fields might also be missing. - var blockTargetWidget = infuseIfExists( $( '#mw-bi-target' ) ), - anonOnlyField = infuseIfExists( $( '#mw-input-wpHardBlock' ).closest( '.oo-ui-fieldLayout' ) ), - enableAutoblockField = infuseIfExists( $( '#mw-input-wpAutoBlock' ).closest( '.oo-ui-fieldLayout' ) ), - hideUserWidget = infuseIfExists( $( '#mw-input-wpHideUser' ) ), - hideUserField = infuseIfExists( $( '#mw-input-wpHideUser' ).closest( '.oo-ui-fieldLayout' ) ), - watchUserField = infuseIfExists( $( '#mw-input-wpWatch' ).closest( '.oo-ui-fieldLayout' ) ), - expiryWidget = infuseIfExists( $( '#mw-input-wpExpiry' ) ), - editingWidget = infuseIfExists( $( '#mw-input-wpEditing' ) ), - editingRestrictionWidget = infuseIfExists( $( '#mw-input-wpEditingRestriction' ) ), - preventTalkPageEdit = infuseIfExists( $( '#mw-input-wpDisableUTEdit' ) ), - pageRestrictionsWidget = infuseIfExists( $( '#mw-input-wpPageRestrictions' ) ), - namespaceRestrictionsWidget = infuseIfExists( $( '#mw-input-wpNamespaceRestrictions' ) ), - createAccountWidget = infuseIfExists( $( '#mw-input-wpCreateAccount' ) ), - userChangedCreateAccount = mw.config.get( 'wgCreateAccountDirty' ), - updatingBlockOptions = false; + var blockTargetWidget, anonOnlyWidget, enableAutoblockWidget, hideUserWidget, watchUserWidget, + expiryWidget, editingWidget, editingRestrictionWidget, preventTalkPageEditWidget, + pageRestrictionsWidget, namespaceRestrictionsWidget, createAccountWidget, data, + enablePartialBlocks, blockAllowsUTEdit, userChangedCreateAccount, updatingBlockOptions; function updateBlockOptions() { var blocktarget = blockTargetWidget.getValue().trim(), @@ -39,46 +26,51 @@ // infinityValues are the values the SpecialBlock class accepts as infinity (sf. wfIsInfinity) infinityValues = [ 'infinite', 'indefinite', 'infinity', 'never' ], isIndefinite = infinityValues.indexOf( expiryValue ) !== -1, - // editingRestrictionWidget only exists if partial blocks is enabled; if not, block must be sitewide - editingRestrictionValue = editingRestrictionWidget ? editingRestrictionWidget.getValue() : 'sitewide', - editingIsSelected = editingWidget ? editingWidget.isSelected() : false, + editingRestrictionValue = enablePartialBlocks ? editingRestrictionWidget.getValue() : 'sitewide', + editingIsSelected = editingWidget.isSelected(), isSitewide = editingIsSelected && editingRestrictionValue === 'sitewide'; - if ( enableAutoblockField ) { - enableAutoblockField.toggle( !isNonEmptyIp ); + enableAutoblockWidget.setDisabled( isNonEmptyIp ); + if ( enableAutoblockWidget.isDisabled() ) { + enableAutoblockWidget.setSelected( false ); + } + + anonOnlyWidget.setDisabled( !isIp && !isEmpty ); + if ( anonOnlyWidget.isDisabled() ) { + anonOnlyWidget.setSelected( false ); } - if ( hideUserField ) { - hideUserField.toggle( !isNonEmptyIp && isIndefinite && isSitewide ); - if ( !hideUserField.isVisible() ) { + + if ( hideUserWidget ) { + hideUserWidget.setDisabled( isNonEmptyIp || !isIndefinite || !isSitewide ); + if ( hideUserWidget.isDisabled() ) { hideUserWidget.setSelected( false ); } } - if ( anonOnlyField ) { - anonOnlyField.toggle( isIp || isEmpty ); - } - if ( watchUserField ) { - watchUserField.toggle( !isIpRange || isEmpty ); + + if ( watchUserWidget ) { + watchUserWidget.setDisabled( isIpRange && !isEmpty ); + if ( watchUserWidget.isDisabled() ) { + watchUserWidget.setSelected( false ); + } } - if ( editingRestrictionWidget ) { + + if ( enablePartialBlocks ) { editingRestrictionWidget.setDisabled( !editingIsSelected ); - } - if ( pageRestrictionsWidget ) { pageRestrictionsWidget.setDisabled( !editingIsSelected || isSitewide ); - } - if ( namespaceRestrictionsWidget ) { namespaceRestrictionsWidget.setDisabled( !editingIsSelected || isSitewide ); + if ( blockAllowsUTEdit ) { + // This option is disabled for partial blocks unless a namespace restriction + // for the User_talk namespace is in place. + preventTalkPageEditWidget.setDisabled( + editingIsSelected && + editingRestrictionValue === 'partial' && + namespaceRestrictionsWidget.getValue().indexOf( + String( mw.config.get( 'wgNamespaceIds' ).user_talk ) + ) === -1 + ); + } } - if ( preventTalkPageEdit && namespaceRestrictionsWidget ) { - // This option is disabled for partial blocks unless a namespace restriction - // for the User_talk namespace is in place. - preventTalkPageEdit.setDisabled( - editingIsSelected && - editingRestrictionValue === 'partial' && - namespaceRestrictionsWidget.getValue().indexOf( - String( mw.config.get( 'wgNamespaceIds' ).user_talk ) - ) === -1 - ); - } + if ( !userChangedCreateAccount ) { updatingBlockOptions = true; createAccountWidget.setSelected( isSitewide ); @@ -87,27 +79,48 @@ } + // This code is also loaded on the "block succeeded" page where there is no form, + // so check for block target widget; if it exists, the form is present + blockTargetWidget = infuseIfExists( $( '#mw-bi-target' ) ); + if ( blockTargetWidget ) { - // Bind functions so they're checked whenever stuff changes + data = require( './config.json' ); + enablePartialBlocks = data.EnablePartialBlocks; + blockAllowsUTEdit = data.BlockAllowsUTEdit; + userChangedCreateAccount = mw.config.get( 'wgCreateAccountDirty' ); + updatingBlockOptions = false; + + // Always present if blockTargetWidget is present + editingWidget = OO.ui.infuse( $( '#mw-input-wpEditing' ) ); + expiryWidget = OO.ui.infuse( $( '#mw-input-wpExpiry' ) ); + createAccountWidget = OO.ui.infuse( $( '#mw-input-wpCreateAccount' ) ); + enableAutoblockWidget = OO.ui.infuse( $( '#mw-input-wpAutoBlock' ) ); + anonOnlyWidget = OO.ui.infuse( $( '#mw-input-wpHardBlock' ) ); blockTargetWidget.on( 'change', updateBlockOptions ); + editingWidget.on( 'change', updateBlockOptions ); expiryWidget.on( 'change', updateBlockOptions ); - if ( editingWidget ) { - editingWidget.on( 'change', updateBlockOptions ); - } - if ( editingRestrictionWidget ) { - editingRestrictionWidget.on( 'change', updateBlockOptions ); - } - if ( namespaceRestrictionsWidget ) { - namespaceRestrictionsWidget.on( 'change', updateBlockOptions ); - } - createAccountWidget.on( 'change', function () { if ( !updatingBlockOptions ) { userChangedCreateAccount = true; } } ); - // Call them now to set initial state (ie. Special:Block/Foobar?wpBlockExpiry=2+hours) + // Present for certain rights + watchUserWidget = infuseIfExists( $( '#mw-input-wpWatch' ) ); + hideUserWidget = infuseIfExists( $( '#mw-input-wpHideUser' ) ); + + // Present for certain global configs + if ( enablePartialBlocks ) { + editingRestrictionWidget = OO.ui.infuse( $( '#mw-input-wpEditingRestriction' ) ); + pageRestrictionsWidget = OO.ui.infuse( $( '#mw-input-wpPageRestrictions' ) ); + namespaceRestrictionsWidget = OO.ui.infuse( $( '#mw-input-wpNamespaceRestrictions' ) ); + editingRestrictionWidget.on( 'change', updateBlockOptions ); + namespaceRestrictionsWidget.on( 'change', updateBlockOptions ); + } + if ( blockAllowsUTEdit ) { + preventTalkPageEditWidget = infuseIfExists( $( '#mw-input-wpDisableUTEdit' ) ); + } + updateBlockOptions(); } } ); diff --git a/resources/src/mediawiki.user.js b/resources/src/mediawiki.user.js index aada50c52a..5f629e7dcb 100644 --- a/resources/src/mediawiki.user.js +++ b/resources/src/mediawiki.user.js @@ -136,10 +136,21 @@ }, /** - * 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 */ diff --git a/resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.styles.less b/resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.styles.less index 90bcc2ed7b..647c983e60 100644 --- a/resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.styles.less +++ b/resources/src/mediawiki.widgets/mw.widgets.DateInputWidget.styles.less @@ -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 { diff --git a/resources/src/startup/mediawiki.js b/resources/src/startup/mediawiki.js index 4c20e9dd06..f961da2652 100644 --- a/resources/src/startup/mediawiki.js +++ b/resources/src/startup/mediawiki.js @@ -917,15 +917,14 @@ * 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 ( typeof registry[ module ].skip === 'string' ) { @@ -939,25 +938,12 @@ } } - 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++ ) { @@ -971,6 +957,7 @@ sortDependencies( deps[ i ], resolved, unresolved ); } } + resolved.push( module ); } @@ -983,7 +970,8 @@ * @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 ); @@ -1001,7 +989,8 @@ */ function resolveStubbornly( modules ) { var saved, - resolved = [], + // Always load base modules + resolved = baseModules.slice(), i = 0; for ( ; i < modules.length; i++ ) { saved = resolved.slice(); @@ -1501,7 +1490,7 @@ * to a query string of the form `foo.bar,baz|bar.baz,quux`. * * See `ResourceLoader::makePackedModulesString()` in PHP, of which this is a port. - * On the server, unpacking is done by `ResourceLoaderContext::expandModuleNames()`. + * On the server, unpacking is done by `ResourceLoader::expandModuleNames()`. * * Note: This is only half of the logic, the other half has to be in #batchRequest(), * because its implementation needs to keep track of potential string size in order @@ -2031,7 +2020,7 @@ * "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' ) { @@ -2055,16 +2044,10 @@ 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 ); }, /** diff --git a/resources/src/startup/startup.js b/resources/src/startup/startup.js index bebf4dcf4a..240757c396 100644 --- a/resources/src/startup/startup.js +++ b/resources/src/startup/startup.js @@ -44,11 +44,11 @@ * * 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 class. See ResourceLoaderClientHtml::getDocumentAttributes(). document.documentElement.className = document.documentElement.className diff --git a/tests/parser/ParserTestRunner.php b/tests/parser/ParserTestRunner.php index 1c93261a86..b40b76927e 100644 --- a/tests/parser/ParserTestRunner.php +++ b/tests/parser/ParserTestRunner.php @@ -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; diff --git a/tests/parser/parserTests.txt b/tests/parser/parserTests.txt index ee33f1d7d1..0facec2513 100644 --- a/tests/parser/parserTests.txt +++ b/tests/parser/parserTests.txt @@ -24146,6 +24146,49 @@ language=nl title=[[MediaWiki:Common.css]]

!! end +!! test +formatdate with invalid month +!! wikitext +{{#formatdate:2019-22-22|dmy}} +!! html +

2019-22-22 +

+!! end + +!! test +formatdate: dots in month name do not match any char (T220563) +!! options +language=de +!! wikitext +{{#formatdate:jun. 3|dmy}} +{{#formatdate:junx 3|dmy}} +!! html +

3 Juni +junx 3 +

+!! end + +!! test +formatdate uses correct capitalisation in French +!! options +language=fr +!! wikitext +{{#formatdate:Juin 3|dmy}} +!! html +

3 juin +

+!! end + +!! test +formatdate uses correct capitalisation in English +!! wikitext +{{#formatdate:june 3|dmy}} +!! html +

3 June +

+!! end + + # # # diff --git a/tests/phpunit/MediaWikiTestCase.php b/tests/phpunit/MediaWikiTestCase.php index 8a38f42b41..fd0cea1023 100644 --- a/tests/phpunit/MediaWikiTestCase.php +++ b/tests/phpunit/MediaWikiTestCase.php @@ -544,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; @@ -588,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(); } @@ -731,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(); } } @@ -762,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' ); } /** @@ -1616,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 ); } } @@ -1630,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. @@ -1781,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/GlobalFunctions/GlobalTest.php b/tests/phpunit/includes/GlobalFunctions/GlobalTest.php index 22fe3cec77..9443b19e06 100644 --- a/tests/phpunit/includes/GlobalFunctions/GlobalTest.php +++ b/tests/phpunit/includes/GlobalFunctions/GlobalTest.php @@ -14,6 +14,7 @@ class GlobalTest extends MediaWikiTestCase { unlink( $readOnlyFile ); $this->setMwGlobals( [ + 'wgReadOnly' => null, 'wgReadOnlyFile' => $readOnlyFile, 'wgUrlProtocols' => [ 'http://', @@ -108,10 +109,6 @@ class GlobalTest extends MediaWikiTestCase { * @covers ::wfReadOnly */ public function testReadOnlyEmpty() { - global $wgReadOnly; - $wgReadOnly = null; - - MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode()->clearCache(); $this->assertFalse( wfReadOnly() ); $this->assertFalse( wfReadOnly() ); } @@ -121,23 +118,17 @@ class GlobalTest extends MediaWikiTestCase { * @covers ::wfReadOnly */ public function testReadOnlySet() { - global $wgReadOnly, $wgReadOnlyFile; - - $readOnlyMode = MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode(); - $readOnlyMode->clearCache(); + global $wgReadOnlyFile; $f = fopen( $wgReadOnlyFile, "wt" ); fwrite( $f, 'Message' ); fclose( $f ); - $wgReadOnly = null; # Check on $wgReadOnlyFile + + // Reset the service to avoid cached results + $this->overrideMwServices(); $this->assertTrue( wfReadOnly() ); $this->assertTrue( wfReadOnly() ); # Check cached - - unlink( $wgReadOnlyFile ); - $readOnlyMode->clearCache(); - $this->assertFalse( wfReadOnly() ); - $this->assertFalse( wfReadOnly() ); } /** @@ -146,9 +137,12 @@ class GlobalTest extends MediaWikiTestCase { */ public function testReadOnlyGlobalChange() { $this->assertFalse( wfReadOnlyReason() ); + $this->setMwGlobals( [ 'wgReadOnly' => 'reason' ] ); + $this->overrideMwServices(); + $this->assertSame( 'reason', wfReadOnlyReason() ); } diff --git a/tests/phpunit/includes/MWNamespaceTest.php b/tests/phpunit/includes/MWNamespaceTest.php deleted file mode 100644 index c95b1eb432..0000000000 --- a/tests/phpunit/includes/MWNamespaceTest.php +++ /dev/null @@ -1,609 +0,0 @@ -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" ); - } -} diff --git a/tests/phpunit/includes/MediaWikiServicesTest.php b/tests/phpunit/includes/MediaWikiServicesTest.php index 1cd40edc65..8fa0cd60ec 100644 --- a/tests/phpunit/includes/MediaWikiServicesTest.php +++ b/tests/phpunit/includes/MediaWikiServicesTest.php @@ -11,7 +11,7 @@ use Wikimedia\Services\ServiceDisabledException; * @group MediaWiki */ class MediaWikiServicesTest extends MediaWikiTestCase { - private $deprecatedServices = [ 'CryptRand' ]; + private $deprecatedServices = []; /** * @return Config @@ -364,7 +364,7 @@ class MediaWikiServicesTest extends MediaWikiTestCase { } ) ); $sortedNames = $names; - sort( $sortedNames ); + natcasesort( $sortedNames ); $this->assertSame( $sortedNames, $names, 'Please keep service getters sorted alphabetically' ); diff --git a/tests/phpunit/includes/ReadOnlyModeTest.php b/tests/phpunit/includes/ReadOnlyModeTest.php index b14424fb3a..2d91d4de9f 100644 --- a/tests/phpunit/includes/ReadOnlyModeTest.php +++ b/tests/phpunit/includes/ReadOnlyModeTest.php @@ -167,28 +167,4 @@ class ReadOnlyModeTest extends MediaWikiTestCase { $rom->setReason( 'override' ); $this->assertSame( 'override', $rom->getReason() ); } - - /** - * @covers ReadOnlyMode::clearCache - * @covers ConfiguredReadOnlyMode::clearCache - */ - public function testClearCache() { - $fileName = $this->getNewTempFile(); - unlink( $fileName ); - $config = new HashConfig( [ - 'ReadOnly' => null, - 'ReadOnlyFile' => $fileName, - ] ); - $cro = new ConfiguredReadOnlyMode( $config ); - $lb = $this->createLB( [ 'lbMessage' => false ] ); - $rom = new ReadOnlyMode( $cro, $lb ); - - $this->assertSame( false, $rom->getReason(), 'initial' ); - - file_put_contents( $fileName, 'file' ); - $this->assertSame( false, $rom->getReason(), 'stale' ); - - $rom->clearCache(); - $this->assertSame( 'file', $rom->getReason(), 'fresh' ); - } } diff --git a/tests/phpunit/includes/ServiceWiringTest.php b/tests/phpunit/includes/ServiceWiringTest.php index 74e8e1b3ea..02e06f8dda 100644 --- a/tests/phpunit/includes/ServiceWiringTest.php +++ b/tests/phpunit/includes/ServiceWiringTest.php @@ -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' ); diff --git a/tests/phpunit/includes/SiteStatsTest.php b/tests/phpunit/includes/SiteStatsTest.php index a6a92c68a6..b0d89a6ae5 100644 --- a/tests/phpunit/includes/SiteStatsTest.php +++ b/tests/phpunit/includes/SiteStatsTest.php @@ -10,11 +10,11 @@ class SiteStatsTest extends MediaWikiTestCase { $this->setService( 'MainWANObjectCache', $cache ); $jobq = JobQueueGroup::singleton(); - $jobq->push( new NullJob( Title::newMainPage(), [] ) ); + $jobq->push( Job::factory( 'null', Title::newMainPage(), [] ) ); $this->assertEquals( 1, SiteStats::jobs(), 'A single job enqueued bumps jobscount stat to 1' ); - $jobq->push( new NullJob( Title::newMainPage(), [] ) ); + $jobq->push( Job::factory( 'null', Title::newMainPage(), [] ) ); $this->assertEquals( 1, SiteStats::jobs(), 'SiteStats::jobs() count does not reflect addition ' . 'of a second job (cached)' diff --git a/tests/phpunit/includes/Storage/NameTableStoreTest.php b/tests/phpunit/includes/Storage/NameTableStoreTest.php index 4c2494a963..fab49fa582 100644 --- a/tests/phpunit/includes/Storage/NameTableStoreTest.php +++ b/tests/phpunit/includes/Storage/NameTableStoreTest.php @@ -216,17 +216,23 @@ class NameTableStoreTest extends MediaWikiTestCase { /** * @dataProvider provideGetName */ - public function testGetName( $cacheBag, $insertCalls, $selectCalls ) { + public function testGetName( BagOStuff $cacheBag, $insertCalls, $selectCalls ) { + $now = microtime( true ); + $cacheBag->setMockTime( $now ); // Check for operations to in-memory cache (IMC) and persistent cache (PC) $store = $this->getNameTableSqlStore( $cacheBag, $insertCalls, $selectCalls ); // Get 1 ID and make sure getName returns correctly $fooId = $store->acquireId( 'foo' ); // regen PC, set IMC, update IMC, tombstone PC + $now += 0.01; $this->assertSame( 'foo', $store->getName( $fooId ) ); // use IMC + $now += 0.01; // Get another ID and make sure getName returns correctly $barId = $store->acquireId( 'bar' ); // update IMC, tombstone PC + $now += 0.01; $this->assertSame( 'bar', $store->getName( $barId ) ); // use IMC + $now += 0.01; // Blitz the cache and make sure it still returns TestingAccessWrapper::newFromObject( $store )->tableCache = null; // clear IMC @@ -236,6 +242,7 @@ class NameTableStoreTest extends MediaWikiTestCase { // Blitz the cache again and get another ID and make sure getName returns correctly TestingAccessWrapper::newFromObject( $store )->tableCache = null; // clear IMC $bazId = $store->acquireId( 'baz' ); // set IMC using interim PC, update IMC, tombstone PC + $now += 0.01; $this->assertSame( 'baz', $store->getName( $bazId ) ); // uses IMC $this->assertSame( 'baz', $store->getName( $bazId ) ); // uses IMC } diff --git a/tests/phpunit/includes/api/ApiQuerySiteinfoTest.php b/tests/phpunit/includes/api/ApiQuerySiteinfoTest.php index d3a4ed4406..55f4a330ce 100644 --- a/tests/phpunit/includes/api/ApiQuerySiteinfoTest.php +++ b/tests/phpunit/includes/api/ApiQuerySiteinfoTest.php @@ -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() { diff --git a/tests/phpunit/includes/cache/MessageCacheTest.php b/tests/phpunit/includes/cache/MessageCacheTest.php index b03a3098d1..2fa662b88b 100644 --- a/tests/phpunit/includes/cache/MessageCacheTest.php +++ b/tests/phpunit/includes/cache/MessageCacheTest.php @@ -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 ); diff --git a/tests/phpunit/includes/http/HttpTest.php b/tests/phpunit/includes/http/HttpTest.php index fedfac6aa1..eee4296281 100644 --- a/tests/phpunit/includes/http/HttpTest.php +++ b/tests/phpunit/includes/http/HttpTest.php @@ -202,7 +202,7 @@ class HttpTest extends MediaWikiTestCase { /** * Constant values are from PHP 5.3.28 using cURL 7.24.0 - * @see https://secure.php.net/manual/en/curl.constants.php + * @see https://www.php.net/manual/en/curl.constants.php * * All constant values are present so that developers don’t need to remember * to add them if added at a later date. The commented out constants were @@ -515,7 +515,7 @@ class MWHttpRequestTester extends MWHttpRequest { if ( !Http::$httpEngine ) { Http::$httpEngine = 'guzzle'; } elseif ( Http::$httpEngine == 'curl' && !function_exists( 'curl_init' ) ) { - throw new DomainException( __METHOD__ . ': curl (https://secure.php.net/curl) is not ' . + throw new DomainException( __METHOD__ . ': curl (https://www.php.net/curl) is not ' . 'installed, but Http::$httpEngine is set to "curl"' ); } @@ -528,7 +528,7 @@ class MWHttpRequestTester extends MWHttpRequest { if ( !wfIniGetBool( 'allow_url_fopen' ) ) { throw new DomainException( __METHOD__ . ': allow_url_fopen needs to be enabled for pure PHP HTTP requests to work. ' - . 'If possible, curl should be used instead. See https://secure.php.net/curl.' ); + . 'If possible, curl should be used instead. See https://www.php.net/curl.' ); } return new PhpHttpRequestTester( $url, $options, $caller ); diff --git a/tests/phpunit/includes/jobqueue/JobQueueTest.php b/tests/phpunit/includes/jobqueue/JobQueueTest.php index 81a80b66d5..1baaa5489e 100644 --- a/tests/phpunit/includes/jobqueue/JobQueueTest.php +++ b/tests/phpunit/includes/jobqueue/JobQueueTest.php @@ -380,12 +380,12 @@ class JobQueueTest extends MediaWikiTestCase { } function newJob( $i = 0, $rootJob = [] ) { - return new NullJob( Title::newMainPage(), + return Job::factory( 'null', Title::newMainPage(), [ 'lives' => 0, 'usleep' => 0, 'removeDuplicates' => 0, 'i' => $i ] + $rootJob ); } function newDedupedJob( $i = 0, $rootJob = [] ) { - return new NullJob( Title::newMainPage(), + return Job::factory( 'null', Title::newMainPage(), [ 'lives' => 0, 'usleep' => 0, 'removeDuplicates' => 1, 'i' => $i ] + $rootJob ); } } diff --git a/tests/phpunit/includes/jobqueue/JobTest.php b/tests/phpunit/includes/jobqueue/JobTest.php index 769b1930fe..9fe3e3d1a4 100644 --- a/tests/phpunit/includes/jobqueue/JobTest.php +++ b/tests/phpunit/includes/jobqueue/JobTest.php @@ -29,31 +29,31 @@ class JobTest extends MediaWikiTestCase { return [ [ $this->getMockJob( false ), - 'someCommand ' . $requestId + 'someCommand Special: ' . $requestId ], [ $this->getMockJob( [ 'key' => 'val' ] ), - 'someCommand key=val ' . $requestId + 'someCommand Special: key=val ' . $requestId ], [ $this->getMockJob( [ 'key' => [ 'inkey' => 'inval' ] ] ), - 'someCommand key={"inkey":"inval"} ' . $requestId + 'someCommand Special: key={"inkey":"inval"} ' . $requestId ], [ $this->getMockJob( [ 'val1' ] ), - 'someCommand 0=val1 ' . $requestId + 'someCommand Special: 0=val1 ' . $requestId ], [ $this->getMockJob( [ 'val1', 'val2' ] ), - 'someCommand 0=val1 1=val2 ' . $requestId + 'someCommand Special: 0=val1 1=val2 ' . $requestId ], [ $this->getMockJob( [ new stdClass() ] ), - 'someCommand 0=object(stdClass) ' . $requestId + 'someCommand Special: 0=object(stdClass) ' . $requestId ], [ $this->getMockJob( [ $mockToStringObj ] ), - 'someCommand 0={STRING_OBJ_VAL} ' . $requestId + 'someCommand Special: 0={STRING_OBJ_VAL} ' . $requestId ], [ $this->getMockJob( [ @@ -72,7 +72,7 @@ class JobTest extends MediaWikiTestCase { ], "triggeredRecursive" => true ] ), - 'someCommand pages={"932737":[0,"Robert_James_Waller"]} ' . + 'someCommand Special: pages={"932737":[0,"Robert_James_Waller"]} ' . 'rootJobSignature=45868e99bba89064e4483743ebb9b682ef95c1a7 ' . 'rootJobTimestamp=20160309110158 masterPos=' . '{"file":"db1023-bin.001288","pos":"308257743","asOfTime":' . @@ -85,11 +85,13 @@ class JobTest extends MediaWikiTestCase { } public function getMockJob( $params ) { + $title = new Title(); $mock = $this->getMockForAbstractClass( Job::class, - [ 'someCommand', new Title(), $params ], + [ 'someCommand', $title, $params ], 'SomeJob' ); + return $mock; } @@ -115,7 +117,7 @@ class JobTest extends MediaWikiTestCase { return [ 'class name' => [ 'NullJob' ], 'closure' => [ function ( Title $title, array $params ) { - return new NullJob( $title, $params ); + return Job::factory( 'null', $title, $params ); } ], 'function' => [ [ $this, 'newNullJob' ] ], 'static function' => [ self::class . '::staticNullJob' ] @@ -123,11 +125,91 @@ class JobTest extends MediaWikiTestCase { } public function newNullJob( Title $title, array $params ) { - return new NullJob( $title, $params ); + return Job::factory( 'null', $title, $params ); } public static function staticNullJob( Title $title, array $params ) { - return new NullJob( $title, $params ); + return Job::factory( 'null', $title, $params ); + } + + /** + * @covers Job::factory + * @covers Job::__construct() + */ + public function testJobSignatureGeneric() { + $testPage = Title::makeTitle( NS_PROJECT, 'x' ); + $blankTitle = Title::makeTitle( NS_SPECIAL, '' ); + $params = [ 'z' => 1, 'lives' => 1, 'usleep' => 0 ]; + $paramsWithTitle = $params + [ 'namespace' => NS_PROJECT, 'title' => 'x' ]; + + $job = new NullJob( [ 'namespace' => NS_PROJECT, 'title' => 'x' ] + $params ); + $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() ); + $this->assertJobParamsMatch( $job, $paramsWithTitle ); + + $job = Job::factory( 'null', $testPage, $params ); + $this->assertEquals( $blankTitle->getPrefixedText(), $job->getTitle()->getPrefixedText() ); + $this->assertJobParamsMatch( $job, $params ); + + $job = Job::factory( 'null', $paramsWithTitle ); + $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() ); + $this->assertJobParamsMatch( $job, $paramsWithTitle ); + + $job = Job::factory( 'null', $params ); + $this->assertTrue( $blankTitle->equals( $job->getTitle() ) ); + $this->assertJobParamsMatch( $job, $params ); + } + + /** + * @covers Job::factory + * @covers Job::__construct() + */ + public function testJobSignatureTitleBased() { + $testPage = Title::makeTitle( NS_PROJECT, 'x' ); + $blankTitle = Title::makeTitle( NS_SPECIAL, '' ); + $params = [ 'z' => 1, 'causeAction' => 'unknown', 'causeAgent' => 'unknown' ]; + $paramsWithTitle = $params + [ 'namespace' => NS_PROJECT, 'title' => 'x' ]; + + $job = new RefreshLinksJob( $testPage, $params ); + $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() ); + $this->assertSame( $testPage, $job->getTitle() ); + $this->assertJobParamsMatch( $job, $paramsWithTitle ); + $this->assertSame( $testPage, $job->getTitle() ); + + $job = Job::factory( 'refreshLinks', $testPage, $params ); + $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() ); + $this->assertJobParamsMatch( $job, $paramsWithTitle ); + + $job = Job::factory( 'refreshLinks', $paramsWithTitle ); + $this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() ); + $this->assertJobParamsMatch( $job, $paramsWithTitle ); + + $job = Job::factory( 'refreshLinks', $params ); + $this->assertTrue( $blankTitle->equals( $job->getTitle() ) ); + $this->assertJobParamsMatch( $job, $params ); + } + + /** + * @covers Job::factory + * @covers Job::__construct() + */ + public function testJobSignatureTitleBasedIncomplete() { + $testPage = Title::makeTitle( NS_PROJECT, 'x' ); + $blankTitle = Title::makeTitle( NS_SPECIAL, '' ); + $params = [ 'z' => 1, 'causeAction' => 'unknown', 'causeAgent' => 'unknown' ]; + + $job = new RefreshLinksJob( $testPage, $params + [ 'namespace' => 0 ] ); + $this->assertEquals( $blankTitle->getPrefixedText(), $job->getTitle()->getPrefixedText() ); + $this->assertJobParamsMatch( $job, $params + [ 'namespace' => 0 ] ); + + $job = new RefreshLinksJob( $testPage, $params + [ 'title' => 'x' ] ); + $this->assertEquals( $blankTitle->getPrefixedText(), $job->getTitle()->getPrefixedText() ); + $this->assertJobParamsMatch( $job, $params + [ 'title' => 'x' ] ); } + private function assertJobParamsMatch( IJobSpecification $job, array $params ) { + $actual = $job->getParams(); + unset( $actual['requestId'] ); + + $this->assertEquals( $actual, $params ); + } } diff --git a/tests/phpunit/includes/jobqueue/jobs/ClearUserWatchlistJobTest.php b/tests/phpunit/includes/jobqueue/jobs/ClearUserWatchlistJobTest.php index 27cae8aa2f..1a2941d5d2 100644 --- a/tests/phpunit/includes/jobqueue/jobs/ClearUserWatchlistJobTest.php +++ b/tests/phpunit/includes/jobqueue/jobs/ClearUserWatchlistJobTest.php @@ -51,13 +51,9 @@ class ClearUserWatchlistJobTest extends MediaWikiTestCase { $this->setMwGlobals( 'wgUpdateRowsPerQuery', 2 ); JobQueueGroup::singleton()->push( - new ClearUserWatchlistJob( - null, - [ - 'userId' => $user->getId(), - 'maxWatchlistId' => $maxId, - ] - ) + new ClearUserWatchlistJob( [ + 'userId' => $user->getId(), 'maxWatchlistId' => $maxId, + ] ) ); $this->assertEquals( 1, JobQueueGroup::singleton()->getQueueSizes()['clearUserWatchlist'] ); diff --git a/tests/phpunit/includes/page/WikiPageDbTestBase.php b/tests/phpunit/includes/page/WikiPageDbTestBase.php index fbe0356d04..3a3feeea11 100644 --- a/tests/phpunit/includes/page/WikiPageDbTestBase.php +++ b/tests/phpunit/includes/page/WikiPageDbTestBase.php @@ -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/resourceloader/ResourceLoaderContextTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php index 1b7e0fe401..5c53040b0f 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php @@ -2,10 +2,9 @@ /** * See also: - * - ResourceLoaderTest::testExpandModuleNames * - ResourceLoaderImageModuleTest::testContext * - * @group Cache + * @group ResourceLoader * @covers ResourceLoaderContext */ class ResourceLoaderContextTest extends PHPUnit\Framework\TestCase { diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php index 3f7925f914..7239afc8c1 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php @@ -288,12 +288,12 @@ class ResourceLoaderTest extends ResourceLoaderTestCase { /** * @dataProvider providePackedModules - * @covers ResourceLoaderContext::expandModuleNames + * @covers ResourceLoader::expandModuleNames */ public function testExpandModuleNames( $desc, $modules, $packed, $unpacked = null ) { $this->assertEquals( $unpacked ?: $modules, - ResourceLoaderContext::expandModuleNames( $packed ), + ResourceLoader::expandModuleNames( $packed ), $desc ); } diff --git a/tests/phpunit/includes/title/NamespaceInfoTest.php b/tests/phpunit/includes/title/NamespaceInfoTest.php new file mode 100644 index 0000000000..cc7df8d31f --- /dev/null +++ b/tests/phpunit/includes/title/NamespaceInfoTest.php @@ -0,0 +1,613 @@ +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" ); + } +} diff --git a/tests/phpunit/maintenance/DumpAsserter.php b/tests/phpunit/maintenance/DumpAsserter.php index 5b4c6efd50..ad33f6e154 100644 --- a/tests/phpunit/maintenance/DumpAsserter.php +++ b/tests/phpunit/maintenance/DumpAsserter.php @@ -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(); diff --git a/tests/phpunit/maintenance/DumpTestCase.php b/tests/phpunit/maintenance/DumpTestCase.php index 26c9b92dbc..7647915ab9 100644 --- a/tests/phpunit/maintenance/DumpTestCase.php +++ b/tests/phpunit/maintenance/DumpTestCase.php @@ -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() ) { diff --git a/tests/phpunit/maintenance/backupTextPassTest.php b/tests/phpunit/maintenance/backupTextPassTest.php index 0d4bc56cb0..e059834e29 100644 --- a/tests/phpunit/maintenance/backupTextPassTest.php +++ b/tests/phpunit/maintenance/backupTextPassTest.php @@ -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 = ' BackupDumperTestP1 @@ -570,7 +572,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase { ' . ( $this->revId1_1 + $i * self::$numOfRevs ) . ' 2012-04-01T16:46:05Z - 127.0.0.1 + ' . $username . ' + ' . $userid . ' BackupDumperTestP1Summary1 wikitext @@ -588,7 +591,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase { ' . ( $this->revId2_1 + $i * self::$numOfRevs ) . ' 2012-04-01T16:46:05Z - 127.0.0.1 + ' . $username . ' + ' . $userid . ' BackupDumperTestP2Summary1 wikitext @@ -601,7 +605,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase { ' . ( $this->revId2_1 + $i * self::$numOfRevs ) . ' 2012-04-01T16:46:05Z - 127.0.0.1 + ' . $username . ' + ' . $userid . ' BackupDumperTestP2Summary2 wikitext @@ -614,7 +619,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase { ' . ( $this->revId2_2 + $i * self::$numOfRevs ) . ' 2012-04-01T16:46:05Z - 127.0.0.1 + ' . $username . ' + ' . $userid . ' BackupDumperTestP2Summary3 wikitext @@ -627,7 +633,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase { ' . ( $this->revId2_3 + $i * self::$numOfRevs ) . ' 2012-04-01T16:46:05Z - 127.0.0.1 + ' . $username . ' + ' . $userid . ' BackupDumperTestP2Summary4 extra wikitext @@ -647,7 +654,8 @@ class TextPassDumperDatabaseTest extends DumpTestCase { ' . ( $this->revId4_1 + $i * self::$numOfRevs ) . ' 2012-04-01T16:46:05Z - 127.0.0.1 + ' . $username . ' + ' . $userid . ' Talk BackupDumperTestP1 Summary1 BackupTextPassTestModel diff --git a/tests/phpunit/suites/ParserTestTopLevelSuite.php b/tests/phpunit/suites/ParserTestTopLevelSuite.php index 77461c5af2..28547d1f84 100644 --- a/tests/phpunit/suites/ParserTestTopLevelSuite.php +++ b/tests/phpunit/suites/ParserTestTopLevelSuite.php @@ -113,7 +113,7 @@ class ParserTestTopLevelSuite extends PHPUnit_Framework_TestSuite { $testsName = $extensionName . '__' . basename( $fileName, '.txt' ); $parserTestClassName = ucfirst( $testsName ); - // Official spec for class names: https://secure.php.net/manual/en/language.oop5.basic.php + // Official spec for class names: https://www.php.net/manual/en/language.oop5.basic.php // Prepend 'ParserTest_' to be paranoid about it not starting with a number $parserTestClassName = 'ParserTest_' . preg_replace( '/[^a-zA-Z0-9_\x7f-\xff]/', '_', $parserTestClassName ); diff --git a/tests/qunit/data/load.mock.php b/tests/qunit/data/load.mock.php index 2238fcebf5..9f57190ea4 100644 --- a/tests/qunit/data/load.mock.php +++ b/tests/qunit/data/load.mock.php @@ -77,8 +77,8 @@ mw.loader.implement( 'testUrlOrder.b', function () {} ); $response = ''; -// Does not support the full behaviour of ResourceLoaderContext::expandModuleNames(), -// Only supports dotless module names joined by comma, +// Does not support the full behaviour of the real load.php. +// This only supports dotless module names joined by comma, // with the exception of the hardcoded cases for testUrl*. if ( isset( $_GET['modules'] ) ) { if ( $_GET['modules'] === 'testUrlInc,testUrlIncDump|testUrlInc.a,b' ) { diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js index e17c78d7af..8b3427f93f 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js @@ -255,7 +255,7 @@ assert.deepEqual( [ { topic: 'resourceloader.exception', - error: 'Unknown dependency: test.load.unreg', + error: 'Unknown module: test.load.unreg', source: 'resolve' } ], capture @@ -281,7 +281,7 @@ assert.deepEqual( [ { topic: 'resourceloader.exception', - error: 'Unknown dependency: test.load.missingdep2', + error: 'Unknown module: test.load.missingdep2', source: 'resolve' } ], capture diff --git a/tests/selenium/specs/page.js b/tests/selenium/specs/page.js index 80e12cd56f..4604ca3b78 100644 --- a/tests/selenium/specs/page.js +++ b/tests/selenium/specs/page.js @@ -80,7 +80,7 @@ describe( 'Page', function () { // check assert.strictEqual( EditPage.heading.getText(), name ); - assert.strictEqual( EditPage.displayedContent.getText(), editContent ); + assert( EditPage.displayedContent.getText().match( new RegExp( editContent ) ) ); } ); it( 'should have history @daily', function () { diff --git a/tests/selenium/specs/rollback.js b/tests/selenium/specs/rollback.js index 805b793098..648e52f600 100644 --- a/tests/selenium/specs/rollback.js +++ b/tests/selenium/specs/rollback.js @@ -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