Add namespace names for Atikamekw (atj)
[lhc/web/wiklou.git] / resources / src / mediawiki.rcfilters / mw.rcfilters.Controller.js
1 ( function ( mw, $ ) {
2 /* eslint no-underscore-dangle: "off" */
3 /**
4 * Controller for the filters in Recent Changes
5 *
6 * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
7 * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel Changes list view model
8 * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
9 */
10 mw.rcfilters.Controller = function MwRcfiltersController( filtersModel, changesListModel, savedQueriesModel ) {
11 this.filtersModel = filtersModel;
12 this.changesListModel = changesListModel;
13 this.savedQueriesModel = savedQueriesModel;
14 this.requestCounter = 0;
15 this.baseFilterState = {};
16 this.uriProcessor = null;
17 this.initializing = false;
18 };
19
20 /* Initialization */
21 OO.initClass( mw.rcfilters.Controller );
22
23 /**
24 * Initialize the filter and parameter states
25 *
26 * @param {Array} filterStructure Filter definition and structure for the model
27 * @param {Object} [namespaceStructure] Namespace definition
28 */
29 mw.rcfilters.Controller.prototype.initialize = function ( filterStructure, namespaceStructure ) {
30 var parsedSavedQueries,
31 uri = new mw.Uri(),
32 $changesList = $( '.mw-changeslist' ).first().contents();
33
34 // Initialize the model
35 this.filtersModel.initializeFilters( filterStructure, namespaceStructure );
36 this._buildBaseFilterState();
37
38 this.uriProcessor = new mw.rcfilters.UriProcessor(
39 this.filtersModel
40 );
41
42 try {
43 parsedSavedQueries = JSON.parse( mw.user.options.get( 'rcfilters-saved-queries' ) || '{}' );
44 } catch ( err ) {
45 parsedSavedQueries = {};
46 }
47
48 // The queries are saved in a minimized state, so we need
49 // to send over the base state so the saved queries model
50 // can normalize them per each query item
51 this.savedQueriesModel.initialize(
52 parsedSavedQueries,
53 this._getBaseFilterState()
54 );
55
56 // Check whether we need to load defaults.
57 // We do this by checking whether the current URI query
58 // contains any parameters recognized by the system.
59 // If it does, we load the given state.
60 // If it doesn't, we have no values at all, and we assume
61 // the user loads the base-page and we load defaults.
62 // Defaults should only be applied on load (if necessary)
63 // or on request
64 this.initializing = true;
65 if (
66 this.savedQueriesModel.getDefault() &&
67 !this.uriProcessor.doesQueryContainRecognizedParams( uri.query )
68 ) {
69 // We have defaults from a saved query.
70 // We will load them straight-forward (as if
71 // they were clicked in the menu) so we trigger
72 // a full ajax request and change of URL
73 this.applySavedQuery( this.savedQueriesModel.getDefault() );
74 } else {
75 // There are either recognized parameters in the URL
76 // or there are none, but there is also no default
77 // saved query (so defaults are from the backend)
78 // We want to update the state but not fetch results
79 // again
80 this.updateStateFromUrl( false );
81
82 // Update the changes list with the existing data
83 // so it gets processed
84 this.changesListModel.update(
85 $changesList.length ? $changesList : 'NO_RESULTS',
86 $( 'fieldset.rcoptions' ).first()
87 );
88 }
89
90 this.initializing = false;
91 this.switchView( 'default' );
92 };
93
94 /**
95 * Switch the view of the filters model
96 *
97 * @param {string} view Requested view
98 */
99 mw.rcfilters.Controller.prototype.switchView = function ( view ) {
100 this.filtersModel.switchView( view );
101 };
102
103 /**
104 * Reset to default filters
105 */
106 mw.rcfilters.Controller.prototype.resetToDefaults = function () {
107 this.uriProcessor.updateModelBasedOnQuery( this._getDefaultParams() );
108 this.updateChangesList();
109 };
110
111 /**
112 * Empty all selected filters
113 */
114 mw.rcfilters.Controller.prototype.emptyFilters = function () {
115 var highlightedFilterNames = this.filtersModel
116 .getHighlightedItems()
117 .map( function ( filterItem ) { return { name: filterItem.getName() }; } );
118
119 this.filtersModel.emptyAllFilters();
120 this.filtersModel.clearAllHighlightColors();
121 // Check all filter interactions
122 this.filtersModel.reassessFilterInteractions();
123
124 this.updateChangesList();
125
126 if ( highlightedFilterNames ) {
127 this._trackHighlight( 'clearAll', highlightedFilterNames );
128 }
129 };
130
131 /**
132 * Update the selected state of a filter
133 *
134 * @param {string} filterName Filter name
135 * @param {boolean} [isSelected] Filter selected state
136 */
137 mw.rcfilters.Controller.prototype.toggleFilterSelect = function ( filterName, isSelected ) {
138 var filterItem = this.filtersModel.getItemByName( filterName );
139
140 if ( !filterItem ) {
141 // If no filter was found, break
142 return;
143 }
144
145 isSelected = isSelected === undefined ? !filterItem.isSelected() : isSelected;
146
147 if ( filterItem.isSelected() !== isSelected ) {
148 this.filtersModel.toggleFilterSelected( filterName, isSelected );
149
150 this.updateChangesList();
151
152 // Check filter interactions
153 this.filtersModel.reassessFilterInteractions( filterItem );
154 }
155 };
156
157 /**
158 * Clear both highlight and selection of a filter
159 *
160 * @param {string} filterName Name of the filter item
161 */
162 mw.rcfilters.Controller.prototype.clearFilter = function ( filterName ) {
163 var filterItem = this.filtersModel.getItemByName( filterName ),
164 isHighlighted = filterItem.isHighlighted();
165
166 if ( filterItem.isSelected() || isHighlighted ) {
167 this.filtersModel.clearHighlightColor( filterName );
168 this.filtersModel.toggleFilterSelected( filterName, false );
169 this.updateChangesList();
170 this.filtersModel.reassessFilterInteractions( filterItem );
171 }
172
173 if ( isHighlighted ) {
174 this._trackHighlight( 'clear', filterName );
175 }
176 };
177
178 /**
179 * Toggle the highlight feature on and off
180 */
181 mw.rcfilters.Controller.prototype.toggleHighlight = function () {
182 this.filtersModel.toggleHighlight();
183 this._updateURL();
184
185 if ( this.filtersModel.isHighlightEnabled() ) {
186 mw.hook( 'RcFilters.highlight.enable' ).fire();
187 }
188 };
189
190 /**
191 * Toggle the namespaces inverted feature on and off
192 */
193 mw.rcfilters.Controller.prototype.toggleInvertedNamespaces = function () {
194 this.filtersModel.toggleInvertedNamespaces();
195 this.updateChangesList();
196 };
197
198 /**
199 * Set the highlight color for a filter item
200 *
201 * @param {string} filterName Name of the filter item
202 * @param {string} color Selected color
203 */
204 mw.rcfilters.Controller.prototype.setHighlightColor = function ( filterName, color ) {
205 this.filtersModel.setHighlightColor( filterName, color );
206 this._updateURL();
207 this._trackHighlight( 'set', { name: filterName, color: color } );
208 };
209
210 /**
211 * Clear highlight for a filter item
212 *
213 * @param {string} filterName Name of the filter item
214 */
215 mw.rcfilters.Controller.prototype.clearHighlightColor = function ( filterName ) {
216 this.filtersModel.clearHighlightColor( filterName );
217 this._updateURL();
218 this._trackHighlight( 'clear', filterName );
219 };
220
221 /**
222 * Save the current model state as a saved query
223 *
224 * @param {string} [label] Label of the saved query
225 */
226 mw.rcfilters.Controller.prototype.saveCurrentQuery = function ( label ) {
227 var highlightedItems = {},
228 highlightEnabled = this.filtersModel.isHighlightEnabled();
229
230 // Prepare highlights
231 this.filtersModel.getHighlightedItems().forEach( function ( item ) {
232 highlightedItems[ item.getName() ] = highlightEnabled ?
233 item.getHighlightColor() : null;
234 } );
235 // These are filter states; highlight is stored as boolean
236 highlightedItems.highlight = this.filtersModel.isHighlightEnabled();
237
238 // Add item
239 this.savedQueriesModel.addNewQuery(
240 label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
241 {
242 filters: this.filtersModel.getSelectedState(),
243 highlights: highlightedItems,
244 invert: this.filtersModel.areNamespacesInverted()
245 }
246 );
247
248 // Save item
249 this._saveSavedQueries();
250 };
251
252 /**
253 * Remove a saved query
254 *
255 * @param {string} queryID Query id
256 */
257 mw.rcfilters.Controller.prototype.removeSavedQuery = function ( queryID ) {
258 var query = this.savedQueriesModel.getItemByID( queryID );
259
260 this.savedQueriesModel.removeItems( [ query ] );
261
262 // Check if this item was the default
263 if ( this.savedQueriesModel.getDefault() === queryID ) {
264 // Nulify the default
265 this.savedQueriesModel.setDefault( null );
266 }
267 this._saveSavedQueries();
268 };
269
270 /**
271 * Rename a saved query
272 *
273 * @param {string} queryID Query id
274 * @param {string} newLabel New label for the query
275 */
276 mw.rcfilters.Controller.prototype.renameSavedQuery = function ( queryID, newLabel ) {
277 var queryItem = this.savedQueriesModel.getItemByID( queryID );
278
279 if ( queryItem ) {
280 queryItem.updateLabel( newLabel );
281 }
282 this._saveSavedQueries();
283 };
284
285 /**
286 * Set a saved query as default
287 *
288 * @param {string} queryID Query Id. If null is given, default
289 * query is reset.
290 */
291 mw.rcfilters.Controller.prototype.setDefaultSavedQuery = function ( queryID ) {
292 this.savedQueriesModel.setDefault( queryID );
293 this._saveSavedQueries();
294 };
295
296 /**
297 * Load a saved query
298 *
299 * @param {string} queryID Query id
300 */
301 mw.rcfilters.Controller.prototype.applySavedQuery = function ( queryID ) {
302 var data, highlights,
303 queryItem = this.savedQueriesModel.getItemByID( queryID );
304
305 if ( queryItem ) {
306 data = queryItem.getData();
307 highlights = data.highlights;
308
309 // Backwards compatibility; initial version mispelled 'highlight' with 'highlights'
310 highlights.highlight = highlights.highlights || highlights.highlight;
311
312 // Update model state from filters
313 this.filtersModel.toggleFiltersSelected( data.filters );
314
315 // Update namespace inverted property
316 this.filtersModel.toggleInvertedNamespaces( !!Number( data.invert ) );
317
318 // Update highlight state
319 this.filtersModel.toggleHighlight( !!Number( highlights.highlight ) );
320 this.filtersModel.getItems().forEach( function ( filterItem ) {
321 var color = highlights[ filterItem.getName() ];
322 if ( color ) {
323 filterItem.setHighlightColor( color );
324 } else {
325 filterItem.clearHighlightColor();
326 }
327 } );
328
329 // Check all filter interactions
330 this.filtersModel.reassessFilterInteractions();
331
332 this.updateChangesList();
333 }
334 };
335
336 /**
337 * Check whether the current filter and highlight state exists
338 * in the saved queries model.
339 *
340 * @return {boolean} Query exists
341 */
342 mw.rcfilters.Controller.prototype.findQueryMatchingCurrentState = function () {
343 var highlightedItems = {};
344
345 // Prepare highlights of the current query
346 this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) {
347 highlightedItems[ item.getName() ] = item.getHighlightColor();
348 } );
349 highlightedItems.highlight = this.filtersModel.isHighlightEnabled();
350
351 return this.savedQueriesModel.findMatchingQuery(
352 {
353 filters: this.filtersModel.getSelectedState(),
354 highlights: highlightedItems,
355 invert: this.filtersModel.areNamespacesInverted()
356 }
357 );
358 };
359
360 /**
361 * Get an object representing the base state of parameters
362 * and highlights.
363 *
364 * This is meant to make sure that the saved queries that are
365 * in memory are always the same structure as what we would get
366 * by calling the current model's "getSelectedState" and by checking
367 * highlight items.
368 *
369 * In cases where a user saved a query when the system had a certain
370 * set of filters, and then a filter was added to the system, we want
371 * to make sure that the stored queries can still be comparable to
372 * the current state, which means that we need the base state for
373 * two operations:
374 *
375 * - Saved queries are stored in "minimal" view (only changed filters
376 * are stored); When we initialize the system, we merge each minimal
377 * query with the base state (using 'getNormalizedFilters') so all
378 * saved queries have the exact same structure as what we would get
379 * by checking the getSelectedState of the filter.
380 * - When we save the queries, we minimize the object to only represent
381 * whatever has actually changed, rather than store the entire
382 * object. To check what actually is different so we can store it,
383 * we need to obtain a base state to compare against, this is
384 * what #_getMinimalFilterList does
385 */
386 mw.rcfilters.Controller.prototype._buildBaseFilterState = function () {
387 var defaultParams = this.filtersModel.getDefaultParams(),
388 highlightedItems = {};
389
390 // Prepare highlights
391 this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) {
392 highlightedItems[ item.getName() ] = null;
393 } );
394 highlightedItems.highlight = false;
395
396 this.baseFilterState = {
397 filters: this.filtersModel.getFiltersFromParameters( defaultParams ),
398 highlights: highlightedItems,
399 invert: false
400 };
401 };
402
403 /**
404 * Get an object representing the base filter state of both
405 * filters and highlights. The structure is similar to what we use
406 * to store each query in the saved queries object:
407 * {
408 * filters: {
409 * filterName: (bool)
410 * },
411 * highlights: {
412 * filterName: (string|null)
413 * }
414 * }
415 *
416 * @return {Object} Object representing the base state of
417 * parameters and highlights
418 */
419 mw.rcfilters.Controller.prototype._getBaseFilterState = function () {
420 return this.baseFilterState;
421 };
422
423 /**
424 * Get an object that holds only the parameters and highlights that have
425 * values different than the base default value.
426 *
427 * This is the reverse of the normalization we do initially on loading and
428 * initializing the saved queries model.
429 *
430 * @param {Object} valuesObject Object representing the state of both
431 * filters and highlights in its normalized version, to be minimized.
432 * @return {Object} Minimal filters and highlights list
433 */
434 mw.rcfilters.Controller.prototype._getMinimalFilterList = function ( valuesObject ) {
435 var result = { filters: {}, highlights: {} },
436 baseState = this._getBaseFilterState();
437
438 // XOR results
439 $.each( valuesObject.filters, function ( name, value ) {
440 if ( baseState.filters !== undefined && baseState.filters[ name ] !== value ) {
441 result.filters[ name ] = value;
442 }
443 } );
444
445 $.each( valuesObject.highlights, function ( name, value ) {
446 if ( baseState.highlights !== undefined && baseState.highlights[ name ] !== value ) {
447 result.highlights[ name ] = value;
448 }
449 } );
450
451 return result;
452 };
453
454 /**
455 * Save the current state of the saved queries model with all
456 * query item representation in the user settings.
457 */
458 mw.rcfilters.Controller.prototype._saveSavedQueries = function () {
459 var stringified,
460 state = this.savedQueriesModel.getState(),
461 controller = this;
462
463 // Minimize before save
464 $.each( state.queries, function ( queryID, info ) {
465 state.queries[ queryID ].data = controller._getMinimalFilterList( info.data );
466 } );
467
468 // Stringify state
469 stringified = JSON.stringify( state );
470
471 if ( stringified.length > 65535 ) {
472 // Sanity check, since the preference can only hold that.
473 return;
474 }
475
476 // Save the preference
477 new mw.Api().saveOption( 'rcfilters-saved-queries', stringified );
478 // Update the preference for this session
479 mw.user.options.set( 'rcfilters-saved-queries', stringified );
480 };
481
482 /**
483 * Synchronize the URL with the current state of the filters
484 * without adding an history entry.
485 */
486 mw.rcfilters.Controller.prototype.replaceUrl = function () {
487 mw.rcfilters.UriProcessor.static.replaceState( this._getUpdatedUri() );
488 };
489
490 /**
491 * Update filter state (selection and highlighting) based
492 * on current URL values.
493 *
494 * @param {boolean} [fetchChangesList=true] Fetch new results into the changes
495 * list based on the updated model.
496 */
497 mw.rcfilters.Controller.prototype.updateStateFromUrl = function ( fetchChangesList ) {
498 fetchChangesList = fetchChangesList === undefined ? true : !!fetchChangesList;
499
500 this.uriProcessor.updateModelBasedOnQuery( new mw.Uri().query );
501
502 // Only update and fetch new results if it is requested
503 if ( fetchChangesList ) {
504 this.updateChangesList();
505 }
506 };
507
508 /**
509 * Update the list of changes and notify the model
510 *
511 * @param {Object} [params] Extra parameters to add to the API call
512 */
513 mw.rcfilters.Controller.prototype.updateChangesList = function ( params ) {
514 this._updateURL( params );
515 this.changesListModel.invalidate();
516 this._fetchChangesList()
517 .then(
518 // Success
519 function ( pieces ) {
520 var $changesListContent = pieces.changes,
521 $fieldset = pieces.fieldset;
522 this.changesListModel.update( $changesListContent, $fieldset );
523 }.bind( this )
524 // Do nothing for failure
525 );
526 };
527
528 /**
529 * Get an object representing the default parameter state, whether
530 * it is from the model defaults or from the saved queries.
531 *
532 * @return {Object} Default parameters
533 */
534 mw.rcfilters.Controller.prototype._getDefaultParams = function () {
535 var data, queryHighlights,
536 savedParams = {},
537 savedHighlights = {},
538 defaultSavedQueryItem = this.savedQueriesModel.getItemByID( this.savedQueriesModel.getDefault() );
539
540 if ( mw.config.get( 'wgStructuredChangeFiltersEnableSaving' ) &&
541 defaultSavedQueryItem ) {
542
543 data = defaultSavedQueryItem.getData();
544
545 queryHighlights = data.highlights || {};
546 savedParams = this.filtersModel.getParametersFromFilters( data.filters || {} );
547
548 // Translate highlights to parameters
549 savedHighlights.highlight = String( Number( queryHighlights.highlight ) );
550 $.each( queryHighlights, function ( filterName, color ) {
551 if ( filterName !== 'highlights' ) {
552 savedHighlights[ filterName + '_color' ] = color;
553 }
554 } );
555
556 return $.extend( true, {}, savedParams, savedHighlights, { invert: data.invert } );
557 }
558
559 return $.extend(
560 { highlight: '0' },
561 this.filtersModel.getDefaultParams()
562 );
563 };
564
565 /**
566 * Get an object representing the default parameter state, whether
567 * it is from the model defaults or from the saved queries.
568 *
569 * @return {Object} Default parameters
570 */
571 mw.rcfilters.Controller.prototype._getDefaultParams = function () {
572 var data, queryHighlights,
573 savedParams = {},
574 savedHighlights = {},
575 defaultSavedQueryItem = this.savedQueriesModel.getItemByID( this.savedQueriesModel.getDefault() );
576
577 if ( mw.config.get( 'wgStructuredChangeFiltersEnableSaving' ) &&
578 defaultSavedQueryItem ) {
579
580 data = defaultSavedQueryItem.getData();
581
582 queryHighlights = data.highlights || {};
583 savedParams = this.filtersModel.getParametersFromFilters( data.filters || {} );
584
585 // Translate highlights to parameters
586 savedHighlights.highlight = String( Number( queryHighlights.highlight ) );
587 $.each( queryHighlights, function ( filterName, color ) {
588 if ( filterName !== 'highlights' ) {
589 savedHighlights[ filterName + '_color' ] = color;
590 }
591 } );
592
593 return $.extend( true, {}, savedParams, savedHighlights );
594 }
595
596 return this.filtersModel.getDefaultParams();
597 };
598
599 /**
600 * Update the URL of the page to reflect current filters
601 *
602 * This should not be called directly from outside the controller.
603 * If an action requires changing the URL, it should either use the
604 * highlighting actions below, or call #updateChangesList which does
605 * the uri corrections already.
606 *
607 * @param {Object} [params] Extra parameters to add to the API call
608 */
609 mw.rcfilters.Controller.prototype._updateURL = function ( params ) {
610 var currentUri = new mw.Uri(),
611 updatedUri = this._getUpdatedUri();
612
613 updatedUri.extend( params || {} );
614
615 if (
616 this.uriProcessor.getVersion( currentUri.query ) !== 2 ||
617 this.uriProcessor.isNewState( currentUri.query, updatedUri.query )
618 ) {
619 if ( this.initializing ) {
620 // Initially, when we just build the first page load
621 // out of defaults, we want to replace the history
622 mw.rcfilters.UriProcessor.static.replaceState( updatedUri );
623 } else {
624 mw.rcfilters.UriProcessor.static.pushState( updatedUri );
625 }
626 }
627 };
628
629 /**
630 * Get an updated mw.Uri object based on the model state
631 *
632 * @return {mw.Uri} Updated Uri
633 */
634 mw.rcfilters.Controller.prototype._getUpdatedUri = function () {
635 var uri = new mw.Uri();
636
637 // Minimize url
638 uri.query = this.uriProcessor.minimizeQuery(
639 $.extend(
640 true,
641 {},
642 // We want to retain unrecognized params
643 // The uri params from model will override
644 // any recognized value in the current uri
645 // query, retain unrecognized params, and
646 // the result will then be minimized
647 uri.query,
648 this.uriProcessor.getUriParametersFromModel(),
649 { urlversion: '2' }
650 )
651 );
652
653 return uri;
654 };
655
656 /**
657 * Fetch the list of changes from the server for the current filters
658 *
659 * @return {jQuery.Promise} Promise object that will resolve with the changes list
660 * or with a string denoting no results.
661 */
662 mw.rcfilters.Controller.prototype._fetchChangesList = function () {
663 var uri = this._getUpdatedUri(),
664 requestId = ++this.requestCounter,
665 latestRequest = function () {
666 return requestId === this.requestCounter;
667 }.bind( this );
668
669 return $.ajax( uri.toString(), { contentType: 'html' } )
670 .then(
671 // Success
672 function ( html ) {
673 var $parsed;
674 if ( !latestRequest() ) {
675 return $.Deferred().reject();
676 }
677
678 $parsed = $( $.parseHTML( html ) );
679
680 return {
681 // Changes list
682 changes: $parsed.find( '.mw-changeslist' ).first().contents(),
683 // Fieldset
684 fieldset: $parsed.find( 'fieldset.rcoptions' ).first()
685 };
686 },
687 // Failure
688 function ( responseObj ) {
689 var $parsed;
690
691 if ( !latestRequest() ) {
692 return $.Deferred().reject();
693 }
694
695 $parsed = $( $.parseHTML( responseObj.responseText ) );
696
697 // Force a resolve state to this promise
698 return $.Deferred().resolve( {
699 changes: 'NO_RESULTS',
700 fieldset: $parsed.find( 'fieldset.rcoptions' ).first()
701 } ).promise();
702 }
703 );
704 };
705
706 /**
707 * Track usage of highlight feature
708 *
709 * @param {string} action
710 * @param {array|object|string} filters
711 */
712 mw.rcfilters.Controller.prototype._trackHighlight = function ( action, filters ) {
713 filters = typeof filters === 'string' ? { name: filters } : filters;
714 filters = !Array.isArray( filters ) ? [ filters ] : filters;
715 mw.track(
716 'event.ChangesListHighlights',
717 {
718 action: action,
719 filters: filters,
720 userId: mw.user.getId()
721 }
722 );
723 };
724
725 }( mediaWiki, jQuery ) );