]> scripts.mit.edu Git - autoinstalls/wordpress.git/blob - wp-includes/js/media-models.js
WordPress 4.0-scripts
[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, PostImage, compare, l10n, media;
6
7         /**
8          * Create and return a media frame.
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 {wp.media.view.MediaFrame} 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
25                 attributes = _.defaults( attributes || {}, {
26                         frame: 'select'
27                 });
28
29                 if ( 'select' === attributes.frame && MediaFrame.Select ) {
30                         frame = new MediaFrame.Select( attributes );
31                 } else if ( 'post' === attributes.frame && MediaFrame.Post ) {
32                         frame = new MediaFrame.Post( attributes );
33                 } else if ( 'manage' === attributes.frame && MediaFrame.Manage ) {
34                         frame = new MediaFrame.Manage( attributes );
35                 } else if ( 'image' === attributes.frame && MediaFrame.ImageDetails ) {
36                         frame = new MediaFrame.ImageDetails( attributes );
37                 } else if ( 'audio' === attributes.frame && MediaFrame.AudioDetails ) {
38                         frame = new MediaFrame.AudioDetails( attributes );
39                 } else if ( 'video' === attributes.frame && MediaFrame.VideoDetails ) {
40                         frame = new MediaFrame.VideoDetails( attributes );
41                 } else if ( 'edit-attachments' === attributes.frame && MediaFrame.EditAttachments ) {
42                         frame = new MediaFrame.EditAttachments( attributes );
43                 }
44
45                 delete attributes.frame;
46
47                 media.frame = frame;
48
49                 return frame;
50         };
51
52         _.extend( media, { model: {}, view: {}, controller: {}, frames: {} });
53
54         // Link any localized strings.
55         l10n = media.model.l10n = typeof _wpMediaModelsL10n === 'undefined' ? {} : _wpMediaModelsL10n;
56
57         // Link any settings.
58         media.model.settings = l10n.settings || {};
59         delete l10n.settings;
60
61         /**
62          * ========================================================================
63          * UTILITIES
64          * ========================================================================
65          */
66
67         /**
68          * A basic comparator.
69          *
70          * @param  {mixed}  a  The primary parameter to compare.
71          * @param  {mixed}  b  The primary parameter to compare.
72          * @param  {string} ac The fallback parameter to compare, a's cid.
73          * @param  {string} bc The fallback parameter to compare, b's cid.
74          * @return {number}    -1: a should come before b.
75          *                      0: a and b are of the same rank.
76          *                      1: b should come before a.
77          */
78         compare = function( a, b, ac, bc ) {
79                 if ( _.isEqual( a, b ) ) {
80                         return ac === bc ? 0 : (ac > bc ? -1 : 1);
81                 } else {
82                         return a > b ? -1 : 1;
83                 }
84         };
85
86         _.extend( media, {
87                 /**
88                  * media.template( id )
89                  *
90                  * Fetches a template by id.
91                  * See wp.template() in `wp-includes/js/wp-util.js`.
92                  *
93                  * @borrows wp.template as template
94                  */
95                 template: wp.template,
96
97                 /**
98                  * media.post( [action], [data] )
99                  *
100                  * Sends a POST request to WordPress.
101                  * See wp.ajax.post() in `wp-includes/js/wp-util.js`.
102                  *
103                  * @borrows wp.ajax.post as post
104                  */
105                 post: wp.ajax.post,
106
107                 /**
108                  * media.ajax( [action], [options] )
109                  *
110                  * Sends an XHR request to WordPress.
111                  * See wp.ajax.send() in `wp-includes/js/wp-util.js`.
112                  *
113                  * @borrows wp.ajax.send as ajax
114                  */
115                 ajax: wp.ajax.send,
116
117                 /**
118                  * Scales a set of dimensions to fit within bounding dimensions.
119                  *
120                  * @param {Object} dimensions
121                  * @returns {Object}
122                  */
123                 fit: function( dimensions ) {
124                         var width     = dimensions.width,
125                                 height    = dimensions.height,
126                                 maxWidth  = dimensions.maxWidth,
127                                 maxHeight = dimensions.maxHeight,
128                                 constraint;
129
130                         // Compare ratios between the two values to determine which
131                         // max to constrain by. If a max value doesn't exist, then the
132                         // opposite side is the constraint.
133                         if ( ! _.isUndefined( maxWidth ) && ! _.isUndefined( maxHeight ) ) {
134                                 constraint = ( width / height > maxWidth / maxHeight ) ? 'width' : 'height';
135                         } else if ( _.isUndefined( maxHeight ) ) {
136                                 constraint = 'width';
137                         } else if (  _.isUndefined( maxWidth ) && height > maxHeight ) {
138                                 constraint = 'height';
139                         }
140
141                         // If the value of the constrained side is larger than the max,
142                         // then scale the values. Otherwise return the originals; they fit.
143                         if ( 'width' === constraint && width > maxWidth ) {
144                                 return {
145                                         width : maxWidth,
146                                         height: Math.round( maxWidth * height / width )
147                                 };
148                         } else if ( 'height' === constraint && height > maxHeight ) {
149                                 return {
150                                         width : Math.round( maxHeight * width / height ),
151                                         height: maxHeight
152                                 };
153                         } else {
154                                 return {
155                                         width : width,
156                                         height: height
157                                 };
158                         }
159                 },
160                 /**
161                  * Truncates a string by injecting an ellipsis into the middle.
162                  * Useful for filenames.
163                  *
164                  * @param {String} string
165                  * @param {Number} [length=30]
166                  * @param {String} [replacement=…]
167                  * @returns {String} The string, unless length is greater than string.length.
168                  */
169                 truncate: function( string, length, replacement ) {
170                         length = length || 30;
171                         replacement = replacement || '…';
172
173                         if ( string.length <= length ) {
174                                 return string;
175                         }
176
177                         return string.substr( 0, length / 2 ) + replacement + string.substr( -1 * length / 2 );
178                 }
179         });
180
181         /**
182          * ========================================================================
183          * MODELS
184          * ========================================================================
185          */
186         /**
187          * wp.media.attachment
188          *
189          * @static
190          * @param {String} id A string used to identify a model.
191          * @returns {wp.media.model.Attachment}
192          */
193         media.attachment = function( id ) {
194                 return Attachment.get( id );
195         };
196
197         /**
198          * wp.media.model.Attachment
199          *
200          * @constructor
201          * @augments Backbone.Model
202          */
203         Attachment = media.model.Attachment = Backbone.Model.extend({
204                 /**
205                  * Triggered when attachment details change
206                  * Overrides Backbone.Model.sync
207                  *
208                  * @param {string} method
209                  * @param {wp.media.model.Attachment} model
210                  * @param {Object} [options={}]
211                  *
212                  * @returns {Promise}
213                  */
214                 sync: function( method, model, options ) {
215                         // If the attachment does not yet have an `id`, return an instantly
216                         // rejected promise. Otherwise, all of our requests will fail.
217                         if ( _.isUndefined( this.id ) ) {
218                                 return $.Deferred().rejectWith( this ).promise();
219                         }
220
221                         // Overload the `read` request so Attachment.fetch() functions correctly.
222                         if ( 'read' === method ) {
223                                 options = options || {};
224                                 options.context = this;
225                                 options.data = _.extend( options.data || {}, {
226                                         action: 'get-attachment',
227                                         id: this.id
228                                 });
229                                 return media.ajax( options );
230
231                         // Overload the `update` request so properties can be saved.
232                         } else if ( 'update' === method ) {
233                                 // If we do not have the necessary nonce, fail immeditately.
234                                 if ( ! this.get('nonces') || ! this.get('nonces').update ) {
235                                         return $.Deferred().rejectWith( this ).promise();
236                                 }
237
238                                 options = options || {};
239                                 options.context = this;
240
241                                 // Set the action and ID.
242                                 options.data = _.extend( options.data || {}, {
243                                         action:  'save-attachment',
244                                         id:      this.id,
245                                         nonce:   this.get('nonces').update,
246                                         post_id: media.model.settings.post.id
247                                 });
248
249                                 // Record the values of the changed attributes.
250                                 if ( model.hasChanged() ) {
251                                         options.data.changes = {};
252
253                                         _.each( model.changed, function( value, key ) {
254                                                 options.data.changes[ key ] = this.get( key );
255                                         }, this );
256                                 }
257
258                                 return media.ajax( options );
259
260                         // Overload the `delete` request so attachments can be removed.
261                         // This will permanently delete an attachment.
262                         } else if ( 'delete' === method ) {
263                                 options = options || {};
264
265                                 if ( ! options.wait ) {
266                                         this.destroyed = true;
267                                 }
268
269                                 options.context = this;
270                                 options.data = _.extend( options.data || {}, {
271                                         action:   'delete-post',
272                                         id:       this.id,
273                                         _wpnonce: this.get('nonces')['delete']
274                                 });
275
276                                 return media.ajax( options ).done( function() {
277                                         this.destroyed = true;
278                                 }).fail( function() {
279                                         this.destroyed = false;
280                                 });
281
282                         // Otherwise, fall back to `Backbone.sync()`.
283                         } else {
284                                 /**
285                                  * Call `sync` directly on Backbone.Model
286                                  */
287                                 return Backbone.Model.prototype.sync.apply( this, arguments );
288                         }
289                 },
290                 /**
291                  * Convert date strings into Date objects.
292                  *
293                  * @param {Object} resp The raw response object, typically returned by fetch()
294                  * @returns {Object} The modified response object, which is the attributes hash
295                  *    to be set on the model.
296                  */
297                 parse: function( resp ) {
298                         if ( ! resp ) {
299                                 return resp;
300                         }
301
302                         resp.date = new Date( resp.date );
303                         resp.modified = new Date( resp.modified );
304                         return resp;
305                 },
306                 /**
307                  * @param {Object} data The properties to be saved.
308                  * @param {Object} options Sync options. e.g. patch, wait, success, error.
309                  *
310                  * @this Backbone.Model
311                  *
312                  * @returns {Promise}
313                  */
314                 saveCompat: function( data, options ) {
315                         var model = this;
316
317                         // If we do not have the necessary nonce, fail immeditately.
318                         if ( ! this.get('nonces') || ! this.get('nonces').update ) {
319                                 return $.Deferred().rejectWith( this ).promise();
320                         }
321
322                         return media.post( 'save-attachment-compat', _.defaults({
323                                 id:      this.id,
324                                 nonce:   this.get('nonces').update,
325                                 post_id: media.model.settings.post.id
326                         }, data ) ).done( function( resp, status, xhr ) {
327                                 model.set( model.parse( resp, xhr ), options );
328                         });
329                 }
330         }, {
331                 /**
332                  * Add a model to the end of the static 'all' collection and return it.
333                  *
334                  * @static
335                  * @param {Object} attrs
336                  * @returns {wp.media.model.Attachment}
337                  */
338                 create: function( attrs ) {
339                         return Attachments.all.push( attrs );
340                 },
341                 /**
342                  * Retrieve a model, or add it to the end of the static 'all' collection before returning it.
343                  *
344                  * @static
345                  * @param {string} id A string used to identify a model.
346                  * @param {Backbone.Model|undefined} attachment
347                  * @returns {wp.media.model.Attachment}
348                  */
349                 get: _.memoize( function( id, attachment ) {
350                         return Attachments.all.push( attachment || { id: id } );
351                 })
352         });
353
354         /**
355          * wp.media.model.PostImage
356          *
357          * @constructor
358          * @augments Backbone.Model
359          **/
360         PostImage = media.model.PostImage = Backbone.Model.extend({
361
362                 initialize: function( attributes ) {
363                         this.attachment = false;
364
365                         if ( attributes.attachment_id ) {
366                                 this.attachment = Attachment.get( attributes.attachment_id );
367                                 if ( this.attachment.get( 'url' ) ) {
368                                         this.dfd = $.Deferred();
369                                         this.dfd.resolve();
370                                 } else {
371                                         this.dfd = this.attachment.fetch();
372                                 }
373                                 this.bindAttachmentListeners();
374                         }
375
376                         // keep url in sync with changes to the type of link
377                         this.on( 'change:link', this.updateLinkUrl, this );
378                         this.on( 'change:size', this.updateSize, this );
379
380                         this.setLinkTypeFromUrl();
381                         this.setAspectRatio();
382
383                         this.set( 'originalUrl', attributes.url );
384                 },
385
386                 bindAttachmentListeners: function() {
387                         this.listenTo( this.attachment, 'sync', this.setLinkTypeFromUrl );
388                         this.listenTo( this.attachment, 'sync', this.setAspectRatio );
389                         this.listenTo( this.attachment, 'change', this.updateSize );
390                 },
391
392                 changeAttachment: function( attachment, props ) {
393                         this.stopListening( this.attachment );
394                         this.attachment = attachment;
395                         this.bindAttachmentListeners();
396
397                         this.set( 'attachment_id', this.attachment.get( 'id' ) );
398                         this.set( 'caption', this.attachment.get( 'caption' ) );
399                         this.set( 'alt', this.attachment.get( 'alt' ) );
400                         this.set( 'size', props.get( 'size' ) );
401                         this.set( 'align', props.get( 'align' ) );
402                         this.set( 'link', props.get( 'link' ) );
403                         this.updateLinkUrl();
404                         this.updateSize();
405                 },
406
407                 setLinkTypeFromUrl: function() {
408                         var linkUrl = this.get( 'linkUrl' ),
409                                 type;
410
411                         if ( ! linkUrl ) {
412                                 this.set( 'link', 'none' );
413                                 return;
414                         }
415
416                         // default to custom if there is a linkUrl
417                         type = 'custom';
418
419                         if ( this.attachment ) {
420                                 if ( this.attachment.get( 'url' ) === linkUrl ) {
421                                         type = 'file';
422                                 } else if ( this.attachment.get( 'link' ) === linkUrl ) {
423                                         type = 'post';
424                                 }
425                         } else {
426                                 if ( this.get( 'url' ) === linkUrl ) {
427                                         type = 'file';
428                                 }
429                         }
430
431                         this.set( 'link', type );
432                 },
433
434                 updateLinkUrl: function() {
435                         var link = this.get( 'link' ),
436                                 url;
437
438                         switch( link ) {
439                                 case 'file':
440                                         if ( this.attachment ) {
441                                                 url = this.attachment.get( 'url' );
442                                         } else {
443                                                 url = this.get( 'url' );
444                                         }
445                                         this.set( 'linkUrl', url );
446                                         break;
447                                 case 'post':
448                                         this.set( 'linkUrl', this.attachment.get( 'link' ) );
449                                         break;
450                                 case 'none':
451                                         this.set( 'linkUrl', '' );
452                                         break;
453                         }
454                 },
455
456                 updateSize: function() {
457                         var size;
458
459                         if ( ! this.attachment ) {
460                                 return;
461                         }
462
463                         if ( this.get( 'size' ) === 'custom' ) {
464                                 this.set( 'width', this.get( 'customWidth' ) );
465                                 this.set( 'height', this.get( 'customHeight' ) );
466                                 this.set( 'url', this.get( 'originalUrl' ) );
467                                 return;
468                         }
469
470                         size = this.attachment.get( 'sizes' )[ this.get( 'size' ) ];
471
472                         if ( ! size ) {
473                                 return;
474                         }
475
476                         this.set( 'url', size.url );
477                         this.set( 'width', size.width );
478                         this.set( 'height', size.height );
479                 },
480
481                 setAspectRatio: function() {
482                         var full;
483
484                         if ( this.attachment && this.attachment.get( 'sizes' ) ) {
485                                 full = this.attachment.get( 'sizes' ).full;
486
487                                 if ( full ) {
488                                         this.set( 'aspectRatio', full.width / full.height );
489                                         return;
490                                 }
491                         }
492
493                         this.set( 'aspectRatio', this.get( 'customWidth' ) / this.get( 'customHeight' ) );
494                 }
495         });
496
497         /**
498          * wp.media.model.Attachments
499          *
500          * @constructor
501          * @augments Backbone.Collection
502          */
503         Attachments = media.model.Attachments = Backbone.Collection.extend({
504                 /**
505                  * @type {wp.media.model.Attachment}
506                  */
507                 model: Attachment,
508                 /**
509                  * @param {Array} [models=[]] Array of models used to populate the collection.
510                  * @param {Object} [options={}]
511                  */
512                 initialize: function( models, options ) {
513                         options = options || {};
514
515                         this.props   = new Backbone.Model();
516                         this.filters = options.filters || {};
517
518                         // Bind default `change` events to the `props` model.
519                         this.props.on( 'change', this._changeFilteredProps, this );
520
521                         this.props.on( 'change:order',   this._changeOrder,   this );
522                         this.props.on( 'change:orderby', this._changeOrderby, this );
523                         this.props.on( 'change:query',   this._changeQuery,   this );
524
525                         // Set the `props` model and fill the default property values.
526                         this.props.set( _.defaults( options.props || {} ) );
527
528                         // Observe another `Attachments` collection if one is provided.
529                         if ( options.observe ) {
530                                 this.observe( options.observe );
531                         }
532                 },
533                 /**
534                  * Automatically sort the collection when the order changes.
535                  *
536                  * @access private
537                  */
538                 _changeOrder: function() {
539                         if ( this.comparator ) {
540                                 this.sort();
541                         }
542                 },
543                 /**
544                  * Set the default comparator only when the `orderby` property is set.
545                  *
546                  * @access private
547                  *
548                  * @param {Backbone.Model} model
549                  * @param {string} orderby
550                  */
551                 _changeOrderby: function( model, orderby ) {
552                         // If a different comparator is defined, bail.
553                         if ( this.comparator && this.comparator !== Attachments.comparator ) {
554                                 return;
555                         }
556
557                         if ( orderby && 'post__in' !== orderby ) {
558                                 this.comparator = Attachments.comparator;
559                         } else {
560                                 delete this.comparator;
561                         }
562                 },
563                 /**
564                  * If the `query` property is set to true, query the server using
565                  * the `props` values, and sync the results to this collection.
566                  *
567                  * @access private
568                  *
569                  * @param {Backbone.Model} model
570                  * @param {Boolean} query
571                  */
572                 _changeQuery: function( model, query ) {
573                         if ( query ) {
574                                 this.props.on( 'change', this._requery, this );
575                                 this._requery();
576                         } else {
577                                 this.props.off( 'change', this._requery, this );
578                         }
579                 },
580                 /**
581                  * @access private
582                  *
583                  * @param {Backbone.Model} model
584                  */
585                 _changeFilteredProps: function( model ) {
586                         // If this is a query, updating the collection will be handled by
587                         // `this._requery()`.
588                         if ( this.props.get('query') ) {
589                                 return;
590                         }
591
592                         var changed = _.chain( model.changed ).map( function( t, prop ) {
593                                 var filter = Attachments.filters[ prop ],
594                                         term = model.get( prop );
595
596                                 if ( ! filter ) {
597                                         return;
598                                 }
599
600                                 if ( term && ! this.filters[ prop ] ) {
601                                         this.filters[ prop ] = filter;
602                                 } else if ( ! term && this.filters[ prop ] === filter ) {
603                                         delete this.filters[ prop ];
604                                 } else {
605                                         return;
606                                 }
607
608                                 // Record the change.
609                                 return true;
610                         }, this ).any().value();
611
612                         if ( ! changed ) {
613                                 return;
614                         }
615
616                         // If no `Attachments` model is provided to source the searches
617                         // from, then automatically generate a source from the existing
618                         // models.
619                         if ( ! this._source ) {
620                                 this._source = new Attachments( this.models );
621                         }
622
623                         this.reset( this._source.filter( this.validator, this ) );
624                 },
625
626                 validateDestroyed: false,
627                 /**
628                  * @param {wp.media.model.Attachment} attachment
629                  * @returns {Boolean}
630                  */
631                 validator: function( attachment ) {
632                         if ( ! this.validateDestroyed && attachment.destroyed ) {
633                                 return false;
634                         }
635                         return _.all( this.filters, function( filter ) {
636                                 return !! filter.call( this, attachment );
637                         }, this );
638                 },
639                 /**
640                  * @param {wp.media.model.Attachment} attachment
641                  * @param {Object} options
642                  * @returns {wp.media.model.Attachments} Returns itself to allow chaining
643                  */
644                 validate: function( attachment, options ) {
645                         var valid = this.validator( attachment ),
646                                 hasAttachment = !! this.get( attachment.cid );
647
648                         if ( ! valid && hasAttachment ) {
649                                 this.remove( attachment, options );
650                         } else if ( valid && ! hasAttachment ) {
651                                 this.add( attachment, options );
652                         }
653
654                         return this;
655                 },
656
657                 /**
658                  * @param {wp.media.model.Attachments} attachments
659                  * @param {object} [options={}]
660                  *
661                  * @fires wp.media.model.Attachments#reset
662                  *
663                  * @returns {wp.media.model.Attachments} Returns itself to allow chaining
664                  */
665                 validateAll: function( attachments, options ) {
666                         options = options || {};
667
668                         _.each( attachments.models, function( attachment ) {
669                                 this.validate( attachment, { silent: true });
670                         }, this );
671
672                         if ( ! options.silent ) {
673                                 this.trigger( 'reset', this, options );
674                         }
675                         return this;
676                 },
677                 /**
678                  * @param {wp.media.model.Attachments} attachments
679                  * @returns {wp.media.model.Attachments} Returns itself to allow chaining
680                  */
681                 observe: function( attachments ) {
682                         this.observers = this.observers || [];
683                         this.observers.push( attachments );
684
685                         attachments.on( 'add change remove', this._validateHandler, this );
686                         attachments.on( 'reset', this._validateAllHandler, this );
687                         this.validateAll( attachments );
688                         return this;
689                 },
690                 /**
691                  * @param {wp.media.model.Attachments} attachments
692                  * @returns {wp.media.model.Attachments} Returns itself to allow chaining
693                  */
694                 unobserve: function( attachments ) {
695                         if ( attachments ) {
696                                 attachments.off( null, null, this );
697                                 this.observers = _.without( this.observers, attachments );
698
699                         } else {
700                                 _.each( this.observers, function( attachments ) {
701                                         attachments.off( null, null, this );
702                                 }, this );
703                                 delete this.observers;
704                         }
705
706                         return this;
707                 },
708                 /**
709                  * @access private
710                  *
711                  * @param {wp.media.model.Attachments} attachment
712                  * @param {wp.media.model.Attachments} attachments
713                  * @param {Object} options
714                  *
715                  * @returns {wp.media.model.Attachments} Returns itself to allow chaining
716                  */
717                 _validateHandler: function( attachment, attachments, options ) {
718                         // If we're not mirroring this `attachments` collection,
719                         // only retain the `silent` option.
720                         options = attachments === this.mirroring ? options : {
721                                 silent: options && options.silent
722                         };
723
724                         return this.validate( attachment, options );
725                 },
726                 /**
727                  * @access private
728                  *
729                  * @param {wp.media.model.Attachments} attachments
730                  * @param {Object} options
731                  * @returns {wp.media.model.Attachments} Returns itself to allow chaining
732                  */
733                 _validateAllHandler: function( attachments, options ) {
734                         return this.validateAll( attachments, options );
735                 },
736                 /**
737                  * @param {wp.media.model.Attachments} attachments
738                  * @returns {wp.media.model.Attachments} Returns itself to allow chaining
739                  */
740                 mirror: function( attachments ) {
741                         if ( this.mirroring && this.mirroring === attachments ) {
742                                 return this;
743                         }
744
745                         this.unmirror();
746                         this.mirroring = attachments;
747
748                         // Clear the collection silently. A `reset` event will be fired
749                         // when `observe()` calls `validateAll()`.
750                         this.reset( [], { silent: true } );
751                         this.observe( attachments );
752
753                         return this;
754                 },
755                 unmirror: function() {
756                         if ( ! this.mirroring ) {
757                                 return;
758                         }
759
760                         this.unobserve( this.mirroring );
761                         delete this.mirroring;
762                 },
763                 /**
764                  * @param {Object} options
765                  * @returns {Promise}
766                  */
767                 more: function( options ) {
768                         var deferred = $.Deferred(),
769                                 mirroring = this.mirroring,
770                                 attachments = this;
771
772                         if ( ! mirroring || ! mirroring.more ) {
773                                 return deferred.resolveWith( this ).promise();
774                         }
775                         // If we're mirroring another collection, forward `more` to
776                         // the mirrored collection. Account for a race condition by
777                         // checking if we're still mirroring that collection when
778                         // the request resolves.
779                         mirroring.more( options ).done( function() {
780                                 if ( this === attachments.mirroring )
781                                         deferred.resolveWith( this );
782                         });
783
784                         return deferred.promise();
785                 },
786                 /**
787                  * @returns {Boolean}
788                  */
789                 hasMore: function() {
790                         return this.mirroring ? this.mirroring.hasMore() : false;
791                 },
792                 /**
793                  * Overrides Backbone.Collection.parse
794                  *
795                  * @param {Object|Array} resp The raw response Object/Array.
796                  * @param {Object} xhr
797                  * @returns {Array} The array of model attributes to be added to the collection
798                  */
799                 parse: function( resp, xhr ) {
800                         if ( ! _.isArray( resp ) ) {
801                                 resp = [resp];
802                         }
803
804                         return _.map( resp, function( attrs ) {
805                                 var id, attachment, newAttributes;
806
807                                 if ( attrs instanceof Backbone.Model ) {
808                                         id = attrs.get( 'id' );
809                                         attrs = attrs.attributes;
810                                 } else {
811                                         id = attrs.id;
812                                 }
813
814                                 attachment = Attachment.get( id );
815                                 newAttributes = attachment.parse( attrs, xhr );
816
817                                 if ( ! _.isEqual( attachment.attributes, newAttributes ) ) {
818                                         attachment.set( newAttributes );
819                                 }
820
821                                 return attachment;
822                         });
823                 },
824                 /**
825                  * @access private
826                  */
827                 _requery: function( refresh ) {
828                         var props;
829                         if ( this.props.get('query') ) {
830                                 props = this.props.toJSON();
831                                 props.cache = ( true !== refresh );
832                                 this.mirror( Query.get( props ) );
833                         }
834                 },
835                 /**
836                  * If this collection is sorted by `menuOrder`, recalculates and saves
837                  * the menu order to the database.
838                  *
839                  * @returns {undefined|Promise}
840                  */
841                 saveMenuOrder: function() {
842                         if ( 'menuOrder' !== this.props.get('orderby') ) {
843                                 return;
844                         }
845
846                         // Removes any uploading attachments, updates each attachment's
847                         // menu order, and returns an object with an { id: menuOrder }
848                         // mapping to pass to the request.
849                         var attachments = this.chain().filter( function( attachment ) {
850                                 return ! _.isUndefined( attachment.id );
851                         }).map( function( attachment, index ) {
852                                 // Indices start at 1.
853                                 index = index + 1;
854                                 attachment.set( 'menuOrder', index );
855                                 return [ attachment.id, index ];
856                         }).object().value();
857
858                         if ( _.isEmpty( attachments ) ) {
859                                 return;
860                         }
861
862                         return media.post( 'save-attachment-order', {
863                                 nonce:       media.model.settings.post.nonce,
864                                 post_id:     media.model.settings.post.id,
865                                 attachments: attachments
866                         });
867                 }
868         }, {
869                 /**
870                  * @static
871                  * Overrides Backbone.Collection.comparator
872                  *
873                  * @param {Backbone.Model} a
874                  * @param {Backbone.Model} b
875                  * @param {Object} options
876                  * @returns {Number} -1 if the first model should come before the second,
877                  *    0 if they are of the same rank and
878                  *    1 if the first model should come after.
879                  */
880                 comparator: function( a, b, options ) {
881                         var key   = this.props.get('orderby'),
882                                 order = this.props.get('order') || 'DESC',
883                                 ac    = a.cid,
884                                 bc    = b.cid;
885
886                         a = a.get( key );
887                         b = b.get( key );
888
889                         if ( 'date' === key || 'modified' === key ) {
890                                 a = a || new Date();
891                                 b = b || new Date();
892                         }
893
894                         // If `options.ties` is set, don't enforce the `cid` tiebreaker.
895                         if ( options && options.ties ) {
896                                 ac = bc = null;
897                         }
898
899                         return ( 'DESC' === order ) ? compare( a, b, ac, bc ) : compare( b, a, bc, ac );
900                 },
901                 /**
902                  * @namespace
903                  */
904                 filters: {
905                         /**
906                          * @static
907                          * Note that this client-side searching is *not* equivalent
908                          * to our server-side searching.
909                          *
910                          * @param {wp.media.model.Attachment} attachment
911                          *
912                          * @this wp.media.model.Attachments
913                          *
914                          * @returns {Boolean}
915                          */
916                         search: function( attachment ) {
917                                 if ( ! this.props.get('search') ) {
918                                         return true;
919                                 }
920
921                                 return _.any(['title','filename','description','caption','name'], function( key ) {
922                                         var value = attachment.get( key );
923                                         return value && -1 !== value.search( this.props.get('search') );
924                                 }, this );
925                         },
926                         /**
927                          * @static
928                          * @param {wp.media.model.Attachment} attachment
929                          *
930                          * @this wp.media.model.Attachments
931                          *
932                          * @returns {Boolean}
933                          */
934                         type: function( attachment ) {
935                                 var type = this.props.get('type');
936                                 return ! type || -1 !== type.indexOf( attachment.get('type') );
937                         },
938                         /**
939                          * @static
940                          * @param {wp.media.model.Attachment} attachment
941                          *
942                          * @this wp.media.model.Attachments
943                          *
944                          * @returns {Boolean}
945                          */
946                         uploadedTo: function( attachment ) {
947                                 var uploadedTo = this.props.get('uploadedTo');
948                                 if ( _.isUndefined( uploadedTo ) ) {
949                                         return true;
950                                 }
951
952                                 return uploadedTo === attachment.get('uploadedTo');
953                         },
954                         /**
955                          * @static
956                          * @param {wp.media.model.Attachment} attachment
957                          *
958                          * @this wp.media.model.Attachments
959                          *
960                          * @returns {Boolean}
961                          */
962                         status: function( attachment ) {
963                                 var status = this.props.get('status');
964                                 if ( _.isUndefined( status ) ) {
965                                         return true;
966                                 }
967
968                                 return status === attachment.get('status');
969                         }
970                 }
971         });
972
973         /**
974          * @static
975          * @member {wp.media.model.Attachments}
976          */
977         Attachments.all = new Attachments();
978
979         /**
980          * wp.media.query
981          *
982          * @static
983          * @returns {wp.media.model.Attachments}
984          */
985         media.query = function( props ) {
986                 return new Attachments( null, {
987                         props: _.extend( _.defaults( props || {}, { orderby: 'date' } ), { query: true } )
988                 });
989         };
990
991         /**
992          * wp.media.model.Query
993          *
994          * A set of attachments that corresponds to a set of consecutively paged
995          * queries on the server.
996          *
997          * Note: Do NOT change this.args after the query has been initialized.
998          *       Things will break.
999          *
1000          * @constructor
1001          * @augments wp.media.model.Attachments
1002          * @augments Backbone.Collection
1003          */
1004         Query = media.model.Query = Attachments.extend({
1005                 /**
1006                  * @global wp.Uploader
1007                  *
1008                  * @param {Array} [models=[]] Array of models used to populate the collection.
1009                  * @param {Object} [options={}]
1010                  */
1011                 initialize: function( models, options ) {
1012                         var allowed;
1013
1014                         options = options || {};
1015                         Attachments.prototype.initialize.apply( this, arguments );
1016
1017                         this.args     = options.args;
1018                         this._hasMore = true;
1019                         this.created  = new Date();
1020
1021                         this.filters.order = function( attachment ) {
1022                                 var orderby = this.props.get('orderby'),
1023                                         order = this.props.get('order');
1024
1025                                 if ( ! this.comparator ) {
1026                                         return true;
1027                                 }
1028
1029                                 // We want any items that can be placed before the last
1030                                 // item in the set. If we add any items after the last
1031                                 // item, then we can't guarantee the set is complete.
1032                                 if ( this.length ) {
1033                                         return 1 !== this.comparator( attachment, this.last(), { ties: true });
1034
1035                                 // Handle the case where there are no items yet and
1036                                 // we're sorting for recent items. In that case, we want
1037                                 // changes that occurred after we created the query.
1038                                 } else if ( 'DESC' === order && ( 'date' === orderby || 'modified' === orderby ) ) {
1039                                         return attachment.get( orderby ) >= this.created;
1040
1041                                 // If we're sorting by menu order and we have no items,
1042                                 // accept any items that have the default menu order (0).
1043                                 } else if ( 'ASC' === order && 'menuOrder' === orderby ) {
1044                                         return attachment.get( orderby ) === 0;
1045                                 }
1046
1047                                 // Otherwise, we don't want any items yet.
1048                                 return false;
1049                         };
1050
1051                         // Observe the central `wp.Uploader.queue` collection to watch for
1052                         // new matches for the query.
1053                         //
1054                         // Only observe when a limited number of query args are set. There
1055                         // are no filters for other properties, so observing will result in
1056                         // false positives in those queries.
1057                         allowed = [ 's', 'order', 'orderby', 'posts_per_page', 'post_mime_type', 'post_parent' ];
1058                         if ( wp.Uploader && _( this.args ).chain().keys().difference( allowed ).isEmpty().value() ) {
1059                                 this.observe( wp.Uploader.queue );
1060                         }
1061                 },
1062                 /**
1063                  * @returns {Boolean}
1064                  */
1065                 hasMore: function() {
1066                         return this._hasMore;
1067                 },
1068                 /**
1069                  * @param {Object} [options={}]
1070                  * @returns {Promise}
1071                  */
1072                 more: function( options ) {
1073                         var query = this;
1074
1075                         if ( this._more && 'pending' === this._more.state() ) {
1076                                 return this._more;
1077                         }
1078
1079                         if ( ! this.hasMore() ) {
1080                                 return $.Deferred().resolveWith( this ).promise();
1081                         }
1082
1083                         options = options || {};
1084                         options.remove = false;
1085
1086                         return this._more = this.fetch( options ).done( function( resp ) {
1087                                 if ( _.isEmpty( resp ) || -1 === this.args.posts_per_page || resp.length < this.args.posts_per_page ) {
1088                                         query._hasMore = false;
1089                                 }
1090                         });
1091                 },
1092                 /**
1093                  * Overrides Backbone.Collection.sync
1094                  * Overrides wp.media.model.Attachments.sync
1095                  *
1096                  * @param {String} method
1097                  * @param {Backbone.Model} model
1098                  * @param {Object} [options={}]
1099                  * @returns {Promise}
1100                  */
1101                 sync: function( method, model, options ) {
1102                         var args, fallback;
1103
1104                         // Overload the read method so Attachment.fetch() functions correctly.
1105                         if ( 'read' === method ) {
1106                                 options = options || {};
1107                                 options.context = this;
1108                                 options.data = _.extend( options.data || {}, {
1109                                         action:  'query-attachments',
1110                                         post_id: media.model.settings.post.id
1111                                 });
1112
1113                                 // Clone the args so manipulation is non-destructive.
1114                                 args = _.clone( this.args );
1115
1116                                 // Determine which page to query.
1117                                 if ( -1 !== args.posts_per_page ) {
1118                                         args.paged = Math.floor( this.length / args.posts_per_page ) + 1;
1119                                 }
1120
1121                                 options.data.query = args;
1122                                 return media.ajax( options );
1123
1124                         // Otherwise, fall back to Backbone.sync()
1125                         } else {
1126                                 /**
1127                                  * Call wp.media.model.Attachments.sync or Backbone.sync
1128                                  */
1129                                 fallback = Attachments.prototype.sync ? Attachments.prototype : Backbone;
1130                                 return fallback.sync.apply( this, arguments );
1131                         }
1132                 }
1133         }, {
1134                 /**
1135                  * @readonly
1136                  */
1137                 defaultProps: {
1138                         orderby: 'date',
1139                         order:   'DESC'
1140                 },
1141                 /**
1142                  * @readonly
1143                  */
1144                 defaultArgs: {
1145                         posts_per_page: 40
1146                 },
1147                 /**
1148                  * @readonly
1149                  */
1150                 orderby: {
1151                         allowed:  [ 'name', 'author', 'date', 'title', 'modified', 'uploadedTo', 'id', 'post__in', 'menuOrder' ],
1152                         valuemap: {
1153                                 'id':         'ID',
1154                                 'uploadedTo': 'parent',
1155                                 'menuOrder':  'menu_order ID'
1156                         }
1157                 },
1158                 /**
1159                  * @readonly
1160                  */
1161                 propmap: {
1162                         'search':    's',
1163                         'type':      'post_mime_type',
1164                         'perPage':   'posts_per_page',
1165                         'menuOrder': 'menu_order',
1166                         'uploadedTo': 'post_parent',
1167                         'status':     'post_status'
1168                 },
1169                 /**
1170                  * @static
1171                  * @method
1172                  *
1173                  * @returns {wp.media.model.Query} A new query.
1174                  */
1175                 // Caches query objects so queries can be easily reused.
1176                 get: (function(){
1177                         /**
1178                          * @static
1179                          * @type Array
1180                          */
1181                         var queries = [];
1182
1183                         /**
1184                          * @param {Object} props
1185                          * @param {Object} options
1186                          * @returns {Query}
1187                          */
1188                         return function( props, options ) {
1189                                 var args     = {},
1190                                         orderby  = Query.orderby,
1191                                         defaults = Query.defaultProps,
1192                                         query,
1193                                         cache    = !! props.cache || _.isUndefined( props.cache );
1194
1195                                 // Remove the `query` property. This isn't linked to a query,
1196                                 // this *is* the query.
1197                                 delete props.query;
1198                                 delete props.cache;
1199
1200                                 // Fill default args.
1201                                 _.defaults( props, defaults );
1202
1203                                 // Normalize the order.
1204                                 props.order = props.order.toUpperCase();
1205                                 if ( 'DESC' !== props.order && 'ASC' !== props.order ) {
1206                                         props.order = defaults.order.toUpperCase();
1207                                 }
1208
1209                                 // Ensure we have a valid orderby value.
1210                                 if ( ! _.contains( orderby.allowed, props.orderby ) ) {
1211                                         props.orderby = defaults.orderby;
1212                                 }
1213
1214                                 // Generate the query `args` object.
1215                                 // Correct any differing property names.
1216                                 _.each( props, function( value, prop ) {
1217                                         if ( _.isNull( value ) ) {
1218                                                 return;
1219                                         }
1220
1221                                         args[ Query.propmap[ prop ] || prop ] = value;
1222                                 });
1223
1224                                 // Fill any other default query args.
1225                                 _.defaults( args, Query.defaultArgs );
1226
1227                                 // `props.orderby` does not always map directly to `args.orderby`.
1228                                 // Substitute exceptions specified in orderby.keymap.
1229                                 args.orderby = orderby.valuemap[ props.orderby ] || props.orderby;
1230
1231                                 // Search the query cache for matches.
1232                                 if ( cache ) {
1233                                         query = _.find( queries, function( query ) {
1234                                                 return _.isEqual( query.args, args );
1235                                         });
1236                                 } else {
1237                                         queries = [];
1238                                 }
1239
1240                                 // Otherwise, create a new query and add it to the cache.
1241                                 if ( ! query ) {
1242                                         query = new Query( [], _.extend( options || {}, {
1243                                                 props: props,
1244                                                 args:  args
1245                                         } ) );
1246                                         queries.push( query );
1247                                 }
1248
1249                                 return query;
1250                         };
1251                 }())
1252         });
1253
1254         /**
1255          * wp.media.model.Selection
1256          *
1257          * Used to manage a selection of attachments in the views.
1258          *
1259          * @constructor
1260          * @augments wp.media.model.Attachments
1261          * @augments Backbone.Collection
1262          */
1263         media.model.Selection = Attachments.extend({
1264                 /**
1265                  * Refresh the `single` model whenever the selection changes.
1266                  * Binds `single` instead of using the context argument to ensure
1267                  * it receives no parameters.
1268                  *
1269                  * @param {Array} [models=[]] Array of models used to populate the collection.
1270                  * @param {Object} [options={}]
1271                  */
1272                 initialize: function( models, options ) {
1273                         /**
1274                          * call 'initialize' directly on the parent class
1275                          */
1276                         Attachments.prototype.initialize.apply( this, arguments );
1277                         this.multiple = options && options.multiple;
1278
1279                         this.on( 'add remove reset', _.bind( this.single, this, false ) );
1280                 },
1281
1282                 /**
1283                  * Override the selection's add method.
1284                  * If the workflow does not support multiple
1285                  * selected attachments, reset the selection.
1286                  *
1287                  * Overrides Backbone.Collection.add
1288                  * Overrides wp.media.model.Attachments.add
1289                  *
1290                  * @param {Array} models
1291                  * @param {Object} options
1292                  * @returns {wp.media.model.Attachment[]}
1293                  */
1294                 add: function( models, options ) {
1295                         if ( ! this.multiple ) {
1296                                 this.remove( this.models );
1297                         }
1298                         /**
1299                          * call 'add' directly on the parent class
1300                          */
1301                         return Attachments.prototype.add.call( this, models, options );
1302                 },
1303
1304                 /**
1305                  * Triggered when toggling (clicking on) an attachment in the modal
1306                  *
1307                  * @param {undefined|boolean|wp.media.model.Attachment} model
1308                  *
1309                  * @fires wp.media.model.Selection#selection:single
1310                  * @fires wp.media.model.Selection#selection:unsingle
1311                  *
1312                  * @returns {Backbone.Model}
1313                  */
1314                 single: function( model ) {
1315                         var previous = this._single;
1316
1317                         // If a `model` is provided, use it as the single model.
1318                         if ( model ) {
1319                                 this._single = model;
1320                         }
1321                         // If the single model isn't in the selection, remove it.
1322                         if ( this._single && ! this.get( this._single.cid ) ) {
1323                                 delete this._single;
1324                         }
1325
1326                         this._single = this._single || this.last();
1327
1328                         // If single has changed, fire an event.
1329                         if ( this._single !== previous ) {
1330                                 if ( previous ) {
1331                                         previous.trigger( 'selection:unsingle', previous, this );
1332
1333                                         // If the model was already removed, trigger the collection
1334                                         // event manually.
1335                                         if ( ! this.get( previous.cid ) ) {
1336                                                 this.trigger( 'selection:unsingle', previous, this );
1337                                         }
1338                                 }
1339                                 if ( this._single ) {
1340                                         this._single.trigger( 'selection:single', this._single, this );
1341                                 }
1342                         }
1343
1344                         // Return the single model, or the last model as a fallback.
1345                         return this._single;
1346                 }
1347         });
1348
1349         // Clean up. Prevents mobile browsers caching
1350         $(window).on('unload', function(){
1351                 window.wp = null;
1352         });
1353
1354 }(jQuery));