b8e112913a628713fc426d6549c1720fa200208f
[lhc/web/wiklou.git] / resources / src / mediawiki.rcfilters / dm / mw.rcfilters.dm.FiltersViewModel.js
1 ( function ( mw, $ ) {
2 /**
3 * View model for the filters selection and display
4 *
5 * @mixins OO.EventEmitter
6 * @mixins OO.EmitterList
7 *
8 * @constructor
9 */
10 mw.rcfilters.dm.FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
11 // Mixin constructor
12 OO.EventEmitter.call( this );
13 OO.EmitterList.call( this );
14
15 this.groups = {};
16 this.defaultParams = {};
17 this.defaultFiltersEmpty = null;
18 this.highlightEnabled = false;
19 this.parameterMap = {};
20 this.emptyParameterState = null;
21
22 this.views = {};
23 this.currentView = 'default';
24
25 // Events
26 this.aggregate( { update: 'filterItemUpdate' } );
27 this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
28 };
29
30 /* Initialization */
31 OO.initClass( mw.rcfilters.dm.FiltersViewModel );
32 OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EventEmitter );
33 OO.mixinClass( mw.rcfilters.dm.FiltersViewModel, OO.EmitterList );
34
35 /* Events */
36
37 /**
38 * @event initialize
39 *
40 * Filter list is initialized
41 */
42
43 /**
44 * @event update
45 *
46 * Model has been updated
47 */
48
49 /**
50 * @event itemUpdate
51 * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
52 *
53 * Filter item has changed
54 */
55
56 /**
57 * @event highlightChange
58 * @param {boolean} Highlight feature is enabled
59 *
60 * Highlight feature has been toggled enabled or disabled
61 */
62
63 /* Methods */
64
65 /**
66 * Re-assess the states of filter items based on the interactions between them
67 *
68 * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
69 * method will go over the state of all items
70 */
71 mw.rcfilters.dm.FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
72 var allSelected,
73 model = this,
74 iterationItems = item !== undefined ? [ item ] : this.getItems();
75
76 iterationItems.forEach( function ( checkedItem ) {
77 var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
78 groupModel = checkedItem.getGroupModel();
79
80 // Check for subsets (included filters) plus the item itself:
81 allCheckedItems.forEach( function ( filterItemName ) {
82 var itemInSubset = model.getItemByName( filterItemName );
83
84 itemInSubset.toggleIncluded(
85 // If any of itemInSubset's supersets are selected, this item
86 // is included
87 itemInSubset.getSuperset().some( function ( supersetName ) {
88 return ( model.getItemByName( supersetName ).isSelected() );
89 } )
90 );
91 } );
92
93 // Update coverage for the changed group
94 if ( groupModel.isFullCoverage() ) {
95 allSelected = groupModel.areAllSelected();
96 groupModel.getItems().forEach( function ( filterItem ) {
97 filterItem.toggleFullyCovered( allSelected );
98 } );
99 }
100 } );
101
102 // Check for conflicts
103 // In this case, we must go over all items, since
104 // conflicts are bidirectional and depend not only on
105 // individual items, but also on the selected states of
106 // the groups they're in.
107 this.getItems().forEach( function ( filterItem ) {
108 var inConflict = false,
109 filterItemGroup = filterItem.getGroupModel();
110
111 // For each item, see if that item is still conflicting
112 $.each( model.groups, function ( groupName, groupModel ) {
113 if ( filterItem.getGroupName() === groupName ) {
114 // Check inside the group
115 inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
116 } else {
117 // According to the spec, if two items conflict from two different
118 // groups, the conflict only lasts if the groups **only have selected
119 // items that are conflicting**. If a group has selected items that
120 // are conflicting and non-conflicting, the scope of the result has
121 // expanded enough to completely remove the conflict.
122
123 // For example, see two groups with conflicts:
124 // userExpLevel: [
125 // {
126 // name: 'experienced',
127 // conflicts: [ 'unregistered' ]
128 // }
129 // ],
130 // registration: [
131 // {
132 // name: 'registered',
133 // },
134 // {
135 // name: 'unregistered',
136 // }
137 // ]
138 // If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
139 // because, inherently, 'experienced' filter only includes registered users, and so
140 // both filters are in conflict with one another.
141 // However, the minute we select 'registered', the scope of our results
142 // has expanded to no longer have a conflict with 'experienced' filter, and
143 // so the conflict is removed.
144
145 // In our case, we need to check if the entire group conflicts with
146 // the entire item's group, so we follow the above spec
147 inConflict = (
148 // The foreign group is in conflict with this item
149 groupModel.areAllSelectedInConflictWith( filterItem ) &&
150 // Every selected member of the item's own group is also
151 // in conflict with the other group
152 filterItemGroup.getSelectedItems().every( function ( otherGroupItem ) {
153 return groupModel.areAllSelectedInConflictWith( otherGroupItem );
154 } )
155 );
156 }
157
158 // If we're in conflict, this will return 'false' which
159 // will break the loop. Otherwise, we're not in conflict
160 // and the loop continues
161 return !inConflict;
162 } );
163
164 // Toggle the item state
165 filterItem.toggleConflicted( inConflict );
166 } );
167 };
168
169 /**
170 * Get whether the model has any conflict in its items
171 *
172 * @return {boolean} There is a conflict
173 */
174 mw.rcfilters.dm.FiltersViewModel.prototype.hasConflict = function () {
175 return this.getItems().some( function ( filterItem ) {
176 return filterItem.isSelected() && filterItem.isConflicted();
177 } );
178 };
179
180 /**
181 * Get the first item with a current conflict
182 *
183 * @return {mw.rcfilters.dm.FilterItem} Conflicted item
184 */
185 mw.rcfilters.dm.FiltersViewModel.prototype.getFirstConflictedItem = function () {
186 var conflictedItem;
187
188 $.each( this.getItems(), function ( index, filterItem ) {
189 if ( filterItem.isSelected() && filterItem.isConflicted() ) {
190 conflictedItem = filterItem;
191 return false;
192 }
193 } );
194
195 return conflictedItem;
196 };
197
198 /**
199 * Set filters and preserve a group relationship based on
200 * the definition given by an object
201 *
202 * @param {Array} filterGroups Filters definition
203 * @param {Object} [views] Extra views definition
204 * Expected in the following format:
205 * {
206 * namespaces: {
207 * label: 'namespaces', // Message key
208 * trigger: ':',
209 * groups: [
210 * {
211 * // Group info
212 * name: 'namespaces' // Parameter name
213 * title: 'namespaces' // Message key
214 * type: 'string_options',
215 * separator: ';',
216 * labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
217 * fullCoverage: true
218 * items: []
219 * }
220 * ]
221 * }
222 * }
223 */
224 mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
225 var filterConflictResult, groupConflictResult,
226 allViews = {},
227 model = this,
228 items = [],
229 groupConflictMap = {},
230 filterConflictMap = {},
231 /*!
232 * Expand a conflict definition from group name to
233 * the list of all included filters in that group.
234 * We do this so that the direct relationship in the
235 * models are consistently item->items rather than
236 * mixing item->group with item->item.
237 *
238 * @param {Object} obj Conflict definition
239 * @return {Object} Expanded conflict definition
240 */
241 expandConflictDefinitions = function ( obj ) {
242 var result = {};
243
244 $.each( obj, function ( key, conflicts ) {
245 var filterName,
246 adjustedConflicts = {};
247
248 conflicts.forEach( function ( conflict ) {
249 var filter;
250
251 if ( conflict.filter ) {
252 filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
253 filter = model.getItemByName( filterName );
254
255 // Rename
256 adjustedConflicts[ filterName ] = $.extend(
257 {},
258 conflict,
259 {
260 filter: filterName,
261 item: filter
262 }
263 );
264 } else {
265 // This conflict is for an entire group. Split it up to
266 // represent each filter
267
268 // Get the relevant group items
269 model.groups[ conflict.group ].getItems().forEach( function ( groupItem ) {
270 // Rebuild the conflict
271 adjustedConflicts[ groupItem.getName() ] = $.extend(
272 {},
273 conflict,
274 {
275 filter: groupItem.getName(),
276 item: groupItem
277 }
278 );
279 } );
280 }
281 } );
282
283 result[ key ] = adjustedConflicts;
284 } );
285
286 return result;
287 };
288
289 // Reset
290 this.clearItems();
291 this.groups = {};
292 this.views = {};
293
294 // Clone
295 filterGroups = OO.copy( filterGroups );
296
297 // Normalize definition from the server
298 filterGroups.forEach( function ( data ) {
299 var i;
300 // What's this information needs to be normalized
301 data.whatsThis = {
302 body: data.whatsThisBody,
303 header: data.whatsThisHeader,
304 linkText: data.whatsThisLinkText,
305 url: data.whatsThisUrl
306 };
307
308 // Title is a msg-key
309 data.title = data.title ? mw.msg( data.title ) : data.name;
310
311 // Filters are given to us with msg-keys, we need
312 // to translate those before we hand them off
313 for ( i = 0; i < data.filters.length; i++ ) {
314 data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
315 data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
316 }
317 } );
318
319 // Collect views
320 allViews = $.extend( true, {
321 'default': {
322 title: mw.msg( 'rcfilters-filterlist-title' ),
323 groups: filterGroups
324 }
325 }, views );
326
327 // Go over all views
328 $.each( allViews, function ( viewName, viewData ) {
329 // Define the view
330 model.views[ viewName ] = {
331 name: viewData.name,
332 title: viewData.title,
333 trigger: viewData.trigger
334 };
335
336 // Go over groups
337 viewData.groups.forEach( function ( groupData ) {
338 var group = groupData.name;
339
340 if ( !model.groups[ group ] ) {
341 model.groups[ group ] = new mw.rcfilters.dm.FilterGroup(
342 group,
343 $.extend( true, {}, groupData, { view: viewName } )
344 );
345 }
346
347 model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
348 items = items.concat( model.groups[ group ].getItems() );
349
350 // Prepare conflicts
351 if ( groupData.conflicts ) {
352 // Group conflicts
353 groupConflictMap[ group ] = groupData.conflicts;
354 }
355
356 groupData.filters.forEach( function ( itemData ) {
357 var filterItem = model.groups[ group ].getItemByParamName( itemData.name );
358 // Filter conflicts
359 if ( itemData.conflicts ) {
360 filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
361 }
362 } );
363 } );
364 } );
365
366 // Add item references to the model, for lookup
367 this.addItems( items );
368
369 // Expand conflicts
370 groupConflictResult = expandConflictDefinitions( groupConflictMap );
371 filterConflictResult = expandConflictDefinitions( filterConflictMap );
372
373 // Set conflicts for groups
374 $.each( groupConflictResult, function ( group, conflicts ) {
375 model.groups[ group ].setConflicts( conflicts );
376 } );
377
378 // Set conflicts for items
379 $.each( filterConflictResult, function ( filterName, conflicts ) {
380 var filterItem = model.getItemByName( filterName );
381 // set conflicts for items in the group
382 filterItem.setConflicts( conflicts );
383 } );
384
385 // Create a map between known parameters and their models
386 $.each( this.groups, function ( group, groupModel ) {
387 if (
388 groupModel.getType() === 'send_unselected_if_any' ||
389 groupModel.getType() === 'boolean'
390 ) {
391 // Individual filters
392 groupModel.getItems().forEach( function ( filterItem ) {
393 model.parameterMap[ filterItem.getParamName() ] = filterItem;
394 } );
395 } else if (
396 groupModel.getType() === 'string_options' ||
397 groupModel.getType() === 'single_option'
398 ) {
399 // Group
400 model.parameterMap[ groupModel.getName() ] = groupModel;
401 }
402 } );
403
404 this.currentView = 'default';
405
406 if ( this.getHighlightedItems().length > 0 ) {
407 this.toggleHighlight( true );
408 }
409
410 // Finish initialization
411 this.emit( 'initialize' );
412 };
413
414 /**
415 * Update filter view model state based on a parameter object
416 *
417 * @param {Object} params Parameters object
418 */
419 mw.rcfilters.dm.FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
420 // For arbitrary numeric single_option values make sure the values
421 // are normalized to fit within the limits
422 $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
423 params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
424 } );
425
426 // Update filter states
427 this.toggleFiltersSelected(
428 this.getFiltersFromParameters(
429 params
430 )
431 );
432
433 // Update highlight state
434 this.getItemsSupportingHighlights().forEach( function ( filterItem ) {
435 var color = params[ filterItem.getName() + '_color' ];
436 if ( color ) {
437 filterItem.setHighlightColor( color );
438 } else {
439 filterItem.clearHighlightColor();
440 }
441 } );
442 this.toggleHighlight( !!Number( params.highlight ) );
443
444 // Check all filter interactions
445 this.reassessFilterInteractions();
446 };
447
448 /**
449 * Get a representation of an empty (falsey) parameter state
450 *
451 * @return {Object} Empty parameter state
452 */
453 mw.rcfilters.dm.FiltersViewModel.prototype.getEmptyParameterState = function () {
454 if ( !this.emptyParameterState ) {
455 this.emptyParameterState = $.extend(
456 true,
457 {},
458 this.getParametersFromFilters( {} ),
459 this.getEmptyHighlightParameters(),
460 { highlight: '0' }
461 );
462 }
463 return this.emptyParameterState;
464 };
465
466 /**
467 * Get a representation of only the non-falsey parameters
468 *
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 mw.rcfilters.dm.FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
474 var result = {};
475
476 parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
477
478 // Params
479 $.each( this.getEmptyParameterState(), function ( param, value ) {
480 if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
481 result[ param ] = parameters[ param ];
482 }
483 } );
484
485 // Highlights
486 Object.keys( this.getEmptyHighlightParameters() ).forEach( function ( param ) {
487 if ( param !== 'highlight' && parameters[ param ] ) {
488 // If a highlight parameter is not undefined and not null
489 // add it to the result
490 // Ignore "highlight" parameter because that, we checked already with
491 // the empty parameter state (and this soon changes to an implicit value)
492 result[ param ] = parameters[ param ];
493 }
494 } );
495
496 return result;
497 };
498
499 /**
500 * Get a representation of the full parameter list, including all base values
501 *
502 * @param {Object} [parameters] A given parameter state to minimize. If not given the current
503 * state of the system will be used.
504 * @param {boolean} [removeExcluded] Remove excluded and sticky parameters
505 * @return {Object} Full parameter representation
506 */
507 mw.rcfilters.dm.FiltersViewModel.prototype.getExpandedParamRepresentation = function ( parameters, removeExcluded ) {
508 var result = {};
509
510 parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();
511
512 result = $.extend(
513 true,
514 {},
515 this.getEmptyParameterState(),
516 parameters
517 );
518
519 if ( removeExcluded ) {
520 result = this.removeExcludedParams( result );
521 }
522
523 return result;
524 };
525
526 /**
527 * Get a parameter representation of the current state of the model
528 *
529 * @param {boolean} [removeExcludedParams] Remove excluded filters from final result
530 * @return {Object} Parameter representation of the current state of the model
531 */
532 mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentParameterState = function ( removeExcludedParams ) {
533 var excludedParams,
534 state = this.getMinimizedParamRepresentation( $.extend(
535 true,
536 {},
537 this.getParametersFromFilters( this.getSelectedState() ),
538 this.getHighlightParameters(),
539 {
540 // HACK: Add highlight. This is only needed while it's
541 // stored as an outside state
542 highlight: String( Number( this.isHighlightEnabled() ) )
543 }
544 ) );
545
546 if ( removeExcludedParams ) {
547 excludedParams = this.getExcludedParams();
548 // Delete all excluded filters
549 $.each( state, function ( param ) {
550 if ( excludedParams.indexOf( param ) > -1 ) {
551 delete state[ param ];
552 }
553 } );
554 }
555
556 return state;
557 };
558
559 /**
560 * Delete excluded and sticky filters from given object. If object isn't given, output
561 * the current filter state without the excluded values
562 *
563 * @param {Object} [filterState] Filter state
564 * @return {Object} Filter state without excluded filters
565 */
566 mw.rcfilters.dm.FiltersViewModel.prototype.removeExcludedFilters = function ( filterState ) {
567 filterState = filterState !== undefined ?
568 $.extend( true, {}, filterState ) :
569 this.getFiltersFromParameters();
570
571 // Remove excluded filters
572 Object.keys( this.getExcludedFiltersState() ).forEach( function ( filterName ) {
573 delete filterState[ filterName ];
574 } );
575
576 // Remove sticky filters
577 Object.keys( this.getStickyFiltersState() ).forEach( function ( filterName ) {
578 delete filterState[ filterName ];
579 } );
580
581 return filterState;
582 };
583 /**
584 * Delete excluded and sticky parameters from given object. If object isn't given, output
585 * the current param state without the excluded values
586 *
587 * @param {Object} [paramState] Parameter state
588 * @return {Object} Parameter state without excluded filters
589 */
590 mw.rcfilters.dm.FiltersViewModel.prototype.removeExcludedParams = function ( paramState ) {
591 paramState = paramState !== undefined ?
592 $.extend( true, {}, paramState ) :
593 this.getCurrentParameterState();
594
595 // Remove excluded filters
596 this.getExcludedParams().forEach( function ( paramName ) {
597 delete paramState[ paramName ];
598 } );
599
600 // Remove sticky filters
601 this.getStickyParams().forEach( function ( paramName ) {
602 delete paramState[ paramName ];
603 } );
604
605 return paramState;
606 };
607
608 /**
609 * Get the names of all available filters
610 *
611 * @return {string[]} An array of filter names
612 */
613 mw.rcfilters.dm.FiltersViewModel.prototype.getFilterNames = function () {
614 return this.getItems().map( function ( item ) { return item.getName(); } );
615 };
616
617 /**
618 * Get the object that defines groups by their name.
619 *
620 * @return {Object} Filter groups
621 */
622 mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroups = function () {
623 return this.groups;
624 };
625
626 /**
627 * Get the object that defines groups that match a certain view by their name.
628 *
629 * @param {string} [view] Requested view. If not given, uses current view
630 * @return {Object} Filter groups matching a display group
631 */
632 mw.rcfilters.dm.FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
633 var result = {};
634
635 view = view || this.getCurrentView();
636
637 $.each( this.groups, function ( groupName, groupModel ) {
638 if ( groupModel.getView() === view ) {
639 result[ groupName ] = groupModel;
640 }
641 } );
642
643 return result;
644 };
645
646 /**
647 * Get an array of filters matching the given display group.
648 *
649 * @param {string} [view] Requested view. If not given, uses current view
650 * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
651 */
652 mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersByView = function ( view ) {
653 var groups,
654 result = [];
655
656 view = view || this.getCurrentView();
657
658 groups = this.getFilterGroupsByView( view );
659
660 $.each( groups, function ( groupName, groupModel ) {
661 result = result.concat( groupModel.getItems() );
662 } );
663
664 return result;
665 };
666
667 /**
668 * Get the trigger for the requested view.
669 *
670 * @param {string} view View name
671 * @return {string} View trigger, if exists
672 */
673 mw.rcfilters.dm.FiltersViewModel.prototype.getViewTrigger = function ( view ) {
674 return ( this.views[ view ] && this.views[ view ].trigger ) || '';
675 };
676 /**
677 * Get the value of a specific parameter
678 *
679 * @param {string} name Parameter name
680 * @return {number|string} Parameter value
681 */
682 mw.rcfilters.dm.FiltersViewModel.prototype.getParamValue = function ( name ) {
683 return this.parameters[ name ];
684 };
685
686 /**
687 * Get the current selected state of the filters
688 *
689 * @return {Object} Filters selected state
690 */
691 mw.rcfilters.dm.FiltersViewModel.prototype.getSelectedState = function () {
692 var i,
693 items = this.getItems(),
694 result = {};
695
696 for ( i = 0; i < items.length; i++ ) {
697 result[ items[ i ].getName() ] = items[ i ].isSelected();
698 }
699
700 return result;
701 };
702
703 /**
704 * Get the current full state of the filters
705 *
706 * @return {Object} Filters full state
707 */
708 mw.rcfilters.dm.FiltersViewModel.prototype.getFullState = function () {
709 var i,
710 items = this.getItems(),
711 result = {};
712
713 for ( i = 0; i < items.length; i++ ) {
714 result[ items[ i ].getName() ] = {
715 selected: items[ i ].isSelected(),
716 conflicted: items[ i ].isConflicted(),
717 included: items[ i ].isIncluded()
718 };
719 }
720
721 return result;
722 };
723
724 /**
725 * Get an object representing default parameters state
726 *
727 * @param {boolean} [excludeHiddenParams] Exclude hidden and sticky params
728 * @return {Object} Default parameter values
729 */
730 mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultParams = function ( excludeHiddenParams ) {
731 var result = {};
732
733 // Get default filter state
734 $.each( this.groups, function ( name, model ) {
735 $.extend( true, result, model.getDefaultParams() );
736 } );
737
738 if ( excludeHiddenParams ) {
739 Object.keys( this.getDefaultHiddenParams() ).forEach( function ( paramName ) {
740 delete result[ paramName ];
741 } );
742 }
743
744 return result;
745 };
746
747 /**
748 * Get an object representing defaults for the hidden parameters state
749 *
750 * @return {Object} Default values for hidden parameters
751 */
752 mw.rcfilters.dm.FiltersViewModel.prototype.getDefaultHiddenParams = function () {
753 var result = {};
754
755 // Get default filter state
756 $.each( this.groups, function ( name, model ) {
757 if ( model.isHidden() ) {
758 $.extend( true, result, model.getDefaultParams() );
759 }
760 } );
761
762 return result;
763 };
764
765 /**
766 * Get a parameter representation of all sticky parameters
767 *
768 * @return {Object} Sticky parameter values
769 */
770 mw.rcfilters.dm.FiltersViewModel.prototype.getStickyParams = function () {
771 var result = [];
772
773 $.each( this.groups, function ( name, model ) {
774 if ( model.isSticky() ) {
775 if ( model.isPerGroupRequestParameter() ) {
776 result.push( name );
777 } else {
778 // Each filter is its own param
779 result = result.concat( model.getItems().map( function ( filterItem ) {
780 return filterItem.getParamName();
781 } ) );
782 }
783 }
784 } );
785
786 return result;
787 };
788
789 /**
790 * Get a parameter representation of all sticky parameters
791 *
792 * @return {Object} Sticky parameter values
793 */
794 mw.rcfilters.dm.FiltersViewModel.prototype.getStickyParamsValues = function () {
795 var result = {};
796
797 $.each( this.groups, function ( name, model ) {
798 if ( model.isSticky() ) {
799 $.extend( true, result, model.getDefaultParams() );
800 }
801 } );
802
803 return result;
804 };
805
806 /**
807 * Get a filter representation of all sticky parameters
808 *
809 * @return {Object} Sticky filters values
810 */
811 mw.rcfilters.dm.FiltersViewModel.prototype.getStickyFiltersState = function () {
812 var result = {};
813
814 $.each( this.groups, function ( name, model ) {
815 if ( model.isSticky() ) {
816 $.extend( true, result, model.getSelectedState() );
817 }
818 } );
819
820 return result;
821 };
822
823 /**
824 * Get a filter representation of all parameters that are marked
825 * as being excluded from saved query.
826 *
827 * @return {Object} Excluded filters values
828 */
829 mw.rcfilters.dm.FiltersViewModel.prototype.getExcludedFiltersState = function () {
830 var result = {};
831
832 $.each( this.groups, function ( name, model ) {
833 if ( model.isExcludedFromSavedQueries() ) {
834 $.extend( true, result, model.getSelectedState() );
835 }
836 } );
837
838 return result;
839 };
840
841 /**
842 * Get the parameter names that represent filters that are excluded
843 * from saved queries.
844 *
845 * @return {string[]} Parameter names
846 */
847 mw.rcfilters.dm.FiltersViewModel.prototype.getExcludedParams = function () {
848 var result = [];
849
850 $.each( this.groups, function ( name, model ) {
851 if ( model.isExcludedFromSavedQueries() ) {
852 if ( model.isPerGroupRequestParameter() ) {
853 result.push( name );
854 } else {
855 // Each filter is its own param
856 result = result.concat( model.getItems().map( function ( filterItem ) {
857 return filterItem.getParamName();
858 } ) );
859 }
860 }
861 } );
862
863 return result;
864 };
865
866 /**
867 * Analyze the groups and their filters and output an object representing
868 * the state of the parameters they represent.
869 *
870 * @param {Object} [filterDefinition] An object defining the filter values,
871 * keyed by filter names.
872 * @return {Object} Parameter state object
873 */
874 mw.rcfilters.dm.FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
875 var groupItemDefinition,
876 result = {},
877 groupItems = this.getFilterGroups();
878
879 if ( filterDefinition ) {
880 groupItemDefinition = {};
881 // Filter definition is "flat", but in effect
882 // each group needs to tell us its result based
883 // on the values in it. We need to split this list
884 // back into groupings so we can "feed" it to the
885 // loop below, and we need to expand it so it includes
886 // all filters (set to false)
887 this.getItems().forEach( function ( filterItem ) {
888 groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
889 groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = !!filterDefinition[ filterItem.getName() ];
890 } );
891 }
892
893 $.each( groupItems, function ( group, model ) {
894 $.extend(
895 result,
896 model.getParamRepresentation(
897 groupItemDefinition ?
898 groupItemDefinition[ group ] : null
899 )
900 );
901 } );
902
903 return result;
904 };
905
906 /**
907 * This is the opposite of the #getParametersFromFilters method; this goes over
908 * the given parameters and translates into a selected/unselected value in the filters.
909 *
910 * @param {Object} params Parameters query object
911 * @return {Object} Filter state object
912 */
913 mw.rcfilters.dm.FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
914 var groupMap = {},
915 model = this,
916 result = {};
917
918 // Go over the given parameters, break apart to groupings
919 // The resulting object represents the group with its parameter
920 // values. For example:
921 // {
922 // group1: {
923 // param1: "1",
924 // param2: "0",
925 // param3: "1"
926 // },
927 // group2: "param4|param5"
928 // }
929 $.each( params, function ( paramName, paramValue ) {
930 var groupName,
931 itemOrGroup = model.parameterMap[ paramName ];
932
933 if ( itemOrGroup ) {
934 groupName = itemOrGroup instanceof mw.rcfilters.dm.FilterItem ?
935 itemOrGroup.getGroupName() : itemOrGroup.getName();
936
937 groupMap[ groupName ] = groupMap[ groupName ] || {};
938 groupMap[ groupName ][ paramName ] = paramValue;
939 }
940 } );
941
942 // Go over all groups, so we make sure we get the complete output
943 // even if the parameters don't include a certain group
944 $.each( this.groups, function ( groupName, groupModel ) {
945 result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
946 } );
947
948 return result;
949 };
950
951 /**
952 * Get the highlight parameters based on current filter configuration
953 *
954 * @return {Object} Object where keys are `<filter name>_color` and values
955 * are the selected highlight colors.
956 */
957 mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightParameters = function () {
958 var result = {};
959
960 this.getItems().forEach( function ( filterItem ) {
961 if ( filterItem.isHighlightSupported() ) {
962 result[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor() || null;
963 }
964 } );
965 result.highlight = String( Number( this.isHighlightEnabled() ) );
966
967 return result;
968 };
969
970 /**
971 * Get an object representing the complete empty state of highlights
972 *
973 * @return {Object} Object containing all the highlight parameters set to their negative value
974 */
975 mw.rcfilters.dm.FiltersViewModel.prototype.getEmptyHighlightParameters = function () {
976 var result = {};
977
978 this.getItems().forEach( function ( filterItem ) {
979 if ( filterItem.isHighlightSupported() ) {
980 result[ filterItem.getName() + '_color' ] = null;
981 }
982 } );
983 result.highlight = '0';
984
985 return result;
986 };
987
988 /**
989 * Extract the highlight values from given object. Since highlights are
990 * the same for filter and parameters, it doesn't matter which one is
991 * given; values will be returned with a full list of the highlights
992 * with colors or null values.
993 *
994 * @param {Object} representation Object containing representation of
995 * some or all highlight values
996 * @return {Object} Object where keys are `<filter name>_color` and values
997 * are the selected highlight colors. The returned object
998 * contains all available filters either with a color value
999 * or with null.
1000 */
1001 mw.rcfilters.dm.FiltersViewModel.prototype.extractHighlightValues = function ( representation ) {
1002 var result = {};
1003
1004 this.getItems().forEach( function ( filterItem ) {
1005 var highlightName = filterItem.getName() + '_color';
1006 result[ highlightName ] = representation[ highlightName ] || null;
1007 } );
1008
1009 return result;
1010 };
1011
1012 /**
1013 * Get an array of currently applied highlight colors
1014 *
1015 * @return {string[]} Currently applied highlight colors
1016 */
1017 mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
1018 var result = [];
1019
1020 this.getHighlightedItems().forEach( function ( filterItem ) {
1021 var color = filterItem.getHighlightColor();
1022
1023 if ( result.indexOf( color ) === -1 ) {
1024 result.push( color );
1025 }
1026 } );
1027
1028 return result;
1029 };
1030
1031 /**
1032 * Sanitize value group of a string_option groups type
1033 * Remove duplicates and make sure to only use valid
1034 * values.
1035 *
1036 * @private
1037 * @param {string} groupName Group name
1038 * @param {string[]} valueArray Array of values
1039 * @return {string[]} Array of valid values
1040 */
1041 mw.rcfilters.dm.FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
1042 var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
1043 return filterItem.getParamName();
1044 } );
1045
1046 return mw.rcfilters.utils.normalizeParamOptions( valueArray, validNames );
1047 };
1048
1049 /**
1050 * Check whether the current filter state is set to all false.
1051 *
1052 * @return {boolean} Current filters are all empty
1053 */
1054 mw.rcfilters.dm.FiltersViewModel.prototype.areCurrentFiltersEmpty = function () {
1055 // Check if there are either any selected items or any items
1056 // that have highlight enabled
1057 return !this.getItems().some( function ( filterItem ) {
1058 return !filterItem.getGroupModel().isHidden() && ( filterItem.isSelected() || filterItem.isHighlighted() );
1059 } );
1060 };
1061
1062 /**
1063 * Get the item that matches the given name
1064 *
1065 * @param {string} name Filter name
1066 * @return {mw.rcfilters.dm.FilterItem} Filter item
1067 */
1068 mw.rcfilters.dm.FiltersViewModel.prototype.getItemByName = function ( name ) {
1069 return this.getItems().filter( function ( item ) {
1070 return name === item.getName();
1071 } )[ 0 ];
1072 };
1073
1074 /**
1075 * Set all filters to false or empty/all
1076 * This is equivalent to display all.
1077 */
1078 mw.rcfilters.dm.FiltersViewModel.prototype.emptyAllFilters = function () {
1079 this.getItems().forEach( function ( filterItem ) {
1080 if ( !filterItem.getGroupModel().isSticky() ) {
1081 this.toggleFilterSelected( filterItem.getName(), false );
1082 }
1083 }.bind( this ) );
1084 };
1085
1086 /**
1087 * Toggle selected state of one item
1088 *
1089 * @param {string} name Name of the filter item
1090 * @param {boolean} [isSelected] Filter selected state
1091 */
1092 mw.rcfilters.dm.FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
1093 var item = this.getItemByName( name );
1094
1095 if ( item ) {
1096 item.toggleSelected( isSelected );
1097 }
1098 };
1099
1100 /**
1101 * Toggle selected state of items by their names
1102 *
1103 * @param {Object} filterDef Filter definitions
1104 */
1105 mw.rcfilters.dm.FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
1106 Object.keys( filterDef ).forEach( function ( name ) {
1107 this.toggleFilterSelected( name, filterDef[ name ] );
1108 }.bind( this ) );
1109 };
1110
1111 /**
1112 * Get a group model from its name
1113 *
1114 * @param {string} groupName Group name
1115 * @return {mw.rcfilters.dm.FilterGroup} Group model
1116 */
1117 mw.rcfilters.dm.FiltersViewModel.prototype.getGroup = function ( groupName ) {
1118 return this.groups[ groupName ];
1119 };
1120
1121 /**
1122 * Get all filters within a specified group by its name
1123 *
1124 * @param {string} groupName Group name
1125 * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
1126 */
1127 mw.rcfilters.dm.FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
1128 return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
1129 };
1130
1131 /**
1132 * Find items whose labels match the given string
1133 *
1134 * @param {string} query Search string
1135 * @param {boolean} [returnFlat] Return a flat array. If false, the result
1136 * is an object whose keys are the group names and values are an array of
1137 * filters per group. If set to true, returns an array of filters regardless
1138 * of their groups.
1139 * @return {Object} An object of items to show
1140 * arranged by their group names
1141 */
1142 mw.rcfilters.dm.FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
1143 var i, searchIsEmpty,
1144 groupTitle,
1145 result = {},
1146 flatResult = [],
1147 view = this.getViewByTrigger( query.substr( 0, 1 ) ),
1148 items = this.getFiltersByView( view );
1149
1150 // Normalize so we can search strings regardless of case and view
1151 query = query.trim().toLowerCase();
1152 if ( view !== 'default' ) {
1153 query = query.substr( 1 );
1154 }
1155 // Trim again to also intercept cases where the spaces were after the trigger
1156 // eg: '# str'
1157 query = query.trim();
1158
1159 // Check if the search if actually empty; this can be a problem when
1160 // we use prefixes to denote different views
1161 searchIsEmpty = query.length === 0;
1162
1163 // item label starting with the query string
1164 for ( i = 0; i < items.length; i++ ) {
1165 if (
1166 searchIsEmpty ||
1167 items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
1168 (
1169 // For tags, we want the parameter name to be included in the search
1170 view === 'tags' &&
1171 items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
1172 )
1173 ) {
1174 result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
1175 result[ items[ i ].getGroupName() ].push( items[ i ] );
1176 flatResult.push( items[ i ] );
1177 }
1178 }
1179
1180 if ( $.isEmptyObject( result ) ) {
1181 // item containing the query string in their label, description, or group title
1182 for ( i = 0; i < items.length; i++ ) {
1183 groupTitle = items[ i ].getGroupModel().getTitle();
1184 if (
1185 searchIsEmpty ||
1186 items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
1187 items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
1188 groupTitle.toLowerCase().indexOf( query ) > -1 ||
1189 (
1190 // For tags, we want the parameter name to be included in the search
1191 view === 'tags' &&
1192 items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
1193 )
1194 ) {
1195 result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
1196 result[ items[ i ].getGroupName() ].push( items[ i ] );
1197 flatResult.push( items[ i ] );
1198 }
1199 }
1200 }
1201
1202 return returnFlat ? flatResult : result;
1203 };
1204
1205 /**
1206 * Get items that are highlighted
1207 *
1208 * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
1209 */
1210 mw.rcfilters.dm.FiltersViewModel.prototype.getHighlightedItems = function () {
1211 return this.getItems().filter( function ( filterItem ) {
1212 return filterItem.isHighlightSupported() &&
1213 filterItem.getHighlightColor();
1214 } );
1215 };
1216
1217 /**
1218 * Get items that allow highlights even if they're not currently highlighted
1219 *
1220 * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
1221 */
1222 mw.rcfilters.dm.FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
1223 return this.getItems().filter( function ( filterItem ) {
1224 return filterItem.isHighlightSupported();
1225 } );
1226 };
1227
1228 /**
1229 * Get all selected items
1230 *
1231 * @return {mw.rcfilters.dm.FilterItem[]} Selected items
1232 */
1233 mw.rcfilters.dm.FiltersViewModel.prototype.getSelectedItems = function () {
1234 var allSelected = [];
1235
1236 $.each( this.getFilterGroups(), function ( groupName, groupModel ) {
1237 allSelected = allSelected.concat( groupModel.getSelectedItems() );
1238 } );
1239
1240 return allSelected;
1241 };
1242 /**
1243 * Switch the current view
1244 *
1245 * @param {string} view View name
1246 * @fires update
1247 */
1248 mw.rcfilters.dm.FiltersViewModel.prototype.switchView = function ( view ) {
1249 if ( this.views[ view ] && this.currentView !== view ) {
1250 this.currentView = view;
1251 this.emit( 'update' );
1252 }
1253 };
1254
1255 /**
1256 * Get the current view
1257 *
1258 * @return {string} Current view
1259 */
1260 mw.rcfilters.dm.FiltersViewModel.prototype.getCurrentView = function () {
1261 return this.currentView;
1262 };
1263
1264 /**
1265 * Get the label for the current view
1266 *
1267 * @param {string} viewName View name
1268 * @return {string} Label for the current view
1269 */
1270 mw.rcfilters.dm.FiltersViewModel.prototype.getViewTitle = function ( viewName ) {
1271 viewName = viewName || this.getCurrentView();
1272
1273 return this.views[ viewName ] && this.views[ viewName ].title;
1274 };
1275
1276 /**
1277 * Get an array of all available view names
1278 *
1279 * @return {string} Available view names
1280 */
1281 mw.rcfilters.dm.FiltersViewModel.prototype.getAvailableViews = function () {
1282 return Object.keys( this.views );
1283 };
1284
1285 /**
1286 * Get the view that fits the given trigger
1287 *
1288 * @param {string} trigger Trigger
1289 * @return {string} Name of view
1290 */
1291 mw.rcfilters.dm.FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
1292 var result = 'default';
1293
1294 $.each( this.views, function ( name, data ) {
1295 if ( data.trigger === trigger ) {
1296 result = name;
1297 }
1298 } );
1299
1300 return result;
1301 };
1302
1303 /**
1304 * Toggle the highlight feature on and off.
1305 * Propagate the change to filter items.
1306 *
1307 * @param {boolean} enable Highlight should be enabled
1308 * @fires highlightChange
1309 */
1310 mw.rcfilters.dm.FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
1311 enable = enable === undefined ? !this.highlightEnabled : enable;
1312
1313 if ( this.highlightEnabled !== enable ) {
1314 // HACK make sure highlights are disabled globally while we toggle on the items,
1315 // otherwise we'll call clearHighlight() and applyHighlight() many many times
1316 this.highlightEnabled = false;
1317 this.getItems().forEach( function ( filterItem ) {
1318 filterItem.toggleHighlight( enable );
1319 } );
1320
1321 this.highlightEnabled = enable;
1322 this.emit( 'highlightChange', this.highlightEnabled );
1323 }
1324 };
1325
1326 /**
1327 * Check if the highlight feature is enabled
1328 * @return {boolean}
1329 */
1330 mw.rcfilters.dm.FiltersViewModel.prototype.isHighlightEnabled = function () {
1331 return !!this.highlightEnabled;
1332 };
1333
1334 /**
1335 * Toggle the inverted namespaces property on and off.
1336 * Propagate the change to namespace filter items.
1337 *
1338 * @param {boolean} enable Inverted property is enabled
1339 */
1340 mw.rcfilters.dm.FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
1341 this.toggleFilterSelected( this.getInvertModel().getName(), enable );
1342 };
1343
1344 /**
1345 * Get the model object that represents the 'invert' filter
1346 *
1347 * @return {mw.rcfilters.dm.FilterItem}
1348 */
1349 mw.rcfilters.dm.FiltersViewModel.prototype.getInvertModel = function () {
1350 return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' );
1351 };
1352
1353 /**
1354 * Set highlight color for a specific filter item
1355 *
1356 * @param {string} filterName Name of the filter item
1357 * @param {string} color Selected color
1358 */
1359 mw.rcfilters.dm.FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
1360 this.getItemByName( filterName ).setHighlightColor( color );
1361 };
1362
1363 /**
1364 * Clear highlight for a specific filter item
1365 *
1366 * @param {string} filterName Name of the filter item
1367 */
1368 mw.rcfilters.dm.FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
1369 this.getItemByName( filterName ).clearHighlightColor();
1370 };
1371
1372 /**
1373 * Clear highlight for all filter items
1374 */
1375 mw.rcfilters.dm.FiltersViewModel.prototype.clearAllHighlightColors = function () {
1376 this.getItems().forEach( function ( filterItem ) {
1377 filterItem.clearHighlightColor();
1378 } );
1379 };
1380
1381 /**
1382 * Return a version of the given string that is without any
1383 * view triggers.
1384 *
1385 * @param {string} str Given string
1386 * @return {string} Result
1387 */
1388 mw.rcfilters.dm.FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
1389 if ( this.getViewByTrigger( str.substr( 0, 1 ) ) !== 'default' ) {
1390 str = str.substr( 1 );
1391 }
1392
1393 return str;
1394 };
1395 }( mediaWiki, jQuery ) );