PageRenderTime 8ms CodeModel.GetById 151ms app.highlight 319ms RepoModel.GetById 138ms app.codeStats 1ms

/ajax/libs/backbone-pageable/1.1.6/backbone-pageable.js

https://bitbucket.org/kolbyjAFK/cdnjs
JavaScript | 1237 lines | 613 code | 155 blank | 469 comment | 223 complexity | 1e671dfbfdbb4634ac48527bad2382c4 MD5 | raw file
   1/*
   2  backbone-pageable
   3  http://github.com/wyuenho/backbone-pageable
   4
   5  Copyright (c) 2013 Jimmy Yuen Ho Wong
   6  Licensed under the MIT @license.
   7*/
   8
   9(function (factory) {
  10
  11  // CommonJS
  12  if (typeof exports == "object") {
  13    module.exports = factory(require("underscore"), require("backbone"));
  14  }
  15  // AMD
  16  else if (typeof define == "function" && define.amd) {
  17    define(["underscore", "backbone"], factory);
  18  }
  19  // Browser
  20  else if (typeof _ !== "undefined" && typeof Backbone !== "undefined") {
  21    var oldPageableCollection = Backbone.PageableCollection;
  22    var PageableCollection = Backbone.PageableCollection = factory(_, Backbone);
  23
  24    /**
  25       __BROWSER ONLY__
  26
  27       If you already have an object named `PageableCollection` attached to the
  28       `Backbone` module, you can use this to return a local reference to this
  29       Backbone.PageableCollection class and reset the name
  30       Backbone.PageableCollection to its previous definition.
  31
  32           // The left hand side gives you a reference to this
  33           // Backbone.PageableCollection implementation, the right hand side
  34           // resets Backbone.PageableCollection to your other
  35           // Backbone.PageableCollection.
  36           var PageableCollection = Backbone.PageableCollection.noConflict();
  37
  38       @static
  39       @member Backbone.PageableCollection
  40       @return {Backbone.PageableCollection}
  41    */
  42    Backbone.PageableCollection.noConflict = function () {
  43      Backbone.PageableCollection = oldPageableCollection;
  44      return PageableCollection;
  45    };
  46  }
  47
  48}(function (_, Backbone) {
  49
  50  "use strict";
  51
  52  var _extend = _.extend;
  53  var _omit = _.omit;
  54  var _clone = _.clone;
  55  var _each = _.each;
  56  var _pick = _.pick;
  57  var _contains = _.contains;
  58  var _isEmpty = _.isEmpty;
  59  var _pairs = _.pairs;
  60  var _invert = _.invert;
  61  var _isArray = _.isArray;
  62  var _isFunction = _.isFunction;
  63  var _keys = _.keys;
  64  var _isUndefined = _.isUndefined;
  65  var ceil = Math.ceil;
  66
  67  var BBColProto = Backbone.Collection.prototype;
  68
  69  function finiteInt (val, name) {
  70    val *= 1;
  71    if (!_.isNumber(val) || _.isNaN(val) || !_.isFinite(val) || ~~val !== val) {
  72      throw new TypeError("`" + name + "` must be a finite integer");
  73    }
  74    return val;
  75  }
  76
  77  function queryStringToParams (qs) {
  78    var kvp, k, v, ls, params = {}, decode = decodeURIComponent;
  79    var kvps = qs.split('&');
  80    for (var i = 0, l = kvps.length; i < l; i++) {
  81      var param = kvps[i];
  82      kvp = param.split('='), k = kvp[0], v = kvp[1] || true;
  83      k = decode(k), ls = params[k];
  84      if (_isArray(ls)) ls.push(v);
  85      else if (ls) params[k] = [ls, v];
  86      else params[k] = v;
  87    }
  88    return params;
  89  }
  90
  91  // Quickly reset a collection by temporarily detaching the comparator of the
  92  // given collection, reset and then attach the comparator back to the
  93  // collection and sort.
  94
  95  // @param {Backbone.Collection} collection
  96  // @param {...*} resetArgs
  97  // @return {Backbone.Collection} collection The same collection instance after
  98  // reset.
  99  function resetQuickly () {
 100
 101    var collection = arguments[0];
 102    var resetArgs = _.toArray(arguments).slice(1);
 103
 104    var comparator = collection.comparator;
 105    collection.comparator = null;
 106
 107    try {
 108      collection.reset.apply(collection, resetArgs);
 109    }
 110    finally {
 111      collection.comparator = comparator;
 112      if (comparator) collection.sort();
 113    }
 114
 115    return collection;
 116  }
 117
 118  var PARAM_TRIM_RE = /[\s'"]/g;
 119  var URL_TRIM_RE = /[<>\s'"]/g;
 120
 121  /**
 122     Drop-in replacement for Backbone.Collection. Supports server-side and
 123     client-side pagination and sorting. Client-side mode also support fully
 124     multi-directional synchronization of changes between pages.
 125
 126     @class Backbone.PageableCollection
 127     @extends Backbone.Collection
 128  */
 129  var PageableCollection = Backbone.Collection.extend({
 130
 131    /**
 132       The container object to store all pagination states.
 133
 134       You can override the default state by extending this class or specifying
 135       them in an `options` hash to the constructor.
 136
 137       @property {Object} state
 138
 139       @property {0|1} [state.firstPage=1] The first page index. Set to 0 if
 140       your server API uses 0-based indices. You should only override this value
 141       during extension, initialization or reset by the server after
 142       fetching. This value should be read only at other times.
 143
 144       @property {number} [state.lastPage=null] The last page index. This value
 145       is __read only__ and it's calculated based on whether `firstPage` is 0 or
 146       1, during bootstrapping, fetching and resetting. Please don't change this
 147       value under any circumstances.
 148
 149       @property {number} [state.currentPage=null] The current page index. You
 150       should only override this value during extension, initialization or reset
 151       by the server after fetching. This value should be read only at other
 152       times. Can be a 0-based or 1-based index, depending on whether
 153       `firstPage` is 0 or 1. If left as default, it will be set to `firstPage`
 154       on initialization.
 155
 156       @property {number} [state.pageSize=25] How many records to show per
 157       page. This value is __read only__ after initialization, if you want to
 158       change the page size after initialization, you must call #setPageSize.
 159
 160       @property {number} [state.totalPages=null] How many pages there are. This
 161       value is __read only__ and it is calculated from `totalRecords`.
 162
 163       @property {number} [state.totalRecords=null] How many records there
 164       are. This value is __required__ under server mode. This value is optional
 165       for client mode as the number will be the same as the number of models
 166       during bootstrapping and during fetching, either supplied by the server
 167       in the metadata, or calculated from the size of the response.
 168
 169       @property {string} [state.sortKey=null] The model attribute to use for
 170       sorting.
 171
 172       @property {-1|0|1} [state.order=-1] The order to use for sorting. Specify
 173       -1 for ascending order or 1 for descending order. If 0, no client side
 174       sorting will be done and the order query parameter will not be sent to
 175       the server during a fetch.
 176    */
 177    state: {
 178      firstPage: 1,
 179      lastPage: null,
 180      currentPage: null,
 181      pageSize: 25,
 182      totalPages: null,
 183      totalRecords: null,
 184      sortKey: null,
 185      order: -1
 186    },
 187
 188    /**
 189       @property {"server"|"client"|"infinite"} [mode="server"] The mode of
 190       operations for this collection. `"server"` paginates on the server-side,
 191       `"client"` paginates on the client-side and `"infinite"` paginates on the
 192       server-side for APIs that do not support `totalRecords`.
 193    */
 194    mode: "server",
 195
 196    /**
 197       A translation map to convert Backbone.PageableCollection state attributes
 198       to the query parameters accepted by your server API.
 199
 200       You can override the default state by extending this class or specifying
 201       them in `options.queryParams` object hash to the constructor.
 202
 203       @property {Object} queryParams
 204       @property {string} [queryParams.currentPage="page"]
 205       @property {string} [queryParams.pageSize="per_page"]
 206       @property {string} [queryParams.totalPages="total_pages"]
 207       @property {string} [queryParams.totalRecords="total_entries"]
 208       @property {string} [queryParams.sortKey="sort_by"]
 209       @property {string} [queryParams.order="order"]
 210       @property {string} [queryParams.directions={"-1": "asc", "1": "desc"}] A
 211       map for translating a Backbone.PageableCollection#state.order constant to
 212       the ones your server API accepts.
 213    */
 214    queryParams: {
 215      currentPage: "page",
 216      pageSize: "per_page",
 217      totalPages: "total_pages",
 218      totalRecords: "total_entries",
 219      sortKey: "sort_by",
 220      order: "order",
 221      directions: {
 222        "-1": "asc",
 223        "1": "desc"
 224      }
 225    },
 226
 227    /**
 228       __CLIENT MODE ONLY__
 229
 230       This collection is the internal storage for the bootstrapped or fetched
 231       models. You can use this if you want to operate on all the pages.
 232
 233       @property {Backbone.Collection} fullCollection
 234    */
 235
 236    /**
 237       Given a list of models or model attributues, bootstraps the full
 238       collection in client mode or infinite mode, or just the page you want in
 239       server mode.
 240
 241       If you want to initialize a collection to a different state than the
 242       default, you can specify them in `options.state`. Any state parameters
 243       supplied will be merged with the default. If you want to change the
 244       default mapping from #state keys to your server API's query parameter
 245       names, you can specifiy an object hash in `option.queryParams`. Likewise,
 246       any mapping provided will be merged with the default. Lastly, all
 247       Backbone.Collection constructor options are also accepted.
 248
 249       See:
 250
 251       - Backbone.PageableCollection#state
 252       - Backbone.PageableCollection#queryParams
 253       - [Backbone.Collection#initialize](http://backbonejs.org/#Collection-constructor)
 254
 255       @param {Array.<Object>} [models]
 256
 257       @param {Object} [options]
 258
 259       @param {function(*, *): number} [options.comparator] If specified, this
 260       comparator is set to the current page under server mode, or the
 261       #fullCollection otherwise.
 262
 263       @param {boolean} [options.full] If `false` and either a
 264       `options.comparator` or `sortKey` is defined, the comparator is attached
 265       to the current page. Default is `true` under client or infinite mode and
 266       the comparator will be attached to the #fullCollection.
 267
 268       @param {Object} [options.state] The state attributes overriding the defaults.
 269
 270       @param {string} [options.state.sortKey] The model attribute to use for
 271       sorting. If specified instead of `options.comparator`, a comparator will
 272       be automatically created using this value, and optionally a sorting order
 273       specified in `options.state.order`. The comparator is then attached to
 274       the new collection instance.
 275
 276       @param {-1|1} [options.state.order] The order to use for sorting. Specify
 277       -1 for ascending order and 1 for descending order.
 278
 279       @param {Object} [options.queryParam]
 280    */
 281    initialize: function (models, options) {
 282
 283      options = options || {};
 284
 285      var mode = this.mode = options.mode || this.mode || PageableProto.mode;
 286
 287      var queryParams = _extend({}, PageableProto.queryParams, this.queryParams,
 288                                options.queryParams || {});
 289
 290      queryParams.directions = _extend({},
 291                                       PageableProto.queryParams.directions,
 292                                       this.queryParams.directions,
 293                                       queryParams.directions || {});
 294
 295      this.queryParams = queryParams;
 296
 297      var state = this.state = _extend({}, PageableProto.state, this.state,
 298                                       options.state || {});
 299
 300      state.currentPage = state.currentPage == null ?
 301        state.firstPage :
 302        state.currentPage;
 303
 304      if (mode != "server" && state.totalRecords == null && !_isEmpty(models)) {
 305        state.totalRecords = models.length;
 306      }
 307
 308      this.switchMode(mode, _extend({fetch: false,
 309                                     resetState: false,
 310                                     models: models}, options));
 311
 312      var comparator = options.comparator;
 313
 314      if (state.sortKey && !comparator) {
 315        this.setSorting(state.sortKey, state.order, options);
 316      }
 317
 318      if (mode != "server") {
 319
 320        if (comparator && options.full) {
 321          delete this.comparator;
 322          var fullCollection = this.fullCollection;
 323          fullCollection.comparator = comparator;
 324          fullCollection.sort();
 325        }
 326
 327        // make sure the models in the current page and full collection have the
 328        // same references
 329        if (models && !_isEmpty(models)) {
 330          this.getPage(state.currentPage);
 331          models.splice.apply(models, [0, models.length].concat(this.models));
 332        }
 333      }
 334
 335      this._initState = _clone(this.state);
 336    },
 337
 338    /**
 339       Makes a Backbone.Collection that contains all the pages.
 340
 341       @private
 342       @param {Array.<Object|Backbone.Model>} models
 343       @param {Object} options Options for Backbone.Collection constructor.
 344       @return {Backbone.Collection}
 345    */
 346    _makeFullCollection: function (models, options) {
 347
 348      var properties = ["url", "model", "sync", "comparator"];
 349      var thisProto = this.constructor.prototype;
 350      var i, length, prop;
 351
 352      var proto = {};
 353      for (i = 0, length = properties.length; i < length; i++) {
 354        prop = properties[i];
 355        if (!_isUndefined(thisProto[prop])) {
 356          proto[prop] = thisProto[prop];
 357        }
 358      }
 359
 360      var fullCollection = new (Backbone.Collection.extend(proto))(models, options);
 361
 362      for (i = 0, length = properties.length; i < length; i++) {
 363        prop = properties[i];
 364        if (this[prop] !== thisProto[prop]) {
 365          fullCollection[prop] = prop;
 366        }
 367      }
 368
 369      return fullCollection;
 370    },
 371
 372    /**
 373       Factory method that returns a Backbone event handler that responses to
 374       the `all` event. The returned event handler will synchronize the current
 375       page collection and the full collection's models.
 376
 377       @private
 378
 379       @param {Backbone.PageableCollection} pageCol
 380       @param {Backbone.Collection} fullCol
 381
 382       @return {function(string, Backbone.Model, Backbone.Collection, Object)}
 383       Collection event handler
 384    */
 385    _makeCollectionEventHandler: function (pageCol, fullCol) {
 386
 387      return function collectionEventHandler (event, model, collection, options) {
 388
 389        var handlers = pageCol._handlers;
 390        _each(_keys(handlers), function (event) {
 391          var handler = handlers[event];
 392          pageCol.off(event, handler);
 393          fullCol.off(event, handler);
 394        });
 395
 396        var state = _clone(pageCol.state);
 397        var firstPage = state.firstPage;
 398        var currentPage = firstPage === 0 ?
 399          state.currentPage :
 400          state.currentPage - 1;
 401        var pageSize = state.pageSize;
 402        var pageStart = currentPage * pageSize, pageEnd = pageStart + pageSize;
 403
 404        if (event == "add") {
 405          var pageIndex, fullIndex, addAt, colToAdd, options = options || {};
 406          if (collection == fullCol) {
 407            fullIndex = fullCol.indexOf(model);
 408            if (fullIndex >= pageStart && fullIndex < pageEnd) {
 409              colToAdd = pageCol;
 410              pageIndex = addAt = fullIndex - pageStart;
 411            }
 412          }
 413          else {
 414            pageIndex = pageCol.indexOf(model);
 415            fullIndex = pageStart + pageIndex;
 416            colToAdd = fullCol;
 417            var addAt = !_isUndefined(options.at) ?
 418              options.at + pageStart :
 419              fullIndex;
 420          }
 421
 422          ++state.totalRecords;
 423          pageCol.state = pageCol._checkState(state);
 424
 425          if (colToAdd) {
 426            colToAdd.add(model, _extend({}, options || {}, {at: addAt}));
 427            var modelToRemove = pageIndex >= pageSize ?
 428              model :
 429              !_isUndefined(options.at) && addAt < pageEnd && pageCol.length > pageSize ?
 430              pageCol.at(pageSize) :
 431              null;
 432            if (modelToRemove) {
 433              var addHandlers = collection._events.add,
 434              popOptions = {onAdd: true};
 435              if (addHandlers.length) {
 436                var lastAddHandler = addHandlers[addHandlers.length - 1];
 437                var oldCallback = lastAddHandler.callback;
 438                lastAddHandler.callback = function () {
 439                  try {
 440                    oldCallback.apply(this, arguments);
 441                    pageCol.remove(modelToRemove, popOptions);
 442                  }
 443                  finally {
 444                    lastAddHandler.callback = oldCallback;
 445                  }
 446                };
 447              }
 448              else pageCol.remove(modelToRemove, popOptions);
 449            }
 450          }
 451        }
 452
 453        // remove the model from the other collection as well
 454        if (event == "remove") {
 455          if (!options.onAdd) {
 456            // decrement totalRecords and update totalPages and lastPage
 457            if (!--state.totalRecords) {
 458              state.totalRecords = null;
 459              state.totalPages = null;
 460            }
 461            else {
 462              var totalPages = state.totalPages = ceil(state.totalRecords / pageSize);
 463              state.lastPage = firstPage === 0 ? totalPages - 1 : totalPages;
 464              if (state.currentPage > totalPages) state.currentPage = state.lastPage;
 465            }
 466            pageCol.state = pageCol._checkState(state);
 467
 468            var nextModel, removedIndex = options.index;
 469            if (collection == pageCol) {
 470              if (nextModel = fullCol.at(pageEnd)) pageCol.push(nextModel);
 471              fullCol.remove(model);
 472            }
 473            else if (removedIndex >= pageStart && removedIndex < pageEnd) {
 474              pageCol.remove(model);
 475              nextModel = fullCol.at(currentPage * (pageSize + removedIndex));
 476              if (nextModel) pageCol.push(nextModel);
 477            }
 478          }
 479          else delete options.onAdd;
 480        }
 481
 482        if (event == "reset" || event == "sort") {
 483          options = collection;
 484          collection = model;
 485
 486          if (collection == pageCol && event == "reset") {
 487            var head = fullCol.models.slice(0, pageStart);
 488            var tail = fullCol.models.slice(pageStart + pageCol.models.length);
 489            options = _extend(options, {silent: true});
 490            resetQuickly(fullCol, head.concat(pageCol.models).concat(tail),
 491                         options);
 492          }
 493
 494          if (event == "reset" || collection == fullCol) {
 495            if (!(state.totalRecords = fullCol.models.length)) {
 496              state.totalRecords = null;
 497              state.totalPages = null;
 498              state.lastPage = state.currentPage = state.firstPage;
 499            }
 500            pageCol.state = pageCol._checkState(state);
 501            if (collection == pageCol) fullCol.trigger(event, fullCol, options);
 502            resetQuickly(pageCol, fullCol.models.slice(pageStart, pageEnd),
 503                         options);
 504          }
 505        }
 506
 507        _each(_keys(handlers), function (event) {
 508          var handler = handlers[event];
 509          _each([pageCol, fullCol], function (col) {
 510            col.on(event, handler);
 511            var callbacks = col._events[event];
 512            callbacks.unshift(callbacks.pop());
 513          });
 514        });
 515      };
 516    },
 517
 518    /**
 519       Sanity check this collection's pagination states. Only perform checks
 520       when all the required pagination state values are defined and not null.
 521       If `totalPages` is undefined or null, it is set to `totalRecords` /
 522       `pageSize`. `lastPage` is set according to whether `firstPage` is 0 or 1
 523       when no error occurs.
 524
 525       @private
 526
 527       @throws {TypeError} If `totalRecords`, `pageSize`, `currentPage` or
 528       `firstPage` is not a finite integer.
 529
 530       @throws {RangeError} If `pageSize`, `currentPage` or `firstPage` is out
 531       of bounds.
 532
 533       @return {Object} Returns the `state` object if no error was found.
 534    */
 535    _checkState: function (state) {
 536
 537      var mode = this.mode;
 538      var links = this.links;
 539      var totalRecords = state.totalRecords;
 540      var pageSize = state.pageSize;
 541      var currentPage = state.currentPage;
 542      var firstPage = state.firstPage;
 543      var totalPages = state.totalPages;
 544
 545      if (totalRecords != null && pageSize != null && currentPage != null &&
 546          firstPage != null && (mode == "infinite" ? links : true)) {
 547
 548        totalRecords = finiteInt(totalRecords, "totalRecords");
 549        pageSize = finiteInt(pageSize, "pageSize");
 550        currentPage = finiteInt(currentPage, "currentPage");
 551        firstPage = finiteInt(firstPage, "firstPage");
 552
 553        if (pageSize < 1) {
 554          throw new RangeError("`pageSize` must be >= 1");
 555        }
 556
 557        totalPages = state.totalPages = ceil(totalRecords / pageSize);
 558
 559        if (firstPage < 0 || firstPage > 1) {
 560          throw new RangeError("`firstPage must be 0 or 1`");
 561        }
 562
 563        state.lastPage = firstPage === 0 ? totalPages - 1 : totalPages;
 564
 565        if (mode == "infinite") {
 566          if (!links[currentPage + '']) {
 567            throw new RangeError("No link found for page " + currentPage);
 568          }
 569        }
 570        else {
 571          if (firstPage === 0 && (currentPage < firstPage || currentPage >= totalPages)) {
 572            throw new RangeError("`currentPage` must be firstPage <= currentPage < totalPages if 0-based. Got " + currentPage + '.');
 573          }
 574          else if (firstPage === 1 && (currentPage < firstPage || currentPage > totalPages)) {
 575            throw new RangeError("`currentPage` must be firstPage <= currentPage <= totalPages if 1-based. Got " + currentPage + '.');
 576          }
 577        }
 578      }
 579
 580      return state;
 581    },
 582
 583    /**
 584       Change the page size of this collection.
 585
 586       For server mode operations, changing the page size will trigger a #fetch
 587       and subsequently a `reset` event.
 588
 589       For client mode operations, changing the page size will `reset` the
 590       current page by recalculating the current page boundary on the client
 591       side.
 592
 593       If `options.fetch` is true, a fetch can be forced if the collection is in
 594       client mode.
 595
 596       @param {number} pageSize The new page size to set to #state.
 597       @param {Object} [options] {@link #fetch} options.
 598       @param {boolean} [options.fetch] If `true`, force a fetch in client mode.
 599
 600       @throws {TypeError} If `pageSize` is not a finite integer.
 601       @throws {RangeError} If `pageSize` is less than 1.
 602
 603       @chainable
 604       @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
 605       from fetch or this.
 606    */
 607    setPageSize: function (pageSize, options) {
 608      pageSize = finiteInt(pageSize, "pageSize");
 609
 610      options = options || {};
 611
 612      this.state = this._checkState(_extend({}, this.state, {
 613        pageSize: pageSize,
 614        totalPages: ceil(this.state.totalRecords / pageSize)
 615      }));
 616
 617      return this.getPage(this.state.currentPage, options);
 618    },
 619
 620    /**
 621       Switching between client, server and infinite mode.
 622
 623       If switching from client to server mode, the #fullCollection is emptied
 624       first and then deleted and a fetch is immediately issued for the current
 625       page from the server. Pass `false` to `options.fetch` to skip fetching.
 626
 627       If switching to infinite mode, and if `options.models` is given for an
 628       array of models, #links will be populated with a URL per page, using the
 629       default URL for this collection.
 630
 631       If switching from server to client mode, all of the pages are immediately
 632       refetched. If you have too many pages, you can pass `false` to
 633       `options.fetch` to skip fetching.
 634
 635       If switching to any mode from infinite mode, the #links will be deleted.
 636
 637       @param {"server"|"client"|"infinite"} [mode] The mode to switch to.
 638
 639       @param {Object} [options]
 640
 641       @param {boolean} [options.fetch=true] If `false`, no fetching is done.
 642
 643       @param {boolean} [options.resetState=true] If 'false', the state is not
 644       reset, but checked for sanity instead.
 645
 646       @chainable
 647       @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
 648       from fetch or this if `options.fetch` is `false`.
 649    */
 650    switchMode: function (mode, options) {
 651
 652      if (!_contains(["server", "client", "infinite"], mode)) {
 653        throw new TypeError('`mode` must be one of "server", "client" or "infinite"');
 654      }
 655
 656      options = options || {fetch: true, resetState: true};
 657
 658      var state = this.state = options.resetState ?
 659        _clone(this._initState) :
 660        this._checkState(_extend({}, this.state));
 661
 662      this.mode = mode;
 663
 664      var self = this;
 665      var fullCollection = this.fullCollection;
 666      var handlers = this._handlers = this._handlers || {}, handler;
 667      if (mode != "server" && !fullCollection) {
 668        fullCollection = this._makeFullCollection(options.models || []);
 669        fullCollection.pageableCollection = this;
 670        this.fullCollection = fullCollection;
 671        var allHandler = this._makeCollectionEventHandler(this, fullCollection);
 672        _each(["add", "remove", "reset", "sort"], function (event) {
 673          handlers[event] = handler = _.bind(allHandler, {}, event);
 674          self.on(event, handler);
 675          fullCollection.on(event, handler);
 676        });
 677        fullCollection.comparator = this._fullComparator;
 678      }
 679      else if (mode == "server" && fullCollection) {
 680        _each(_keys(handlers), function (event) {
 681          handler = handlers[event];
 682          self.off(event, handler);
 683          fullCollection.off(event, handler);
 684        });
 685        delete this._handlers;
 686        this._fullComparator = fullCollection.comparator;
 687        delete this.fullCollection;
 688      }
 689
 690      if (mode == "infinite") {
 691        var links = this.links = {};
 692        var firstPage = state.firstPage;
 693        var totalPages = ceil(state.totalRecords / state.pageSize);
 694        var lastPage = firstPage === 0 ? totalPages - 1 : totalPages || firstPage;
 695        for (var i = state.firstPage; i <= lastPage; i++) {
 696          links[i] = this.url;
 697        }
 698      }
 699      else if (this.links) delete this.links;
 700
 701      return options.fetch ?
 702        this.fetch(_omit(options, "fetch", "resetState")) :
 703        this;
 704    },
 705
 706    /**
 707       @return {boolean} `true` if this collection can page backward, `false`
 708       otherwise.
 709    */
 710    hasPrevious: function () {
 711      var state = this.state;
 712      var currentPage = state.currentPage;
 713      if (this.mode != "infinite") return currentPage > state.firstPage;
 714      return !!this.links[currentPage - 1];
 715    },
 716
 717    /**
 718       @return {boolean} `true` if this collection can page forward, `false`
 719       otherwise.
 720    */
 721    hasNext: function () {
 722      var state = this.state;
 723      var currentPage = this.state.currentPage;
 724      if (this.mode != "infinite") return currentPage < state.lastPage;
 725      return !!this.links[currentPage + 1];
 726    },
 727
 728    /**
 729       Fetch the first page in server mode, or reset the current page of this
 730       collection to the first page in client or infinite mode.
 731
 732       @param {Object} options {@link #getPage} options.
 733
 734       @chainable
 735       @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
 736       from fetch or this.
 737    */
 738    getFirstPage: function (options) {
 739      return this.getPage("first", options);
 740    },
 741
 742    /**
 743       Fetch the previous page in server mode, or reset the current page of this
 744       collection to the previous page in client or infinite mode.
 745
 746       @param {Object} options {@link #getPage} options.
 747
 748       @chainable
 749       @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
 750       from fetch or this.
 751    */
 752    getPreviousPage: function (options) {
 753      return this.getPage("prev", options);
 754    },
 755
 756    /**
 757       Fetch the next page in server mode, or reset the current page of this
 758       collection to the next page in client mode.
 759
 760       @param {Object} options {@link #getPage} options.
 761
 762       @chainable
 763       @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
 764       from fetch or this.
 765    */
 766    getNextPage: function (options) {
 767      return this.getPage("next", options);
 768    },
 769
 770    /**
 771       Fetch the last page in server mode, or reset the current page of this
 772       collection to the last page in client mode.
 773
 774       @param {Object} options {@link #getPage} options.
 775
 776       @chainable
 777       @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
 778       from fetch or this.
 779    */
 780    getLastPage: function (options) {
 781      return this.getPage("last", options);
 782    },
 783
 784    /**
 785       Given a page index, set #state.currentPage to that index. If this
 786       collection is in server mode, fetch the page using the updated state,
 787       otherwise, reset the current page of this collection to the page
 788       specified by `index` in client mode. If `options.fetch` is true, a fetch
 789       can be forced in client mode before resetting the current page. Under
 790       infinite mode, if the index is less than the current page, a reset is
 791       done as in client mode. If the index is greater than the current page
 792       number, a fetch is made with the results **appended** to
 793       #fullCollection. The current page will then be reset after fetching.
 794
 795       @param {number|string} index The page index to go to, or the page name to
 796       look up from #links in infinite mode.
 797       @param {Object} [options] {@link #fetch} options or
 798       [reset](http://backbonejs.org/#Collection-reset) options for client mode
 799       when `options.fetch` is `false`.
 800       @param {boolean} [options.fetch=false] If true, force a {@link #fetch} in
 801       client mode.
 802
 803       @throws {TypeError} If `index` is not a finite integer under server or
 804       client mode, or does not yield a URL from #links under infinite mode.
 805
 806       @throws {RangeError} If `index` is out of bounds.
 807
 808       @chainable
 809       @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
 810       from fetch or this.
 811    */
 812    getPage: function (index, options) {
 813
 814      var mode = this.mode, fullCollection = this.fullCollection;
 815
 816      options = options || {fetch: false};
 817
 818      var state = this.state,
 819      firstPage = state.firstPage,
 820      currentPage = state.currentPage,
 821      lastPage = state.lastPage,
 822      pageSize = state.pageSize;
 823
 824      var pageNum = index;
 825      switch (index) {
 826        case "first": pageNum = firstPage; break;
 827        case "prev": pageNum = currentPage - 1; break;
 828        case "next": pageNum = currentPage + 1; break;
 829        case "last": pageNum = lastPage; break;
 830        default: pageNum = finiteInt(index, "index");
 831      }
 832
 833      this.state = this._checkState(_extend({}, state, {currentPage: pageNum}));
 834
 835      var pageStart = (firstPage === 0 ? pageNum : pageNum - 1) * pageSize;
 836      var pageModels = fullCollection && fullCollection.length ?
 837        fullCollection.models.slice(pageStart, pageStart + pageSize) :
 838        [];
 839      if ((mode == "client" || (mode == "infinite" && !_isEmpty(pageModels))) &&
 840          !options.fetch) {
 841        return resetQuickly(this, pageModels, _omit(options, "fetch"));
 842      }
 843
 844      if (mode == "infinite") options.url = this.links[pageNum];
 845
 846      return this.fetch(_omit(options, "fetch"));
 847    },
 848
 849    /**
 850       Overidden to make `getPage` compatible with Zepto.
 851
 852       @param {string} method
 853       @param {Backbone.Model|Backbone.Collection} model
 854       @param {Object} [options]
 855
 856       @return {XMLHttpRequest}
 857    */
 858    sync: function (method, model, options) {
 859      var self = this;
 860      if (self.mode == "infinite") {
 861        var success = options.success;
 862        var currentPage = self.state.currentPage;
 863        options.success = function (resp, status, xhr) {
 864          var links = self.links;
 865          var newLinks = self.parseLinks(resp, _extend({xhr: xhr}, options));
 866          if (newLinks.first) links[self.state.firstPage] = newLinks.first;
 867          if (newLinks.prev) links[currentPage - 1] = newLinks.prev;
 868          if (newLinks.next) links[currentPage + 1] = newLinks.next;
 869          if (success) success(resp, status, xhr);
 870        };
 871      }
 872
 873      return (BBColProto.sync || Backbone.sync).call(self, method, model, options);
 874    },
 875
 876    /**
 877       Parse pagination links from the server response. Only valid under
 878       infinite mode.
 879
 880       Given a response body and a XMLHttpRequest object, extract pagination
 881       links from them for infinite paging.
 882
 883       This default implementation parses the RFC 5988 `Link` header and extract
 884       3 links from it - `first`, `prev`, `next`. If a `previous` link is found,
 885       it will be found in the `prev` key in the returned object hash. Any
 886       subclasses overriding this method __must__ return an object hash having
 887       only the keys above. If `first` is missing, the collection's default URL
 888       is assumed to be the `first` URL. If `prev` or `next` is missing, it is
 889       assumed to be `null`. An empty object hash must be returned if there are
 890       no links found. If either the response or the header contains information
 891       pertaining to the total number of records on the server,
 892       #state.totalRecords must be set to that number. The default
 893       implementation uses the `last` link from the header to calculate it.
 894
 895       @param {*} resp The deserialized response body.
 896       @param {Object} [options]
 897       @param {XMLHttpRequest} [options.xhr] The XMLHttpRequest object for this
 898       response.
 899       @return {Object}
 900    */
 901    parseLinks: function (resp, options) {
 902      var linkHeader = options.xhr.getResponseHeader("Link");
 903      var relations = ["first", "prev", "previous", "next", "last"];
 904      var links = {};
 905      _each(linkHeader.split(","), function (linkValue) {
 906        var linkParts = linkValue.split(";");
 907        var url = linkParts[0].replace(URL_TRIM_RE, '');
 908        var params = linkParts.slice(1);
 909        _each(params, function (param) {
 910          var paramParts = param.split("=");
 911          var key = paramParts[0].replace(PARAM_TRIM_RE, '');
 912          var value = paramParts[1].replace(PARAM_TRIM_RE, '');
 913          if (key == "rel" && _contains(relations, value)) {
 914            if (value == "previous") links.prev = url;
 915            else links[value] = url;
 916          }
 917        });
 918      });
 919
 920      var last = links.last || '', qsi, qs;
 921      if (qs = (qsi = last.indexOf('?')) ? last.slice(qsi + 1) : '') {
 922        var params = queryStringToParams(qs);
 923
 924        var state = _clone(this.state);
 925        var queryParams = this.queryParams;
 926        var pageSize = state.pageSize;
 927
 928        var totalRecords = params[queryParams.totalRecords] * 1;
 929        var pageNum = params[queryParams.currentPage] * 1;
 930        var totalPages = params[queryParams.totalPages];
 931
 932        if (!totalRecords) {
 933          if (pageNum) totalRecords = (state.firstPage === 0 ?
 934                                       pageNum + 1 :
 935                                       pageNum) * pageSize;
 936          else if (totalPages) totalRecords = totalPages * pageSize;
 937        }
 938
 939        if (totalRecords) state.totalRecords = totalRecords;
 940
 941        this.state = this._checkState(state);
 942      }
 943
 944      delete links.last;
 945
 946      return links;
 947    },
 948
 949    /**
 950       Parse server response data.
 951
 952       This default implementation assumes the response data is in one of two
 953       structures:
 954
 955           [
 956             {}, // Your new pagination state
 957             [{}, ...] // An array of JSON objects
 958           ]
 959
 960       Or,
 961
 962           [{}] // An array of JSON objects
 963
 964       The first structure is the preferred form because the pagination states
 965       may have been updated on the server side, sending them down again allows
 966       this collection to update its states. If the response has a pagination
 967       state object, it is checked for errors.
 968
 969       The second structure is the
 970       [Backbone.Collection#parse](http://backbonejs.org/#Collection-parse)
 971       default.
 972
 973       @param {Array} resp The deserialized response data from the server.
 974
 975       @throws {TypeError} If the `resp` is not an array.
 976
 977       @return {Array.<Object>} An array of model objects
 978    */
 979    parse: function (resp) {
 980
 981      if (!_isArray(resp)) {
 982        return new TypeError("The server response must be an array");
 983      }
 984
 985      if (resp.length === 2 && _.isObject(resp[0]) && _isArray(resp[1])) {
 986
 987        var queryParams = this.queryParams;
 988        var newState = _clone(this.state);
 989        var serverState = resp[0];
 990
 991        _each(_pairs(_omit(queryParams, "directions")), function (kvp) {
 992          var k = kvp[0], v = kvp[1];
 993          newState[k] = serverState[v];
 994        });
 995
 996        if (serverState.order) {
 997          newState.order = _invert(queryParams.directions)[serverState.order] * 1;
 998        }
 999
1000        this.state = this._checkState(newState);
1001
1002        return resp[1];
1003      }
1004
1005      return resp;
1006    },
1007
1008    /**
1009       Fetch a page from the server in server mode, or all the pages in client
1010       mode. Under infinite mode, the current page is refetched by default and
1011       then reset.
1012
1013       The query string is constructed by translating the current pagination
1014       state to your server API query parameter using #queryParams.  The current
1015       page will reset after fetch.
1016
1017       @param {Object} [options] Accepts all
1018       [Backbone.Collection#fetch](http://backbonejs.org/#Collection-fetch)
1019       options.
1020
1021       @return {XMLHttpRequest}
1022    */
1023    fetch: function (options) {
1024
1025      options = options || {};
1026
1027      var state = this._checkState(this.state);
1028
1029      var mode = this.mode;
1030
1031      if (mode == "infinite" && !options.url) {
1032        options.url = this.links[state.currentPage];
1033      }
1034
1035      var data = options.data || {};
1036
1037      // dedup query params
1038      var url = options.url || _.result(this, "url") || '';
1039      var qsi = url.indexOf('?');
1040      if (qsi != -1) {
1041        _extend(data, queryStringToParams(url.slice(qsi + 1)));
1042        url = url.slice(0, qsi);
1043      }
1044
1045      options.url = url;
1046      options.data = data;
1047
1048      // map params except directions
1049      var queryParams = this.mode == "client" ?
1050        _pick(this.queryParams, "sortKey", "order") :
1051        _omit(_pick(this.queryParams, _keys(PageableProto.queryParams)),
1052              "directions");
1053
1054      var i, kvp, k, v, kvps = _pairs(queryParams), thisCopy = _clone(this);
1055      for (i = 0; i < kvps.length; i++) {
1056        kvp = kvps[i], k = kvp[0], v = kvp[1];
1057        v = _isFunction(v) ? v.call(thisCopy) : v;
1058        if (state[k] != null && v != null) {
1059          data[v] = state[k];
1060        }
1061      }
1062
1063      // fix up sorting parameters
1064      if (state.sortKey && state.order) {
1065        data[queryParams.order] = this.queryParams.directions[state.order + ""];
1066      }
1067      else if (!state.sortKey) delete data[queryParams.order];
1068
1069      // map extra query parameters
1070      var extraKvps = _pairs(_omit(this.queryParams,
1071                                   _keys(PageableProto.queryParams)));
1072      for (i = 0; i < extraKvps.length; i++) {
1073        kvp = extraKvps[i];
1074        v = kvp[1];
1075        v = _isFunction(v) ? v.call(thisCopy) : v;
1076        data[kvp[0]] = v;
1077      }
1078
1079      var fullCollection = this.fullCollection, links = this.links;
1080
1081      if (mode != "server") {
1082
1083        var self = this;
1084        var success = options.success;
1085        options.success = function (col, resp, opts) {
1086
1087          // make sure the caller's intent is obeyed
1088          opts = opts || {};
1089          if (_isUndefined(options.silent)) delete opts.silent;
1090          else opts.silent = options.silent;
1091
1092          var models = col.models;
1093          var currentPage = state.currentPage;
1094
1095          if (mode == "client") resetQuickly(fullCollection, models, opts);
1096          else if (links[currentPage]) { // refetching a page
1097            var pageSize = state.pageSize;
1098            var pageStart = (state.firstPage === 0 ?
1099                             currentPage :
1100                             currentPage - 1) * pageSize;
1101            var fullModels = fullCollection.models;
1102            var head = fullModels.slice(0, pageStart);
1103            var tail = fullModels.slice(pageStart + pageSize);
1104            fullModels = head.concat(models).concat(tail);
1105            fullCollection.update(fullModels,
1106                                  _extend({silent: true, sort: false}, opts));
1107            if (fullCollection.comparator) fullCollection.sort();
1108            fullCollection.trigger("reset", fullCollection, opts);
1109          }
1110          else { // fetching new page
1111            fullCollection.add(models, _extend({at: fullCollection.length,
1112                                                silent: true}, opts));
1113            fullCollection.trigger("reset", fullCollection, opts);
1114          }
1115
1116          if (success) success(col, resp, opts);
1117        };
1118
1119        // silent the first reset from backbone
1120        return BBColProto.fetch.call(self, _extend({}, options, {silent: true}));
1121      }
1122
1123      return BBColProto.fetch.call(this, options);
1124    },
1125
1126    /**
1127       Convenient method for making a `comparator` sorted by a model attribute
1128       identified by `sortKey` and ordered by `order`.
1129
1130       Like a Backbone.Collection, a Backbone.PageableCollection will maintain
1131       the __current page__ in sorted order on the client side if a `comparator`
1132       is attached to it. If the collection is in client mode, you can attach a
1133       comparator to #fullCollection to have all the pages reflect the global
1134       sorting order by specifying an option `full` to `true`. You __must__ call
1135       `sort` manually or #fullCollection.sort after calling this method to
1136       force a resort.
1137
1138       While you can use this method to sort the current page in server mode,
1139       the sorting order may not reflect the global sorting order due to the
1140       additions or removals of the records on the server since the last
1141       fetch. If you want the most updated page in a global sorting order, it is
1142       recommended that you set #state.sortKey and optionally #state.order, and
1143       then call #fetch.
1144
1145       @protected
1146
1147       @param {string} [sortKey=this.state.sortKey] See `state.sortKey`.
1148       @param {number} [order=this.state.order] See `state.order`.
1149
1150       See [Backbone.Collection.comparator](http://backbonejs.org/#Collection-comparator).
1151    */
1152    _makeComparator: function (sortKey, order) {
1153
1154      var state = this.state;
1155
1156      sortKey = sortKey || state.sortKey;
1157      order = order || state.order;
1158
1159      if (!sortKey || !order) return;
1160
1161      return function (left, right) {
1162        var l = left.get(sortKey), r = right.get(sortKey), t;
1163        if (order === 1) t = l, l = r, r = t;
1164        if (l === r) return 0;
1165        else if (l < r) return -1;
1166        return 1;
1167      };
1168    },
1169
1170    /**
1171       Adjusts the sorting for this pageable collection.
1172
1173       Given a `sortKey` and an `order`, sets `state.sortKey` and
1174       `state.order`. A comparator can be applied on the client side to sort in
1175       the order defined if `options.side` is `"client"`. By default the
1176       comparator is applied to the #fullCollection. Set `options.full` to
1177       `false` to apply a comparator to the current page under any mode. Setting
1178       `sortKey` to `null` removes the comparator from both the current page and
1179       the full collection.
1180
1181       @chainable
1182
1183       @param {string} sortKey See `state.sortKey`.
1184       @param {number} [order=this.state.order] See `state.order`.
1185       @param {Object} [options]
1186       @param {"server"|"client"} [options.side] By default, `"client"` if
1187       `mode` is `"client"`, `"server"` otherwise.
1188       @param {boolean} [options.full=true]
1189    */
1190    setSorting: function (sortKey, order, options) {
1191
1192      var state = this.state;
1193
1194      state.sortKey = sortKey;
1195      state.order = order = order || state.order;
1196
1197      var fullCollection = this.fullCollection;
1198
1199      var delComp = false, delFullComp = false;
1200
1201      if (!sortKey) delComp = delFullComp = true;
1202
1203      var mode = this.mode;
1204      options = _extend({side: mode == "client" ? mode : "server", full: true},
1205                        options);
1206
1207      var comparator = this._makeComparator(sortKey, order);
1208
1209      var full = options.full, side = options.side;
1210
1211      if (side == "client") {
1212        if (full) {
1213          if (fullCollection) fullCollection.comparator = comparator;
1214          delComp = true;
1215        }
1216        else {
1217          this.comparator = comparator;
1218          delFullComp = true;
1219        }
1220      }
1221      else if (side == "server" && !full) {
1222        this.comparator = comparator;
1223      }
1224
1225      if (delComp) delete this.comparator;
1226      if (delFullComp && fullCollection) delete fullCollection.comparator;
1227
1228      return this;
1229    }
1230
1231  });
1232
1233  var PageableProto = PageableCollection.prototype;
1234
1235  return PageableCollection;
1236
1237}));