text: /*
   * jQuery Dynatable plugin 0.3.1
   *
   * Copyright (c) 2014 Steve Schwartz (JangoSteve)
   *
   * Dual licensed under the AGPL and Proprietary licenses:
   *   http://www.dynatable.com/license/
   *
   * Date: Tue Jan 02 2014
   */
  //
  
  (function($) {
    var defaults,
        mergeSettings,
        dt,
        Model,
        modelPrototypes = {
          dom: Dom,
          domColumns: DomColumns,
          records: Records,
          recordsCount: RecordsCount,
          processingIndicator: ProcessingIndicator,
          state: State,
          sorts: Sorts,
          sortsHeaders: SortsHeaders,
          queries: Queries,
          inputsSearch: InputsSearch,
          paginationPage: PaginationPage,
          paginationPerPage: PaginationPerPage,
          paginationLinks: PaginationLinks
        },
        utility,
        build,
        processAll,
        initModel,
        defaultRowWriter,
        defaultCellWriter,
        defaultAttributeWriter,
        defaultAttributeReader;
  
    //-----------------------------------------------------------------
    // Cached plugin global defaults
    //-----------------------------------------------------------------
  
    defaults = {
      features: {
        paginate: true,
        sort: true,
        pushState: true,
        search: true,
        recordCount: true,
        perPageSelect: true
      },
      table: {
        defaultColumnIdStyle: 'camelCase',
        columns: null,
        headRowSelector: 'thead tr', // or e.g. tr:first-child
        bodyRowSelector: 'tbody tr',
        headRowClass: null,
        copyHeaderAlignment: true,
        copyHeaderClass: false
      },
      inputs: {
        queries: null,
        sorts: null,
        multisort: ['ctrlKey', 'shiftKey', 'metaKey'],
        page: null,
        queryEvent: 'blur change',
        recordCountTarget: null,
        recordCountPlacement: 'after',
        paginationLinkTarget: null,
        paginationLinkPlacement: 'after',
        paginationClass: 'dynatable-pagination-links',
        paginationLinkClass: 'dynatable-page-link',
        paginationPrevClass: 'dynatable-page-prev',
        paginationNextClass: 'dynatable-page-next',
        paginationActiveClass: 'dynatable-active-page',
        paginationDisabledClass: 'dynatable-disabled-page',
        paginationPrev: 'Previous',
        paginationNext: 'Next',
        paginationGap: [1,2,2,1],
        searchTarget: null,
        searchPlacement: 'before',
        searchText: 'Search: ',
        perPageTarget: null,
        perPagePlacement: 'before',
        perPageText: 'Show: ',
        pageText: 'Pages: ',
        recordCountPageBoundTemplate: '{pageLowerBound} to {pageUpperBound} of',
        recordCountPageUnboundedTemplate: '{recordsShown} of',
        recordCountTotalTemplate: '{recordsQueryCount} {collectionName}',
        recordCountFilteredTemplate: ' (filtered from {recordsTotal} total records)',
        recordCountText: 'Showing',
        recordCountTextTemplate: '{text} {pageTemplate} {totalTemplate} {filteredTemplate}',
        recordCountTemplate: '<span id="dynatable-record-count-{elementId}" class="dynatable-record-count">{textTemplate}</span>',
        processingText: 'Processing...'
      },
      dataset: {
        ajax: false,
        ajaxUrl: null,
        ajaxCache: null,
        ajaxOnLoad: false,
        ajaxMethod: 'GET',
        ajaxDataType: 'json',
        totalRecordCount: null,
        queries: {},
        queryRecordCount: null,
        page: null,
        perPageDefault: 10,
        perPageOptions: [10,20,50,100],
        sorts: {},
        sortsKeys: [],
        sortTypes: {},
        records: null
      },
      writers: {
        _rowWriter: defaultRowWriter,
        _cellWriter: defaultCellWriter,
        _attributeWriter: defaultAttributeWriter
      },
      readers: {
        _rowReader: null,
        _attributeReader: defaultAttributeReader
      },
      params: {
        dynatable: 'dynatable',
        queries: 'queries',
        sorts: 'sorts',
        page: 'page',
        perPage: 'perPage',
        offset: 'offset',
        records: 'records',
        record: null,
        queryRecordCount: 'queryRecordCount',
        totalRecordCount: 'totalRecordCount'
      }
    };
  
    //-----------------------------------------------------------------
    // Each dynatable instance inherits from this,
    // set properties specific to instance
    //-----------------------------------------------------------------
  
    dt = {
      init: function(element, options) {
        this.settings = mergeSettings(options);
        this.element = element;
        this.$element = $(element);
  
        // All the setup that doesn't require element or options
        build.call(this);
  
        return this;
      },
  
      process: function(skipPushState) {
        processAll.call(this, skipPushState);
      }
    };
  
    //-----------------------------------------------------------------
    // Cached plugin global functions
    //-----------------------------------------------------------------
  
    mergeSettings = function(options) {
      var newOptions = $.extend(true, {}, defaults, options);
  
      // TODO: figure out a better way to do this.
      // Doing `extend(true)` causes any elements that are arrays
      // to merge the default and options arrays instead of overriding the defaults.
      if (options) {
        if (options.inputs) {
          if (options.inputs.multisort) {
            newOptions.inputs.multisort = options.inputs.multisort;
          }
          if (options.inputs.paginationGap) {
            newOptions.inputs.paginationGap = options.inputs.paginationGap;
          }
        }
        if (options.dataset && options.dataset.perPageOptions) {
          newOptions.dataset.perPageOptions = options.dataset.perPageOptions;
        }
      }
  
      return newOptions;
    };
  
    build = function() {
      this.$element.trigger('dynatable:preinit', this);
  
      for (model in modelPrototypes) {
        if (modelPrototypes.hasOwnProperty(model)) {
          var modelInstance = this[model] = new modelPrototypes[model](this, this.settings);
          if (modelInstance.initOnLoad()) {
            modelInstance.init();
          }
        }
      }
  
      this.$element.trigger('dynatable:init', this);
  
      if (!this.settings.dataset.ajax || (this.settings.dataset.ajax && this.settings.dataset.ajaxOnLoad) || this.settings.features.paginate || (this.settings.features.sort && !$.isEmptyObject(this.settings.dataset.sorts))) {
        this.process();
      }
    };
  
    processAll = function(skipPushState) {
      var data = {};
  
      this.$element.trigger('dynatable:beforeProcess', data);
  
      if (!$.isEmptyObject(this.settings.dataset.queries)) { data[this.settings.params.queries] = this.settings.dataset.queries; }
      // TODO: Wrap this in a try/rescue block to hide the processing indicator and indicate something went wrong if error
      this.processingIndicator.show();
  
      if (this.settings.features.sort && !$.isEmptyObject(this.settings.dataset.sorts)) { data[this.settings.params.sorts] = this.settings.dataset.sorts; }
      if (this.settings.features.paginate && this.settings.dataset.page) {
        var page = this.settings.dataset.page,
            perPage = this.settings.dataset.perPage;
        data[this.settings.params.page] = page;
        data[this.settings.params.perPage] = perPage;
        data[this.settings.params.offset] = (page - 1) * perPage;
      }
      if (this.settings.dataset.ajaxData) { $.extend(data, this.settings.dataset.ajaxData); }
  
      // If ajax, sends query to ajaxUrl with queries and sorts serialized and appended in ajax data
      // otherwise, executes queries and sorts on in-page data
      if (this.settings.dataset.ajax) {
        var _this = this;
        var options = {
          type: _this.settings.dataset.ajaxMethod,
          dataType: _this.settings.dataset.ajaxDataType,
          data: data,
          error: function(xhr, error) {
            _this.$element.trigger('dynatable:ajax:error', {xhr: xhr, error : error});
          },
          success: function(response) {
            _this.$element.trigger('dynatable:ajax:success', response);
            // Merge ajax results and meta-data into dynatables cached data
            _this.records.updateFromJson(response);
            // update table with new records
            _this.dom.update();
  
            if (!skipPushState && _this.state.initOnLoad()) {
              _this.state.push(data);
            }
          },
          complete: function() {
            _this.processingIndicator.hide();
          }
        };
        // Do not pass url to `ajax` options if blank
        if (this.settings.dataset.ajaxUrl) {
          options.url = this.settings.dataset.ajaxUrl;
  
        // If ajaxUrl is blank, then we're using the current page URL,
        // we need to strip out any query, sort, or page data controlled by dynatable
        // that may have been in URL when page loaded, so that it doesn't conflict with
        // what's passed in with the data ajax parameter
        } else {
          options.url = utility.refreshQueryString(window.location.href, {}, this.settings);
        }
        if (this.settings.dataset.ajaxCache !== null) { options.cache = this.settings.dataset.ajaxCache; }
  
        $.ajax(options);
      } else {
        this.records.resetOriginal();
        this.queries.run();
        if (this.settings.features.sort) {
          this.records.sort();
        }
        if (this.settings.features.paginate) {
          this.records.paginate();
        }
        this.dom.update();
        this.processingIndicator.hide();
  
        if (!skipPushState && this.state.initOnLoad()) {
          this.state.push(data);
        }
      }
  
      this.$element.addClass('dynatable-loaded');
      this.$element.trigger('dynatable:afterProcess', data);
    };
  
    //Function custom
    function createAction(id, uuid){
  	  var view, edit, del, html;
  	  view = "<a class='btn btn-primary btn-xs' data-uuid='"+uuid+"' href='/"+id+"/view'><i class='fa fa-folder'></i> View </a>";
  	  edit = "<a class='btn btn-info btn-xs' href='"+id+"/edit/'><i class='fa fa-pencil'></i> Edit </a>";
  	  del = "<a class='btn btn-danger btn-xs' href='"+id+"/delete'><i class='fa fa-trash-o'></i> Delete </a>";
  	  html = view+edit+del;
  	  return html;
    }
    //End function custom
  
    function defaultRowWriter(rowIndex, record, columns, cellWriter) {
      var tr = '';
  
      // grab the record's attribute for each column
      for (var i = 0, len = columns.length; i < len; i++) {
        tr += cellWriter(columns[i], record);
      }
  
      return '<tr>' + tr + '</tr>';
    };
  
    function defaultCellWriter(column, record) {
  	  console.log(record.uuid);
      var html = column.attributeWriter(record),
          td = '<td';
      //Custom
      if(column.id=="createdAt"){
    	  //console.log("createdAt ", record.created_at.date);
    	  html = record.created_at.date;
    	}
      if(column.id=="updatedAt"){
      	//  console.log("updatedAt ", record.updated_at.date);
      	  html = record.updated_at.date;
      }
      if(column.id=="action"){
    	 // console.log("show action ");
    	  //html = createAction(record.id, record.uuid);
      }
      //End Custom
      if (column.hidden || column.textAlign) {
        td += ' style="';
  
        // keep cells for hidden column headers hidden
        if (column.hidden) {
          td += 'display: none;';
        }
  
        // keep cells aligned as their column headers are aligned
        if (column.textAlign) {
          td += 'text-align: ' + column.textAlign + ';';
        }
  
        td += '"';
      }
  
      if (column.cssClass) {
        td += ' class="' + column.cssClass + '"';
      }
  
      return td + '>' + html + '</td>';
    };
  
  
    function defaultAttributeWriter(record) {
      // `this` is the column object in settings.columns
      // TODO: automatically convert common types, such as arrays and objects, to string
      return record[this.id];
    };
  
    function defaultAttributeReader(cell, record) {
      return $(cell).html();
    };
  
    //-----------------------------------------------------------------
    // Dynatable object model prototype
    // (all object models get these default functions)
    //-----------------------------------------------------------------
  
    Model = {
      initOnLoad: function() {
        return true;
      },
  
      init: function() {}
    };
  
    for (model in modelPrototypes) {
      if (modelPrototypes.hasOwnProperty(model)) {
        var modelPrototype = modelPrototypes[model];
        modelPrototype.prototype = Model;
      }
    }
  
    //-----------------------------------------------------------------
    // Dynatable object models
    //-----------------------------------------------------------------
  
    function Dom(obj, settings) {
      var _this = this;
  
      // update table contents with new records array
      // from query (whether ajax or not)
      this.update = function() {
        var rows = '',
            columns = settings.table.columns,
            rowWriter = settings.writers._rowWriter,
            cellWriter = settings.writers._cellWriter;
  
        obj.$element.trigger('dynatable:beforeUpdate', rows);
  
        // loop through records
        for (var i = 0, len = settings.dataset.records.length; i < len; i++) {
          var record = settings.dataset.records[i],
              tr = rowWriter(i, record, columns, cellWriter);
          rows += tr;
        }
  
        // Appended dynatable interactive elements
        if (settings.features.recordCount) {
          $('#dynatable-record-count-' + obj.element.id).replaceWith(obj.recordsCount.create());
        }
        if (settings.features.paginate) {
          $('#dynatable-pagination-links-' + obj.element.id).replaceWith(obj.paginationLinks.create());
          if (settings.features.perPageSelect) {
            $('#dynatable-per-page-' + obj.element.id).val(parseInt(settings.dataset.perPage));
          }
        }
  
        // Sort headers functionality
        if (settings.features.sort && columns) {
          obj.sortsHeaders.removeAllArrows();
          for (var i = 0, len = columns.length; i < len; i++) {
            var column = columns[i],
                sortedByColumn = utility.allMatch(settings.dataset.sorts, column.sorts, function(sorts, sort) { return sort in sorts; }),
                value = settings.dataset.sorts[column.sorts[0]];
  
            if (sortedByColumn) {
              obj.$element.find('[data-dynatable-column="' + column.id + '"]').find('.dynatable-sort-header').each(function(){
                if (value == 1) {
                  obj.sortsHeaders.appendArrowUp($(this));
                } else {
                  obj.sortsHeaders.appendArrowDown($(this));
                }
              });
            }
          }
        }
  
        // Query search functionality
        if (settings.inputs.queries || settings.features.search) {
          var allQueries = settings.inputs.queries || $();
          if (settings.features.search) {
            allQueries = allQueries.add('#dynatable-query-search-' + obj.element.id);
          }
  
          allQueries.each(function() {
            var $this = $(this),
                q = settings.dataset.queries[$this.data('dynatable-query')];
            $this.val(q || '');
          });
        }
  
        obj.$element.find(settings.table.bodyRowSelector).remove();
        obj.$element.append(rows);
  
        obj.$element.trigger('dynatable:afterUpdate', rows);
      };
    };
  
    function DomColumns(obj, settings) {
      var _this = this;
  
      this.initOnLoad = function() {
        return obj.$element.is('table');
      };
  
      this.init = function() {
        settings.table.columns = [];
        this.getFromTable();
      };
  
      // initialize table[columns] array
      this.getFromTable = function() {
        var $columns = obj.$element.find(settings.table.headRowSelector).children('th,td');
        if ($columns.length) {
          $columns.each(function(index){
            _this.add($(this), index, true);
          });
        } else {
          return $.error("Couldn't find any columns headers in '" + settings.table.headRowSelector + " th,td'. If your header row is different, specify the selector in the table: headRowSelector option.");
        }
      };
  
      this.add = function($column, position, skipAppend, skipUpdate) {
        var columns = settings.table.columns,
            label = $column.text(),
            id = $column.data('dynatable-column') || utility.normalizeText(label, settings.table.defaultColumnIdStyle),
            dataSorts = $column.data('dynatable-sorts'),
            sorts = dataSorts ? $.map(dataSorts.split(','), function(text) { return $.trim(text); }) : [id];
  
        // If the column id is blank, generate an id for it
        if ( !id ) {
          this.generate($column);
          id = $column.data('dynatable-column');
        }
        // Add column data to plugin instance
        columns.splice(position, 0, {
          index: position,
          label: label,
          id: id,
          attributeWriter: settings.writers[id] || settings.writers._attributeWriter,
          attributeReader: settings.readers[id] || settings.readers._attributeReader,
          sorts: sorts,
          hidden: $column.css('display') === 'none',
          textAlign: settings.table.copyHeaderAlignment && $column.css('text-align'),
          cssClass: settings.table.copyHeaderClass && $column.attr('class')
        });
  
        // Modify header cell
        $column
          .attr('data-dynatable-column', id)
          .addClass('dynatable-head');
        if (settings.table.headRowClass) { $column.addClass(settings.table.headRowClass); }
  
        // Append column header to table
        if (!skipAppend) {
          var domPosition = position + 1,
              $sibling = obj.$element.find(settings.table.headRowSelector)
                .children('th:nth-child(' + domPosition + '),td:nth-child(' + domPosition + ')').first(),
              columnsAfter = columns.slice(position + 1, columns.length);
  
          if ($sibling.length) {
            $sibling.before($column);
          // sibling column doesn't yet exist (maybe this is the last column in the header row)
          } else {
            obj.$element.find(settings.table.headRowSelector).append($column);
          }
  
          obj.sortsHeaders.attachOne($column.get());
  
          // increment the index of all columns after this one that was just inserted
          if (columnsAfter.length) {
            for (var i = 0, len = columnsAfter.length; i < len; i++) {
              columnsAfter[i].index += 1;
            }
          }
  
          if (!skipUpdate) {
            obj.dom.update();
          }
        }
  
        return dt;
      };
  
      this.remove = function(columnIndexOrId) {
        var columns = settings.table.columns,
            length = columns.length;
  
        if (typeof(columnIndexOrId) === "number") {
          var column = columns[columnIndexOrId];
          this.removeFromTable(column.id);
          this.removeFromArray(columnIndexOrId);
        } else {
          // Traverse columns array in reverse order so that subsequent indices
          // don't get messed up when we delete an item from the array in an iteration
          for (var i = columns.length - 1; i >= 0; i--) {
            var column = columns[i];
  
            if (column.id === columnIndexOrId) {
              this.removeFromTable(columnIndexOrId);
              this.removeFromArray(i);
            }
          }
        }
  
        obj.dom.update();
      };
  
      this.removeFromTable = function(columnId) {
        obj.$element.find(settings.table.headRowSelector).children('[data-dynatable-column="' + columnId + '"]').first()
          .remove();
      };
  
      this.removeFromArray = function(index) {
        var columns = settings.table.columns,
            adjustColumns;
        columns.splice(index, 1);
        adjustColumns = columns.slice(index, columns.length);
        for (var i = 0, len = adjustColumns.length; i < len; i++) {
          adjustColumns[i].index -= 1;
        }
      };
  
      this.generate = function($cell) {
        var cell = $cell === undefined ? $('<th></th>') : $cell;
        return this.attachGeneratedAttributes(cell);
      };
  
      this.attachGeneratedAttributes = function($cell) {
        // Use increment to create unique column name that is the same each time the page is reloaded,
        // in order to avoid errors with mismatched attribute names when loading cached `dataset.records` array
        var increment = obj.$element.find(settings.table.headRowSelector).children('th[data-dynatable-generated]').length;
        return $cell
          .attr('data-dynatable-column', 'dynatable-generated-' + increment) //+ utility.randomHash(),
          .attr('data-dynatable-no-sort', 'true')
          .attr('data-dynatable-generated', increment);
      };
    };
  
    function Records(obj, settings) {
      var _this = this;
  
      this.initOnLoad = function() {
        return !settings.dataset.ajax;
      };
  
      this.init = function() {
        if (settings.dataset.records === null) {
          settings.dataset.records = this.getFromTable();
  
          if (!settings.dataset.queryRecordCount) {
            settings.dataset.queryRecordCount = this.count();
          }
  
          if (!settings.dataset.totalRecordCount){
            settings.dataset.totalRecordCount = settings.dataset.queryRecordCount;
          }
        }
  
        // Create cache of original full recordset (unpaginated and unqueried)
        settings.dataset.originalRecords = $.extend(true, [], settings.dataset.records);
      };
  
      // merge ajax response json with cached data including
      // meta-data and records
      this.updateFromJson = function(data) {
        var records;
        if (settings.params.records === "_root") {
          records = data;
        } else if (settings.params.records in data) {
          records = data[settings.params.records];
        }
        if (settings.params.record) {
          var len = records.length - 1;
          for (var i = 0; i < len; i++) {
            records[i] = records[i][settings.params.record];
          }
        }
        if (settings.params.queryRecordCount in data) {
          settings.dataset.queryRecordCount = data[settings.params.queryRecordCount];
        }
        if (settings.params.totalRecordCount in data) {
          settings.dataset.totalRecordCount = data[settings.params.totalRecordCount];
        }
        settings.dataset.records = records;
      };
  
      // For really advanced sorting,
      // see http://james.padolsey.com/javascript/sorting-elements-with-jquery/
      this.sort = function() {
        var sort = [].sort,
            sorts = settings.dataset.sorts,
            sortsKeys = settings.dataset.sortsKeys,
            sortTypes = settings.dataset.sortTypes;
  
        var sortFunction = function(a, b) {
          var comparison;
          if ($.isEmptyObject(sorts)) {
            comparison = obj.sorts.functions['originalPlacement'](a, b);
          } else {
            for (var i = 0, len = sortsKeys.length; i < len; i++) {
              var attr = sortsKeys[i],
                  direction = sorts[attr],
                  sortType = sortTypes[attr] || obj.sorts.guessType(a, b, attr);
              comparison = obj.sorts.functions[sortType](a, b, attr, direction);
              // Don't need to sort any further unless this sort is a tie between a and b,
              // so break the for loop unless tied
              if (comparison !== 0) { break; }
            }
          }
          return comparison;
        }
  
        return sort.call(settings.dataset.records, sortFunction);
      };
  
      this.paginate = function() {
        var bounds = this.pageBounds(),
            first = bounds[0], last = bounds[1];
        settings.dataset.records = settings.dataset.records.slice(first, last);
      };
  
      this.resetOriginal = function() {
        settings.dataset.records = settings.dataset.originalRecords || [];
      };
  
      this.pageBounds = function() {
        var page = settings.dataset.page || 1,
            first = (page - 1) * settings.dataset.perPage,
            last = Math.min(first + settings.dataset.perPage, settings.dataset.queryRecordCount);
        return [first,last];
      };
  
      // get initial recordset to populate table
      // if ajax, call ajaxUrl
      // otherwise, initialize from in-table records
      this.getFromTable = function() {
        var records = [],
            columns = settings.table.columns,
            tableRecords = obj.$element.find(settings.table.bodyRowSelector);
  
        tableRecords.each(function(index){
          var record = {};
          record['dynatable-original-index'] = index;
          $(this).find('th,td').each(function(index) {
            if (columns[index] === undefined) {
              // Header cell didn't exist for this column, so let's generate and append
              // a new header cell with a randomly generated name (so we can store and
              // retrieve the contents of this column for each record)
              obj.domColumns.add(obj.domColumns.generate(), columns.length, false, true); // don't skipAppend, do skipUpdate
            }
            var value = columns[index].attributeReader(this, record),
                attr = columns[index].id;
  
            // If value from table is HTML, let's get and cache the text equivalent for
            // the default string sorting, since it rarely makes sense for sort headers
            // to sort based on HTML tags.
            if (typeof(value) === "string" && value.match(/\s*\<.+\>/)) {
              if (! record['dynatable-sortable-text']) {
                record['dynatable-sortable-text'] = {};
              }
              record['dynatable-sortable-text'][attr] = $.trim($('<div></div>').html(value).text());
            }
  
            record[attr] = value;
          });
          // Allow configuration function which alters record based on attributes of
          // table row (e.g. from html5 data- attributes)
          if (typeof(settings.readers._rowReader) === "function") {
            settings.readers._rowReader(index, this, record);
          }
          records.push(record);
        });
        return records; // 1st row is header
      };
  
      // count records from table
      this.count = function() {
        return settings.dataset.records.length;
      };
    };
  
    function RecordsCount(obj, settings) {
      this.initOnLoad = function() {
        return settings.features.recordCount;
      };
  
      this.init = function() {
        this.attach();
      };
  
      this.create = function() {
        var pageTemplate = '',
            filteredTemplate = '',
            options = {
              elementId: obj.element.id,
              recordsShown: obj.records.count(),
              recordsQueryCount: settings.dataset.queryRecordCount,
              recordsTotal: settings.dataset.totalRecordCount,
              collectionName: settings.params.records === "_root" ? "records" : settings.params.records,
              text: settings.inputs.recordCountText
            };
  
        if (settings.features.paginate) {
  
          // If currently displayed records are a subset (page) of the entire collection
          if (options.recordsShown < options.recordsQueryCount) {
            var bounds = obj.records.pageBounds();
            options.pageLowerBound = bounds[0] + 1;
            options.pageUpperBound = bounds[1];
            pageTemplate = settings.inputs.recordCountPageBoundTemplate;
  
          // Else if currently displayed records are the entire collection
          } else if (options.recordsShown === options.recordsQueryCount) {
            pageTemplate = settings.inputs.recordCountPageUnboundedTemplate;
          }
        }
  
        // If collection for table is queried subset of collection
        if (options.recordsQueryCount < options.recordsTotal) {
          filteredTemplate = settings.inputs.recordCountFilteredTemplate;
        }
  
        // Populate templates with options
        options.pageTemplate = utility.template(pageTemplate, options);
        options.filteredTemplate = utility.template(filteredTemplate, options);
        options.totalTemplate = utility.template(settings.inputs.recordCountTotalTemplate, options);
        options.textTemplate = utility.template(settings.inputs.recordCountTextTemplate, options);
  
        return utility.template(settings.inputs.recordCountTemplate, options);
      };
  
      this.attach = function() {
        var $target = settings.inputs.recordCountTarget ? $(settings.inputs.recordCountTarget) : obj.$element;
        $target[settings.inputs.recordCountPlacement](this.create());
      };
    };
  
    function ProcessingIndicator(obj, settings) {
      this.init = function() {
        this.attach();
      };
  
      this.create = function() {
        var $processing = $('<div></div>', {
              html: '<span>' + settings.inputs.processingText + '</span>',
              id: 'dynatable-processing-' + obj.element.id,
              'class': 'dynatable-processing',
              style: 'position: absolute; display: none;'
            });
  
        return $processing;
      };
  
      this.position = function() {
        var $processing = $('#dynatable-processing-' + obj.element.id),
            $span = $processing.children('span'),
            spanHeight = $span.outerHeight(),
            spanWidth = $span.outerWidth(),
            $covered = obj.$element,
            offset = $covered.offset(),
            height = $covered.outerHeight(), width = $covered.outerWidth();
  
        $processing
          .offset({left: offset.left, top: offset.top})
          .width(width)
          .height(height)
        $span
          .offset({left: offset.left + ( (width - spanWidth) / 2 ), top: offset.top + ( (height - spanHeight) / 2 )});
  
        return $processing;
      };
  
      this.attach = function() {
        obj.$element.before(this.create());
      };
  
      this.show = function() {
        $('#dynatable-processing-' + obj.element.id).show();
        this.position();
      };
  
      this.hide = function() {
        $('#dynatable-processing-' + obj.element.id).hide();
      };
    };
  
    function State(obj, settings) {
      this.initOnLoad = function() {
        // Check if pushState option is true, and if browser supports it
        return settings.features.pushState && history.pushState;
      };
  
      this.init = function() {
        window.onpopstate = function(event) {
          if (event.state && event.state.dynatable) {
            obj.state.pop(event);
          }
        }
      };
  
      this.push = function(data) {
        var urlString = window.location.search,
            urlOptions,
            path,
            params,
            hash,
            newParams,
            cacheStr,
            cache,
            // replaceState on initial load, then pushState after that
            firstPush = !(window.history.state && window.history.state.dynatable),
            pushFunction = firstPush ? 'replaceState' : 'pushState';
  
        if (urlString && /^\?/.test(urlString)) { urlString = urlString.substring(1); }
        $.extend(urlOptions, data);
  
        params = utility.refreshQueryString(urlString, data, settings);
        if (params) { params = '?' + params; }
        hash = window.location.hash;
        path = window.location.pathname;
  
        obj.$element.trigger('dynatable:push', data);
  
        cache = { dynatable: { dataset: settings.dataset } };
        if (!firstPush) { cache.dynatable.scrollTop = $(window).scrollTop(); }
        cacheStr = JSON.stringify(cache);
  
        // Mozilla has a 640k char limit on what can be stored in pushState.
        // See "limit" in https://developer.mozilla.org/en/DOM/Manipulating_the_browser_history#The_pushState().C2.A0method
        // and "dataStr.length" in http://wine.git.sourceforge.net/git/gitweb.cgi?p=wine/wine-gecko;a=patch;h=43a11bdddc5fc1ff102278a120be66a7b90afe28
        //
        // Likewise, other browsers may have varying (undocumented) limits.
        // Also, Firefox's limit can be changed in about:config as browser.history.maxStateObjectSize
        // Since we don't know what the actual limit will be in any given situation, we'll just try caching and rescue
        // any exceptions by retrying pushState without caching the records.
        //
        // I have absolutely no idea why perPageOptions suddenly becomes an array-like object instead of an array,
        // but just recently, this started throwing an error if I don't convert it:
        // 'Uncaught Error: DATA_CLONE_ERR: DOM Exception 25'
        cache.dynatable.dataset.perPageOptions = $.makeArray(cache.dynatable.dataset.perPageOptions);
  
        try {
          window.history[pushFunction](cache, "Dynatable state", path + params + hash);
        } catch(error) {
          // Make cached records = null, so that `pop` will rerun process to retrieve records
          cache.dynatable.dataset.records = null;
          window.history[pushFunction](cache, "Dynatable state", path + params + hash);
        }
      };
  
      this.pop = function(event) {
        var data = event.state.dynatable;
        settings.dataset = data.dataset;
  
        if (data.scrollTop) { $(window).scrollTop(data.scrollTop); }
  
        // If dataset.records is cached from pushState
        if ( data.dataset.records ) {
          obj.dom.update();
        } else {
          obj.process(true);
        }
      };
    };
  
    function Sorts(obj, settings) {
      this.initOnLoad = function() {
        return settings.features.sort;
      };
  
      this.init = function() {
        var sortsUrl = window.location.search.match(new RegExp(settings.params.sorts + '[^&=]*=[^&]*', 'g'));
        if (sortsUrl) {
          settings.dataset.sorts = utility.deserialize(sortsUrl)[settings.params.sorts];
        }
        if (!settings.dataset.sortsKeys.length) {
          settings.dataset.sortsKeys = utility.keysFromObject(settings.dataset.sorts);
        }
      };
  
      this.add = function(attr, direction) {
        var sortsKeys = settings.dataset.sortsKeys,
            index = $.inArray(attr, sortsKeys);
        settings.dataset.sorts[attr] = direction;
        obj.$element.trigger('dynatable:sorts:added', [attr, direction]);
        if (index === -1) { sortsKeys.push(attr); }
        return dt;
      };
  
      this.remove = function(attr) {
        var sortsKeys = settings.dataset.sortsKeys,
            index = $.inArray(attr, sortsKeys);
        delete settings.dataset.sorts[attr];
        obj.$element.trigger('dynatable:sorts:removed', attr);
        if (index !== -1) { sortsKeys.splice(index, 1); }
        return dt;
      };
  
      this.clear = function() {
        settings.dataset.sorts = {};
        settings.dataset.sortsKeys.length = 0;
        obj.$element.trigger('dynatable:sorts:cleared');
      };
  
      // Try to intelligently guess which sort function to use
      // based on the type of attribute values.
      // Consider using something more robust than `typeof` (http://javascriptweblog.wordpress.com/2011/08/08/fixing-the-javascript-typeof-operator/)
      this.guessType = function(a, b, attr) {
        var types = {
              string: 'string',
              number: 'number',
              'boolean': 'number',
              object: 'number' // dates and null values are also objects, this works...
            },
            attrType = a[attr] ? typeof(a[attr]) : typeof(b[attr]),
            type = types[attrType] || 'number';
        return type;
      };
  
      // Built-in sort functions
      // (the most common use-cases I could think of)
      this.functions = {
        number: function(a, b, attr, direction) {
          return a[attr] === b[attr] ? 0 : (direction > 0 ? a[attr] - b[attr] : b[attr] - a[attr]);
        },
        string: function(a, b, attr, direction) {
          var aAttr = (a['dynatable-sortable-text'] && a['dynatable-sortable-text'][attr]) ? a['dynatable-sortable-text'][attr] : a[attr],
              bAttr = (b['dynatable-sortable-text'] && b['dynatable-sortable-text'][attr]) ? b['dynatable-sortable-text'][attr] : b[attr],
              comparison;
          aAttr = aAttr.toLowerCase();
          bAttr = bAttr.toLowerCase();
          comparison = aAttr === bAttr ? 0 : (direction > 0 ? aAttr > bAttr : bAttr > aAttr);
          // force false boolean value to -1, true to 1, and tie to 0
          return comparison === false ? -1 : (comparison - 0);
        },
        originalPlacement: function(a, b) {
          return a['dynatable-original-index'] - b['dynatable-original-index'];
        }
      };
    };
  
    // turn table headers into links which add sort to sorts array
    function SortsHeaders(obj, settings) {
      var _this = this;
  
      this.initOnLoad = function() {
        return settings.features.sort;
      };
  
      this.init = function() {
        this.attach();
      };
  
      this.create = function(cell) {
        var $cell = $(cell),
            $link = $('<a></a>', {
              'class': 'dynatable-sort-header',
              href: '#',
              html: $cell.html()
            }),
            id = $cell.data('dynatable-column'),
            column = utility.findObjectInArray(settings.table.columns, {id: id});
  
        $link.bind('click', function(e) {
          _this.toggleSort(e, $link, column);
          obj.process();
  
          e.preventDefault();
        });
  
        if (this.sortedByColumn($link, column)) {
          if (this.sortedByColumnValue(column) == 1) {
            this.appendArrowUp($link);
          } else {
            this.appendArrowDown($link);
          }
        }
  
        return $link;
      };
  
      this.removeAll = function() {
        obj.$element.find(settings.table.headRowSelector).children('th,td').each(function(){
          _this.removeAllArrows();
          _this.removeOne(this);
        });
      };
  
      this.removeOne = function(cell) {
        var $cell = $(cell),
            $link = $cell.find('.dynatable-sort-header');
        if ($link.length) {
          var html = $link.html();
          $link.remove();
          $cell.html($cell.html() + html);
        }
      };
  
      this.attach = function() {
        obj.$element.find(settings.table.headRowSelector).children('th,td').each(function(){
          _this.attachOne(this);
        });
      };
  
      this.attachOne = function(cell) {
        var $cell = $(cell);
        if (!$cell.data('dynatable-no-sort')) {
          $cell.html(this.create(cell));
        }
      };
  
      this.appendArrowUp = function($link) {
        this.removeArrow($link);
        $link.append("<span class='dynatable-arrow'> &#9650;</span>");
      };
  
      this.appendArrowDown = function($link) {
        this.removeArrow($link);
        $link.append("<span class='dynatable-arrow'> &#9660;</span>");
      };
  
      this.removeArrow = function($link) {
        // Not sure why `parent()` is needed, the arrow should be inside the link from `append()` above
        $link.find('.dynatable-arrow').remove();
      };
  
      this.removeAllArrows = function() {
        obj.$element.find('.dynatable-arrow').remove();
      };
  
      this.toggleSort = function(e, $link, column) {
        var sortedByColumn = this.sortedByColumn($link, column),
            value = this.sortedByColumnValue(column);
        // Clear existing sorts unless this is a multisort event
        if (!settings.inputs.multisort || !utility.anyMatch(e, settings.inputs.multisort, function(evt, key) { return e[key]; })) {
          this.removeAllArrows();
          obj.sorts.clear();
        }
  
        // If sorts for this column are already set
        if (sortedByColumn) {
          // If ascending, then make descending
          if (value == 1) {
            for (var i = 0, len = column.sorts.length; i < len; i++) {
              obj.sorts.add(column.sorts[i], -1);
            }
            this.appendArrowDown($link);
          // If descending, remove sort
          } else {
            for (var i = 0, len = column.sorts.length; i < len; i++) {
              obj.sorts.remove(column.sorts[i]);
            }
            this.removeArrow($link);
          }
        // Otherwise, if not already set, set to ascending
        } else {
          for (var i = 0, len = column.sorts.length; i < len; i++) {
            obj.sorts.add(column.sorts[i], 1);
          }
          this.appendArrowUp($link);
        }
      };
  
      this.sortedByColumn = function($link, column) {
        return utility.allMatch(settings.dataset.sorts, column.sorts, function(sorts, sort) { return sort in sorts; });
      };
  
      this.sortedByColumnValue = function(column) {
        return settings.dataset.sorts[column.sorts[0]];
      };
    };
  
    function Queries(obj, settings) {
      var _this = this;
  
      this.initOnLoad = function() {
        return settings.inputs.queries || settings.features.search;
      };
  
      this.init = function() {
        var queriesUrl = window.location.search.match(new RegExp(settings.params.queries + '[^&=]*=[^&]*', 'g'));
  
        settings.dataset.queries = queriesUrl ? utility.deserialize(queriesUrl)[settings.params.queries] : {};
        if (settings.dataset.queries === "") { settings.dataset.queries = {}; }
  
        if (settings.inputs.queries) {
          this.setupInputs();
        }
      };
  
      this.add = function(name, value) {
        // reset to first page since query will change records
        if (settings.features.paginate) {
          settings.dataset.page = 1;
        }
        settings.dataset.queries[name] = value;
        obj.$element.trigger('dynatable:queries:added', [name, value]);
        return dt;
      };
  
      this.remove = function(name) {
        delete settings.dataset.queries[name];
        obj.$element.trigger('dynatable:queries:removed', name);
        return dt;
      };
  
      this.run = function() {
        for (query in settings.dataset.queries) {
          if (settings.dataset.queries.hasOwnProperty(query)) {
            var value = settings.dataset.queries[query];
            if (_this.functions[query] === undefined) {
              // Try to lazily evaluate query from column names if not explicitly defined
              var queryColumn = utility.findObjectInArray(settings.table.columns, {id: query});
              if (queryColumn) {
                _this.functions[query] = function(record, queryValue) {
                  return record[query] == queryValue;
                };
              } else {
                $.error("Query named '" + query + "' called, but not defined in queries.functions");
                continue; // to skip to next query
              }
            }
            // collect all records that return true for query
            settings.dataset.records = $.map(settings.dataset.records, function(record) {
              return _this.functions[query](record, value) ? record : null;
            });
          }
        }
        settings.dataset.queryRecordCount = obj.records.count();
      };
  
      // Shortcut for performing simple query from built-in search
      this.runSearch = function(q) {
        var origQueries = $.extend({}, settings.dataset.queries);
        if (q) {
          this.add('search', q);
        } else {
          this.remove('search');
        }
        if (!utility.objectsEqual(settings.dataset.queries, origQueries)) {
          obj.process();
        }
      };
  
      this.setupInputs = function() {
        settings.inputs.queries.each(function() {
          var $this = $(this),
              event = $this.data('dynatable-query-event') || settings.inputs.queryEvent,
              query = $this.data('dynatable-query') || $this.attr('name') || this.id,
              queryFunction = function(e) {
                var q = $(this).val();
                if (q === "") { q = undefined; }
                if (q === settings.dataset.queries[query]) { return false; }
                if (q) {
                  _this.add(query, q);
                } else {
                  _this.remove(query);
                }
                obj.process();
                e.preventDefault();
              };
  
          $this
            .attr('data-dynatable-query', query)
            .bind(event, queryFunction)
            .bind('keypress', function(e) {
              if (e.which == 13) {
                queryFunction.call(this, e);
              }
            });
  
          if (settings.dataset.queries[query]) { $this.val(decodeURIComponent(settings.dataset.queries[query])); }
        });
      };
  
      // Query functions for in-page querying
      // each function should take a record and a value as input
      // and output true of false as to whether the record is a match or not
      this.functions = {
        search: function(record, queryValue) {
          var contains = false;
          // Loop through each attribute of record
          for (attr in record) {
            if (record.hasOwnProperty(attr)) {
              var attrValue = record[attr];
              if (typeof(attrValue) === "string" && attrValue.toLowerCase().indexOf(queryValue.toLowerCase()) !== -1) {
                contains = true;
                // Don't need to keep searching attributes once found
                break;
              } else {
                continue;
              }
            }
          }
          return contains;
        }
      };
    };
  
    function InputsSearch(obj, settings) {
      var _this = this;
  
      this.initOnLoad = function() {
        return settings.features.search;
      };
  
      this.init = function() {
        this.attach();
      };
  
      this.create = function() {
        var $search = $('<input />', {
              type: 'search',
              id: 'dynatable-query-search-' + obj.element.id,
              'data-dynatable-query': 'search',
              value: settings.dataset.queries.search
            }),
            $searchSpan = $('<span></span>', {
              id: 'dynatable-search-' + obj.element.id,
              'class': 'dynatable-search',
              text: settings.inputs.searchText
            }).append($search);
  
        $search
          .bind(settings.inputs.queryEvent, function() {
            obj.queries.runSearch($(this).val());
          })
          .bind('keypress', function(e) {
            if (e.which == 13) {
              obj.queries.runSearch($(this).val());
              e.preventDefault();
            }
          });
        return $searchSpan;
      };
  
      this.attach = function() {
        var $target = settings.inputs.searchTarget ? $(settings.inputs.searchTarget) : obj.$element;
        $target[settings.inputs.searchPlacement](this.create());
      };
    };
  
    // provide a public function for selecting page
    function PaginationPage(obj, settings) {
      this.initOnLoad = function() {
        return settings.features.paginate;
      };
  
      this.init = function() {
        var pageUrl = window.location.search.match(new RegExp(settings.params.page + '=([^&]*)'));
        // If page is present in URL parameters and pushState is enabled
        // (meaning that it'd be possible for dynatable to have put the
        // page parameter in the URL)
        if (pageUrl && settings.features.pushState) {
          this.set(pageUrl[1]);
        } else {
          this.set(1);
        }
      };
  
      this.set = function(page) {
        var newPage = parseInt(page, 10);
        settings.dataset.page = newPage;
        obj.$element.trigger('dynatable:page:set', newPage);
      }
    };
  
    function PaginationPerPage(obj, settings) {
      var _this = this;
  
      this.initOnLoad = function() {
        return settings.features.paginate;
      };
  
      this.init = function() {
        var perPageUrl = window.location.search.match(new RegExp(settings.params.perPage + '=([^&]*)'));
  
        // If perPage is present in URL parameters and pushState is enabled
        // (meaning that it'd be possible for dynatable to have put the
        // perPage parameter in the URL)
        if (perPageUrl && settings.features.pushState) {
          // Don't reset page to 1 on init, since it might override page
          // set on init from URL
          this.set(perPageUrl[1], true);
        } else {
          this.set(settings.dataset.perPageDefault, true);
        }
  
        if (settings.features.perPageSelect) {
          this.attach();
        }
      };
  
      this.create = function() {
        var $select = $('<select>', {
              id: 'dynatable-per-page-' + obj.element.id,
              'class': 'dynatable-per-page-select'
            });
  
        for (var i = 0, len = settings.dataset.perPageOptions.length; i < len; i++) {
          var number = settings.dataset.perPageOptions[i],
              selected = settings.dataset.perPage == number ? 'selected="selected"' : '';
          $select.append('<option value="' + number + '" ' + selected + '>' + number + '</option>');
        }
  
        $select.bind('change', function(e) {
          _this.set($(this).val());
          obj.process();
        });
  
        return $('<span />', {
          'class': 'dynatable-per-page'
        }).append("<span class='dynatable-per-page-label'>" + settings.inputs.perPageText + "</span>").append($select);
      };
  
      this.attach = function() {
        var $target = settings.inputs.perPageTarget ? $(settings.inputs.perPageTarget) : obj.$element;
        $target[settings.inputs.perPagePlacement](this.create());
      };
  
      this.set = function(number, skipResetPage) {
        var newPerPage = parseInt(number);
        if (!skipResetPage) { obj.paginationPage.set(1); }
        settings.dataset.perPage = newPerPage;
        obj.$element.trigger('dynatable:perPage:set', newPerPage);
      };
    };
  
    // pagination links which update dataset.page attribute
    function PaginationLinks(obj, settings) {
      var _this = this;
  
      this.initOnLoad = function() {
        return settings.features.paginate;
      };
  
      this.init = function() {
        this.attach();
      };
  
      this.create = function() {
        var pageLinks = '<ul id="' + 'dynatable-pagination-links-' + obj.element.id + '" class="' + settings.inputs.paginationClass + '">',
            pageLinkClass = settings.inputs.paginationLinkClass,
            activePageClass = settings.inputs.paginationActiveClass,
            disabledPageClass = settings.inputs.paginationDisabledClass,
            pages = Math.ceil(settings.dataset.queryRecordCount / settings.dataset.perPage),
            page = settings.dataset.page,
            breaks = [
              settings.inputs.paginationGap[0],
              settings.dataset.page - settings.inputs.paginationGap[1],
              settings.dataset.page + settings.inputs.paginationGap[2],
              (pages + 1) - settings.inputs.paginationGap[3]
            ];
  
        pageLinks += '<li><span>' + settings.inputs.pageText + '</span></li>';
  
        for (var i = 1; i <= pages; i++) {
          if ( (i > breaks[0] && i < breaks[1]) || (i > breaks[2] && i < breaks[3])) {
            // skip to next iteration in loop
            continue;
          } else {
            var li = obj.paginationLinks.buildLink(i, i, pageLinkClass, page == i, activePageClass),
                breakIndex,
                nextBreak;
  
            // If i is not between one of the following
            // (1 + (settings.paginationGap[0]))
            // (page - settings.paginationGap[1])
            // (page + settings.paginationGap[2])
            // (pages - settings.paginationGap[3])
            breakIndex = $.inArray(i, breaks);
            nextBreak = breaks[breakIndex + 1];
            if (breakIndex > 0 && i !== 1 && nextBreak && nextBreak > (i + 1)) {
              var ellip = '<li><span class="dynatable-page-break">&hellip;</span></li>';
              li = breakIndex < 2 ? ellip + li : li + ellip;
            }
  
            if (settings.inputs.paginationPrev && i === 1) {
              var prevLi = obj.paginationLinks.buildLink(page - 1, settings.inputs.paginationPrev, pageLinkClass + ' ' + settings.inputs.paginationPrevClass, page === 1, disabledPageClass);
              li = prevLi + li;
            }
            if (settings.inputs.paginationNext && i === pages) {
              var nextLi = obj.paginationLinks.buildLink(page + 1, settings.inputs.paginationNext, pageLinkClass + ' ' + settings.inputs.paginationNextClass, page === pages, disabledPageClass);
              li += nextLi;
            }
  
            pageLinks += li;
          }
        }
  
        pageLinks += '</ul>';
  
        // only bind page handler to non-active and non-disabled page links
        var selector = '#dynatable-pagination-links-' + obj.element.id + ' a.' + pageLinkClass + ':not(.' + activePageClass + ',.' + disabledPageClass + ')';
        // kill any existing delegated-bindings so they don't stack up
        $(document).undelegate(selector, 'click.dynatable');
        $(document).delegate(selector, 'click.dynatable', function(e) {
          $this = $(this);
          $this.closest(settings.inputs.paginationClass).find('.' + activePageClass).removeClass(activePageClass);
          $this.addClass(activePageClass);
  
          obj.paginationPage.set($this.data('dynatable-page'));
          obj.process();
          e.preventDefault();
        });
  
        return pageLinks;
      };
  
      this.buildLink = function(page, label, linkClass, conditional, conditionalClass) {
        var link = '<a data-dynatable-page=' + page + ' class="' + linkClass,
            li = '<li';
  
        if (conditional) {
          link += ' ' + conditionalClass;
          li += ' class="' + conditionalClass + '"';
        }
  
        link += '">' + label + '</a>';
        li += '>' + link + '</li>';
  
        return li;
      };
  
      this.attach = function() {
        // append page links *after* delegate-event-binding so it doesn't need to
        // find and select all page links to bind event
        var $target = settings.inputs.paginationLinkTarget ? $(settings.inputs.paginationLinkTarget) : obj.$element;
        $target[settings.inputs.paginationLinkPlacement](obj.paginationLinks.create());
      };
    };
  
    utility = dt.utility = {
      normalizeText: function(text, style) {
        text = this.textTransform[style](text);
        return text;
      },
      textTransform: {
        trimDash: function(text) {
          return text.replace(/^\s+|\s+$/g, "").replace(/\s+/g, "-");
        },
        camelCase: function(text) {
          text = this.trimDash(text);
          return text
            .replace(/(\-[a-zA-Z])/g, function($1){return $1.toUpperCase().replace('-','');})
            .replace(/([A-Z])([A-Z]+)/g, function($1,$2,$3){return $2 + $3.toLowerCase();})
            .replace(/^[A-Z]/, function($1){return $1.toLowerCase();});
        },
        dashed: function(text) {
          text = this.trimDash(text);
          return this.lowercase(text);
        },
        underscore: function(text) {
          text = this.trimDash(text);
          return this.lowercase(text.replace(/(-)/g, '_'));
        },
        lowercase: function(text) {
          return text.replace(/([A-Z])/g, function($1){return $1.toLowerCase();});
        }
      },
      // Deserialize params in URL to object
      // see http://stackoverflow.com/questions/1131630/javascript-jquery-param-inverse-function/3401265#3401265
      deserialize: function(query) {
        if (!query) return {};
        // modified to accept an array of partial URL strings
        if (typeof(query) === "object") { query = query.join('&'); }
  
        var hash = {},
            vars = query.split("&");
  
        for (var i = 0; i < vars.length; i++) {
          var pair = vars[i].split("="),
              k = decodeURIComponent(pair[0]),
              v, m;
  
          if (!pair[1]) { continue };
          v = decodeURIComponent(pair[1].replace(/\+/g, ' '));
  
          // modified to parse multi-level parameters (e.g. "hi[there][dude]=whatsup" => hi: {there: {dude: "whatsup"}})
          while (m = k.match(/([^&=]+)\[([^&=]+)\]$/)) {
            var origV = v;
            k = m[1];
            v = {};
  
            // If nested param ends in '][', then the regex above erroneously included half of a trailing '[]',
            // which indicates the end-value is part of an array
            if (m[2].substr(m[2].length-2) == '][') { // must use substr for IE to understand it
              v[m[2].substr(0,m[2].length-2)] = [origV];
            } else {
              v[m[2]] = origV;
            }
          }
  
          // If it is the first entry with this name
          if (typeof hash[k] === "undefined") {
            if (k.substr(k.length-2) != '[]') { // not end with []. cannot use negative index as IE doesn't understand it
              hash[k] = v;
            } else {
              hash[k] = [v];
            }
          // If subsequent entry with this name and not array
          } else if (typeof hash[k] === "string") {
            hash[k] = v;  // replace it
          // modified to add support for objects
          } else if (typeof hash[k] === "object") {
            hash[k] = $.extend({}, hash[k], v);
          // If subsequent entry with this name and is array
          } else {
            hash[k].push(v);
          }
        }
        return hash;
      },
      refreshQueryString: function(urlString, data, settings) {
        var _this = this,
            queryString = urlString.split('?'),
            path = queryString.shift(),
            urlOptions;
  
        urlOptions = this.deserialize(urlString);
  
        // Loop through each dynatable param and update the URL with it
        for (attr in settings.params) {
          if (settings.params.hasOwnProperty(attr)) {
            var label = settings.params[attr];
            // Skip over parameters matching attributes for disabled features (i.e. leave them untouched),
            // because if the feature is turned off, then parameter name is a coincidence and it's unrelated to dynatable.
            if (
              (!settings.features.sort && attr == "sorts") ||
                (!settings.features.paginate && _this.anyMatch(attr, ["page", "perPage", "offset"], function(attr, param) { return attr == param; }))
            ) {
              continue;
            }
  
            // Delete page and offset from url params if on page 1 (default)
            if ((attr === "page" || attr === "offset") && data["page"] === 1) {
              if (urlOptions[label]) {
                delete urlOptions[label];
              }
              continue;
            }
  
            // Delete perPage from url params if default perPage value
            if (attr === "perPage" && data[label] == settings.dataset.perPageDefault) {
              if (urlOptions[label]) {
                delete urlOptions[label];
              }
              continue;
            }
  
            // For queries, we're going to handle each possible query parameter individually here instead of
            // handling the entire queries object below, since we need to make sure that this is a query controlled by dynatable.
            if (attr == "queries" && data[label]) {
              var queries = settings.inputs.queries || [],
                  inputQueries = $.makeArray(queries.map(function() { return $(this).attr('name') }));
  
              if (settings.features.search) { inputQueries.push('search'); }
  
              for (var i = 0, len = inputQueries.length; i < len; i++) {
                var attr = inputQueries[i];
                if (data[label][attr]) {
                  if (typeof urlOptions[label] === 'undefined') { urlOptions[label] = {}; }
                  urlOptions[label][attr] = data[label][attr];
                } else {
                  if (urlOptions && urlOptions[label] && urlOptions[label][attr]) { delete urlOptions[label][attr]; }
                }
              }
              continue;
            }
  
            // If we haven't returned true by now, then we actually want to update the parameter in the URL
            if (data[label]) {
              urlOptions[label] = data[label];
            } else {
              delete urlOptions[label];
            }
          }
        }
        return $.param(urlOptions);
      },
      // Get array of keys from object
      // see http://stackoverflow.com/questions/208016/how-to-list-the-properties-of-a-javascript-object/208020#208020
      keysFromObject: function(obj){
        var keys = [];
        for (var key in obj){
          keys.push(key);
        }
        return keys;
      },
      // Find an object in an array of objects by attributes.
      // E.g. find object with {id: 'hi', name: 'there'} in an array of objects
      findObjectInArray: function(array, objectAttr) {
        var _this = this,
            foundObject;
        for (var i = 0, len = array.length; i < len; i++) {
          var item = array[i];
          // For each object in array, test to make sure all attributes in objectAttr match
          if (_this.allMatch(item, objectAttr, function(item, key, value) { return item[key] == value; })) {
            foundObject = item;
            break;
          }
        }
        return foundObject;
      },
      // Return true if supplied test function passes for ALL items in an array
      allMatch: function(item, arrayOrObject, test) {
        // start off with true result by default
        var match = true,
            isArray = $.isArray(arrayOrObject);
        // Loop through all items in array
        $.each(arrayOrObject, function(key, value) {
          var result = isArray ? test(item, value) : test(item, key, value);
          // If a single item tests false, go ahead and break the array by returning false
          // and return false as result,
          // otherwise, continue with next iteration in loop
          // (if we make it through all iterations without overriding match with false,
          // then we can return the true result we started with by default)
          if (!result) { return match = false; }
        });
        return match;
      },
      // Return true if supplied test function passes for ANY items in an array
      anyMatch: function(item, arrayOrObject, test) {
        var match = false,
            isArray = $.isArray(arrayOrObject);
  
        $.each(arrayOrObject, function(key, value) {
          var result = isArray ? test(item, value) : test(item, key, value);
          if (result) {
            // As soon as a match is found, set match to true, and return false to stop the `$.each` loop
            match = true;
            return false;
          }
        });
        return match;
      },
      // Return true if two objects are equal
      // (i.e. have the same attributes and attribute values)
      objectsEqual: function(a, b) {
        for (attr in a) {
          if (a.hasOwnProperty(attr)) {
            if (!b.hasOwnProperty(attr) || a[attr] !== b[attr]) {
              return false;
            }
          }
        }
        for (attr in b) {
          if (b.hasOwnProperty(attr) && !a.hasOwnProperty(attr)) {
            return false;
          }
        }
        return true;
      },
      // Taken from http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/105074#105074
      randomHash: function() {
        return (((1+Math.random())*0x10000)|0).toString(16).substring(1);
      },
      // Adapted from http://stackoverflow.com/questions/377961/efficient-javascript-string-replacement/378001#378001
      template: function(str, data) {
        return str.replace(/{(\w*)}/g, function(match, key) {
          return data.hasOwnProperty(key) ? data[key] : "";
        });
      }
    };
  
    //-----------------------------------------------------------------
    // Build the dynatable plugin
    //-----------------------------------------------------------------
  
    // Object.create support test, and fallback for browsers without it
    if ( typeof Object.create !== "function" ) {
      Object.create = function (o) {
        function F() {}
        F.prototype = o;
        return new F();
      };
    }
  
    //-----------------------------------------------------------------
    // Global dynatable plugin setting defaults
    //-----------------------------------------------------------------
  
    $.dynatableSetup = function(options) {
      defaults = mergeSettings(options);
    };
  
    // Create dynatable plugin based on a defined object
    $.dynatable = function( object ) {
      $.fn['dynatable'] = function( options ) {
        return this.each(function() {
          if ( ! $.data( this, 'dynatable' ) ) {
            $.data( this, 'dynatable', Object.create(object).init(this, options) );
          }
        });
      };
    };
  
    $.dynatable(dt);
  
  })(jQuery);
  