]> scripts.mit.edu Git - autoinstalls/wordpress.git/blob - wp-includes/js/media-models.js
WordPress 3.8
[autoinstalls/wordpress.git] / wp-includes / js / media-models.js
1 /* global _wpMediaModelsL10n:false */
2 window.wp = window.wp || {};
3
4 (function($){
5         var Attachment, Attachments, Query, compare, l10n, media;
6
7         /**
8          * wp.media( attributes )
9          *
10          * Handles the default media experience. Automatically creates
11          * and opens a media frame, and returns the result.
12          * Does nothing if the controllers do not exist.
13          *
14          * @param  {object} attributes The properties passed to the main media controller.
15          * @return {object}            A media workflow.
16          */
17         media = wp.media = function( attributes ) {
18                 var MediaFrame = media.view.MediaFrame,
19                         frame;
20
21                 if ( ! MediaFrame )
22                         return;
23
24                 attributes = _.defaults( attributes || {}, {
25                         frame: 'select'
26                 });
27
28                 if ( 'select' === attributes.frame && MediaFrame.Select )
29                         frame = new MediaFrame.Select( attributes );
30                 else if ( 'post' === attributes.frame && MediaFrame.Post )
31                         frame = new MediaFrame.Post( attributes );
32
33                 delete attributes.frame;
34
35                 return frame;
36         };
37
38         _.extend( media, { model: {}, view: {}, controller: {}, frames: {} });
39
40         // Link any localized strings.
41         l10n = media.model.l10n = typeof _wpMediaModelsL10n === 'undefined' ? {} : _wpMediaModelsL10n;
42
43         // Link any settings.
44         media.model.settings = l10n.settings || {};
45         delete l10n.settings;
46
47         /**
48          * ========================================================================
49          * UTILITIES
50          * ========================================================================
51          */
52
53         /**
54          * A basic comparator.
55          *
56          * @param  {mixed}  a  The primary parameter to compare.
57          * @param  {mixed}  b  The primary parameter to compare.
58          * @param  {string} ac The fallback parameter to compare, a's cid.
59          * @param  {string} bc The fallback parameter to compare, b's cid.
60          * @return {number}    -1: a should come before b.
61          *                      0: a and b are of the same rank.
62          *                      1: b should come before a.
63          */
64         compare = function( a, b, ac, bc ) {
65                 if ( _.isEqual( a, b ) )
66                         return ac === bc ? 0 : (ac > bc ? -1 : 1);
67                 else
68                         return a > b ? -1 : 1;
69         };
70
71         _.extend( media, {
72                 /**
73                  * media.template( id )
74                  *
75                  * Fetches a template by id.
76                  * See wp.template() in `wp-includes/js/wp-util.js`.
77                  */
78                 template: wp.template,
79
80                 /**
81                  * media.post( [action], [data] )
82                  *
83                  * Sends a POST request to WordPress.
84                  * See wp.ajax.post() in `wp-includes/js/wp-util.js`.
85                  */
86                 post: wp.ajax.post,
87
88                 /**
89                  * media.ajax( [action], [options] )
90                  *
91                  * Sends an XHR request to WordPress.
92                  * See wp.ajax.send() in `wp-includes/js/wp-util.js`.
93                  */
94                 ajax: wp.ajax.send,
95
96                 // Scales a set of dimensions to fit within bounding dimensions.
97                 fit: function( dimensions ) {
98                         var width     = dimensions.width,
99                                 height    = dimensions.height,
100                                 maxWidth  = dimensions.maxWidth,
101                                 maxHeight = dimensions.maxHeight,
102                                 constraint;
103
104                         // Compare ratios between the two values to determine which
105                         // max to constrain by. If a max value doesn't exist, then the
106                         // opposite side is the constraint.
107                         if ( ! _.isUndefined( maxWidth ) && ! _.isUndefined( maxHeight ) ) {
108                                 constraint = ( width / height > maxWidth / maxHeight ) ? 'width' : 'height';
109                         } else if ( _.isUndefined( maxHeight ) ) {
110                                 constraint = 'width';
111                         } else if (  _.isUndefined( maxWidth ) && height > maxHeight ) {
112                                 constraint = 'height';
113                         }
114
115                         // If the value of the constrained side is larger than the max,
116                         // then scale the values. Otherwise return the originals; they fit.
117                         if ( 'width' === constraint && width > maxWidth ) {
118                                 return {
119                                         width : maxWidth,
120                                         height: Math.round( maxWidth * height / width )
121                                 };
122                         } else if ( 'height' === constraint && height > maxHeight ) {
123                                 return {
124                                         width : Math.round( maxHeight * width / height ),
125                                         height: maxHeight
126                                 };
127                         } else {
128                                 return {
129                                         width : width,
130                                         height: height
131                                 };
132                         }
133                 },
134
135                 // Truncates a string by injecting an ellipsis into the middle.
136                 // Useful for filenames.
137                 truncate: function( string, length, replacement ) {
138                         length = length || 30;
139                         replacement = replacement || '…';
140
141                         if ( string.length <= length )
142                                 return string;
143
144                         return string.substr( 0, length / 2 ) + replacement + string.substr( -1 * length / 2 );
145                 }
146         });
147
148
149         /**
150          * ========================================================================
151          * MODELS
152          * ========================================================================
153          */
154
155         /**
156          * wp.media.attachment
157          */
158         media.attachment = function( id ) {
159                 return Attachment.get( id );
160         };
161
162         /**
163          * wp.media.model.Attachment
164          */
165         Attachment = media.model.Attachment = Backbone.Model.extend({
166                 sync: function( method, model, options ) {
167                         // If the attachment does not yet have an `id`, return an instantly
168                         // rejected promise. Otherwise, all of our requests will fail.
169                         if ( _.isUndefined( this.id ) )
170                                 return $.Deferred().rejectWith( this ).promise();
171
172                         // Overload the `read` request so Attachment.fetch() functions correctly.
173                         if ( 'read' === method ) {
174                                 options = options || {};
175                                 options.context = this;
176                                 options.data = _.extend( options.data || {}, {
177                                         action: 'get-attachment',
178                                         id: this.id
179                                 });
180                                 return media.ajax( options );
181
182                         // Overload the `update` request so properties can be saved.
183                         } else if ( 'update' === method ) {
184                                 // If we do not have the necessary nonce, fail immeditately.
185                                 if ( ! this.get('nonces') || ! this.get('nonces').update )
186                                         return $.Deferred().rejectWith( this ).promise();
187
188                                 options = options || {};
189                                 options.context = this;
190
191                                 // Set the action and ID.
192                                 options.data = _.extend( options.data || {}, {
193                                         action:  'save-attachment',
194                                         id:      this.id,
195                                         nonce:   this.get('nonces').update,
196                                         post_id: media.model.settings.post.id
197                                 });
198
199                                 // Record the values of the changed attributes.
200                                 if ( model.hasChanged() ) {
201                                         options.data.changes = {};
202
203                                         _.each( model.changed, function( value, key ) {
204                                                 options.data.changes[ key ] = this.get( key );
205                                         }, this );
206                                 }
207
208                                 return media.ajax( options );
209
210                         // Overload the `delete` request so attachments can be removed.
211                         // This will permanently delete an attachment.
212                         } else if ( 'delete' === method ) {
213                                 options = options || {};
214
215                                 if ( ! options.wait )
216                                         this.destroyed = true;
217
218                                 options.context = this;
219                                 options.data = _.extend( options.data || {}, {
220                                         action:   'delete-post',
221                                         id:       this.id,
222                                         _wpnonce: this.get('nonces')['delete']
223                                 });
224
225                                 return media.ajax( options ).done( function() {
226                                         this.destroyed = true;
227                                 }).fail( function() {
228                                         this.destroyed = false;
229                                 });
230
231                         // Otherwise, fall back to `Backbone.sync()`.
232                         } else {
233                                 return Backbone.Model.prototype.sync.apply( this, arguments );
234                         }
235                 },
236
237                 parse: function( resp ) {
238                         if ( ! resp )
239                                 return resp;
240
241                         // Convert date strings into Date objects.
242                         resp.date = new Date( resp.date );
243                         resp.modified = new Date( resp.modified );
244                         return resp;
245                 },
246
247                 saveCompat: function( data, options ) {
248                         var model = this;
249
250                         // If we do not have the necessary nonce, fail immeditately.
251                         if ( ! this.get('nonces') || ! this.get('nonces').update )
252                                 return $.Deferred().rejectWith( this ).promise();
253
254                         return media.post( 'save-attachment-compat', _.defaults({
255                                 id:      this.id,
256                                 nonce:   this.get('nonces').update,
257                                 post_id: media.model.settings.post.id
258                         }, data ) ).done( function( resp, status, xhr ) {
259                                 model.set( model.parse( resp, xhr ), options );
260                         });
261                 }
262         }, {
263                 create: function( attrs ) {
264                         return Attachments.all.push( attrs );
265                 },
266
267                 get: _.memoize( function( id, attachment ) {
268                         return Attachments.all.push( attachment || { id: id } );
269                 })
270         });
271
272         /**
273          * wp.media.model.Attachments
274          */
275         Attachments = media.model.Attachments = Backbone.Collection.extend({
276                 model: Attachment,
277
278                 initialize: function( models, options ) {
279                         options = options || {};
280
281                         this.props   = new Backbone.Model();
282                         this.filters = options.filters || {};
283
284                         // Bind default `change` events to the `props` model.
285                         this.props.on( 'change', this._changeFilteredProps, this );
286
287                         this.props.on( 'change:order',   this._changeOrder,   this );
288                         this.props.on( 'change:orderby', this._changeOrderby, this );
289                         this.props.on( 'change:query',   this._changeQuery,   this );
290
291                         // Set the `props` model and fill the default property values.
292                         this.props.set( _.defaults( options.props || {} ) );
293
294                         // Observe another `Attachments` collection if one is provided.
295                         if ( options.observe )
296                                 this.observe( options.observe );
297                 },
298
299                 // Automatically sort the collection when the order changes.
300                 _changeOrder: function() {
301                         if ( this.comparator )
302                                 this.sort();
303                 },
304
305                 // Set the default comparator only when the `orderby` property is set.
306                 _changeOrderby: function( model, orderby ) {
307                         // If a different comparator is defined, bail.
308                         if ( this.comparator && this.comparator !== Attachments.comparator )
309                                 return;
310
311                         if ( orderby && 'post__in' !== orderby )
312                                 this.comparator = Attachments.comparator;
313                         else
314                                 delete this.comparator;
315                 },
316
317                 // If the `query` property is set to true, query the server using
318                 // the `props` values, and sync the results to this collection.
319                 _changeQuery: function( model, query ) {
320                         if ( query ) {
321                                 this.props.on( 'change', this._requery, this );
322                                 this._requery();
323                         } else {
324                                 this.props.off( 'change', this._requery, this );
325                         }
326                 },
327
328                 _changeFilteredProps: function( model ) {
329                         // If this is a query, updating the collection will be handled by
330                         // `this._requery()`.
331                         if ( this.props.get('query') )
332                                 return;
333
334                         var changed = _.chain( model.changed ).map( function( t, prop ) {
335                                 var filter = Attachments.filters[ prop ],
336                                         term = model.get( prop );
337
338                                 if ( ! filter )
339                                         return;
340
341                                 if ( term && ! this.filters[ prop ] )
342                                         this.filters[ prop ] = filter;
343                                 else if ( ! term && this.filters[ prop ] === filter )
344                                         delete this.filters[ prop ];
345                                 else
346                                         return;
347
348                                 // Record the change.
349                                 return true;
350                         }, this ).any().value();
351
352                         if ( ! changed )
353                                 return;
354
355                         // If no `Attachments` model is provided to source the searches
356                         // from, then automatically generate a source from the existing
357                         // models.
358                         if ( ! this._source )
359                                 this._source = new Attachments( this.models );
360
361                         this.reset( this._source.filter( this.validator, this ) );
362                 },
363
364                 validateDestroyed: false,
365
366                 validator: function( attachment ) {
367                         if ( ! this.validateDestroyed && attachment.destroyed )
368                                 return false;
369                         return _.all( this.filters, function( filter ) {
370                                 return !! filter.call( this, attachment );
371                         }, this );
372                 },
373
374                 validate: function( attachment, options ) {
375                         var valid = this.validator( attachment ),
376                                 hasAttachment = !! this.get( attachment.cid );
377
378                         if ( ! valid && hasAttachment )
379                                 this.remove( attachment, options );
380                         else if ( valid && ! hasAttachment )
381                                 this.add( attachment, options );
382
383                         return this;
384                 },
385
386                 validateAll: function( attachments, options ) {
387                         options = options || {};
388
389                         _.each( attachments.models, function( attachment ) {
390                                 this.validate( attachment, { silent: true });
391                         }, this );
392
393                         if ( ! options.silent )
394                                 this.trigger( 'reset', this, options );
395
396                         return this;
397                 },
398
399                 observe: function( attachments ) {
400                         this.observers = this.observers || [];
401                         this.observers.push( attachments );
402
403                         attachments.on( 'add change remove', this._validateHandler, this );
404                         attachments.on( 'reset', this._validateAllHandler, this );
405                         this.validateAll( attachments );
406                         return this;
407                 },
408
409                 unobserve: function( attachments ) {
410                         if ( attachments ) {
411                                 attachments.off( null, null, this );
412                                 this.observers = _.without( this.observers, attachments );
413
414                         } else {
415                                 _.each( this.observers, function( attachments ) {
416                                         attachments.off( null, null, this );
417                                 }, this );
418                                 delete this.observers;
419                         }
420
421                         return this;
422                 },
423
424                 _validateHandler: function( attachment, attachments, options ) {
425                         // If we're not mirroring this `attachments` collection,
426                         // only retain the `silent` option.
427                         options = attachments === this.mirroring ? options : {
428                                 silent: options && options.silent
429                         };
430
431                         return this.validate( attachment, options );
432                 },
433
434                 _validateAllHandler: function( attachments, options ) {
435                         return this.validateAll( attachments, options );
436                 },
437
438                 mirror: function( attachments ) {
439                         if ( this.mirroring && this.mirroring === attachments )
440                                 return this;
441
442                         this.unmirror();
443                         this.mirroring = attachments;
444
445                         // Clear the collection silently. A `reset` event will be fired
446                         // when `observe()` calls `validateAll()`.
447                         this.reset( [], { silent: true } );
448                         this.observe( attachments );
449
450                         return this;
451                 },
452
453                 unmirror: function() {
454                         if ( ! this.mirroring )
455                                 return;
456
457                         this.unobserve( this.mirroring );
458                         delete this.mirroring;
459                 },
460
461                 more: function( options ) {
462                         var deferred = $.Deferred(),
463                                 mirroring = this.mirroring,
464                                 attachments = this;
465
466                         if ( ! mirroring || ! mirroring.more )
467                                 return deferred.resolveWith( this ).promise();
468
469                         // If we're mirroring another collection, forward `more` to
470                         // the mirrored collection. Account for a race condition by
471                         // checking if we're still mirroring that collection when
472                         // the request resolves.
473                         mirroring.more( options ).done( function() {
474                                 if ( this === attachments.mirroring )
475                                         deferred.resolveWith( this );
476                         });
477
478                         return deferred.promise();
479                 },
480
481                 hasMore: function() {
482                         return this.mirroring ? this.mirroring.hasMore() : false;
483                 },
484
485                 parse: function( resp, xhr ) {
486                         if ( ! _.isArray( resp ) )
487                                 resp = [resp];
488
489                         return _.map( resp, function( attrs ) {
490                                 var id, attachment, newAttributes;
491
492                                 if ( attrs instanceof Backbone.Model ) {
493                                         id = attrs.get( 'id' );
494                                         attrs = attrs.attributes;
495                                 } else {
496                                         id = attrs.id;
497                                 }
498
499                                 attachment = Attachment.get( id );
500                                 newAttributes = attachment.parse( attrs, xhr );
501
502                                 if ( ! _.isEqual( attachment.attributes, newAttributes ) )
503                                         attachment.set( newAttributes );
504
505                                 return attachment;
506                         });
507                 },
508
509                 _requery: function() {
510                         if ( this.props.get('query') )
511                                 this.mirror( Query.get( this.props.toJSON() ) );
512                 },
513
514                 // If this collection is sorted by `menuOrder`, recalculates and saves
515                 // the menu order to the database.
516                 saveMenuOrder: function() {
517                         if ( 'menuOrder' !== this.props.get('orderby') )
518                                 return;
519
520                         // Removes any uploading attachments, updates each attachment's
521                         // menu order, and returns an object with an { id: menuOrder }
522                         // mapping to pass to the request.
523                         var attachments = this.chain().filter( function( attachment ) {
524                                 return ! _.isUndefined( attachment.id );
525                         }).map( function( attachment, index ) {
526                                 // Indices start at 1.
527                                 index = index + 1;
528                                 attachment.set( 'menuOrder', index );
529                                 return [ attachment.id, index ];
530                         }).object().value();
531
532                         if ( _.isEmpty( attachments ) )
533                                 return;
534
535                         return media.post( 'save-attachment-order', {
536                                 nonce:       media.model.settings.post.nonce,
537                                 post_id:     media.model.settings.post.id,
538                                 attachments: attachments
539                         });
540                 }
541         }, {
542                 comparator: function( a, b, options ) {
543                         var key   = this.props.get('orderby'),
544                                 order = this.props.get('order') || 'DESC',
545                                 ac    = a.cid,
546                                 bc    = b.cid;
547
548                         a = a.get( key );
549                         b = b.get( key );
550
551                         if ( 'date' === key || 'modified' === key ) {
552                                 a = a || new Date();
553                                 b = b || new Date();
554                         }
555
556                         // If `options.ties` is set, don't enforce the `cid` tiebreaker.
557                         if ( options && options.ties )
558                                 ac = bc = null;
559
560                         return ( 'DESC' === order ) ? compare( a, b, ac, bc ) : compare( b, a, bc, ac );
561                 },
562
563                 filters: {
564                         // Note that this client-side searching is *not* equivalent
565                         // to our server-side searching.
566                         search: function( attachment ) {
567                                 if ( ! this.props.get('search') )
568                                         return true;
569
570                                 return _.any(['title','filename','description','caption','name'], function( key ) {
571                                         var value = attachment.get( key );
572                                         return value && -1 !== value.search( this.props.get('search') );
573                                 }, this );
574                         },
575
576                         type: function( attachment ) {
577                                 var type = this.props.get('type');
578                                 return ! type || -1 !== type.indexOf( attachment.get('type') );
579                         },
580
581                         uploadedTo: function( attachment ) {
582                                 var uploadedTo = this.props.get('uploadedTo');
583                                 if ( _.isUndefined( uploadedTo ) )
584                                         return true;
585
586                                 return uploadedTo === attachment.get('uploadedTo');
587                         }
588                 }
589         });
590
591         Attachments.all = new Attachments();
592
593         /**
594          * wp.media.query
595          */
596         media.query = function( props ) {
597                 return new Attachments( null, {
598                         props: _.extend( _.defaults( props || {}, { orderby: 'date' } ), { query: true } )
599                 });
600         };
601
602         /**
603          * wp.media.model.Query
604          *
605          * A set of attachments that corresponds to a set of consecutively paged
606          * queries on the server.
607          *
608          * Note: Do NOT change this.args after the query has been initialized.
609          *       Things will break.
610          */
611         Query = media.model.Query = Attachments.extend({
612                 initialize: function( models, options ) {
613                         var allowed;
614
615                         options = options || {};
616                         Attachments.prototype.initialize.apply( this, arguments );
617
618                         this.args     = options.args;
619                         this._hasMore = true;
620                         this.created  = new Date();
621
622                         this.filters.order = function( attachment ) {
623                                 var orderby = this.props.get('orderby'),
624                                         order = this.props.get('order');
625
626                                 if ( ! this.comparator )
627                                         return true;
628
629                                 // We want any items that can be placed before the last
630                                 // item in the set. If we add any items after the last
631                                 // item, then we can't guarantee the set is complete.
632                                 if ( this.length ) {
633                                         return 1 !== this.comparator( attachment, this.last(), { ties: true });
634
635                                 // Handle the case where there are no items yet and
636                                 // we're sorting for recent items. In that case, we want
637                                 // changes that occurred after we created the query.
638                                 } else if ( 'DESC' === order && ( 'date' === orderby || 'modified' === orderby ) ) {
639                                         return attachment.get( orderby ) >= this.created;
640
641                                 // If we're sorting by menu order and we have no items,
642                                 // accept any items that have the default menu order (0).
643                                 } else if ( 'ASC' === order && 'menuOrder' === orderby ) {
644                                         return attachment.get( orderby ) === 0;
645                                 }
646
647                                 // Otherwise, we don't want any items yet.
648                                 return false;
649                         };
650
651                         // Observe the central `wp.Uploader.queue` collection to watch for
652                         // new matches for the query.
653                         //
654                         // Only observe when a limited number of query args are set. There
655                         // are no filters for other properties, so observing will result in
656                         // false positives in those queries.
657                         allowed = [ 's', 'order', 'orderby', 'posts_per_page', 'post_mime_type', 'post_parent' ];
658                         if ( wp.Uploader && _( this.args ).chain().keys().difference( allowed ).isEmpty().value() )
659                                 this.observe( wp.Uploader.queue );
660                 },
661
662                 hasMore: function() {
663                         return this._hasMore;
664                 },
665
666                 more: function( options ) {
667                         var query = this;
668
669                         if ( this._more && 'pending' === this._more.state() )
670                                 return this._more;
671
672                         if ( ! this.hasMore() )
673                                 return $.Deferred().resolveWith( this ).promise();
674
675                         options = options || {};
676                         options.remove = false;
677
678                         return this._more = this.fetch( options ).done( function( resp ) {
679                                 if ( _.isEmpty( resp ) || -1 === this.args.posts_per_page || resp.length < this.args.posts_per_page )
680                                         query._hasMore = false;
681                         });
682                 },
683
684                 sync: function( method, model, options ) {
685                         var args, fallback;
686
687                         // Overload the read method so Attachment.fetch() functions correctly.
688                         if ( 'read' === method ) {
689                                 options = options || {};
690                                 options.context = this;
691                                 options.data = _.extend( options.data || {}, {
692                                         action:  'query-attachments',
693                                         post_id: media.model.settings.post.id
694                                 });
695
696                                 // Clone the args so manipulation is non-destructive.
697                                 args = _.clone( this.args );
698
699                                 // Determine which page to query.
700                                 if ( -1 !== args.posts_per_page )
701                                         args.paged = Math.floor( this.length / args.posts_per_page ) + 1;
702
703                                 options.data.query = args;
704                                 return media.ajax( options );
705
706                         // Otherwise, fall back to Backbone.sync()
707                         } else {
708                                 fallback = Attachments.prototype.sync ? Attachments.prototype : Backbone;
709                                 return fallback.sync.apply( this, arguments );
710                         }
711                 }
712         }, {
713                 defaultProps: {
714                         orderby: 'date',
715                         order:   'DESC'
716                 },
717
718                 defaultArgs: {
719                         posts_per_page: 40
720                 },
721
722                 orderby: {
723                         allowed:  [ 'name', 'author', 'date', 'title', 'modified', 'uploadedTo', 'id', 'post__in', 'menuOrder' ],
724                         valuemap: {
725                                 'id':         'ID',
726                                 'uploadedTo': 'parent',
727                                 'menuOrder':  'menu_order ID'
728                         }
729                 },
730
731                 propmap: {
732                         'search':    's',
733                         'type':      'post_mime_type',
734                         'perPage':   'posts_per_page',
735                         'menuOrder': 'menu_order',
736                         'uploadedTo': 'post_parent'
737                 },
738
739                 // Caches query objects so queries can be easily reused.
740                 get: (function(){
741                         var queries = [];
742
743                         return function( props, options ) {
744                                 var args     = {},
745                                         orderby  = Query.orderby,
746                                         defaults = Query.defaultProps,
747                                         query;
748
749                                 // Remove the `query` property. This isn't linked to a query,
750                                 // this *is* the query.
751                                 delete props.query;
752
753                                 // Fill default args.
754                                 _.defaults( props, defaults );
755
756                                 // Normalize the order.
757                                 props.order = props.order.toUpperCase();
758                                 if ( 'DESC' !== props.order && 'ASC' !== props.order )
759                                         props.order = defaults.order.toUpperCase();
760
761                                 // Ensure we have a valid orderby value.
762                                 if ( ! _.contains( orderby.allowed, props.orderby ) )
763                                         props.orderby = defaults.orderby;
764
765                                 // Generate the query `args` object.
766                                 // Correct any differing property names.
767                                 _.each( props, function( value, prop ) {
768                                         if ( _.isNull( value ) )
769                                                 return;
770
771                                         args[ Query.propmap[ prop ] || prop ] = value;
772                                 });
773
774                                 // Fill any other default query args.
775                                 _.defaults( args, Query.defaultArgs );
776
777                                 // `props.orderby` does not always map directly to `args.orderby`.
778                                 // Substitute exceptions specified in orderby.keymap.
779                                 args.orderby = orderby.valuemap[ props.orderby ] || props.orderby;
780
781                                 // Search the query cache for matches.
782                                 query = _.find( queries, function( query ) {
783                                         return _.isEqual( query.args, args );
784                                 });
785
786                                 // Otherwise, create a new query and add it to the cache.
787                                 if ( ! query ) {
788                                         query = new Query( [], _.extend( options || {}, {
789                                                 props: props,
790                                                 args:  args
791                                         } ) );
792                                         queries.push( query );
793                                 }
794
795                                 return query;
796                         };
797                 }())
798         });
799
800         /**
801          * wp.media.model.Selection
802          *
803          * Used to manage a selection of attachments in the views.
804          */
805         media.model.Selection = Attachments.extend({
806                 initialize: function( models, options ) {
807                         Attachments.prototype.initialize.apply( this, arguments );
808                         this.multiple = options && options.multiple;
809
810                         // Refresh the `single` model whenever the selection changes.
811                         // Binds `single` instead of using the context argument to ensure
812                         // it receives no parameters.
813                         this.on( 'add remove reset', _.bind( this.single, this, false ) );
814                 },
815
816                 // Override the selection's add method.
817                 // If the workflow does not support multiple
818                 // selected attachments, reset the selection.
819                 add: function( models, options ) {
820                         if ( ! this.multiple )
821                                 this.remove( this.models );
822
823                         return Attachments.prototype.add.call( this, models, options );
824                 },
825
826                 single: function( model ) {
827                         var previous = this._single;
828
829                         // If a `model` is provided, use it as the single model.
830                         if ( model )
831                                 this._single = model;
832
833                         // If the single model isn't in the selection, remove it.
834                         if ( this._single && ! this.get( this._single.cid ) )
835                                 delete this._single;
836
837                         this._single = this._single || this.last();
838
839                         // If single has changed, fire an event.
840                         if ( this._single !== previous ) {
841                                 if ( previous ) {
842                                         previous.trigger( 'selection:unsingle', previous, this );
843
844                                         // If the model was already removed, trigger the collection
845                                         // event manually.
846                                         if ( ! this.get( previous.cid ) )
847                                                 this.trigger( 'selection:unsingle', previous, this );
848                                 }
849                                 if ( this._single )
850                                         this._single.trigger( 'selection:single', this._single, this );
851                         }
852
853                         // Return the single model, or the last model as a fallback.
854                         return this._single;
855                 }
856         });
857
858         // Clean up. Prevents mobile browsers caching
859         $(window).on('unload', function(){
860                 window.wp = null;
861         });
862
863 }(jQuery));