Localisation updates from https://translatewiki.net.
[mediawiki.git] / resources / src / mediawiki.rcfilters / dm / FiltersViewModel.js
blob5dfe5cd4c41985317ea09297005ba53dc33e5cc7
1 const FilterGroup = require( './FilterGroup.js' ),
2         FilterItem = require( './FilterItem.js' ),
3         utils = require( '../utils.js' );
5 /**
6  * View model for the filters selection and display.
7  *
8  * @class mw.rcfilters.dm.FiltersViewModel
9  * @ignore
10  * @mixes OO.EventEmitter
11  * @mixes OO.EmitterList
12  */
13 const FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
14         // Mixin constructor
15         OO.EventEmitter.call( this );
16         OO.EmitterList.call( this );
18         this.groups = {};
19         this.defaultParams = {};
20         this.highlightEnabled = false;
21         this.parameterMap = {};
22         this.emptyParameterState = null;
24         this.views = {};
25         this.currentView = 'default';
26         this.searchQuery = null;
28         // Events
29         this.aggregate( { update: 'filterItemUpdate' } );
30         this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
33 /* Initialization */
34 OO.initClass( FiltersViewModel );
35 OO.mixinClass( FiltersViewModel, OO.EventEmitter );
36 OO.mixinClass( FiltersViewModel, OO.EmitterList );
38 /* Events */
40 /**
41  * Filter list is initialized.
42  *
43  * @event initialize
44  * @ignore
45  */
47 /**
48  * Model has been updated.
49  *
50  * @event update
51  * @ignore
52  */
54 /**
55  * Filter item has changed.
56  *
57  * @event itemUpdate
58  * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
59  * @ignore
60  */
62 /**
63  * Highlight feature has been toggled enabled or disabled.
64  *
65  * @event highlightChange
66  * @param {boolean} Highlight feature is enabled
67  * @ignore
68  */
70 /* Methods */
72 /**
73  * Re-assess the states of filter items based on the interactions between them
74  *
75  * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
76  *  method will go over the state of all items
77  */
78 FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
79         const iterationItems = item !== undefined ? [ item ] : this.getItems();
81         iterationItems.forEach( ( checkedItem ) => {
82                 const allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
83                         groupModel = checkedItem.getGroupModel();
85                 // Check for subsets (included filters) plus the item itself:
86                 allCheckedItems.forEach( ( filterItemName ) => {
87                         const itemInSubset = this.getItemByName( filterItemName );
89                         itemInSubset.toggleIncluded(
90                                 // If any of itemInSubset's supersets are selected, this item
91                                 // is included
92                                 itemInSubset.getSuperset().some( ( supersetName ) => ( this.getItemByName( supersetName ).isSelected() ) )
93                         );
94                 } );
96                 // Update coverage for the changed group
97                 if ( groupModel.isFullCoverage() ) {
98                         const allSelected = groupModel.areAllSelected();
99                         groupModel.getItems().forEach( ( filterItem ) => {
100                                 filterItem.toggleFullyCovered( allSelected );
101                         } );
102                 }
103         } );
105         // Check for conflicts
106         // In this case, we must go over all items, since
107         // conflicts are bidirectional and depend not only on
108         // individual items, but also on the selected states of
109         // the groups they're in.
110         this.getItems().forEach( ( filterItem ) => {
111                 let inConflict = false;
112                 const filterItemGroup = filterItem.getGroupModel();
114                 // For each item, see if that item is still conflicting
115                 // eslint-disable-next-line no-jquery/no-each-util
116                 $.each( this.groups, ( groupName, groupModel ) => {
117                         if ( filterItem.getGroupName() === groupName ) {
118                                 // Check inside the group
119                                 inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
120                         } else {
121                                 // According to the spec, if two items conflict from two different
122                                 // groups, the conflict only lasts if the groups **only have selected
123                                 // items that are conflicting**. If a group has selected items that
124                                 // are conflicting and non-conflicting, the scope of the result has
125                                 // expanded enough to completely remove the conflict.
127                                 // For example, see two groups with conflicts:
128                                 // userExpLevel: [
129                                 //   {
130                                 //     name: 'experienced',
131                                 //     conflicts: [ 'unregistered' ]
132                                 //   }
133                                 // ],
134                                 // registration: [
135                                 //   {
136                                 //     name: 'registered',
137                                 //   },
138                                 //   {
139                                 //     name: 'unregistered',
140                                 //   }
141                                 // ]
142                                 // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
143                                 // because, inherently, 'experienced' filter only includes registered users, and so
144                                 // both filters are in conflict with one another.
145                                 // However, the minute we select 'registered', the scope of our results
146                                 // has expanded to no longer have a conflict with 'experienced' filter, and
147                                 // so the conflict is removed.
149                                 // In our case, we need to check if the entire group conflicts with
150                                 // the entire item's group, so we follow the above spec
151                                 inConflict = (
152                                         // The foreign group is in conflict with this item
153                                         groupModel.areAllSelectedInConflictWith( filterItem ) &&
154                                         // Every selected member of the item's own group is also
155                                         // in conflict with the other group
156                                         filterItemGroup.findSelectedItems().every( ( otherGroupItem ) => groupModel.areAllSelectedInConflictWith( otherGroupItem ) )
157                                 );
158                         }
160                         // If we're in conflict, this will return 'false' which
161                         // will break the loop. Otherwise, we're not in conflict
162                         // and the loop continues
163                         return !inConflict;
164                 } );
166                 // Toggle the item state
167                 filterItem.toggleConflicted( inConflict );
168         } );
172  * Get whether the model has any conflict in its items
174  * @return {boolean} There is a conflict
175  */
176 FiltersViewModel.prototype.hasConflict = function () {
177         return this.getItems().some( ( filterItem ) => filterItem.isSelected() && filterItem.isConflicted() );
181  * Get the first item with a current conflict
183  * @ignore
184  * @return {mw.rcfilters.dm.FilterItem|undefined} Conflicted item or undefined when not found
185  */
186 FiltersViewModel.prototype.getFirstConflictedItem = function () {
187         const items = this.getItems();
188         for ( let i = 0; i < items.length; i++ ) {
189                 const filterItem = items[ i ];
190                 if ( filterItem.isSelected() && filterItem.isConflicted() ) {
191                         return filterItem;
192                 }
193         }
197  * Set filters and preserve a group relationship based on
198  * the definition given by an object
200  * @param {Array} filterGroups Filters definition
201  * @param {Object} [views] Extra views definition
202  *  Expected in the following format:
203  *  {
204  *     namespaces: {
205  *       label: 'namespaces', // Message key
206  *       trigger: ':',
207  *       groups: [
208  *         {
209  *            // Group info
210  *            name: 'namespaces' // Parameter name
211  *            title: 'namespaces' // Message key
212  *            type: 'string_options',
213  *            separator: ';',
214  *            labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
215  *            fullCoverage: true
216  *            items: []
217  *         }
218  *       ]
219  *     }
220  *  }
221  */
222 FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
223         const items = [],
224                 groupConflictMap = {},
225                 filterConflictMap = {},
226                 /*!
227                  * Expand a conflict definition from group name to
228                  * the list of all included filters in that group.
229                  * We do this so that the direct relationship in the
230                  * models are consistently item->items rather than
231                  * mixing item->group with item->item.
232                  *
233                  * @param {Object} obj Conflict definition
234                  * @return {Object} Expanded conflict definition
235                  */
236                 expandConflictDefinitions = ( obj ) => {
237                         const result = {};
239                         // eslint-disable-next-line no-jquery/no-each-util
240                         $.each( obj, ( key, conflicts ) => {
241                                 const adjustedConflicts = {};
243                                 conflicts.forEach( ( conflict ) => {
244                                         let filter;
246                                         if ( conflict.filter ) {
247                                                 const filterName = this.groups[ conflict.group ].getPrefixedName( conflict.filter );
248                                                 filter = this.getItemByName( filterName );
250                                                 // Rename
251                                                 adjustedConflicts[ filterName ] = Object.assign(
252                                                         {},
253                                                         conflict,
254                                                         {
255                                                                 filter: filterName,
256                                                                 item: filter
257                                                         }
258                                                 );
259                                         } else {
260                                                 // This conflict is for an entire group. Split it up to
261                                                 // represent each filter
263                                                 // Get the relevant group items
264                                                 this.groups[ conflict.group ].getItems().forEach( ( groupItem ) => {
265                                                         // Rebuild the conflict
266                                                         adjustedConflicts[ groupItem.getName() ] = Object.assign(
267                                                                 {},
268                                                                 conflict,
269                                                                 {
270                                                                         filter: groupItem.getName(),
271                                                                         item: groupItem
272                                                                 }
273                                                         );
274                                                 } );
275                                         }
276                                 } );
278                                 result[ key ] = adjustedConflicts;
279                         } );
281                         return result;
282                 };
284         // Reset
285         this.clearItems();
286         this.groups = {};
287         this.views = {};
289         // Clone
290         filterGroups = OO.copy( filterGroups );
292         // Normalize definition from the server
293         filterGroups.forEach( ( data ) => {
294                 let i;
295                 // What's this information needs to be normalized
296                 data.whatsThis = {
297                         body: data.whatsThisBody,
298                         header: data.whatsThisHeader,
299                         linkText: data.whatsThisLinkText,
300                         url: data.whatsThisUrl
301                 };
303                 // Title is a msg-key
304                 // eslint-disable-next-line mediawiki/msg-doc
305                 data.title = data.title ? mw.msg( data.title ) : data.name;
307                 // Filters are given to us with msg-keys, we need
308                 // to translate those before we hand them off
309                 for ( i = 0; i < data.filters.length; i++ ) {
310                         // eslint-disable-next-line mediawiki/msg-doc
311                         data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
312                         // eslint-disable-next-line mediawiki/msg-doc
313                         data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
314                 }
315         } );
317         // Collect views
318         const allViews = $.extend( true, {
319                 default: {
320                         title: mw.msg( 'rcfilters-filterlist-title' ),
321                         groups: filterGroups
322                 }
323         }, views );
325         // Go over all views
326         // eslint-disable-next-line no-jquery/no-each-util
327         $.each( allViews, ( viewName, viewData ) => {
328                 // Define the view
329                 this.views[ viewName ] = {
330                         name: viewData.name,
331                         title: viewData.title,
332                         trigger: viewData.trigger
333                 };
335                 // Go over groups
336                 viewData.groups.forEach( ( groupData ) => {
337                         const group = groupData.name;
339                         if ( !this.groups[ group ] ) {
340                                 this.groups[ group ] = new FilterGroup(
341                                         group,
342                                         $.extend( true, {}, groupData, { view: viewName } )
343                                 );
344                         }
346                         this.groups[ group ].initializeFilters( groupData.filters, groupData.default );
347                         items.push( ...this.groups[ group ].getItems() );
349                         // Prepare conflicts
350                         if ( groupData.conflicts ) {
351                                 // Group conflicts
352                                 groupConflictMap[ group ] = groupData.conflicts;
353                         }
355                         groupData.filters.forEach( ( itemData ) => {
356                                 const filterItem = this.groups[ group ].getItemByParamName( itemData.name );
357                                 // Filter conflicts
358                                 if ( itemData.conflicts ) {
359                                         filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
360                                 }
361                         } );
362                 } );
363         } );
365         // Add item references to the model, for lookup
366         this.addItems( items );
368         // Expand conflicts
369         const groupConflictResult = expandConflictDefinitions( groupConflictMap );
370         const filterConflictResult = expandConflictDefinitions( filterConflictMap );
372         // Set conflicts for groups
373         // eslint-disable-next-line no-jquery/no-each-util
374         $.each( groupConflictResult, ( group, conflicts ) => {
375                 this.groups[ group ].setConflicts( conflicts );
376         } );
378         // Set conflicts for items
379         // eslint-disable-next-line no-jquery/no-each-util
380         $.each( filterConflictResult, ( filterName, conflicts ) => {
381                 const filterItem = this.getItemByName( filterName );
382                 // set conflicts for items in the group
383                 filterItem.setConflicts( conflicts );
384         } );
386         // Create a map between known parameters and their models
387         // eslint-disable-next-line no-jquery/no-each-util
388         $.each( this.groups, ( group, groupModel ) => {
389                 if (
390                         groupModel.getType() === 'send_unselected_if_any' ||
391                         groupModel.getType() === 'boolean' ||
392                         groupModel.getType() === 'any_value'
393                 ) {
394                         // Individual filters
395                         groupModel.getItems().forEach( ( filterItem ) => {
396                                 this.parameterMap[ filterItem.getParamName() ] = filterItem;
397                         } );
398                 } else if (
399                         groupModel.getType() === 'string_options' ||
400                         groupModel.getType() === 'single_option'
401                 ) {
402                         // Group
403                         this.parameterMap[ groupModel.getName() ] = groupModel;
404                 }
405         } );
407         this.setSearch( '' );
409         this.updateHighlightedState();
411         // Finish initialization
412         this.emit( 'initialize' );
416  * Update filter view model state based on a parameter object
418  * @param {Object} params Parameters object
419  */
420 FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
421         // For arbitrary numeric single_option values make sure the values
422         // are normalized to fit within the limits
423         // eslint-disable-next-line no-jquery/no-each-util
424         $.each( this.getFilterGroups(), ( groupName, groupModel ) => {
425                 params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
426         } );
428         // Update filter values
429         const filtersValue = this.getFiltersFromParameters( params );
430         Object.keys( filtersValue ).forEach( ( filterName ) => {
431                 this.getItemByName( filterName ).setValue( filtersValue[ filterName ] );
432         } );
434         // Update highlight state
435         this.getItemsSupportingHighlights().forEach( ( filterItem ) => {
436                 const color = params[ filterItem.getName() + '_color' ];
437                 if ( color ) {
438                         filterItem.setHighlightColor( color );
439                 } else {
440                         filterItem.clearHighlightColor();
441                 }
442         } );
443         this.updateHighlightedState();
445         // Check all filter interactions
446         this.reassessFilterInteractions();
450  * Get a representation of an empty (falsey) parameter state
452  * @return {Object} Empty parameter state
453  */
454 FiltersViewModel.prototype.getEmptyParameterState = function () {
455         if ( !this.emptyParameterState ) {
456                 this.emptyParameterState = $.extend(
457                         true,
458                         {},
459                         this.getParametersFromFilters( {} ),
460                         this.getEmptyHighlightParameters()
461                 );
462         }
463         return this.emptyParameterState;
467  * Get a representation of only the non-falsey parameters
469  * @param {Object} [parameters] A given parameter state to minimize. If not given the current
470  *  state of the system will be used.
471  * @return {Object} Empty parameter state
472  */
473 FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
474         const result = {};
476         parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
478         // Params
479         // eslint-disable-next-line no-jquery/no-each-util
480         $.each( this.getEmptyParameterState(), ( param, value ) => {
481                 if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
482                         result[ param ] = parameters[ param ];
483                 }
484         } );
486         // Highlights
487         Object.keys( this.getEmptyHighlightParameters() ).forEach( ( param ) => {
488                 if ( parameters[ param ] ) {
489                         // If a highlight parameter is not undefined and not null
490                         // add it to the result
491                         result[ param ] = parameters[ param ];
492                 }
493         } );
495         return result;
499  * Get a representation of the full parameter list, including all base values
501  * @return {Object} Full parameter representation
502  */
503 FiltersViewModel.prototype.getExpandedParamRepresentation = function () {
504         return $.extend(
505                 true,
506                 {},
507                 this.getEmptyParameterState(),
508                 this.getCurrentParameterState()
509         );
513  * Get a parameter representation of the current state of the model
515  * @param {boolean} [removeStickyParams] Remove sticky filters from final result
516  * @return {Object} Parameter representation of the current state of the model
517  */
518 FiltersViewModel.prototype.getCurrentParameterState = function ( removeStickyParams ) {
519         let state = this.getMinimizedParamRepresentation( $.extend(
520                 true,
521                 {},
522                 this.getParametersFromFilters( this.getSelectedState() ),
523                 this.getHighlightParameters()
524         ) );
526         if ( removeStickyParams ) {
527                 state = this.removeStickyParams( state );
528         }
530         return state;
534  * Delete sticky parameters from given object.
536  * @param {Object} paramState Parameter state
537  * @return {Object} Parameter state without sticky parameters
538  */
539 FiltersViewModel.prototype.removeStickyParams = function ( paramState ) {
540         this.getStickyParams().forEach( ( paramName ) => {
541                 delete paramState[ paramName ];
542         } );
544         return paramState;
548  * Turn the highlight feature on or off
549  */
550 FiltersViewModel.prototype.updateHighlightedState = function () {
551         this.toggleHighlight( this.getHighlightedItems().length > 0 );
555  * Get the object that defines groups by their name.
557  * @return {Object} Filter groups
558  */
559 FiltersViewModel.prototype.getFilterGroups = function () {
560         return this.groups;
564  * Get the object that defines groups that match a certain view by their name.
566  * @param {string} [view] Requested view. If not given, uses current view
567  * @return {Object} Filter groups matching a display group
568  */
569 FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
570         const result = {};
572         view = view || this.getCurrentView();
574         // eslint-disable-next-line no-jquery/no-each-util
575         $.each( this.groups, ( groupName, groupModel ) => {
576                 if ( groupModel.getView() === view ) {
577                         result[ groupName ] = groupModel;
578                 }
579         } );
581         return result;
585  * Get an array of filters matching the given display group.
587  * @ignore
588  * @param {string} [view] Requested view. If not given, uses current view
589  * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
590  */
591 FiltersViewModel.prototype.getFiltersByView = function ( view ) {
592         const result = [];
594         view = view || this.getCurrentView();
596         const groups = this.getFilterGroupsByView( view );
598         // eslint-disable-next-line no-jquery/no-each-util
599         $.each( groups, ( groupName, groupModel ) => {
600                 result.push( ...groupModel.getItems() );
601         } );
603         return result;
607  * Get the trigger for the requested view.
609  * @param {string} view View name
610  * @return {string} View trigger, if exists
611  */
612 FiltersViewModel.prototype.getViewTrigger = function ( view ) {
613         return ( this.views[ view ] && this.views[ view ].trigger ) || '';
617  * Get the value of a specific parameter
619  * @param {string} name Parameter name
620  * @return {number|string} Parameter value
621  */
622 FiltersViewModel.prototype.getParamValue = function ( name ) {
623         return this.parameters[ name ];
627  * Get the current selected state of the filters
629  * @param {boolean} [onlySelected] return an object containing only the filters with a value
630  * @return {Object} Filters selected state
631  */
632 FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) {
633         const items = this.getItems(),
634                 result = {};
636         for ( let i = 0; i < items.length; i++ ) {
637                 if ( !onlySelected || items[ i ].getValue() ) {
638                         result[ items[ i ].getName() ] = items[ i ].getValue();
639                 }
640         }
642         return result;
646  * Get the current full state of the filters
648  * @return {Object} Filters full state
649  */
650 FiltersViewModel.prototype.getFullState = function () {
651         const items = this.getItems(),
652                 result = {};
654         for ( let i = 0; i < items.length; i++ ) {
655                 result[ items[ i ].getName() ] = {
656                         selected: items[ i ].isSelected(),
657                         conflicted: items[ i ].isConflicted(),
658                         included: items[ i ].isIncluded()
659                 };
660         }
662         return result;
666  * Get an object representing default parameters state
668  * @return {Object} Default parameter values
669  */
670 FiltersViewModel.prototype.getDefaultParams = function () {
671         const result = {};
673         // Get default filter state
674         // eslint-disable-next-line no-jquery/no-each-util
675         $.each( this.groups, ( name, model ) => {
676                 if ( !model.isSticky() ) {
677                         $.extend( true, result, model.getDefaultParams() );
678                 }
679         } );
681         return result;
685  * Get a parameter representation of all sticky parameters
687  * @return {Object} Sticky parameter values
688  */
689 FiltersViewModel.prototype.getStickyParams = function () {
690         let result = [];
692         // eslint-disable-next-line no-jquery/no-each-util
693         $.each( this.groups, ( name, model ) => {
694                 if ( model.isSticky() ) {
695                         if ( model.isPerGroupRequestParameter() ) {
696                                 result.push( name );
697                         } else {
698                                 // Each filter is its own param
699                                 result = result.concat( model.getItems().map( ( filterItem ) => filterItem.getParamName() ) );
700                         }
701                 }
702         } );
704         return result;
708  * Get a parameter representation of all sticky parameters
710  * @return {Object} Sticky parameter values
711  */
712 FiltersViewModel.prototype.getStickyParamsValues = function () {
713         const result = {};
715         // eslint-disable-next-line no-jquery/no-each-util
716         $.each( this.groups, ( name, model ) => {
717                 if ( model.isSticky() ) {
718                         $.extend( true, result, model.getParamRepresentation() );
719                 }
720         } );
722         return result;
726  * Analyze the groups and their filters and output an object representing
727  * the state of the parameters they represent.
729  * @param {Object} [filterDefinition] An object defining the filter values,
730  *  keyed by filter names.
731  * @return {Object} Parameter state object
732  */
733 FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
734         let groupItemDefinition;
735         const result = {},
736                 groupItems = this.getFilterGroups();
738         if ( filterDefinition ) {
739                 groupItemDefinition = {};
740                 // Filter definition is "flat", but in effect
741                 // each group needs to tell us its result based
742                 // on the values in it. We need to split this list
743                 // back into groupings so we can "feed" it to the
744                 // loop below, and we need to expand it so it includes
745                 // all filters (set to false)
746                 this.getItems().forEach( ( filterItem ) => {
747                         groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
748                         groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] );
749                 } );
750         }
752         // eslint-disable-next-line no-jquery/no-each-util
753         $.each( groupItems, ( group, model ) => {
754                 Object.assign(
755                         result,
756                         model.getParamRepresentation(
757                                 groupItemDefinition ?
758                                         groupItemDefinition[ group ] : null
759                         )
760                 );
761         } );
763         return result;
767  * This is the opposite of the #getParametersFromFilters method; this goes over
768  * the given parameters and translates into a selected/unselected value in the filters.
770  * @param {Object} params Parameters query object
771  * @return {Object} Filter state object
772  */
773 FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
774         const groupMap = {};
775         let result = {};
777         // Go over the given parameters, break apart to groupings
778         // The resulting object represents the group with its parameter
779         // values. For example:
780         // {
781         //    group1: {
782         //       param1: "1",
783         //       param2: "0",
784         //       param3: "1"
785         //    },
786         //    group2: "param4|param5"
787         // }
788         // eslint-disable-next-line no-jquery/no-each-util
789         $.each( params, ( paramName, paramValue ) => {
790                 const itemOrGroup = this.parameterMap[ paramName ];
792                 if ( itemOrGroup ) {
793                         const groupName = itemOrGroup instanceof FilterItem ?
794                                 itemOrGroup.getGroupName() : itemOrGroup.getName();
796                         groupMap[ groupName ] = groupMap[ groupName ] || {};
797                         groupMap[ groupName ][ paramName ] = paramValue;
798                 }
799         } );
801         // Go over all groups, so we make sure we get the complete output
802         // even if the parameters don't include a certain group
803         // eslint-disable-next-line no-jquery/no-each-util
804         $.each( this.groups, ( groupName, groupModel ) => {
805                 result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
806         } );
808         return result;
812  * Get the highlight parameters based on current filter configuration
814  * @return {Object} Object where keys are `<filter name>_color` and values
815  *                  are the selected highlight colors.
816  */
817 FiltersViewModel.prototype.getHighlightParameters = function () {
818         const highlightEnabled = this.isHighlightEnabled(),
819                 result = {};
821         this.getItems().forEach( ( filterItem ) => {
822                 if ( filterItem.isHighlightSupported() ) {
823                         result[ filterItem.getName() + '_color' ] = highlightEnabled && filterItem.isHighlighted() ?
824                                 filterItem.getHighlightColor() :
825                                 null;
826                 }
827         } );
829         return result;
833  * Get an object representing the complete empty state of highlights
835  * @return {Object} Object containing all the highlight parameters set to their negative value
836  */
837 FiltersViewModel.prototype.getEmptyHighlightParameters = function () {
838         const result = {};
840         this.getItems().forEach( ( filterItem ) => {
841                 if ( filterItem.isHighlightSupported() ) {
842                         result[ filterItem.getName() + '_color' ] = null;
843                 }
844         } );
846         return result;
850  * Get an array of currently applied highlight colors
852  * @return {string[]} Currently applied highlight colors
853  */
854 FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
855         const result = [];
857         if ( this.isHighlightEnabled() ) {
858                 this.getHighlightedItems().forEach( ( filterItem ) => {
859                         const color = filterItem.getHighlightColor();
861                         if ( result.indexOf( color ) === -1 ) {
862                                 result.push( color );
863                         }
864                 } );
865         }
867         return result;
871  * Sanitize value group of a string_option groups type
872  * Remove duplicates and make sure to only use valid
873  * values.
875  * @private
876  * @param {string} groupName Group name
877  * @param {string[]} valueArray Array of values
878  * @return {string[]} Array of valid values
879  */
880 FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
881         const validNames = this.getGroupFilters( groupName ).map( ( filterItem ) => filterItem.getParamName() );
883         return utils.normalizeParamOptions( valueArray, validNames );
887  * Check whether no visible filter is selected.
889  * Filter groups that are hidden or sticky are not shown in the
890  * active filters area and therefore not included in this check.
892  * @return {boolean} No visible filter is selected
893  */
894 FiltersViewModel.prototype.areVisibleFiltersEmpty = function () {
895         // Check if there are either any selected items or any items
896         // that have highlight enabled
897         return !this.getItems().some( ( filterItem ) => {
898                 const visible = !filterItem.getGroupModel().isSticky() && !filterItem.getGroupModel().isHidden(),
899                         active = ( filterItem.isSelected() || filterItem.isHighlighted() );
900                 return visible && active;
901         } );
905  * Check whether the namespace invert state is a valid one. A valid invert state is one
906  * where there are actual namespaces selected.
908  * This is done to compare states to previous ones that may have had the invert model
909  * selected but effectively had no namespaces, so are not effectively different than
910  * ones where invert is not selected.
912  * @return {boolean} Invert is effectively selected
913  */
914 FiltersViewModel.prototype.areNamespacesEffectivelyInverted = function () {
915         return this.getNamespacesInvertModel().isSelected() &&
916                 this.findSelectedItems().some( ( itemModel ) => itemModel.getGroupModel().getName() === 'namespace' );
920  * Check whether the tag invert state is a valid one. A valid invert state is one
921  * where there are actual tags selected.
923  * This is done to compare states to previous ones that may have had the invert model
924  * selected but effectively had no tags, so are not effectively different than
925  * ones where invert is not selected.
927  * @return {boolean} Invert is effectively selected
928  */
929 FiltersViewModel.prototype.areTagsEffectivelyInverted = function () {
930         return this.getTagsInvertModel().isSelected() &&
931                 this.findSelectedItems().some( ( itemModel ) => itemModel.getGroupModel().getName() === 'tagfilter' );
935  * Get the item that matches the given name
937  * @ignore
938  * @param {string} name Filter name
939  * @return {mw.rcfilters.dm.FilterItem} Filter item
940  */
941 FiltersViewModel.prototype.getItemByName = function ( name ) {
942         return this.getItems().filter( ( item ) => name === item.getName() )[ 0 ];
946  * Set all filters to false or empty/all
947  * This is equivalent to display all.
948  */
949 FiltersViewModel.prototype.emptyAllFilters = function () {
950         this.getItems().forEach( ( filterItem ) => {
951                 if ( !filterItem.getGroupModel().isSticky() ) {
952                         this.toggleFilterSelected( filterItem.getName(), false );
953                 }
954         } );
958  * Toggle selected state of one item
960  * @param {string} name Name of the filter item
961  * @param {boolean} [isSelected] Filter selected state
962  */
963 FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
964         const item = this.getItemByName( name );
966         if ( item ) {
967                 item.toggleSelected( isSelected );
968         }
972  * Toggle selected state of items by their names
974  * @param {Object} filterDef Filter definitions
975  */
976 FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
977         Object.keys( filterDef ).forEach( ( name ) => {
978                 this.toggleFilterSelected( name, filterDef[ name ] );
979         } );
983  * Get a group model from its name
985  * @ignore
986  * @param {string} groupName Group name
987  * @return {mw.rcfilters.dm.FilterGroup} Group model
988  */
989 FiltersViewModel.prototype.getGroup = function ( groupName ) {
990         return this.groups[ groupName ];
994  * Get all filters within a specified group by its name
996  * @ignore
997  * @param {string} groupName Group name
998  * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
999  */
1000 FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
1001         return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
1005  * Find items whose labels match the given string
1007  * @param {string} query Search string
1008  * @param {boolean} [returnFlat] Return a flat array. If false, the result
1009  *  is an object whose keys are the group names and values are an array of
1010  *  filters per group. If set to true, returns an array of filters regardless
1011  *  of their groups.
1012  * @return {Object} An object of items to show
1013  *  arranged by their group names
1014  */
1015 FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
1016         const result = {},
1017                 flatResult = [],
1018                 view = this.getViewByTrigger( query.slice( 0, 1 ) ),
1019                 items = this.getFiltersByView( view );
1021         // Normalize so we can search strings regardless of case and view
1022         query = query.trim().toLowerCase();
1023         if ( view !== 'default' ) {
1024                 query = query.slice( 1 );
1025         }
1026         // Trim again to also intercept cases where the spaces were after the trigger
1027         // eg: '#   str'
1028         query = query.trim();
1030         // Check if the search if actually empty; this can be a problem when
1031         // we use prefixes to denote different views
1032         const searchIsEmpty = query.length === 0;
1034         // item label starting with the query string
1035         for ( let i = 0; i < items.length; i++ ) {
1036                 if (
1037                         searchIsEmpty ||
1038                         items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
1039                         (
1040                                 // For tags, we want the parameter name to be included in the search
1041                                 view === 'tags' &&
1042                                 items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
1043                         )
1044                 ) {
1045                         result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
1046                         result[ items[ i ].getGroupName() ].push( items[ i ] );
1047                         flatResult.push( items[ i ] );
1048                 }
1049         }
1051         if ( $.isEmptyObject( result ) ) {
1052                 // item containing the query string in their label, description, or group title
1053                 for ( let i = 0; i < items.length; i++ ) {
1054                         const groupTitle = items[ i ].getGroupModel().getTitle();
1055                         if (
1056                                 searchIsEmpty ||
1057                                 items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
1058                                 items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
1059                                 groupTitle.toLowerCase().indexOf( query ) > -1 ||
1060                                 (
1061                                         // For tags, we want the parameter name to be included in the search
1062                                         view === 'tags' &&
1063                                         items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
1064                                 )
1065                         ) {
1066                                 result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
1067                                 result[ items[ i ].getGroupName() ].push( items[ i ] );
1068                                 flatResult.push( items[ i ] );
1069                         }
1070                 }
1071         }
1073         return returnFlat ? flatResult : result;
1077  * Get items that are highlighted
1079  * @ignore
1080  * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
1081  */
1082 FiltersViewModel.prototype.getHighlightedItems = function () {
1083         return this.getItems().filter( ( filterItem ) => filterItem.isHighlightSupported() &&
1084                         filterItem.getHighlightColor() );
1088  * Get items that allow highlights even if they're not currently highlighted
1090  * @ignore
1091  * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
1092  */
1093 FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
1094         return this.getItems().filter( ( filterItem ) => filterItem.isHighlightSupported() );
1098  * Get all selected items
1100  * @ignore
1101  * @return {mw.rcfilters.dm.FilterItem[]} Selected items
1102  */
1103 FiltersViewModel.prototype.findSelectedItems = function () {
1104         let allSelected = [];
1106         // eslint-disable-next-line no-jquery/no-each-util
1107         $.each( this.getFilterGroups(), ( groupName, groupModel ) => {
1108                 allSelected = allSelected.concat( groupModel.findSelectedItems() );
1109         } );
1111         return allSelected;
1115  * Get the current view
1117  * @return {string} Current view
1118  */
1119 FiltersViewModel.prototype.getCurrentView = function () {
1120         return this.currentView;
1124  * Get the label for the current view
1126  * @param {string} viewName View name
1127  * @return {string} Label for the current view
1128  */
1129 FiltersViewModel.prototype.getViewTitle = function ( viewName ) {
1130         viewName = viewName || this.getCurrentView();
1132         return this.views[ viewName ] && this.views[ viewName ].title;
1136  * Get the view that fits the given trigger
1138  * @param {string} trigger Trigger
1139  * @return {string} Name of view
1140  */
1141 FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
1142         let result = 'default';
1144         // eslint-disable-next-line no-jquery/no-each-util
1145         $.each( this.views, ( name, data ) => {
1146                 if ( data.trigger === trigger ) {
1147                         result = name;
1148                 }
1149         } );
1151         return result;
1155  * Return a version of the given string that is without any
1156  * view triggers.
1158  * @param {string} str Given string
1159  * @return {string} Result
1160  */
1161 FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
1162         if ( this.getViewFromString( str ) !== 'default' ) {
1163                 str = str.slice( 1 );
1164         }
1166         return str;
1170  * Get the view from the given string by a trigger, if it exists
1172  * @param {string} str Given string
1173  * @return {string} View name
1174  */
1175 FiltersViewModel.prototype.getViewFromString = function ( str ) {
1176         return this.getViewByTrigger( str.slice( 0, 1 ) );
1180  * Set the current search for the system.
1181  * This also dictates what items and groups are visible according
1182  * to the search in #findMatches
1184  * @param {string} searchQuery Search query, including triggers
1185  * @fires searchChange
1186  */
1187 FiltersViewModel.prototype.setSearch = function ( searchQuery ) {
1188         let visibleGroups, visibleGroupNames;
1190         if ( this.searchQuery !== searchQuery ) {
1191                 // Check if the view changed
1192                 this.switchView( this.getViewFromString( searchQuery ) );
1194                 visibleGroups = this.findMatches( searchQuery );
1195                 visibleGroupNames = Object.keys( visibleGroups );
1197                 // Update visibility of items and groups
1198                 // eslint-disable-next-line no-jquery/no-each-util
1199                 $.each( this.getFilterGroups(), ( groupName, groupModel ) => {
1200                         // Check if the group is visible at all
1201                         groupModel.toggleVisible( visibleGroupNames.indexOf( groupName ) !== -1 );
1202                         groupModel.setVisibleItems( visibleGroups[ groupName ] || [] );
1203                 } );
1205                 this.searchQuery = searchQuery;
1206                 this.emit( 'searchChange', this.searchQuery );
1207         }
1211  * Get the current search
1213  * @return {string} Current search query
1214  */
1215 FiltersViewModel.prototype.getSearch = function () {
1216         return this.searchQuery;
1220  * Switch the current view
1222  * @private
1223  * @param {string} view View name
1224  */
1225 FiltersViewModel.prototype.switchView = function ( view ) {
1226         if ( this.views[ view ] && this.currentView !== view ) {
1227                 this.currentView = view;
1228         }
1232  * Toggle the highlight feature on and off.
1233  * Propagate the change to filter items.
1235  * @param {boolean} enable Highlight should be enabled
1236  * @fires highlightChange
1237  */
1238 FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
1239         enable = enable === undefined ? !this.highlightEnabled : enable;
1241         if ( this.highlightEnabled !== enable ) {
1242                 this.highlightEnabled = enable;
1243                 this.emit( 'highlightChange', this.highlightEnabled );
1244         }
1248  * Check if the highlight feature is enabled
1250  * @return {boolean}
1251  */
1252 FiltersViewModel.prototype.isHighlightEnabled = function () {
1253         return !!this.highlightEnabled;
1257  * Toggle the inverted tags property on and off.
1258  * Propagate the change to tag filter items.
1260  * @param {boolean} enable Inverted property is enabled
1261  */
1262 FiltersViewModel.prototype.toggleInvertedTags = function ( enable ) {
1263         this.toggleFilterSelected( this.getTagsInvertModel().getName(), enable );
1267  * Toggle the inverted namespaces property on and off.
1268  * Propagate the change to namespace filter items.
1270  * @param {boolean} enable Inverted property is enabled
1271  */
1272 FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
1273         this.toggleFilterSelected( this.getNamespacesInvertModel().getName(), enable );
1277  * Get the model object that represents the 'invert' filter
1279  * @ignore
1280  * @param {string} view
1281  * @return {mw.rcfilters.dm.FilterItem|null}
1282  */
1283 FiltersViewModel.prototype.getInvertModel = function ( view ) {
1284         if ( view === 'namespaces' ) {
1285                 return this.getNamespacesInvertModel();
1286         }
1287         if ( view === 'tags' ) {
1288                 return this.getTagsInvertModel();
1289         }
1291         return null;
1295  * Get the model object that represents the 'invert' filter
1297  * @ignore
1298  * @return {mw.rcfilters.dm.FilterItem}
1299  */
1300 FiltersViewModel.prototype.getNamespacesInvertModel = function () {
1301         return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' );
1305  * Get the model object that represents the 'invert' filter
1307  * @ignore
1308  * @return {mw.rcfilters.dm.FilterItem}
1309  */
1310 FiltersViewModel.prototype.getTagsInvertModel = function () {
1311         return this.getGroup( 'invertTagsGroup' ).getItemByParamName( 'inverttags' );
1315  * Set highlight color for a specific filter item
1317  * @param {string} filterName Name of the filter item
1318  * @param {string} color Selected color
1319  */
1320 FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
1321         this.getItemByName( filterName ).setHighlightColor( color );
1325  * Clear highlight for a specific filter item
1327  * @param {string} filterName Name of the filter item
1328  */
1329 FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
1330         this.getItemByName( filterName ).clearHighlightColor();
1333 module.exports = FiltersViewModel;