<commit_before>/*jshint jquery: true, browser: true*/
/*global _: false, Backbone: false, ActionQueue: true*/

(function(L) {
  'use strict';
  /**
   * Stores a single note.
   **/
  L.models.Note = Backbone.Model.extend({
    urlRoot: '/note',
    defaults: function() {
      return {
        contents: '',
        meta: {},
        version: 0,
        modified: true,
        id: Math.ceil(Math.random()*2147483647), // must be an int32.
        created: Date.now(),
        edited: Date.now()
      };
    },
    initialized: function() {
      this.on('change:contents change:meta', _.bind(this._onChange, this), this);
    },
    _onChange: function(model, value, options) {
      // Allow setting without marking the note as modified.
      // Also, don't set modified on fetch.
      // TODO: the fetching part shouldn't be necessary, don't add
      // partially fetched notes to the collection!
      if (!(options && (options.fetching || options.nomodify))) {
        this.set({ 'modified': true });
      }
    },
    /**
     * Change the contents of a note and trigger parse events.
     *
     * @param {String} newContents New note contents
     * @param {Window} window The window from which this note was changed (optional)
     *
     **/
    changeContents: function(newContents, window) {
      var noteJSON = _.kmap(this.toJSON(), _.clone);
      noteJSON.contents = newContents;
      // TODO: This should use a different event
      L.gvent.trigger("note:request:parse note:request:parse:change", noteJSON, window);
      noteJSON.edited = Date.now();
      this.set(noteJSON);
      this.save();
    },
    /**
     * Merge a new note with this one.
     *
     * @param {Object} attrs The new note's attributes
     *
     * Merge strategy:
     *
     *   ( old &&  new) && (old == new) -> new
     *   ( old &&  new) && (old != new) -> old + new
     *   ( old && !new)                 -> old
     *   (!old &&  new)                 -> new
     **/
    merge: function(attrs) {
      var newContents;
      var oldContents = this.get('contents');
      if (oldContents && _.str.trim(oldContents).length > 0) {
        if (attrs.contents && _.str.trim(attrs.contents).length > 0) {
          if (_.str.trim(attrs.contents) === _.str.trim(oldContents)) {
            newContents = attrs.contents;
          } else {
            newContents = attrs.contents + '
--
' + oldContents;
          }
        } else {
          newContents = oldContents;
        }
      } else {
        newContents = attrs.contents;
      }
      this.set({
        'meta': _.defaults({}, this.get('meta'), attrs.meta),
        'contents': newContents,
        'edited': Math.max(attrs.edited, this.get('edited')),
        'version': attrs.version
      });
    },
    /**
     * Move this note from it's current collection to a new one.
     *
     * @param {Backbone.Collection} collection The destination collection.
     * @param {Object} [options] Options
     **/
    moveTo: function(collection, options) {
      if (collection === this.collection) {
        return;
      }

      this.collection.remove(this, options);
      collection.unshift(this, options);
      this.set('modified', true, options);
      if (!(options && options.nosave)) {
        this.save();
      }
    }
  });

  L.models.NoteCollection = Backbone.Collection.extend({
    model: L.models.Note,
    /**
     * Reorder contents by list of IDs
     *
     * @param {Array<int>} newOrder A list of note IDs
     * @param {Object} [options]
     **/
    setOrder: function(newOrder, options) {
      options = options || {};
      var orderMap = _.kmap(_.invert(newOrder), _.mask(parseInt, 0)),
          posCounter = 0,
      appendOffset = this.models.length;
      this.models = _.sortBy(this.models, function(note) {
        var pos = orderMap[note.id];
        // Put the note at the beginning if position unknown.
        if (pos === undefined) {
          return posCounter++;
        } else {
          return pos + appendOffset;
        }
      });

      if (!options.silent) {
        this.trigger('sort', this, options);
      }
    },
    // Reinsert a note in it's sorted position
    reinsert: function(note) {
      this.remove(note);
      var idx = this.sortedIndex(note);
      this.add(note, {at: idx});
    },
    /**
     * Get the list of in-order note IDs.
     *
     * @return {Array} Returns an Array of note IDs
     **/
    getOrder: function() {
      return this.pluck("id");
    }
  });

  // Note. This collection must never contain notes that don't exist in in
  // the notebook.  This shouldn't be a problem in bug-free code but search
  // assumes this (to reduce flickering).
  L.models.FilterableNoteCollection = L.models.NoteCollection.extend({
    initialize : function(attrs, options) {
      this.searchQueue = new ActionQueue(50);
      this.searchQueue.start();
      this._searchCursor = 0;
      this.backingCollection = options.track;
      this.listenTo(this.backingCollection, 'add', this.maybeAddNew);
      this.listenTo(this.backingCollection, 'remove', this._onRemove);
      this.listenTo(this.backingCollection, 'reset', _.mask(this.reset));
      this.listenTo(this.backingCollection, 'sort', _.mask(this.sort));
      this.on('reset', _.mask(this._filter), this);
      this.on('remove', function(note, c, options) {
        if (this.indexOf(note) < this._searchCursor) {
          this._searchCursor--;
        }
      });
      this.on('add', function(note, c, options) {
        if (this.indexOf(note) < this._searchCursor) {
          this._searchCursor++;
        }
      });
      this.reset();
    },
    _chunk: 10,
    _onRemove: function(note, notes, options) {
      this.remove(note, options);
    },
    comparator: function(note) {
      // Backbone checks the number of arguments (1 = absolute, 2 = compare)
      // Underscore passes an extra argument (the index in the underlying
      // list). The note must be before this index in the filtered list.
      var lastIndex;
      if (typeof(arguments[1]) === "number") {
        lastIndex = arguments[1]+1;
      }

      var val = this.backingCollection.lastIndexOf(note, lastIndex);
      // Just in case
      if (val < 0) {
        val = this.backingCollection.indexOf(note);
      }
      return val;
    },
    /**
     * Add new note iff it matches the current search terms.
     *
     * @param {LisIt.models.Note} note The note to consider adding.
     **/
    maybeAddNew: function(note) {
      if (this.matcher(note)) {
        this.add(note, {'new': true});
      }
    },
    /**
     * Check if a note matches the current search terms.
     *
     * @param {ListIt.models.Note} note The note to test
     *
     * @return {boolean} Returns true iff the note matches the terms.
     **/
    matcher: function(note) {
      if (!this._terms) {
        return true;
      }
      var text = L.util.clean(note.get('contents').toLowerCase());
      return L.util.matchTerms(this._terms, text);
    },
    /**
     * Filter the list by the given search terms (as text).
     *
     * @param{String} text the search terms as text (Ex: "search for this but -not -this")
     *
     * @return {int} Returns a unique ID representing this search.
     **/
    search: function(text) {
      // Extract the terms
      var newTerms = L.util.extractTerms(text);

      // Don't search if the terms haven't changed.
      if (_.isEqual(this._terms, newTerms)) {
        return this.searchID;
      }
      return this._filter(newTerms);
    },
    /**
     * Given the parsed search terms, perform the actual search.
     **/
    _filter: function(newTerms) {

      this.searchQueue.clear();
      if (this.searching) {
        this.searching = false;
        this.trigger("search:abort search:end", this._terms, this.searchID);
        debug('search::cancel');
      }

      this._searchCursor = 0;

      debug('search::start');

      var that = this;
      if (arguments.length > 0) {
        this._terms = newTerms;
      }
      this.searching = true;
      this.searchID = Math.random();
      this.trigger("search:begin", this._terms, this.searchID);

      _.each(_.chunk(this.backingCollection, this._chunk), function(chunk) {
        that.searchQueue.add(function() {
          _.each(chunk, function(note) {
            if (that.matcher(note)) {
              that.add(note, {at: that._searchCursor, sort: false});
              that._searchCursor++;
            } else {
              that.remove(note);
            }
          });
        });
      });

      this.searchQueue.add(function() {
        that.searching = false;
        debug('search::end');
        that.trigger('search:complete search:end', that._terms, that.searchID);
      });
      return this.searchID;
    }
  });

  L.models.NoteBook = Backbone.RelModel.extend({
    url: '/notebook',
    autoFetch: true,
    autoFetchRelated: true,
    defaults : {
      version: 0
    },
    isNew: function() {
      return false;
    },
    initialized: function() {
      var that = this;
      var debouncedSave = _.debounce(_.bind(that.save, that), 100);
      // No need to add this early.
      // Ensures that pinned notes go on top.
      this.get('notes').comparator = function(note) {
        return (note.get('meta') || {}).pinned ? 0 : 1;
      };

      // Re-insert note on pinned change.
      this.listenTo(this.get('notes'), 'change:meta', function(note, meta) {
        var pMeta = note.previous('meta');
        if (meta.pinned !== pMeta.pinned) {
          _.defer(function() {
            note.collection.reinsert(note);
          });
        }
      });

      _.each(that.relations, function(v, k) {
        that.listenTo(that.get(k), 'add remove', function(model, collection, options) {
          if (!(options && options.nosave)) {
            debouncedSave();
          }
        });
      });
    },
    /**
     * Import notes
     *
     * @param{String} string The contents of the file to import.
     * @param{String} type The type of file stored in string.
     *
     * @exception {Error} Throws "Invalid Importer" if the type cannot be imported.
     *
     **/
    importString: function(string, type) {
      if (!_.has(this.importers, type)) {
        throw new Error("Invalid Importer");
      }
      var json = this.importers[type].importer(string);
      if (json.notes) {
        var notes = this.get('notes');
        _.each(json.notes, function(note) {
          notes.create(note);
        });
      }
      if (json.deletedNotes) {
        var deletedNotes = this.get('deletedNotes');
        _.each(json.deletedNotes, function(note) {
          deletedNotes.create(note);
        });
      }
    },
    /**
     * Export notes
     *
     * @param {String} type The type to which to export
     *
     * @exception {Error} Throws "Invalid Exporter" if type cannot be exported.
     * @return {String} Returns the exported notes in the requested format (type).
     *
     **/
    exportString: function(type) {
      if (!_.has(this.exporters, type)) {
        throw new Error("Invalid Exporter");
      }
      return this.exporters[type].exporter(L.notebook);
    },
    /**
     * The exporters. These should be of the form:
     *   <type>: {
     *     display: '<display name>',
     *     exporter: function(<notebook instance>) -> @type{String}
     *   }
     **/
    exporters: {
      json: {
        display: 'JSON',
        exporter: function(notebook) {
          return JSON.stringify(notebook.toJSON({include: true}));
        }
      },
      txt: {
        display : 'Text',
        exporter: function(notebook) {
          return notebook.get('notes').reduce(function(txt, n) {
            return txt + '* ' + L.util.clean(n.get('contents')).replace(/\n/g, '\n  ') + '\n';
          }, '');
        }
      },
      html: {
        display : 'HTML',
        exporter: function(notebook) {
          return L.templates['exported-notes']({noteContents: notebook.get('notes').pluck('contents')});
        }
      },
      csv: {
        display : 'CSV',
        exporter: function(notebook) {
          var noteArray=[];
          noteArray.push(["Contents","Deleted","ID","Version","Created","Edited","Meta"]);
          var convertCSV = function(isDeleted, n) {
            return [_.str.q(n.get('contents').replace(/"/g, '""')),
              isDeleted,
              n.get('id'),
              n.get('version'),
              new Date(n.get('created')),
              new Date(n.get('edited')),
              _.str.q(JSON.stringify(n.get('meta')).replace(/"/g, '""'))];
          };
          noteArray.push.apply(noteArray, notebook.get('notes').map(_.partial(convertCSV, false)));
          noteArray.push.apply(noteArray, notebook.get('deletedNotes').map(_.partial(convertCSV, true)));
          return noteArray.join('\n') + '\n';
        }
      }
    },
    importers: {
      json: {
        display: 'JSON',
        importer : function(string) {
          return JSON.parse(string);
        }
      },
      txt: {
        display: 'Text',
        importer: function(string) {
          string = _.str.trim(string);
          var noteStrings;
          var bullet = string[0];
          if ("*-+".indexOf(bullet) >= 0) {
            string = string.replace(/\n\s*/g, '\n'); // TODO to greedy
            string = string.replace(new RegExp("^\\"+bullet+"\\s*"), ""); // Strip first
            noteStrings = string.split(new RegExp("\n\\s*\\"+bullet+"\\s*"));
            noteStrings = _.map(noteStrings, function(str) {
              return str.replace(/\n/g, '
');
            });
          } else {
            noteStrings = string.split('\n');
          }

          return {
            notes: _.map(noteStrings, function(noteString) {
              return {contents: noteString};
            })
          };
        }
      }
    },
    relations: {
      notes: {
        type: L.models.NoteCollection,
        includeInJSON: "id"
      },
      deletedNotes: {
        type: L.models.NoteCollection,
        includeInJSON: "id"
      }
    },
    createNote: function(obj, window, options) {
      var notes = this.get('notes');
      var note = new L.models.Note(obj, options);

      // Metadata
      var noteJSON = note.toJSON();
      // TODO: This should use a different event
      // TODO: Move this into the collection. Call when NEW (unsaved) note is added.
      L.gvent.trigger("note:request:parse note:request:parse:new", noteJSON, window);
      note.set(noteJSON);
      note.save();

      // Gets first insertable position (will add to top).
      // Insert
      var idx = notes.sortedIndex(note);
      notes.add(note, {at: idx});
      this.trigger("create", note);
      return note;
    },
    trashNote: function(note) {
      note.moveTo(this.get('deletedNotes'));
      this.trigger("delete", note);
      return note;
    },
    untrashNote: function(note) {
      note.moveTo(this.get('notes'));
      this.trigger("undelete", note);
      return note;
    },
    getNote: function(id) {
      return (this.get('deletedNotes').get(id) || this.get('notes').get(id));
    }
  });
})(ListIt);
<commit_msg>Mark notes modified on import<commit_after>/*jshint jquery: true, browser: true*/
/*global _: false, Backbone: false, ActionQueue: true*/

(function(L) {
  'use strict';
  /**
   * Stores a single note.
   **/
  L.models.Note = Backbone.Model.extend({
    urlRoot: '/note',
    defaults: function() {
      return {
        contents: '',
        meta: {},
        version: 0,
        modified: true,
        id: Math.ceil(Math.random()*2147483647), // must be an int32.
        created: Date.now(),
        edited: Date.now()
      };
    },
    initialized: function() {
      this.on('change:contents change:meta', _.bind(this._onChange, this), this);
    },
    _onChange: function(model, value, options) {
      // Allow setting without marking the note as modified.
      // Also, don't set modified on fetch.
      // TODO: the fetching part shouldn't be necessary, don't add
      // partially fetched notes to the collection!
      if (!(options && (options.fetching || options.nomodify))) {
        this.set({ 'modified': true });
      }
    },
    /**
     * Change the contents of a note and trigger parse events.
     *
     * @param {String} newContents New note contents
     * @param {Window} window The window from which this note was changed (optional)
     *
     **/
    changeContents: function(newContents, window) {
      var noteJSON = _.kmap(this.toJSON(), _.clone);
      noteJSON.contents = newContents;
      // TODO: This should use a different event
      L.gvent.trigger("note:request:parse note:request:parse:change", noteJSON, window);
      noteJSON.edited = Date.now();
      this.set(noteJSON);
      this.save();
    },
    /**
     * Merge a new note with this one.
     *
     * @param {Object} attrs The new note's attributes
     *
     * Merge strategy:
     *
     *   ( old &&  new) && (old == new) -> new
     *   ( old &&  new) && (old != new) -> old + new
     *   ( old && !new)                 -> old
     *   (!old &&  new)                 -> new
     **/
    merge: function(attrs) {
      var newContents;
      var oldContents = this.get('contents');
      if (oldContents && _.str.trim(oldContents).length > 0) {
        if (attrs.contents && _.str.trim(attrs.contents).length > 0) {
          if (_.str.trim(attrs.contents) === _.str.trim(oldContents)) {
            newContents = attrs.contents;
          } else {
            newContents = attrs.contents + '
--
' + oldContents;
          }
        } else {
          newContents = oldContents;
        }
      } else {
        newContents = attrs.contents;
      }
      this.set({
        'meta': _.defaults({}, this.get('meta'), attrs.meta),
        'contents': newContents,
        'edited': Math.max(attrs.edited, this.get('edited')),
        'version': attrs.version
      });
    },
    /**
     * Move this note from it's current collection to a new one.
     *
     * @param {Backbone.Collection} collection The destination collection.
     * @param {Object} [options] Options
     **/
    moveTo: function(collection, options) {
      if (collection === this.collection) {
        return;
      }

      this.collection.remove(this, options);
      collection.unshift(this, options);
      this.set('modified', true, options);
      if (!(options && options.nosave)) {
        this.save();
      }
    }
  });

  L.models.NoteCollection = Backbone.Collection.extend({
    model: L.models.Note,
    /**
     * Reorder contents by list of IDs
     *
     * @param {Array<int>} newOrder A list of note IDs
     * @param {Object} [options]
     **/
    setOrder: function(newOrder, options) {
      options = options || {};
      var orderMap = _.kmap(_.invert(newOrder), _.mask(parseInt, 0)),
          posCounter = 0,
      appendOffset = this.models.length;
      this.models = _.sortBy(this.models, function(note) {
        var pos = orderMap[note.id];
        // Put the note at the beginning if position unknown.
        if (pos === undefined) {
          return posCounter++;
        } else {
          return pos + appendOffset;
        }
      });

      if (!options.silent) {
        this.trigger('sort', this, options);
      }
    },
    // Reinsert a note in it's sorted position
    reinsert: function(note) {
      this.remove(note);
      var idx = this.sortedIndex(note);
      this.add(note, {at: idx});
    },
    /**
     * Get the list of in-order note IDs.
     *
     * @return {Array} Returns an Array of note IDs
     **/
    getOrder: function() {
      return this.pluck("id");
    }
  });

  // Note. This collection must never contain notes that don't exist in in
  // the notebook.  This shouldn't be a problem in bug-free code but search
  // assumes this (to reduce flickering).
  L.models.FilterableNoteCollection = L.models.NoteCollection.extend({
    initialize : function(attrs, options) {
      this.searchQueue = new ActionQueue(50);
      this.searchQueue.start();
      this._searchCursor = 0;
      this.backingCollection = options.track;
      this.listenTo(this.backingCollection, 'add', this.maybeAddNew);
      this.listenTo(this.backingCollection, 'remove', this._onRemove);
      this.listenTo(this.backingCollection, 'reset', _.mask(this.reset));
      this.listenTo(this.backingCollection, 'sort', _.mask(this.sort));
      this.on('reset', _.mask(this._filter), this);
      this.on('remove', function(note, c, options) {
        if (this.indexOf(note) < this._searchCursor) {
          this._searchCursor--;
        }
      });
      this.on('add', function(note, c, options) {
        if (this.indexOf(note) < this._searchCursor) {
          this._searchCursor++;
        }
      });
      this.reset();
    },
    _chunk: 10,
    _onRemove: function(note, notes, options) {
      this.remove(note, options);
    },
    comparator: function(note) {
      // Backbone checks the number of arguments (1 = absolute, 2 = compare)
      // Underscore passes an extra argument (the index in the underlying
      // list). The note must be before this index in the filtered list.
      var lastIndex;
      if (typeof(arguments[1]) === "number") {
        lastIndex = arguments[1]+1;
      }

      var val = this.backingCollection.lastIndexOf(note, lastIndex);
      // Just in case
      if (val < 0) {
        val = this.backingCollection.indexOf(note);
      }
      return val;
    },
    /**
     * Add new note iff it matches the current search terms.
     *
     * @param {LisIt.models.Note} note The note to consider adding.
     **/
    maybeAddNew: function(note) {
      if (this.matcher(note)) {
        this.add(note, {'new': true});
      }
    },
    /**
     * Check if a note matches the current search terms.
     *
     * @param {ListIt.models.Note} note The note to test
     *
     * @return {boolean} Returns true iff the note matches the terms.
     **/
    matcher: function(note) {
      if (!this._terms) {
        return true;
      }
      var text = L.util.clean(note.get('contents').toLowerCase());
      return L.util.matchTerms(this._terms, text);
    },
    /**
     * Filter the list by the given search terms (as text).
     *
     * @param{String} text the search terms as text (Ex: "search for this but -not -this")
     *
     * @return {int} Returns a unique ID representing this search.
     **/
    search: function(text) {
      // Extract the terms
      var newTerms = L.util.extractTerms(text);

      // Don't search if the terms haven't changed.
      if (_.isEqual(this._terms, newTerms)) {
        return this.searchID;
      }
      return this._filter(newTerms);
    },
    /**
     * Given the parsed search terms, perform the actual search.
     **/
    _filter: function(newTerms) {

      this.searchQueue.clear();
      if (this.searching) {
        this.searching = false;
        this.trigger("search:abort search:end", this._terms, this.searchID);
        debug('search::cancel');
      }

      this._searchCursor = 0;

      debug('search::start');

      var that = this;
      if (arguments.length > 0) {
        this._terms = newTerms;
      }
      this.searching = true;
      this.searchID = Math.random();
      this.trigger("search:begin", this._terms, this.searchID);

      _.each(_.chunk(this.backingCollection, this._chunk), function(chunk) {
        that.searchQueue.add(function() {
          _.each(chunk, function(note) {
            if (that.matcher(note)) {
              that.add(note, {at: that._searchCursor, sort: false});
              that._searchCursor++;
            } else {
              that.remove(note);
            }
          });
        });
      });

      this.searchQueue.add(function() {
        that.searching = false;
        debug('search::end');
        that.trigger('search:complete search:end', that._terms, that.searchID);
      });
      return this.searchID;
    }
  });

  L.models.NoteBook = Backbone.RelModel.extend({
    url: '/notebook',
    autoFetch: true,
    autoFetchRelated: true,
    defaults : {
      version: 0
    },
    isNew: function() {
      return false;
    },
    initialized: function() {
      var that = this;
      var debouncedSave = _.debounce(_.bind(that.save, that), 100);
      // No need to add this early.
      // Ensures that pinned notes go on top.
      this.get('notes').comparator = function(note) {
        return (note.get('meta') || {}).pinned ? 0 : 1;
      };

      // Re-insert note on pinned change.
      this.listenTo(this.get('notes'), 'change:meta', function(note, meta) {
        var pMeta = note.previous('meta');
        if (meta.pinned !== pMeta.pinned) {
          _.defer(function() {
            note.collection.reinsert(note);
          });
        }
      });

      _.each(that.relations, function(v, k) {
        that.listenTo(that.get(k), 'add remove', function(model, collection, options) {
          if (!(options && options.nosave)) {
            debouncedSave();
          }
        });
      });
    },
    /**
     * Import notes
     *
     * @param{String} string The contents of the file to import.
     * @param{String} type The type of file stored in string.
     *
     * @exception {Error} Throws "Invalid Importer" if the type cannot be imported.
     *
     **/
    importString: function(string, type) {
      if (!_.has(this.importers, type)) {
        throw new Error("Invalid Importer");
      }
      var json = this.importers[type].importer(string);
      if (json.notes) {
        var notes = this.get('notes');
        _.each(json.notes, function(note) {
          note.modified = true;
          notes.create(note);
        });
      }
      if (json.deletedNotes) {
        var deletedNotes = this.get('deletedNotes');
        _.each(json.deletedNotes, function(note) {
          note.modified = true;
          deletedNotes.create(note);
        });
      }
    },
    /**
     * Export notes
     *
     * @param {String} type The type to which to export
     *
     * @exception {Error} Throws "Invalid Exporter" if type cannot be exported.
     * @return {String} Returns the exported notes in the requested format (type).
     *
     **/
    exportString: function(type) {
      if (!_.has(this.exporters, type)) {
        throw new Error("Invalid Exporter");
      }
      return this.exporters[type].exporter(L.notebook);
    },
    /**
     * The exporters. These should be of the form:
     *   <type>: {
     *     display: '<display name>',
     *     exporter: function(<notebook instance>) -> @type{String}
     *   }
     **/
    exporters: {
      json: {
        display: 'JSON',
        exporter: function(notebook) {
          return JSON.stringify(notebook.toJSON({include: true}));
        }
      },
      txt: {
        display : 'Text',
        exporter: function(notebook) {
          return notebook.get('notes').reduce(function(txt, n) {
            return txt + '* ' + L.util.clean(n.get('contents')).replace(/\n/g, '\n  ') + '\n';
          }, '');
        }
      },
      html: {
        display : 'HTML',
        exporter: function(notebook) {
          return L.templates['exported-notes']({noteContents: notebook.get('notes').pluck('contents')});
        }
      },
      csv: {
        display : 'CSV',
        exporter: function(notebook) {
          var noteArray=[];
          noteArray.push(["Contents","Deleted","ID","Version","Created","Edited","Meta"]);
          var convertCSV = function(isDeleted, n) {
            return [_.str.q(n.get('contents').replace(/"/g, '""')),
              isDeleted,
              n.get('id'),
              n.get('version'),
              new Date(n.get('created')),
              new Date(n.get('edited')),
              _.str.q(JSON.stringify(n.get('meta')).replace(/"/g, '""'))];
          };
          noteArray.push.apply(noteArray, notebook.get('notes').map(_.partial(convertCSV, false)));
          noteArray.push.apply(noteArray, notebook.get('deletedNotes').map(_.partial(convertCSV, true)));
          return noteArray.join('\n') + '\n';
        }
      }
    },
    importers: {
      json: {
        display: 'JSON',
        importer : function(string) {
          return JSON.parse(string);
        }
      },
      txt: {
        display: 'Text',
        importer: function(string) {
          string = _.str.trim(string);
          var noteStrings;
          var bullet = string[0];
          if ("*-+".indexOf(bullet) >= 0) {
            string = string.replace(/\n\s*/g, '\n'); // TODO to greedy
            string = string.replace(new RegExp("^\\"+bullet+"\\s*"), ""); // Strip first
            noteStrings = string.split(new RegExp("\n\\s*\\"+bullet+"\\s*"));
            noteStrings = _.map(noteStrings, function(str) {
              return str.replace(/\n/g, '
');
            });
          } else {
            noteStrings = string.split('\n');
          }

          return {
            notes: _.map(noteStrings, function(noteString) {
              return {contents: noteString};
            })
          };
        }
      }
    },
    relations: {
      notes: {
        type: L.models.NoteCollection,
        includeInJSON: "id"
      },
      deletedNotes: {
        type: L.models.NoteCollection,
        includeInJSON: "id"
      }
    },
    createNote: function(obj, window, options) {
      var notes = this.get('notes');
      var note = new L.models.Note(obj, options);

      // Metadata
      var noteJSON = note.toJSON();
      // TODO: This should use a different event
      // TODO: Move this into the collection. Call when NEW (unsaved) note is added.
      L.gvent.trigger("note:request:parse note:request:parse:new", noteJSON, window);
      note.set(noteJSON);
      note.save();

      // Gets first insertable position (will add to top).
      // Insert
      var idx = notes.sortedIndex(note);
      notes.add(note, {at: idx});
      this.trigger("create", note);
      return note;
    },
    trashNote: function(note) {
      note.moveTo(this.get('deletedNotes'));
      this.trigger("delete", note);
      return note;
    },
    untrashNote: function(note) {
      note.moveTo(this.get('notes'));
      this.trigger("undelete", note);
      return note;
    },
    getNote: function(id) {
      return (this.get('deletedNotes').get(id) || this.get('notes').get(id));
    }
  });
})(ListIt);
<|endoftext|>


<commit_before>/*jshint jquery: true, browser: true*/
/*global _: false, Backbone: false, ActionQueue: true*/

(function(L) {
  'use strict';
  /**
   * Stores a single note.
   **/
  L.models.Note = Backbone.Model.extend({
    urlRoot: '/note',
    defaults: function() {
      return {
        contents: '',
        meta: {},
        version: 0,
        modified: true,
        id: Math.ceil(Math.random()*2147483647), // must be an int32.
        created: Date.now(),
        edited: Date.now()
      };
    },
    initialize: function() {
      this.on('error', function(n, e, o) {
        error('Note storage failure', n, e, o);
      });
    },
    initialized: function() {
      this.on('change:contents change:meta', _.bind(this._onChange, this), this);
    },
    _onChange: function(model, value, options) {
      // Allow setting without marking the note as modified.
      // Also, don't set modified on fetch.
      // TODO: the fetching part shouldn't be necessary, don't add
      // partially fetched notes to the collection!
      if (!(options && (options.fetching || options.nomodify))) {
        this.set({ 'modified': true });
      }
    },
    /**
     * Change the contents of a note and trigger parse events.
     *
     * @param {String} newContents New note contents
     * @param {Window} window The window from which this note was changed (optional)
     *
     **/
    changeContents: function(newContents, window) {
      var noteJSON = _.kmap(this.toJSON(), _.clone);
      noteJSON.contents = newContents;
      // TODO: This should use a different event
      L.gvent.trigger("note:request:parse note:request:parse:change", noteJSON, window);
      noteJSON.edited = Date.now();
      this.set(noteJSON);
      this.save();
    },
    /**
     * Merge a new note with this one.
     *
     * @param {Object} attrs The new note's attributes
     *
     * Merge strategy:
     *
     *   ( old &&  new) && (old == new) -> new
     *   ( old &&  new) && (old != new) -> old + new
     *   ( old && !new)                 -> old
     *   (!old &&  new)                 -> new
     **/
    merge: function(attrs) {
      var newContents;
      var oldContents = this.get('contents');
      if (oldContents && _.str.trim(oldContents).length > 0) {
        if (attrs.contents && _.str.trim(attrs.contents).length > 0) {
          if (_.str.trim(attrs.contents) === _.str.trim(oldContents)) {
            newContents = attrs.contents;
          } else {
            newContents = attrs.contents + '
--
' + oldContents;
          }
        } else {
          newContents = oldContents;
        }
      } else {
        newContents = attrs.contents;
      }
      this.set({
        'meta': _.defaults({}, this.get('meta'), attrs.meta),
        'contents': newContents,
        'edited': Math.max(attrs.edited, this.get('edited')),
        'version': attrs.version
      });
    },
    /**
     * Move this note from it's current collection to a new one.
     *
     * @param {Backbone.Collection} collection The destination collection.
     * @param {Object} [options] Options
     **/
    moveTo: function(collection, options) {
      if (collection === this.collection) {
        return;
      }

      this.collection.remove(this, options);
      collection.unshift(this, options);
      if (!(options && options.nomodify)) {
        this.set('modified', true, options);
      }
      if (!(options && options.nosave)) {
        this.save();
      }
    }
  });

  L.models.NoteCollection = Backbone.Collection.extend({
    model: L.models.Note,
    /**
     * Reorder contents by list of IDs
     *
     * @param {Array<int>} newOrder A list of note IDs
     * @param {Object} [options]
     **/
    setOrder: function(newOrder, options) {
      options = options || {};
      var orderMap = _.kmap(_.invert(newOrder), _.mask(parseInt, 0)),
          posCounter = 0,
      appendOffset = this.models.length;
      this.models = _.sortBy(this.models, function(note) {
        var pos = orderMap[note.id];
        // Put the note at the beginning if position unknown.
        if (pos === undefined) {
          return posCounter++;
        } else {
          return pos + appendOffset;
        }
      });

      if (!options.silent) {
        this.trigger('sort', this, options);
      }
    },
    // Reinsert a note in it's sorted position
    reinsert: function(note) {
      this.remove(note);
      var idx = this.sortedIndex(note);
      this.add(note, {at: idx});
    },
    /**
     * Get the list of in-order note IDs.
     *
     * @return {Array} Returns an Array of note IDs
     **/
    getOrder: function() {
      return this.pluck("id");
    }
  });

  // Note. This collection must never contain notes that don't exist in in
  // the notebook.  This shouldn't be a problem in bug-free code but search
  // assumes this (to reduce flickering).
  L.models.FilterableNoteCollection = L.models.NoteCollection.extend({
    initialize : function(attrs, options) {
      this.searchQueue = new ActionQueue(50);
      this.searchQueue.start();
      this._searchCursor = 0;
      this.backingCollection = options.track;
      this.listenTo(this.backingCollection, 'add', this.maybeAddNew);
      this.listenTo(this.backingCollection, 'remove', this._onRemove);
      this.listenTo(this.backingCollection, 'reset', _.mask(this.reset));
      this.listenTo(this.backingCollection, 'sort', _.mask(this.sort));
      this.on('reset', _.mask(this._filter), this);
      this.on('remove', function(note, c, options) {
        if (this.indexOf(note) < this._searchCursor) {
          this._searchCursor--;
        }
      });
      this.on('add', function(note, c, options) {
        if (this.indexOf(note) < this._searchCursor) {
          this._searchCursor++;
        }
      });
      this.reset();
    },
    _chunk: 10,
    _onRemove: function(note, notes, options) {
      this.remove(note, options);
    },
    comparator: function(note) {
      // Backbone checks the number of arguments (1 = absolute, 2 = compare)
      // Underscore passes an extra argument (the index in the underlying
      // list). The note must be before this index in the filtered list.
      var val;
      if (typeof(arguments[1]) === "number") {
        val = this.backingCollection.lastIndexOf(note, arguments[1]+1);
      }
      if (val < 0) {
        val = this.backingCollection.indexOf(note);
      }
      return val;
    },
    /**
     * Add new note iff it matches the current search terms.
     *
     * @param {LisIt.models.Note} note The note to consider adding.
     **/
    maybeAddNew: function(note) {
      if (this.matcher(note)) {
        // Avoid sorting. Optimize for loading.
        var idx = this.comparator(note, this.backingCollection.length);

        if (idx > 0) {
          this.add(note, {'new': true, sort: false, at: idx});
        } else {
          // This should never happen but check anyways.
          this.add(note, {'new': true});
        }
      }
    },
    /**
     * Check if a note matches the current search terms.
     *
     * @param {ListIt.models.Note} note The note to test
     *
     * @return {boolean} Returns true iff the note matches the terms.
     **/
    matcher: function(note) {
      if (!this._terms) {
        return true;
      }
      var text = L.util.clean(note.get('contents').toLowerCase());
      return L.util.matchTerms(this._terms, text);
    },
    /**
     * Filter the list by the given search terms (as text).
     *
     * @param{String} text the search terms as text (Ex: "search for this but -not -this")
     *
     * @return {int} Returns a unique ID representing this search.
     **/
    search: function(text) {
      // Extract the terms
      var newTerms = L.util.extractTerms(text);

      // Don't search if the terms haven't changed.
      if (_.isEqual(this._terms, newTerms)) {
        return this.searchID;
      }
      return this._filter(newTerms);
    },
    /**
     * Given the parsed search terms, perform the actual search.
     **/
    _filter: function(newTerms) {

      this.searchQueue.clear();
      if (this.searching) {
        this.searching = false;
        this.trigger("search:abort search:end", this._terms, this.searchID);
        debug('search::cancel');
      }

      this._searchCursor = 0;

      debug('search::start');

      var that = this;
      if (arguments.length > 0) {
        this._terms = newTerms;
      }
      this.searching = true;
      this.searchID = Math.random();
      this.trigger("search:begin", this._terms, this.searchID);

      _.each(_.chunk(this.backingCollection, this._chunk), function(chunk) {
        that.searchQueue.add(function() {
          _.each(chunk, function(note) {
            if (that.matcher(note)) {
              that.add(note, {at: that._searchCursor, sort: false});
              that._searchCursor++;
            } else {
              that.remove(note);
            }
          });
        });
      });

      this.searchQueue.add(function() {
        that.searching = false;
        debug('search::end');
        that.trigger('search:complete search:end', that._terms, that.searchID);
      });
      return this.searchID;
    }
  });

  L.models.NoteBook = Backbone.RelModel.extend({
    url: '/notebook',
    autoFetch: true,
    autoFetchRelated: true,
    defaults : {
      version: 0
    },
    isNew: function() {
      return false;
    },
    initialize: function() {
      var that = this;
      // Start saving immediately after loading before fetching related.
      this.once('sync error', function() {
        var debouncedSave = _.debounce(_.bind(that.save, that), 100);
        // Perform an initial flush to save any changes that might have occured
        // durring fetch.
        that.save();
        _.each(that.relations, function(v, k) {
          that.listenTo(that.get(k), 'add remove', function(model, collection, options) {
            if (!(options && options.nosave)) {
              debouncedSave();
            }
          });
        });
      });
    },
    initialized: function() {
      // No need to add this early.
      // Ensures that pinned notes go on top.
      this.get('notes').comparator = function(note) {
        return (note.get('meta') || {}).pinned ? 0 : 1;
      };

      // Re-insert note on pinned change.
      this.listenTo(this.get('notes'), 'change:meta', function(note, meta) {
        var pMeta = note.previous('meta');
        // pinned may be undefined etc.
        if (Boolean(meta.pinned) !== Boolean(pMeta.pinned)) {
          _.defer(function() {
            note.collection.reinsert(note);
          });
        }
      });
    },
    /**
     * Import notes
     *
     * @param{String} string The contents of the file to import.
     * @param{String} type The type of file stored in string.
     *
     * @exception {Error} Throws "Invalid Importer" if the type cannot be imported.
     *
     **/
    importString: function(string, type) {
      if (!_.has(this.importers, type)) {
        throw new Error("Invalid Importer");
      }
      var json = this.importers[type].importer(string);
      if (json.notes) {
        var notes = this.get('notes');
        _.each(json.notes, function(note) {
          note.modified = true;
          notes.create(note);
        });
      }
      if (json.deletedNotes) {
        var deletedNotes = this.get('deletedNotes');
        _.each(json.deletedNotes, function(note) {
          note.modified = true;
          deletedNotes.create(note);
        });
      }
    },
    /**
     * Export notes
     *
     * @param {String} type The type to which to export
     *
     * @exception {Error} Throws "Invalid Exporter" if type cannot be exported.
     * @return {String} Returns the exported notes in the requested format (type).
     *
     **/
    exportString: function(type) {
      if (!_.has(this.exporters, type)) {
        throw new Error("Invalid Exporter");
      }
      return this.exporters[type].exporter(L.notebook);
    },
    /**
     * The exporters. These should be of the form:
     *   <type>: {
     *     display: '<display name>',
     *     exporter: function(<notebook instance>) -> @type{String}
     *   }
     **/
    exporters: {
      json: {
        display: 'JSON',
        exporter: function(notebook) {
          return JSON.stringify(notebook.toJSON({include: true}));
        }
      },
      txt: {
        display : 'Text',
        exporter: function(notebook) {
          return notebook.get('notes').reduce(function(txt, n) {
            return txt + '* ' + L.util.clean(n.get('contents')).replace(/\n/g, '\n  ') + '\n';
          }, '');
        }
      },
      html: {
        display : 'HTML',
        exporter: function(notebook) {
          return L.templates['exported-notes']({noteContents: notebook.get('notes').pluck('contents')});
        }
      },
      csv: {
        display : 'CSV',
        exporter: function(notebook) {
          var noteArray=[];
          noteArray.push(["Contents","Deleted","ID","Version","Created","Edited","Meta"]);
          var convertCSV = function(isDeleted, n) {
            return [_.str.q(n.get('contents').replace(/"/g, '""')),
              isDeleted,
              n.get('id'),
              n.get('version'),
              new Date(n.get('created')),
              new Date(n.get('edited')),
              _.str.q(JSON.stringify(n.get('meta')).replace(/"/g, '""'))];
          };
          noteArray.push.apply(noteArray, notebook.get('notes').map(_.partial(convertCSV, false)));
          noteArray.push.apply(noteArray, notebook.get('deletedNotes').map(_.partial(convertCSV, true)));
          return noteArray.join('\n') + '\n';
        }
      }
    },
    importers: {
      json: {
        display: 'JSON',
        importer : function(string) {
          return JSON.parse(string);
        }
      },
      txt: {
        display: 'Text',
        importer: function(string) {
          string = _.str.trim(string);
          var noteStrings;
          var bullet = string[0];
          if ("*-+".indexOf(bullet) >= 0) {
            string = string.replace(/\n\s*/g, '\n'); // TODO to greedy
            string = string.replace(new RegExp("^\\"+bullet+"\\s*"), ""); // Strip first
            noteStrings = string.split(new RegExp("\n\\s*\\"+bullet+"\\s*"));
            noteStrings = _.map(noteStrings, function(str) {
              return str.replace(/\n/g, '
');
            });
          } else {
            noteStrings = string.split('\n');
          }

          return {
            notes: _.map(noteStrings, function(noteString) {
              return {contents: noteString};
            })
          };
        }
      }
    },
    relations: {
      notes: {
        type: L.models.NoteCollection,
        includeInJSON: "id"
      },
      deletedNotes: {
        type: L.models.NoteCollection,
        includeInJSON: "id"
      }
    },
    createNote: function(obj, window, options) {
      var notes = this.get('notes');
      var note = new L.models.Note(obj, options);

      // Metadata
      var noteJSON = note.toJSON();
      // TODO: This should use a different event
      // TODO: Move this into the collection. Call when NEW (unsaved) note is added.
      L.gvent.trigger("note:request:parse note:request:parse:new", noteJSON, window);
      note.set(noteJSON);
      note.save();

      // Gets first insertable position (will add to top).
      // Insert
      var idx = notes.sortedIndex(note);
      notes.add(note, {at: idx});
      this.trigger("create", note);
      return note;
    },
    trashNote: function(note) {
      note.moveTo(this.get('deletedNotes'));
      this.trigger("delete", note);
      return note;
    },
    untrashNote: function(note) {
      note.moveTo(this.get('notes'));
      this.trigger("undelete", note);
      return note;
    },
    getNote: function(id) {
      return (this.get('deletedNotes').get(id) || this.get('notes').get(id));
    }
  });
})(ListIt);
<commit_msg>Don't debounce note collection saving<commit_after>/*jshint jquery: true, browser: true*/
/*global _: false, Backbone: false, ActionQueue: true*/

(function(L) {
  'use strict';
  /**
   * Stores a single note.
   **/
  L.models.Note = Backbone.Model.extend({
    urlRoot: '/note',
    defaults: function() {
      return {
        contents: '',
        meta: {},
        version: 0,
        modified: true,
        id: Math.ceil(Math.random()*2147483647), // must be an int32.
        created: Date.now(),
        edited: Date.now()
      };
    },
    initialize: function() {
      this.on('error', function(n, e, o) {
        error('Note storage failure', n, e, o);
      });
    },
    initialized: function() {
      this.on('change:contents change:meta', _.bind(this._onChange, this), this);
    },
    _onChange: function(model, value, options) {
      // Allow setting without marking the note as modified.
      // Also, don't set modified on fetch.
      // TODO: the fetching part shouldn't be necessary, don't add
      // partially fetched notes to the collection!
      if (!(options && (options.fetching || options.nomodify))) {
        this.set({ 'modified': true });
      }
    },
    /**
     * Change the contents of a note and trigger parse events.
     *
     * @param {String} newContents New note contents
     * @param {Window} window The window from which this note was changed (optional)
     *
     **/
    changeContents: function(newContents, window) {
      var noteJSON = _.kmap(this.toJSON(), _.clone);
      noteJSON.contents = newContents;
      // TODO: This should use a different event
      L.gvent.trigger("note:request:parse note:request:parse:change", noteJSON, window);
      noteJSON.edited = Date.now();
      this.set(noteJSON);
      this.save();
    },
    /**
     * Merge a new note with this one.
     *
     * @param {Object} attrs The new note's attributes
     *
     * Merge strategy:
     *
     *   ( old &&  new) && (old == new) -> new
     *   ( old &&  new) && (old != new) -> old + new
     *   ( old && !new)                 -> old
     *   (!old &&  new)                 -> new
     **/
    merge: function(attrs) {
      var newContents;
      var oldContents = this.get('contents');
      if (oldContents && _.str.trim(oldContents).length > 0) {
        if (attrs.contents && _.str.trim(attrs.contents).length > 0) {
          if (_.str.trim(attrs.contents) === _.str.trim(oldContents)) {
            newContents = attrs.contents;
          } else {
            newContents = attrs.contents + '
--
' + oldContents;
          }
        } else {
          newContents = oldContents;
        }
      } else {
        newContents = attrs.contents;
      }
      this.set({
        'meta': _.defaults({}, this.get('meta'), attrs.meta),
        'contents': newContents,
        'edited': Math.max(attrs.edited, this.get('edited')),
        'version': attrs.version
      });
    },
    /**
     * Move this note from it's current collection to a new one.
     *
     * @param {Backbone.Collection} collection The destination collection.
     * @param {Object} [options] Options
     **/
    moveTo: function(collection, options) {
      if (collection === this.collection) {
        return;
      }

      this.collection.remove(this, options);
      collection.unshift(this, options);
      if (!(options && options.nomodify)) {
        this.set('modified', true, options);
      }
      if (!(options && options.nosave)) {
        this.save();
      }
    }
  });

  L.models.NoteCollection = Backbone.Collection.extend({
    model: L.models.Note,
    /**
     * Reorder contents by list of IDs
     *
     * @param {Array<int>} newOrder A list of note IDs
     * @param {Object} [options]
     **/
    setOrder: function(newOrder, options) {
      options = options || {};
      var orderMap = _.kmap(_.invert(newOrder), _.mask(parseInt, 0)),
          posCounter = 0,
      appendOffset = this.models.length;
      this.models = _.sortBy(this.models, function(note) {
        var pos = orderMap[note.id];
        // Put the note at the beginning if position unknown.
        if (pos === undefined) {
          return posCounter++;
        } else {
          return pos + appendOffset;
        }
      });

      if (!options.silent) {
        this.trigger('sort', this, options);
      }
    },
    // Reinsert a note in it's sorted position
    reinsert: function(note) {
      this.remove(note);
      var idx = this.sortedIndex(note);
      this.add(note, {at: idx});
    },
    /**
     * Get the list of in-order note IDs.
     *
     * @return {Array} Returns an Array of note IDs
     **/
    getOrder: function() {
      return this.pluck("id");
    }
  });

  // Note. This collection must never contain notes that don't exist in in
  // the notebook.  This shouldn't be a problem in bug-free code but search
  // assumes this (to reduce flickering).
  L.models.FilterableNoteCollection = L.models.NoteCollection.extend({
    initialize : function(attrs, options) {
      this.searchQueue = new ActionQueue(50);
      this.searchQueue.start();
      this._searchCursor = 0;
      this.backingCollection = options.track;
      this.listenTo(this.backingCollection, 'add', this.maybeAddNew);
      this.listenTo(this.backingCollection, 'remove', this._onRemove);
      this.listenTo(this.backingCollection, 'reset', _.mask(this.reset));
      this.listenTo(this.backingCollection, 'sort', _.mask(this.sort));
      this.on('reset', _.mask(this._filter), this);
      this.on('remove', function(note, c, options) {
        if (this.indexOf(note) < this._searchCursor) {
          this._searchCursor--;
        }
      });
      this.on('add', function(note, c, options) {
        if (this.indexOf(note) < this._searchCursor) {
          this._searchCursor++;
        }
      });
      this.reset();
    },
    _chunk: 10,
    _onRemove: function(note, notes, options) {
      this.remove(note, options);
    },
    comparator: function(note) {
      // Backbone checks the number of arguments (1 = absolute, 2 = compare)
      // Underscore passes an extra argument (the index in the underlying
      // list). The note must be before this index in the filtered list.
      var val;
      if (typeof(arguments[1]) === "number") {
        val = this.backingCollection.lastIndexOf(note, arguments[1]+1);
      }
      if (val < 0) {
        val = this.backingCollection.indexOf(note);
      }
      return val;
    },
    /**
     * Add new note iff it matches the current search terms.
     *
     * @param {LisIt.models.Note} note The note to consider adding.
     **/
    maybeAddNew: function(note) {
      if (this.matcher(note)) {
        // Avoid sorting. Optimize for loading.
        var idx = this.comparator(note, this.backingCollection.length);

        if (idx > 0) {
          this.add(note, {'new': true, sort: false, at: idx});
        } else {
          // This should never happen but check anyways.
          this.add(note, {'new': true});
        }
      }
    },
    /**
     * Check if a note matches the current search terms.
     *
     * @param {ListIt.models.Note} note The note to test
     *
     * @return {boolean} Returns true iff the note matches the terms.
     **/
    matcher: function(note) {
      if (!this._terms) {
        return true;
      }
      var text = L.util.clean(note.get('contents').toLowerCase());
      return L.util.matchTerms(this._terms, text);
    },
    /**
     * Filter the list by the given search terms (as text).
     *
     * @param{String} text the search terms as text (Ex: "search for this but -not -this")
     *
     * @return {int} Returns a unique ID representing this search.
     **/
    search: function(text) {
      // Extract the terms
      var newTerms = L.util.extractTerms(text);

      // Don't search if the terms haven't changed.
      if (_.isEqual(this._terms, newTerms)) {
        return this.searchID;
      }
      return this._filter(newTerms);
    },
    /**
     * Given the parsed search terms, perform the actual search.
     **/
    _filter: function(newTerms) {

      this.searchQueue.clear();
      if (this.searching) {
        this.searching = false;
        this.trigger("search:abort search:end", this._terms, this.searchID);
        debug('search::cancel');
      }

      this._searchCursor = 0;

      debug('search::start');

      var that = this;
      if (arguments.length > 0) {
        this._terms = newTerms;
      }
      this.searching = true;
      this.searchID = Math.random();
      this.trigger("search:begin", this._terms, this.searchID);

      _.each(_.chunk(this.backingCollection, this._chunk), function(chunk) {
        that.searchQueue.add(function() {
          _.each(chunk, function(note) {
            if (that.matcher(note)) {
              that.add(note, {at: that._searchCursor, sort: false});
              that._searchCursor++;
            } else {
              that.remove(note);
            }
          });
        });
      });

      this.searchQueue.add(function() {
        that.searching = false;
        debug('search::end');
        that.trigger('search:complete search:end', that._terms, that.searchID);
      });
      return this.searchID;
    }
  });

  L.models.NoteBook = Backbone.RelModel.extend({
    url: '/notebook',
    autoFetch: true,
    autoFetchRelated: true,
    defaults : {
      version: 0
    },
    isNew: function() {
      return false;
    },
    initialize: function() {
      var that = this;
      // Start saving immediately after loading before fetching related.
      this.once('sync error', function() {
        // Perform an initial flush to save any changes that might have occured
        // durring fetch.
        that.save();
        _.each(that.relations, function(v, k) {
          that.listenTo(that.get(k), 'add remove', function(model, collection, options) {
            if (!(options && options.nosave)) {
              that.save();
            }
          });
        });
      });
    },
    initialized: function() {
      // No need to add this early.
      // Ensures that pinned notes go on top.
      this.get('notes').comparator = function(note) {
        return (note.get('meta') || {}).pinned ? 0 : 1;
      };

      // Re-insert note on pinned change.
      this.listenTo(this.get('notes'), 'change:meta', function(note, meta) {
        var pMeta = note.previous('meta');
        // pinned may be undefined etc.
        if (Boolean(meta.pinned) !== Boolean(pMeta.pinned)) {
          _.defer(function() {
            note.collection.reinsert(note);
          });
        }
      });
    },
    /**
     * Import notes
     *
     * @param{String} string The contents of the file to import.
     * @param{String} type The type of file stored in string.
     *
     * @exception {Error} Throws "Invalid Importer" if the type cannot be imported.
     *
     **/
    importString: function(string, type) {
      if (!_.has(this.importers, type)) {
        throw new Error("Invalid Importer");
      }
      var json = this.importers[type].importer(string);
      if (json.notes) {
        var notes = this.get('notes');
        _.each(json.notes, function(note) {
          note.modified = true;
          notes.create(note);
        });
      }
      if (json.deletedNotes) {
        var deletedNotes = this.get('deletedNotes');
        _.each(json.deletedNotes, function(note) {
          note.modified = true;
          deletedNotes.create(note);
        });
      }
    },
    /**
     * Export notes
     *
     * @param {String} type The type to which to export
     *
     * @exception {Error} Throws "Invalid Exporter" if type cannot be exported.
     * @return {String} Returns the exported notes in the requested format (type).
     *
     **/
    exportString: function(type) {
      if (!_.has(this.exporters, type)) {
        throw new Error("Invalid Exporter");
      }
      return this.exporters[type].exporter(L.notebook);
    },
    /**
     * The exporters. These should be of the form:
     *   <type>: {
     *     display: '<display name>',
     *     exporter: function(<notebook instance>) -> @type{String}
     *   }
     **/
    exporters: {
      json: {
        display: 'JSON',
        exporter: function(notebook) {
          return JSON.stringify(notebook.toJSON({include: true}));
        }
      },
      txt: {
        display : 'Text',
        exporter: function(notebook) {
          return notebook.get('notes').reduce(function(txt, n) {
            return txt + '* ' + L.util.clean(n.get('contents')).replace(/\n/g, '\n  ') + '\n';
          }, '');
        }
      },
      html: {
        display : 'HTML',
        exporter: function(notebook) {
          return L.templates['exported-notes']({noteContents: notebook.get('notes').pluck('contents')});
        }
      },
      csv: {
        display : 'CSV',
        exporter: function(notebook) {
          var noteArray=[];
          noteArray.push(["Contents","Deleted","ID","Version","Created","Edited","Meta"]);
          var convertCSV = function(isDeleted, n) {
            return [_.str.q(n.get('contents').replace(/"/g, '""')),
              isDeleted,
              n.get('id'),
              n.get('version'),
              new Date(n.get('created')),
              new Date(n.get('edited')),
              _.str.q(JSON.stringify(n.get('meta')).replace(/"/g, '""'))];
          };
          noteArray.push.apply(noteArray, notebook.get('notes').map(_.partial(convertCSV, false)));
          noteArray.push.apply(noteArray, notebook.get('deletedNotes').map(_.partial(convertCSV, true)));
          return noteArray.join('\n') + '\n';
        }
      }
    },
    importers: {
      json: {
        display: 'JSON',
        importer : function(string) {
          return JSON.parse(string);
        }
      },
      txt: {
        display: 'Text',
        importer: function(string) {
          string = _.str.trim(string);
          var noteStrings;
          var bullet = string[0];
          if ("*-+".indexOf(bullet) >= 0) {
            string = string.replace(/\n\s*/g, '\n'); // TODO to greedy
            string = string.replace(new RegExp("^\\"+bullet+"\\s*"), ""); // Strip first
            noteStrings = string.split(new RegExp("\n\\s*\\"+bullet+"\\s*"));
            noteStrings = _.map(noteStrings, function(str) {
              return str.replace(/\n/g, '
');
            });
          } else {
            noteStrings = string.split('\n');
          }

          return {
            notes: _.map(noteStrings, function(noteString) {
              return {contents: noteString};
            })
          };
        }
      }
    },
    relations: {
      notes: {
        type: L.models.NoteCollection,
        includeInJSON: "id"
      },
      deletedNotes: {
        type: L.models.NoteCollection,
        includeInJSON: "id"
      }
    },
    createNote: function(obj, window, options) {
      var notes = this.get('notes');
      var note = new L.models.Note(obj, options);

      // Metadata
      var noteJSON = note.toJSON();
      // TODO: This should use a different event
      // TODO: Move this into the collection. Call when NEW (unsaved) note is added.
      L.gvent.trigger("note:request:parse note:request:parse:new", noteJSON, window);
      note.set(noteJSON);
      note.save();

      // Gets first insertable position (will add to top).
      // Insert
      var idx = notes.sortedIndex(note);
      notes.add(note, {at: idx});
      this.trigger("create", note);
      return note;
    },
    trashNote: function(note) {
      note.moveTo(this.get('deletedNotes'));
      this.trigger("delete", note);
      return note;
    },
    untrashNote: function(note) {
      note.moveTo(this.get('notes'));
      this.trigger("undelete", note);
      return note;
    },
    getNote: function(id) {
      return (this.get('deletedNotes').get(id) || this.get('notes').get(id));
    }
  });
})(ListIt);
<|endoftext|>


<commit_before>'use strict';

let express = require('express');
let nunjucks = require('nunjucks');
let bodyParser = require('body-parser');
let url = require('url');
let Countdown = require('js/shared/countdown');
let utils = require('./utils');
let app = express();
const auth = require('js/server/auth');
const errors = require('js/server/errors');
const colors = require('js/shared/colors');
const statuses = require('js/shared/status-constants');

let server = require('http').Server(app);

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled rejection at: Promise', promise, 'reason', reason);
});

utils.init(app);

app.use((req, res, next) => {
  res.locals.title = 'Hack Cambridge';
  res.locals.colors = colors;
  const port = (app.settings.env == 'development') ? ':' + req.app.settings.port : '';
  const protocol = (app.settings.env == 'development') ? req.protocol : 'https';
  res.locals.requestedUrl = req.requestedUrl = url.parse(
    protocol + '://' + req.hostname + port + req.originalUrl
  );
  next();
});

// Static file serving
let staticOptions = { };
if (app.settings.env != 'development') {
  staticOptions.maxAge = 60 * 60 * 365 * 1000;
}
app.use(require('compression')());
app.use('/assets', express.static(utils.resolvePath('assets/dist'), staticOptions));

auth.setUpAuth(app);

// View rendering
nunjucks.configure(utils.resolvePath('src/views'), {
  autoescape: true,
  noCache: app.settings.env == 'development',
  express: app
});

app.locals.asset = utils.asset;
app.locals.loadAsset = utils.loadAsset;
app.locals.markdownResource = utils.loadMarkdown;

if (process.env.BS_SNIPPET) {
  app.locals.browserSync = process.env.BS_SNIPPET;
}

// Routes

app.use(bodyParser.urlencoded({ extended: true }));
app.use('/api', require('./api'));
app.use('/apply', require('./apply/router'));
app.use('/hcapi', require('./hcapi'));

app.get('/', (req, res) => {
  res.render('splash.html');
});

app.get('/terms-and-conditions', (req, res) => {
  res.render('terms-and-conditions.html');
});

app.get('/terms', (req, res) => {
  // This URL was used in 2017 and previously, redirect it to the new location
  res.redirect(301, '/terms-and-conditions');
});

app.get('/privacy-policy', (req, res) => {
  res.render('privacy-policy.html');
});

app.get('/privacy', (req, res) => {
  // This URL was used in 2017 and previously, redirect it to the new location
  res.redirect(301, '/privacy-policy');
});

app.get('/pay', (req, res) => {
  res.render('pay.html', {
    title: 'Make a payment to Hack Cambridge',
    stripeKey: process.env.STRIPE_PUBLISH_KEY
  });
});

app.get('/event', (req, res) => {
  res.render('event.html', {
    title: 'Hack Cambridge Recurse',
    api_demos: utils.loadResource('api_demos'),
    workshops: utils.loadResource('workshops'),
    prizes: utils.loadResource('prizes'),
    schedule: utils.loadResource('schedule'),
    apis: utils.loadResource('apis')
  });
});

app.get('/live', (req, res) => {
  res.render('live.html', {
    title: 'Hack Cambridge Recurse',
    sponsors: utils.loadResource('sponsors'),
    pusherKey: process.env.PUSHER_KEY,
  });
});

app.get('/volunteers', (req, res) => {
  res.redirect(302, 'https://goo.gl/forms/2jHTyCKiXQgGR6Jy2');
});

app.get('/favicon.ico', (req, res) => {
  res.sendFile(utils.resolvePath('assets/images/favicon.ico'));
});

app.get('/sponsorship', (req, res) => {
  res.render('sponsorship.html');
});

app.use((req, res) => {
  res.status(404).render('404.html');
});

app.use(errors.middleware);

// Start server
app.set('port', (process.env.PORT || 3000));

module.exports = app;

server.listen(app.get('port'), () => {
  console.log('Node app is running on port', app.get('port'));
});
<commit_msg>Take into account file renaming<commit_after>'use strict';

let express = require('express');
let nunjucks = require('nunjucks');
let bodyParser = require('body-parser');
let url = require('url');
let Countdown = require('js/shared/countdown');
let utils = require('./utils');
let app = express();
const auth = require('js/server/auth');
const errors = require('js/server/errors');
const colors = require('js/shared/colors');
const statuses = require('js/shared/status-constants');

let server = require('http').Server(app);

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled rejection at: Promise', promise, 'reason', reason);
});

utils.init(app);

app.use((req, res, next) => {
  res.locals.title = 'Hack Cambridge';
  res.locals.colors = colors;
  const port = (app.settings.env == 'development') ? ':' + req.app.settings.port : '';
  const protocol = (app.settings.env == 'development') ? req.protocol : 'https';
  res.locals.requestedUrl = req.requestedUrl = url.parse(
    protocol + '://' + req.hostname + port + req.originalUrl
  );
  next();
});

// Static file serving
let staticOptions = { };
if (app.settings.env != 'development') {
  staticOptions.maxAge = 60 * 60 * 365 * 1000;
}
app.use(require('compression')());
app.use('/assets', express.static(utils.resolvePath('assets/dist'), staticOptions));

auth.setUpAuth(app);

// View rendering
nunjucks.configure(utils.resolvePath('src/views'), {
  autoescape: true,
  noCache: app.settings.env == 'development',
  express: app
});

app.locals.asset = utils.asset;
app.locals.loadAsset = utils.loadAsset;
app.locals.markdownResource = utils.loadMarkdown;

if (process.env.BS_SNIPPET) {
  app.locals.browserSync = process.env.BS_SNIPPET;
}

// Routes

app.use(bodyParser.urlencoded({ extended: true }));
app.use('/api', require('./api'));
app.use('/apply', require('./apply/router'));
app.use('/hcapi', require('./hcapi'));

app.get('/', (req, res) => {
  res.render('index.html');
});

app.get('/terms-and-conditions', (req, res) => {
  res.render('terms-and-conditions.html');
});

app.get('/terms', (req, res) => {
  // This URL was used in 2017 and previously, redirect it to the new location
  res.redirect(301, '/terms-and-conditions');
});

app.get('/privacy-policy', (req, res) => {
  res.render('privacy-policy.html');
});

app.get('/privacy', (req, res) => {
  // This URL was used in 2017 and previously, redirect it to the new location
  res.redirect(301, '/privacy-policy');
});

app.get('/pay', (req, res) => {
  res.render('pay.html', {
    title: 'Make a payment to Hack Cambridge',
    stripeKey: process.env.STRIPE_PUBLISH_KEY
  });
});

app.get('/event', (req, res) => {
  res.render('event.html', {
    title: 'Hack Cambridge Recurse',
    api_demos: utils.loadResource('api_demos'),
    workshops: utils.loadResource('workshops'),
    prizes: utils.loadResource('prizes'),
    schedule: utils.loadResource('schedule'),
    apis: utils.loadResource('apis')
  });
});

app.get('/live', (req, res) => {
  res.render('live.html', {
    title: 'Hack Cambridge Recurse',
    sponsors: utils.loadResource('sponsors'),
    pusherKey: process.env.PUSHER_KEY,
  });
});

app.get('/volunteers', (req, res) => {
  res.redirect(302, 'https://goo.gl/forms/2jHTyCKiXQgGR6Jy2');
});

app.get('/favicon.ico', (req, res) => {
  res.sendFile(utils.resolvePath('assets/images/favicon.ico'));
});

app.get('/sponsorship', (req, res) => {
  res.render('sponsorship.html');
});

app.use((req, res) => {
  res.status(404).render('404.html');
});

app.use(errors.middleware);

// Start server
app.set('port', (process.env.PORT || 3000));

module.exports = app;

server.listen(app.get('port'), () => {
  console.log('Node app is running on port', app.get('port'));
});
<|endoftext|>


<commit_before>/**
* TabNavigator UI-Component for static and dinamic content.
* @name TabNavigator
* @class TabNavigator
* @augments ch.Controllers
* @memberOf ch
* @param {object} conf Object with configuration properties
* @returns itself
*/

ch.tabNavigator = function(conf){

	/**
	* Reference to a internal component instance, saves all the information and configuration properties.
	* @private
	* @name ch.TabNavigator#that
	* @type object
	*/
	var that = this;

	conf = ch.clon(conf);

	that.conf = conf;

/**
*	Inheritance
*/

	that = ch.controllers.call(that);
	that.parent = ch.clon(that);

/**
*	Private Members
*/

	/**
	* The actual location hash, is used to know if there's a specific tab selected.
	* @private
	* @name ch.TabNavigator#hash
	* @type string
	*/
	var hash = window.location.hash.replace("#!", "");

	/**
	* A boolean property to know if the some tag should be selected.
	* @private
	* @name ch.TabNavigator#hashed
	* @type boolean
	* @default false
	*/
	var hashed = false;

	/**
	* Get wich tab is selected.
	* @private
	* @name ch.TabNavigator#selected
	* @type number
	*/
	var selected = conf.selected - 1 || conf.value - 1 || 0;

	/**
	* Create controller's children.
	* @private
	* @name ch.TabNavigator#createTabs
	* @function
	*/

	var createTabs = function(){

		// Children
		that.$triggers.find("a").each(function(i, e){

			// Tab context
			var tab = {};
				tab.uid = that.uid + "#" + i;
				tab.type = "tab";
				tab.element = e;
				tab.$element = $(e);
				tab.controller = that["public"];

				tab.$element.attr("role","tab")

			// Tab configuration
			var config = {};
				config.open = (selected == i);
				config.onShow = function(){
					selected = i;
				};

			if(ch.utils.hasOwn(that.conf, "cache")) {
				config.cache = that.conf.cache;
			};

		/**
		* Callback function
		* @name ch.TabNavigator#onContentLoad
		* @event
		* @public
		*/
			if (ch.utils.hasOwn(that.conf, "onContentLoad")) config.onContentLoad = that.conf.onContentLoad;
		/**
		* Callback function
		* @name ch.TabNavigator#onContentError
		* @event
		* @public
		*/
			if (ch.utils.hasOwn(that.conf, "onContentError")) config.onContentError = that.conf.onContentError;

			// Create Tabs
			that.children.push(
				ch.tab.call(tab, config)
			);

			// Bind new click to have control
			$(e).unbind("click").bind("click", function(event){
				that.prevent(event);
				select(i + 1);
			});

		});

		return;

	};

	/**
	* Select a child to show its content.
	* @name ch.TabNavigator#select
	* @private
	* @function
	*/
	var select = function(tab){

		tab = that.children[tab - 1];

		if(tab === that.children[selected]) return; // Don't click me if I'm open

		// Hide my open bro
		$.each(that.children, function(i, e){
			if(tab !== e) e.hide();
		});

		tab.show();

		//Change location hash
		window.location.hash = "#!" + tab.$content.attr("id");

	/**
	* Callback function
	* @name ch.TabNavigator#onSelect
	* @event
	* @public
	*/
		that.callbacks("onSelect");
		// new callback
		that.trigger("select");

	return that;
	};

/**
*	Protected Members
*/

	/**
	* The component's triggers container.
	* @private
	* @name ch.TabNavigator#$triggers
	* @type jQuery
	*/
	that.$triggers = that.$element.children(":first").addClass("ch-tabNavigator-triggers").attr("role","tablist");

	/**
	* The component's content.
	* @private
	* @name ch.TabNavigator#$content
	* @type jQuery
	*/
	that.$content = that.$triggers.next().addClass("ch-tabNavigator-content box").attr("role","presentation");

/**
*	Public Members
*/

	/**
	* The component's instance unique identifier.
	* @public
	* @name ch.TabNavigator#uid
	* @type number
	*/

	/**
	* The element reference.
	* @public
	* @name ch.TabNavigator#element
	* @type HTMLElement
	*/

	/**
	* The component's type.
	* @public
	* @name ch.TabNavigator#type
	* @type string
	*/

	/**
	* Children instances associated to this controller.
	* @public
	* @name ch.TabNavigator#children
	* @type collection
	*/
	that["public"].children = that.children;

	/**
	* Select a specific child.
	* @public
	* @function
	* @name ch.TabNavigator#select
	* @param {number} tab Tab's index.
	*/
	that["public"].select = function(tab){
		select(tab);

		return that["public"];
	};

	/**
	* Returns the selected child's index.
	* @public
	* @function
	* @name ch.TabNavigator#getSelected
	* @returns {number} selected Tab's index.
	*/
	that["public"].getSelected = function(){ return (selected + 1); };

/**
*	Default event delegation
*/

	that.$element.addClass("ch-tabNavigator");

	createTabs();

	//Default: Load hash tab or Open first tab
	for(var i = that.children.length; i-=1; ){
		if ( that.children[i].$content.attr("id") === hash ) {
			select(i + 1);

			hashed = true;

			break;
		};
	};

	return that;

};

ch.factory("tabNavigator");

/**
* Simple unit of content for TabNavigators.
* @abstract
* @name Tab
* @class Tab
* @augments ch.Navs
* @memberOf ch
* @param {object} conf Object with configuration properties
* @returns itself
*/

ch.tab = function(conf){
	/**
	* Reference to a internal component instance, saves all the information and configuration properties.
	* @private
	* @name ch.Tab#that
	* @type object
	*/
	var that = this;

	conf = ch.clon(conf);
	conf.icon = false;

	that.conf = conf;

/**
*	Inheritance
*/

	that = ch.navs.call(that);
	that.parent = ch.clon(that);

/**
*	Private Members
*/
	/**
	* Creates the basic structure for the tab's content.
	* @private
	* @name ch.Tab#createContent
	* @function
	*/
	var createContent = function(){
		var href = that.element.href.split("#");
		var controller = that.$element.parents(".ch-tabNavigator");
		var content = controller.find("#" + href[1]);

		// If there are a tabContent...
		if (content.length > 0) {

			return content;

		// If tabContent doesn't exists
		} else {
			/**
			* Content configuration property.
			* @public
			* @name ch.Tab#source
			* @type string
			*/
			that.source = that.element.href;

			var id = (href.length == 2) ? href[1] : "ch-tab" + that.uid.replace("#","-");

			// Create tabContent
			return $("
").appendTo( controller.children().eq(1) );
		}

	};

/**
*	Protected Members
*/
	/**
	* Reference to the trigger element.
	* @private
	* @name ch.Tab#$trigger
	* @type jQuery
	*/
	that.$trigger = that.$element;

	/**
	* The component's content.
	* @private
	* @name ch.Tab#$content
	* @type jQuery
	*/
	that.$content = createContent();

	/**
	* Process the show event.
	* @private
	* @function
	* @name ch.Tab#show
	* @returns jQuery
	*/
	that.show = function(event){
		that.prevent(event);

		// Load my content if I'need an ajax request
		if (ch.utils.hasOwn(that, "source")) {
			that.content();
		}

		// Show me
		that.parent.show(event);

		// Set me as hidden false
		that.$content.attr("aria-hidden","false");

		// It removes the class ch-js-hide because the content be visible on click
		that.$content.hasClass('ch-js-hide')?that.$content.removeClass('ch-js-hide'):null;

		// When click or enter to the tab, then it will be focused
		that.$trigger.focus();

		return that;
	};

	/**
	* Process the hide event.
	* @private
	* @function
	* @name ch.Tab#hide
	* @returns jQuery
	*/
	that.hide = function(event){
		that.prevent(event);

		// Hide me
		that.parent.hide(event);

		// Set all inactive tabs as hidden
		that.$content.attr("aria-hidden","true");

		return that;
	};

	/**
	* This callback is triggered when async data is loaded into component's content, when ajax content comes back.
	* @protected
	* @name ch.Tab#contentCallback
	*/
	that["public"].on("contentLoad", function(event, context){

		that.$content.html(that.staticContent);

		if (ch.utils.hasOwn(conf, "onContentLoad")) {
			conf.onContentLoad.call(context, that.staticContent);
		}

	});

	/**
	* This callback is triggered when async request fails.
	* @public
	* @name contentCallback
	* @returns {Chico-UI Object}
	* @memberOf ch.TabNavigator
	*/
	that["public"].on("contentError", function(event, data){

		that.$content.html(that.staticContent);

		// Get the original that.source
		var originalSource = that.source;

		if (ch.utils.hasOwn(conf, "onContentError")) {
			conf.onContentError.call(data.context, data.jqXHR, data.textStatus, data.errorThrown);
		}

		// Reset content configuration
		that.source = originalSource;
		that.staticContent = undefined;

	});

/**
*	Public Members
*/

/**
*	Default event delegation
*/

	that.configBehavior();
	var hidden = that.$content.hasClass("ch-hide")?true:false;
		that.$content.attr("role","tabpanel").attr("aria-hidden",hidden);
		that.$trigger.attr("role","tab");
	return that;
}<commit_msg>GH-308 TabNavigator: added aria-controls attr missed<commit_after>/**
* TabNavigator UI-Component for static and dinamic content.
* @name TabNavigator
* @class TabNavigator
* @augments ch.Controllers
* @memberOf ch
* @param {object} conf Object with configuration properties
* @returns itself
*/

ch.tabNavigator = function(conf){

	/**
	* Reference to a internal component instance, saves all the information and configuration properties.
	* @private
	* @name ch.TabNavigator#that
	* @type object
	*/
	var that = this;

	conf = ch.clon(conf);

	that.conf = conf;

/**
*	Inheritance
*/

	that = ch.controllers.call(that);
	that.parent = ch.clon(that);

/**
*	Private Members
*/

	/**
	* The actual location hash, is used to know if there's a specific tab selected.
	* @private
	* @name ch.TabNavigator#hash
	* @type string
	*/
	var hash = window.location.hash.replace("#!", "");

	/**
	* A boolean property to know if the some tag should be selected.
	* @private
	* @name ch.TabNavigator#hashed
	* @type boolean
	* @default false
	*/
	var hashed = false;

	/**
	* Get wich tab is selected.
	* @private
	* @name ch.TabNavigator#selected
	* @type number
	*/
	var selected = conf.selected - 1 || conf.value - 1 || 0;

	/**
	* Create controller's children.
	* @private
	* @name ch.TabNavigator#createTabs
	* @function
	*/

	var createTabs = function(){

		// Children
		that.$triggers.find("a").each(function(i, e){

			// Tab context
			var tab = {};
				tab.uid = that.uid + "#" + i;
				tab.type = "tab";
				tab.element = e;
				tab.$element = $(e);
				tab.controller = that["public"];

				tab.$element.attr("role","tab");

			// Tab configuration
			var config = {};
				config.open = (selected == i);
				config.onShow = function(){
					selected = i;
				};

			if(ch.utils.hasOwn(that.conf, "cache")) {
				config.cache = that.conf.cache;
			};

		/**
		* Callback function
		* @name ch.TabNavigator#onContentLoad
		* @event
		* @public
		*/
			if (ch.utils.hasOwn(that.conf, "onContentLoad")) config.onContentLoad = that.conf.onContentLoad;
		/**
		* Callback function
		* @name ch.TabNavigator#onContentError
		* @event
		* @public
		*/
			if (ch.utils.hasOwn(that.conf, "onContentError")) config.onContentError = that.conf.onContentError;

			// Create Tabs
			that.children.push(
				ch.tab.call(tab, config)
			);

			// Bind new click to have control
			$(e).unbind("click").bind("click", function(event){
				that.prevent(event);
				select(i + 1);
			});

		});

		return;

	};

	/**
	* Select a child to show its content.
	* @name ch.TabNavigator#select
	* @private
	* @function
	*/
	var select = function(tab){

		tab = that.children[tab - 1];

		if(tab === that.children[selected]) return; // Don't click me if I'm open

		// Hide my open bro
		$.each(that.children, function(i, e){
			if(tab !== e) e.hide();
		});

		tab.show();

		//Change location hash
		window.location.hash = "#!" + tab.$content.attr("id");

	/**
	* Callback function
	* @name ch.TabNavigator#onSelect
	* @event
	* @public
	*/
		that.callbacks("onSelect");
		// new callback
		that.trigger("select");

	return that;
	};

/**
*	Protected Members
*/

	/**
	* The component's triggers container.
	* @private
	* @name ch.TabNavigator#$triggers
	* @type jQuery
	*/
	that.$triggers = that.$element.children(":first").addClass("ch-tabNavigator-triggers").attr("role","tablist");

	/**
	* The component's content.
	* @private
	* @name ch.TabNavigator#$content
	* @type jQuery
	*/
	that.$content = that.$triggers.next().addClass("ch-tabNavigator-content box").attr("role","presentation");

/**
*	Public Members
*/

	/**
	* The component's instance unique identifier.
	* @public
	* @name ch.TabNavigator#uid
	* @type number
	*/

	/**
	* The element reference.
	* @public
	* @name ch.TabNavigator#element
	* @type HTMLElement
	*/

	/**
	* The component's type.
	* @public
	* @name ch.TabNavigator#type
	* @type string
	*/

	/**
	* Children instances associated to this controller.
	* @public
	* @name ch.TabNavigator#children
	* @type collection
	*/
	that["public"].children = that.children;

	/**
	* Select a specific child.
	* @public
	* @function
	* @name ch.TabNavigator#select
	* @param {number} tab Tab's index.
	*/
	that["public"].select = function(tab){
		select(tab);

		return that["public"];
	};

	/**
	* Returns the selected child's index.
	* @public
	* @function
	* @name ch.TabNavigator#getSelected
	* @returns {number} selected Tab's index.
	*/
	that["public"].getSelected = function(){ return (selected + 1); };

/**
*	Default event delegation
*/

	that.$element.addClass("ch-tabNavigator");

	createTabs();

	//Default: Load hash tab or Open first tab
	for(var i = that.children.length; i-=1; ){
		if ( that.children[i].$content.attr("id") === hash ) {
			select(i + 1);

			hashed = true;

			break;
		};
	};

	return that;

};

ch.factory("tabNavigator");

/**
* Simple unit of content for TabNavigators.
* @abstract
* @name Tab
* @class Tab
* @augments ch.Navs
* @memberOf ch
* @param {object} conf Object with configuration properties
* @returns itself
*/

ch.tab = function(conf){
	/**
	* Reference to a internal component instance, saves all the information and configuration properties.
	* @private
	* @name ch.Tab#that
	* @type object
	*/
	var that = this;

	conf = ch.clon(conf);
	conf.icon = false;

	that.conf = conf;

/**
*	Inheritance
*/

	that = ch.navs.call(that);
	that.parent = ch.clon(that);

/**
*	Private Members
*/
	/**
	* Creates the basic structure for the tab's content.
	* @private
	* @name ch.Tab#createContent
	* @function
	*/
	var createContent = function(){
		var href = that.element.href.split("#");
		var controller = that.$element.parents(".ch-tabNavigator");
		var content = controller.find("#" + href[1]);

		// If there are a tabContent...
		if (content.length > 0) {

			return content;

		// If tabContent doesn't exists
		} else {
			/**
			* Content configuration property.
			* @public
			* @name ch.Tab#source
			* @type string
			*/
			that.source = that.element.href;

			var id = (href.length == 2) ? href[1] : "ch-tab" + that.uid.replace("#","-");

			// Create tabContent
			return $("
").appendTo( controller.children().eq(1) );
		}

	};

/**
*	Protected Members
*/
	/**
	* Reference to the trigger element.
	* @private
	* @name ch.Tab#$trigger
	* @type jQuery
	*/
	that.$trigger = that.$element;

	/**
	* The component's content.
	* @private
	* @name ch.Tab#$content
	* @type jQuery
	*/
	that.$content = createContent();

	/**
	* Process the show event.
	* @private
	* @function
	* @name ch.Tab#show
	* @returns jQuery
	*/
	that.show = function(event){
		that.prevent(event);

		// Load my content if I'need an ajax request
		if (ch.utils.hasOwn(that, "source")) {
			that.content();
		}

		// Show me
		that.parent.show(event);

		// Set me as hidden false
		that.$content.attr("aria-hidden","false");

		// It removes the class ch-js-hide because the content be visible on click
		that.$content.hasClass('ch-js-hide')?that.$content.removeClass('ch-js-hide'):null;

		// When click or enter to the tab, then it will be focused
		that.$trigger.focus();

		return that;
	};

	/**
	* Process the hide event.
	* @private
	* @function
	* @name ch.Tab#hide
	* @returns jQuery
	*/
	that.hide = function(event){
		that.prevent(event);

		// Hide me
		that.parent.hide(event);

		// Set all inactive tabs as hidden
		that.$content.attr("aria-hidden","true");

		return that;
	};

	/**
	* This callback is triggered when async data is loaded into component's content, when ajax content comes back.
	* @protected
	* @name ch.Tab#contentCallback
	*/
	that["public"].on("contentLoad", function(event, context){

		that.$content.html(that.staticContent);

		if (ch.utils.hasOwn(conf, "onContentLoad")) {
			conf.onContentLoad.call(context, that.staticContent);
		}

	});

	/**
	* This callback is triggered when async request fails.
	* @public
	* @name contentCallback
	* @returns {Chico-UI Object}
	* @memberOf ch.TabNavigator
	*/
	that["public"].on("contentError", function(event, data){

		that.$content.html(that.staticContent);

		// Get the original that.source
		var originalSource = that.source;

		if (ch.utils.hasOwn(conf, "onContentError")) {
			conf.onContentError.call(data.context, data.jqXHR, data.textStatus, data.errorThrown);
		}

		// Reset content configuration
		that.source = originalSource;
		that.staticContent = undefined;

	});

/**
*	Public Members
*/

/**
*	Default event delegation
*/

	that.configBehavior();
	// Add the attr for WAI-ARIA to the tabs and tabpanel
	var hidden = that.$content.hasClass("ch-hide")?true:false;
		that.$content.attr("role","tabpanel").attr("aria-hidden",hidden);
		that.$trigger.attr("role","tab");
		that.$trigger.attr("arial-controls",that.$content.attr("id"));
	return that;
}<|endoftext|>


<commit_before>/**
 *	Tabs Navigator
 *	@author
 *	@Contructor
 *	@return An interface object
 */

ui.tabNavigator = function(conf){

    var that = ui.controllers();

    conf.publish = that.publish;

	var $triggers = $(conf.element).children(':first').find('a');
	var $htmlContent = $(conf.element).children(':first').next();

	// Global configuration
	$(conf.element).addClass('ch-tabNavigator');
	$(conf.element).children(':first').addClass('ch-tabNavigator-triggers');
	$triggers.addClass('ch-tabNavigator-trigger');
	$htmlContent.addClass('ch-tabNavigator-content box');

	// Starts (Mother is pregnant, and her children born)
	$.each($triggers, function(i, e){
		that.children.push(ui.tab(i, e, conf));
	});

    // TODO: Normalizar las nomenclaturas de métodos, "show" debería ser "select"
	var show = function(event, tab){
		//ui.instances.tabNavigator[conf.instance].tabs[tab].shoot(event);

        that.children[tab].shoot(event);

        /* The potato is ready!!
		Use this to execute a specific tab on console (on h1 click)
		$('h1').click(function(event){
			ui.instances.tabNavigator[0].show(event, 2);
		});*/

        // return publish object
        return conf.publish;
	};

    // create the publish object to be returned
	conf.publish.uid = conf.id;
	conf.publish.element = conf.element;
	conf.publish.type = "tabNavigator";
	conf.publish.tabs = that.children;
	conf.publish.select = function(tab){ return show($.Event(), tab) };

    //Default: Open first tab by ajax or not.
	show($.Event(), 0);

	return conf.publish;

};

/**
 *	Tab
 *	@author
 *	@Contructor
 *	@return An interface object
 */

ui.tab = function(index, element, conf){
	var that = ui.navs(); // Inheritance
	var display = element.href.split('#');
	var $tabContent = $(element).parents('.ch-tabNavigator').find('#' + display[1]);

	// Global configuration
	that.conf = {
		name: 'tab',
		$trigger: $(element).addClass('ch-tabNavigator-trigger'),
		callbacks: conf.callbacks
	};

	var results = function(){

        // If there are a tabContent...
		if ( $tabContent.attr('id') ) {
			return $tabContent;
		// If tabContent doesn't exists
		} else {
			// Set ajax configuration
			that.conf.ajax = true;

			// Create tabContent
			var w = $('<div>').attr('id', 'ch-tab' + index);
				w.hide().appendTo( that.conf.$trigger.parents('.ch-tabNavigator').find('.ch-tabNavigator-content') );
			return w;
		};
	};
	that.conf.$htmlContent = results();

	// Hide all closed tabs
	if(!that.status) that.conf.$htmlContent.hide();

	// Process show event
	that.shoot = function(event){
		that.prevent(event);

		var tabs = conf.publish.tabs; //ui.instances.tabNavigator[conf.id].tabs; // All my bros
		if(tabs[index].status) return; // Don't click me if I'm open

		// Hide my open bro
		$.each(tabs, function(i, e){
			if(e.status) e.hide(event, e.conf);
		});

		// Load my content if I'need an ajax request
		if(that.conf.$htmlContent.html() === '') that.conf.$htmlContent.html( that.loadContent(that.conf) );

		// Show me
		that.show(event, that.conf);
	};

	// Events
	that.conf.$trigger.bind('click', that.shoot);

	return that;
}
<commit_msg>UI-177 Tab Navigator ya carga el primer tab si esta setado por ajax.<commit_after>/**
 *	Tabs Navigator
 *	@author
 *	@Contructor
 *	@return An interface object
 */

ui.tabNavigator = function(conf){

    var that = ui.controllers();

    conf.publish = that.publish;

	var $triggers = $(conf.element).children(':first').find('a');
	var $htmlContent = $(conf.element).children(':first').next();

	// Global configuration
	$(conf.element).addClass('ch-tabNavigator');
	$(conf.element).children(':first').addClass('ch-tabNavigator-triggers');
	$triggers.addClass('ch-tabNavigator-trigger');
	$htmlContent.addClass('ch-tabNavigator-content box');

	// Starts (Mother is pregnant, and her children born)
	$.each($triggers, function(i, e){
		that.children.push(ui.tab(i, e, conf));
	});

    // TODO: Normalizar las nomenclaturas de métodos, "show" debería ser "select"
	var show = function(event, tab){
		//ui.instances.tabNavigator[conf.instance].tabs[tab].shoot(event);

        that.children[tab].shoot(event);

        /* The potato is ready!!
		Use this to execute a specific tab on console (on h1 click)
		$('h1').click(function(event){
			ui.instances.tabNavigator[0].show(event, 2);
		});*/

        // return publish object
        return conf.publish;
	};

	//Default: Open first tab in any case. TODO: remove $.Event()
	show($.Event(), 0);

    // create the publish object to be returned
	conf.publish.uid = conf.id;
	conf.publish.element = conf.element;
	conf.publish.type = "tabNavigator";
	conf.publish.tabs = that.children;
	conf.publish.select = function(tab){ return show($.Event(), tab) };

	return conf.publish;

};

/**
 *	Tab
 *	@author
 *	@Contructor
 *	@return An interface object
 */

ui.tab = function(index, element, conf){
	var that = ui.navs(); // Inheritance
	var display = element.href.split('#');
	var $tabContent = $(element).parents('.ch-tabNavigator').find('#' + display[1]);

	// Global configuration
	that.conf = {
		name: 'tab',
		$trigger: $(element).addClass('ch-tabNavigator-trigger'),
		callbacks: conf.callbacks
	};

	var results = function(){

        // If there are a tabContent...
		if ( $tabContent.attr('id') ) {
			return $tabContent;
		// If tabContent doesn't exists
		} else {
			// Set ajax configuration
			that.conf.ajax = true;

			// Create tabContent
			var w = $('<div>').attr('id', 'ch-tab' + index);
				w.hide().appendTo( that.conf.$trigger.parents('.ch-tabNavigator').find('.ch-tabNavigator-content') );
			return w;
		};
	};
	that.conf.$htmlContent = results();

	// Hide all closed tabs
	if(!that.status) that.conf.$htmlContent.hide();

	// Process show event
	that.shoot = function(event){
		that.prevent(event);

		var tabs = conf.publish.tabs; //ui.instances.tabNavigator[conf.id].tabs; // All my bros
		if(tabs[index].status) return; // Don't click me if I'm open

		// Hide my open bro
		$.each(tabs, function(i, e){
			if(e.status) e.hide(event, e.conf);
		});

		// Load my content if I'need an ajax request
		if(that.conf.$htmlContent.html() === '') that.conf.$htmlContent.html( that.loadContent(that.conf) );

		// Show me
		that.show(event, that.conf);
	};

	// Events
	that.conf.$trigger.bind('click', that.shoot);

	return that;
}
<|endoftext|>


<commit_before>/***
TiddlySpace extensions for [[chrjs]]
***/
//{{{
(function($) {

tiddlyweb.routes.spaces = "{host}/spaces";
tiddlyweb.routes.space = "{host}/spaces/{name}";
tiddlyweb.routes.members = "{host}/spaces/{name}/members";
tiddlyweb.routes.member = "{host}/spaces/{name}/members/{username}";

tiddlyweb.Space = function(name, host) {
	tiddlyweb.Resource.apply(this, ["space", host]);
	this.name = name;
};
tiddlyweb.Space.prototype = new tiddlyweb.Resource();
$.extend(tiddlyweb.Space.prototype, {
	create: function(callback, errback) { // API wrapper
		this.put.apply(this, arguments);
	},
	members: function() {
		return new MemberCollection(this);
	},
	includes: function() {
		return new IncludesCollection(this);
	}
});

var Member = function(username, space) {
	tiddlyweb.Resource.apply(this, ["member", space.host]);
	this.name = space.name;
	this.username = username;
};
Member.prototype = new tiddlyweb.Resource();

var MemberCollection = function(space) {
	tiddlyweb.Collection.apply(this, ["members", space.host, {
		name: space.name
	}]);
};
MemberCollection.prototype = new tiddlyweb.Collection();
$.extend(MemberCollection.prototype, {
	add: function(username, callback, errback) {
		var member = new Member(username, this);
		member.put(callback, errback);
	},
	remove: function(username, callback, errback) {
		var member = new Member(username, this);
		member["delete"](callback, errback);
	}
});

var IncludesCollection = function(space) {
	tiddlyweb.Collection.apply(this, ["space", space.host, {
		name: space.name
	}]);
};
IncludesCollection.prototype = new tiddlyweb.Collection();
$.extend(IncludesCollection.prototype, {
	get: function(callback, errback) {
		var self = this;
		var recipe = new tiddlyweb.Recipe(this.name + "_public", this.host);
		recipe.get(function(recipe, status, xhr) {
			var inclusions = $.map(recipe.recipe, function(item, i) {
				var arr = item[0].split("_public");
				return (arr[0] != self.name && arr[1] === "") ? arr[0] : null;
			});
			callback(inclusions, status, xhr);
		}, function(xhr, error, exc) {
			errback(xhr, error, exc, self);
		});
	},
	add: function(name, callback, errback) {
		var self = this;
		var names = typeof(name) === "string" ? [ name ] : name;
		$.ajax({
			type: "post",
			url: this.route(),
			contentType: "json",
			data: $.toJSON({ "subscriptions": names }),
			success: function(response, status, xhr) {
				callback(self, status, xhr);
			},
			errback: function(xhr, error, exc) {
				errback(xhr, error, exc, self);
			}
		});
	},
	remove: function(name, callback, errback) {
		var self = this;
		var names = typeof(name) === "string" ? [ name ] : name;
		$.ajax({
			type: "post",
			contentType: "json",
			url: this.route(),
			data: $.toJSON({ "unsubscriptions": names }),
			success: function(response, status, xhr) {
				callback(self, status, xhr);
			},
			errback: function(xhr, error, exc) {
				errback(xhr, error, exc, self);
			}
		});
	}
});

})(jQuery);
//}}}
<commit_msg>use json2 instead of $.toJSON<commit_after>/***
TiddlySpace extensions for [[chrjs]]
***/
//{{{
(function($) {

tiddlyweb.routes.spaces = "{host}/spaces";
tiddlyweb.routes.space = "{host}/spaces/{name}";
tiddlyweb.routes.members = "{host}/spaces/{name}/members";
tiddlyweb.routes.member = "{host}/spaces/{name}/members/{username}";

tiddlyweb.Space = function(name, host) {
	tiddlyweb.Resource.apply(this, ["space", host]);
	this.name = name;
};
tiddlyweb.Space.prototype = new tiddlyweb.Resource();
$.extend(tiddlyweb.Space.prototype, {
	create: function(callback, errback) { // API wrapper
		this.put.apply(this, arguments);
	},
	members: function() {
		return new MemberCollection(this);
	},
	includes: function() {
		return new IncludesCollection(this);
	}
});

var Member = function(username, space) {
	tiddlyweb.Resource.apply(this, ["member", space.host]);
	this.name = space.name;
	this.username = username;
};
Member.prototype = new tiddlyweb.Resource();

var MemberCollection = function(space) {
	tiddlyweb.Collection.apply(this, ["members", space.host, {
		name: space.name
	}]);
};
MemberCollection.prototype = new tiddlyweb.Collection();
$.extend(MemberCollection.prototype, {
	add: function(username, callback, errback) {
		var member = new Member(username, this);
		member.put(callback, errback);
	},
	remove: function(username, callback, errback) {
		var member = new Member(username, this);
		member["delete"](callback, errback);
	}
});

var IncludesCollection = function(space) {
	tiddlyweb.Collection.apply(this, ["space", space.host, {
		name: space.name
	}]);
};
IncludesCollection.prototype = new tiddlyweb.Collection();
$.extend(IncludesCollection.prototype, {
	get: function(callback, errback) {
		var self = this;
		var recipe = new tiddlyweb.Recipe(this.name + "_public", this.host);
		recipe.get(function(recipe, status, xhr) {
			var inclusions = $.map(recipe.recipe, function(item, i) {
				var arr = item[0].split("_public");
				return (arr[0] != self.name && arr[1] === "") ? arr[0] : null;
			});
			callback(inclusions, status, xhr);
		}, function(xhr, error, exc) {
			errback(xhr, error, exc, self);
		});
	},
	add: function(name, callback, errback) {
		var self = this;
		var names = typeof(name) === "string" ? [ name ] : name;
		$.ajax({
			type: "post",
			url: this.route(),
			contentType: "json",
			data: JSON.stringify({ "subscriptions": names }),
			success: function(response, status, xhr) {
				callback(self, status, xhr);
			},
			errback: function(xhr, error, exc) {
				errback(xhr, error, exc, self);
			}
		});
	},
	remove: function(name, callback, errback) {
		var self = this;
		var names = typeof(name) === "string" ? [ name ] : name;
		$.ajax({
			type: "post",
			contentType: "json",
			url: this.route(),
			data: JSON.stringify({ "unsubscriptions": names }),
			success: function(response, status, xhr) {
				callback(self, status, xhr);
			},
			errback: function(xhr, error, exc) {
				errback(xhr, error, exc, self);
			}
		});
	}
});

})(jQuery);
//}}}
<|endoftext|>