PageRenderTime 68ms CodeModel.GetById 2ms app.highlight 57ms RepoModel.GetById 1ms app.codeStats 0ms

/wp/wp-includes/js/media-models.js

https://github.com/10degrees/sotonopus
JavaScript | 1328 lines | 692 code | 152 blank | 484 comment | 183 complexity | 3be938ad328a872fbf37861af960498e MD5 | raw file
   1/* global _wpMediaModelsL10n:false */
   2window.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() {
 828			if ( this.props.get('query') ) {
 829				this.mirror( Query.get( this.props.toJSON() ) );
 830			}
 831		},
 832		/**
 833		 * If this collection is sorted by `menuOrder`, recalculates and saves
 834		 * the menu order to the database.
 835		 *
 836		 * @returns {undefined|Promise}
 837		 */
 838		saveMenuOrder: function() {
 839			if ( 'menuOrder' !== this.props.get('orderby') ) {
 840				return;
 841			}
 842
 843			// Removes any uploading attachments, updates each attachment's
 844			// menu order, and returns an object with an { id: menuOrder }
 845			// mapping to pass to the request.
 846			var attachments = this.chain().filter( function( attachment ) {
 847				return ! _.isUndefined( attachment.id );
 848			}).map( function( attachment, index ) {
 849				// Indices start at 1.
 850				index = index + 1;
 851				attachment.set( 'menuOrder', index );
 852				return [ attachment.id, index ];
 853			}).object().value();
 854
 855			if ( _.isEmpty( attachments ) ) {
 856				return;
 857			}
 858
 859			return media.post( 'save-attachment-order', {
 860				nonce:       media.model.settings.post.nonce,
 861				post_id:     media.model.settings.post.id,
 862				attachments: attachments
 863			});
 864		}
 865	}, {
 866		/**
 867		 * @static
 868		 * Overrides Backbone.Collection.comparator
 869		 *
 870		 * @param {Backbone.Model} a
 871		 * @param {Backbone.Model} b
 872		 * @param {Object} options
 873		 * @returns {Number} -1 if the first model should come before the second,
 874		 *    0 if they are of the same rank and
 875		 *    1 if the first model should come after.
 876		 */
 877		comparator: function( a, b, options ) {
 878			var key   = this.props.get('orderby'),
 879				order = this.props.get('order') || 'DESC',
 880				ac    = a.cid,
 881				bc    = b.cid;
 882
 883			a = a.get( key );
 884			b = b.get( key );
 885
 886			if ( 'date' === key || 'modified' === key ) {
 887				a = a || new Date();
 888				b = b || new Date();
 889			}
 890
 891			// If `options.ties` is set, don't enforce the `cid` tiebreaker.
 892			if ( options && options.ties ) {
 893				ac = bc = null;
 894			}
 895
 896			return ( 'DESC' === order ) ? compare( a, b, ac, bc ) : compare( b, a, bc, ac );
 897		},
 898		/**
 899		 * @namespace
 900		 */
 901		filters: {
 902			/**
 903			 * @static
 904			 * Note that this client-side searching is *not* equivalent
 905			 * to our server-side searching.
 906			 *
 907			 * @param {wp.media.model.Attachment} attachment
 908			 *
 909			 * @this wp.media.model.Attachments
 910			 *
 911			 * @returns {Boolean}
 912			 */
 913			search: function( attachment ) {
 914				if ( ! this.props.get('search') ) {
 915					return true;
 916				}
 917
 918				return _.any(['title','filename','description','caption','name'], function( key ) {
 919					var value = attachment.get( key );
 920					return value && -1 !== value.search( this.props.get('search') );
 921				}, this );
 922			},
 923			/**
 924			 * @static
 925			 * @param {wp.media.model.Attachment} attachment
 926			 *
 927			 * @this wp.media.model.Attachments
 928			 *
 929			 * @returns {Boolean}
 930			 */
 931			type: function( attachment ) {
 932				var type = this.props.get('type');
 933				return ! type || -1 !== type.indexOf( attachment.get('type') );
 934			},
 935			/**
 936			 * @static
 937			 * @param {wp.media.model.Attachment} attachment
 938			 *
 939			 * @this wp.media.model.Attachments
 940			 *
 941			 * @returns {Boolean}
 942			 */
 943			uploadedTo: function( attachment ) {
 944				var uploadedTo = this.props.get('uploadedTo');
 945				if ( _.isUndefined( uploadedTo ) ) {
 946					return true;
 947				}
 948
 949				return uploadedTo === attachment.get('uploadedTo');
 950			}
 951		}
 952	});
 953
 954	/**
 955	 * @static
 956	 * @member {wp.media.model.Attachments}
 957	 */
 958	Attachments.all = new Attachments();
 959
 960	/**
 961	 * wp.media.query
 962	 *
 963	 * @static
 964	 * @returns {wp.media.model.Attachments}
 965	 */
 966	media.query = function( props ) {
 967		return new Attachments( null, {
 968			props: _.extend( _.defaults( props || {}, { orderby: 'date' } ), { query: true } )
 969		});
 970	};
 971
 972	/**
 973	 * wp.media.model.Query
 974	 *
 975	 * A set of attachments that corresponds to a set of consecutively paged
 976	 * queries on the server.
 977	 *
 978	 * Note: Do NOT change this.args after the query has been initialized.
 979	 *       Things will break.
 980	 *
 981	 * @constructor
 982	 * @augments wp.media.model.Attachments
 983	 * @augments Backbone.Collection
 984	 */
 985	Query = media.model.Query = Attachments.extend({
 986		/**
 987		 * @global wp.Uploader
 988		 *
 989		 * @param {Array} [models=[]] Array of models used to populate the collection.
 990		 * @param {Object} [options={}]
 991		 */
 992		initialize: function( models, options ) {
 993			var allowed;
 994
 995			options = options || {};
 996			Attachments.prototype.initialize.apply( this, arguments );
 997
 998			this.args     = options.args;
 999			this._hasMore = true;
1000			this.created  = new Date();
1001
1002			this.filters.order = function( attachment ) {
1003				var orderby = this.props.get('orderby'),
1004					order = this.props.get('order');
1005
1006				if ( ! this.comparator ) {
1007					return true;
1008				}
1009
1010				// We want any items that can be placed before the last
1011				// item in the set. If we add any items after the last
1012				// item, then we can't guarantee the set is complete.
1013				if ( this.length ) {
1014					return 1 !== this.comparator( attachment, this.last(), { ties: true });
1015
1016				// Handle the case where there are no items yet and
1017				// we're sorting for recent items. In that case, we want
1018				// changes that occurred after we created the query.
1019				} else if ( 'DESC' === order && ( 'date' === orderby || 'modified' === orderby ) ) {
1020					return attachment.get( orderby ) >= this.created;
1021
1022				// If we're sorting by menu order and we have no items,
1023				// accept any items that have the default menu order (0).
1024				} else if ( 'ASC' === order && 'menuOrder' === orderby ) {
1025					return attachment.get( orderby ) === 0;
1026				}
1027
1028				// Otherwise, we don't want any items yet.
1029				return false;
1030			};
1031
1032			// Observe the central `wp.Uploader.queue` collection to watch for
1033			// new matches for the query.
1034			//
1035			// Only observe when a limited number of query args are set. There
1036			// are no filters for other properties, so observing will result in
1037			// false positives in those queries.
1038			allowed = [ 's', 'order', 'orderby', 'posts_per_page', 'post_mime_type', 'post_parent' ];
1039			if ( wp.Uploader && _( this.args ).chain().keys().difference( allowed ).isEmpty().value() ) {
1040				this.observe( wp.Uploader.queue );
1041			}
1042		},
1043		/**
1044		 * @returns {Boolean}
1045		 */
1046		hasMore: function() {
1047			return this._hasMore;
1048		},
1049		/**
1050		 * @param {Object} [options={}]
1051		 * @returns {Promise}
1052		 */
1053		more: function( options ) {
1054			var query = this;
1055
1056			if ( this._more && 'pending' === this._more.state() ) {
1057				return this._more;
1058			}
1059
1060			if ( ! this.hasMore() ) {
1061				return $.Deferred().resolveWith( this ).promise();
1062			}
1063
1064			options = options || {};
1065			options.remove = false;
1066
1067			return this._more = this.fetch( options ).done( function( resp ) {
1068				if ( _.isEmpty( resp ) || -1 === this.args.posts_per_page || resp.length < this.args.posts_per_page ) {
1069					query._hasMore = false;
1070				}
1071			});
1072		},
1073		/**
1074		 * Overrides Backbone.Collection.sync
1075		 * Overrides wp.media.model.Attachments.sync
1076		 *
1077		 * @param {String} method
1078		 * @param {Backbone.Model} model
1079		 * @param {Object} [options={}]
1080		 * @returns {Promise}
1081		 */
1082		sync: function( method, model, options ) {
1083			var args, fallback;
1084
1085			// Overload the read method so Attachment.fetch() functions correctly.
1086			if ( 'read' === method ) {
1087				options = options || {};
1088				options.context = this;
1089				options.data = _.extend( options.data || {}, {
1090					action:  'query-attachments',
1091					post_id: media.model.settings.post.id
1092				});
1093
1094				// Clone the args so manipulation is non-destructive.
1095				args = _.clone( this.args );
1096
1097				// Determine which page to query.
1098				if ( -1 !== args.posts_per_page ) {
1099					args.paged = Math.floor( this.length / args.posts_per_page ) + 1;
1100				}
1101
1102				options.data.query = args;
1103				return media.ajax( options );
1104
1105			// Otherwise, fall back to Backbone.sync()
1106			} else {
1107				/**
1108				 * Call wp.media.model.Attachments.sync or Backbone.sync
1109				 */
1110				fallback = Attachments.prototype.sync ? Attachments.prototype : Backbone;
1111				return fallback.sync.apply( this, arguments );
1112			}
1113		}
1114	}, {
1115		/**
1116		 * @readonly
1117		 */
1118		defaultProps: {
1119			orderby: 'date',
1120			order:   'DESC'
1121		},
1122		/**
1123		 * @readonly
1124		 */
1125		defaultArgs: {
1126			posts_per_page: 40
1127		},
1128		/**
1129		 * @readonly
1130		 */
1131		orderby: {
1132			allowed:  [ 'name', 'author', 'date', 'title', 'modified', 'uploadedTo', 'id', 'post__in', 'menuOrder' ],
1133			valuemap: {
1134				'id':         'ID',
1135				'uploadedTo': 'parent',
1136				'menuOrder':  'menu_order ID'
1137			}
1138		},
1139		/**
1140		 * @readonly
1141		 */
1142		propmap: {
1143			'search':    's',
1144			'type':      'post_mime_type',
1145			'perPage':   'posts_per_page',
1146			'menuOrder': 'menu_order',
1147			'uploadedTo': 'post_parent'
1148		},
1149		/**
1150		 * @static
1151		 * @method
1152		 *
1153		 * @returns {wp.media.model.Query} A new query.
1154		 */
1155		// Caches query objects so queries can be easily reused.
1156		get: (function(){
1157			/**
1158			 * @static
1159			 * @type Array
1160			 */
1161			var queries = [];
1162
1163			/**
1164			 * @param {Object} props
1165			 * @param {Object} options
1166			 * @returns {Query}
1167			 */
1168			return function( props, options ) {
1169				var args     = {},
1170					orderby  = Query.orderby,
1171					defaults = Query.defaultProps,
1172					query;
1173
1174				// Remove the `query` property. This isn't linked to a query,
1175				// this *is* the query.
1176				delete props.query;
1177
1178				// Fill default args.
1179				_.defaults( props, defaults );
1180
1181				// Normalize the order.
1182				props.order = props.order.toUpperCase();
1183				if ( 'DESC' !== props.order && 'ASC' !== props.order ) {
1184					props.order = defaults.order.toUpperCase();
1185				}
1186
1187				// Ensure we have a valid orderby value.
1188				if ( ! _.contains( orderby.allowed, props.orderby ) ) {
1189					props.orderby = defaults.orderby;
1190				}
1191
1192				// Generate the query `args` object.
1193				// Correct any differing property names.
1194				_.each( props, function( value, prop ) {
1195					if ( _.isNull( value ) ) {
1196						return;
1197					}
1198
1199					args[ Query.propmap[ prop ] || prop ] = value;
1200				});
1201
1202				// Fill any other default query args.
1203				_.defaults( args, Query.defaultArgs );
1204
1205				// `props.orderby` does not always map directly to `args.orderby`.
1206				// Substitute exceptions specified in orderby.keymap.
1207				args.orderby = orderby.valuemap[ props.orderby ] || props.orderby;
1208
1209				// Search the query cache for matches.
1210				query = _.find( queries, function( query ) {
1211					return _.isEqual( query.args, args );
1212				});
1213
1214				// Otherwise, create a new query and add it to the cache.
1215				if ( ! query ) {
1216					query = new Query( [], _.extend( options || {}, {
1217						props: props,
1218						args:  args
1219					} ) );
1220					queries.push( query );
1221				}
1222
1223				return query;
1224			};
1225		}())
1226	});
1227
1228	/**
1229	 * wp.media.model.Selection
1230	 *
1231	 * Used to manage a selection of attachments in the views.
1232	 *
1233	 * @constructor
1234	 * @augments wp.media.model.Attachments
1235	 * @augments Backbone.Collection
1236	 */
1237	media.model.Selection = Attachments.extend({
1238		/**
1239		 * Refresh the `single` model whenever the selection changes.
1240		 * Binds `single` instead of using the context argument to ensure
1241		 * it receives no parameters.
1242		 *
1243		 * @param {Array} [models=[]] Array of models used to populate the collection.
1244		 * @param {Object} [options={}]
1245		 */
1246		initialize: function( models, options ) {
1247			/**
1248			 * call 'initialize' directly on the parent class
1249			 */
1250			Attachments.prototype.initialize.apply( this, arguments );
1251			this.multiple = options && options.multiple;
1252
1253			this.on( 'add remove reset', _.bind( this.single, this, false ) );
1254		},
1255
1256		/**
1257		 * Override the selection's add method.
1258		 * If the workflow does not support multiple
1259		 * selected attachments, reset the selection.
1260		 *
1261		 * Overrides Backbone.Collection.add
1262		 * Overrides wp.media.model.Attachments.add
1263		 *
1264		 * @param {Array} models
1265		 * @param {Object} options
1266		 * @returns {wp.media.model.Attachment[]}
1267		 */
1268		add: function( models, options ) {
1269			if ( ! this.multiple ) {
1270				this.remove( this.models );
1271			}
1272			/**
1273			 * call 'add' directly on the parent class
1274			 */
1275			return Attachments.prototype.add.call( this, models, options );
1276		},
1277
1278		/**
1279		 * Triggered when toggling (clicking on) an attachment in the modal
1280		 *
1281		 * @param {undefined|boolean|wp.media.model.Attachment} model
1282		 *
1283		 * @fires wp.media.model.Selection#selection:single
1284		 * @fires wp.media.model.Selection#selection:unsingle
1285		 *
1286		 * @returns {Backbone.Model}
1287		 */
1288		single: function( model ) {
1289			var previous = this._single;
1290
1291			// If a `model` is provided, use it as the single model.
1292			if ( model ) {
1293				this._single = model;
1294			}
1295			// If the single model isn't in the selection, remove it.
1296			if ( this._single && ! this.get( this._single.cid ) ) {
1297				delete this._single;
1298			}
1299
1300			this._single = this._single || this.last();
1301
1302			// If single has changed, fire an event.
1303			if ( this._single !== previous ) {
1304				if ( previous ) {
1305					previous.trigger( 'selection:unsingle', previous, this );
1306
1307					// If the model was already removed, trigger the collection
1308					// event manually.
1309					if ( ! this.get( previous.cid ) ) {
1310						this.trigger( 'selection:unsingle', previous, this );
1311					}
1312				}
1313				if ( this._single ) {
1314					this._single.trigger( 'selection:single', this._single, this );
1315				}
1316			}
1317
1318			// Return the single model, or the last model as a fallback.
1319			return this._single;
1320		}
1321	});
1322
1323	// Clean up. Prevents mobile browsers caching
1324	$(window).on('unload', function(){
1325		window.wp = null;
1326	});
1327
1328}(jQuery));