PageRenderTime 298ms CodeModel.GetById 14ms app.highlight 218ms RepoModel.GetById 50ms app.codeStats 0ms

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

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