new file mode 100644
--- /dev/null
+++ b/reviewboard/reviewboard/static/rb/js/resources/models/localResourceModel_mozreview.js
@@ -0,0 +1,577 @@
+var indexOf = [].indexOf || function(item) {
+ for (var i = 0, l = this.length; i < l; i++) {
+ if (i in this && this[i] === item) {
+ return i;
+ }
+ }
+ return -1;
+};
+
+/**
+ * The base model for all Dual Storage resource models. It extends the BaseResource Model.
+ *
+ * This will default to using the REST Api, except when the user is offline.
+ * The user can continue to make changes and they will be synced when they are back online.
+ *
+ * Any model needing offline support are expected to extend this.
+ *
+ * Based off of https://github.com/nilbus/Backbone.dualStorage
+ */
+
+RB.modelUpdatedWithResponse = function(model, response) {
+ var modelClone;
+ var storeName = model.get('storeName');
+ modelClone = new RB[storeName]();
+ modelClone.idAttribute = model.idAttribute;
+ modelClone.set(model.attributes);
+ modelClone.set(model.parse(response));
+ return modelClone;
+};
+RB.parseRemoteResponse = function(object, response) {
+ if (!(object && object.parseBeforeLocalSave)) {
+ return response;
+ }
+ if (_.isFunction(object.parseBeforeLocalSave)) {
+ return object.parseBeforeLocalSave(response);
+ }
+};
+
+RB.getModelFromLocalStore = function(storeName, data, parentObject) {
+ var model = new RB[storeName](data);
+ model.set('parentObject', parentObject);
+ return model;
+};
+
+RB.LocalResource = RB.BaseResource.extend({
+ defaults: function() {
+ return _.defaults({
+ remote: true,
+ local: true
+ }, RB.BaseResource.prototype.defaults());
+ },
+ offlineStatusCodes: [408, 502],
+ hasTempId: function() {
+ return _.isString(this.id) && this.id.length === 36 && this.id.indexOf('t') === 0;
+ },
+ getStoreName: function(model) {
+ return _.result(model.attributes, 'storeName') || _.result(model, 'urlRoot') || _.result(model, 'url');
+ },
+ callbackTranslator: {
+ needsTranslation: Backbone.VERSION === '0.9.10',
+ forBackboneCaller: function(callback) {
+ if (this.needsTranslation) {
+ return function(model, resp, options) {
+ return callback.call(null, resp, options);
+ };
+ } else {
+ return callback;
+ }
+ },
+ forDualstorageCaller: function(callback, model, options) {
+ if (this.needsTranslation) {
+ return function(resp) {
+ return callback.call(null, model, resp, options);
+ };
+ } else {
+ return callback;
+ }
+ }
+ },
+ syncDirty: function(options) {
+ if(!options) {
+ options = {};
+ }
+ var success = options.success ? options.success: function() {};
+ var error = options.error ? options.error: function() {};
+
+ var model = this;
+ var ids, ref, results, store, dirty;
+ store = localStorage.getItem((model.getStoreName(model)) + '_dirty');
+ ids = (store && store.split(',')) || [];
+ results = [];
+ dirty = [];
+ _.each(ids, function(id, i) {
+ var data = JSON.parse(localStorage.getItem(model.getStoreName(model) + id));
+ var parsedData = {};
+ if (_.isFunction(model.parseResourceData)) {
+ parsedData = model.parseResourceData.call(model, data);
+ }
+
+ _.each(model.attrToJsonMap, function(val, key) {
+ if (data[val]) {
+ parsedData[key] = data[val];
+ delete data[val];
+ }
+ });
+ _.extend(parsedData, data);
+
+ ref = RB.getModelFromLocalStore(model.getStoreName(model), parsedData, model.get('parentObject'));
+ var storeName = model.getStoreName(model) + id;
+ options.success = function(args) {
+ if(_.isFunction(success)) {
+ success.call(model, [args]);
+ }
+ localStorage.removeItem(storeName);
+ };
+ options.error = function() {
+ dirty.push(id);
+ };
+ options.dirty = true;
+ results.push(ref !== null ? ref.save(options) : void 0);
+ });
+
+ if (!dirty.length) {
+ localStorage.removeItem(model.getStoreName(model));
+ localStorage.removeItem(model.getStoreName(model) + '_dirty');
+ } else {
+ localStorage.setItem(model.getStoreName(model) + '_dirty', dirty);
+ }
+ return results;
+ },
+ syncDestroyed: function(options) {
+ if(!options) {
+ options = {};
+ }
+ var ids, model, results, store;
+ model = this;
+ store = localStorage.getItem((model.getStoreName(model)) + '_destroyed');
+ ids = (store && store.split(',')) || [];
+ results = [];
+ _.each(ids, function(id, i) {
+ model = RB.getModelFromLocalStore(model.getStoreName(model), {
+ id: id
+ }, model.get('parentObject'));
+ model.set(model.idAttribute, id);
+ var destroyed = model.destroy(options);
+ results.push(destroyed);
+ if (destroyed) {
+ var parentObject = model.get('parentObject');
+ if (parentObject) {
+ parentObject.trigger('destroy');
+ } else {
+ model.trigger('destroy');
+ }
+ }
+ });
+ // return results;
+ },
+ destroyedModelIds: function() {
+ var store;
+ store = localStorage.getItem((this.getStoreName(this)) + '_destroyed');
+ return (store && store.split(',')) || [];
+ },
+ syncDirtyAndDestroyed: function(options) {
+ this.syncDirty(options);
+ return this.syncDestroyed(options);
+ },
+ /*
+ * Adds some of the data that the server normally responds with, while in offline mode
+ *
+ * This can be overridden by subclasses.
+ */
+ parseOfflineData: function(data) {
+ return data;
+ },
+ parseAndTriggerSave: function(model, options) {
+ var data = JSON.parse(localStorage.getItem(model.getStoreName(model) + model.id));
+ data = model.parseOfflineData.call(model, data);
+ data = model.parseResourceData.call(model, data);
+ model.set(data);
+ return model.trigger('saved', options);
+ },
+ localsync: function(method, model, options) {
+ var preExisting, response, store;
+ store = new RB.Store(options.storeName);
+ response = (function() {
+ switch (method) {
+ case 'read':
+ if (model instanceof Backbone.Model) {
+ return store.find(model);
+ } else {
+ return store.findAll();
+ }
+ break;
+ case 'hasDirtyOrDestroyed':
+ return store.hasDirtyOrDestroyed();
+ case 'clear':
+ return store.clear();
+ case 'create':
+ if (options.add && !options.merge && (preExisting = store.find(model))) {
+ return preExisting;
+ } else {
+ var url = _.result(model, 'url');
+ model.url = url;
+ model = store.create(model, options);
+ if (options.dirty) {
+ store.dirty(model);
+ }
+ model.parseAndTriggerSave(model, options);
+ return model;
+ }
+ break;
+ case 'update':
+ store.update(model, options);
+ model.parseAndTriggerSave(model, options);
+ if (options.dirty) {
+ return store.dirty(model);
+ } else {
+ return store.clean(model, 'dirty');
+ }
+ break;
+ case 'delete':
+ var _id = model.id;
+ var exists = localStorage.getItem((model.getStoreName(model)) + _id);
+ if (exists) {
+ store.destroy(model);
+ if (options.dirty && !model.hasTempId()) {
+ return store.destroyed(model);
+ } else {
+ store.clean(model, 'dirty');
+ return store.clean(model, 'destroyed');
+ }
+ } else {
+ console.log('does not exist');
+ }
+ break;
+ }
+ })();
+ if (response) {
+ if (response.toJSON) {
+ response = response.toJSON(options);
+ }
+ if (response.attributes) {
+ response = response.attributes;
+ }
+ }
+ if (!options.ignoreCallbacks) {
+ if (response) {
+ if (options.context) {
+ options.success.call(options.context, response);
+ } else {
+ options.success.call(model, response);
+ }
+ } else {
+ if (options.context) {
+ options.error.call(options.context, 'Record not found');
+ } else {
+ options.error.call(model, 'Record not found');
+ }
+ }
+ }
+ return response;
+ },
+ hasOfflineStatusCode: function(xhr) {
+ var offlineStatusCodes = this.offlineStatusCodes;
+ var ref;
+ if (!xhr) {
+ return true;
+ }
+ if (_.isFunction(offlineStatusCodes)) {
+ offlineStatusCodes = offlineStatusCodes(xhr);
+ }
+ return xhr.status === 0 || (ref = xhr.status, indexOf.call(offlineStatusCodes, ref) >= 0);
+ },
+ useOfflineStorage: function(model, method, options, success, error, context) {
+ options.dirty = true;
+ options.ignoreCallbacks = false;
+ options.success = success;
+ options.error = error;
+ options.context = context;
+ return model.localsync(method, model, options);
+ },
+ relayErrorCallback: function(model, method, options, xhr, success, error, context) {
+ var online;
+ online = !this.hasOfflineStatusCode(xhr);
+ if (online) {
+ return error.call(context, xhr);
+ } else {
+ return this.useOfflineStorage(model, method, options, success, error, context);
+ }
+ },
+ /**
+ * Delete the object's resource on the server.
+ *
+ * An object must either be loaded or have a parent resource linking to
+ * this object's list resource URL for an object to be deleted.
+ *
+ * Args:
+ * options (object):
+ * Object with success and error callbacks.
+ *
+ * context (object):
+ * Context to bind when executing callbacks.
+ */
+ destroy: function(options, context) {
+ options = options || {};
+ var model, error, success;
+ var parentObject = this.get('parentObject');
+ model = _.clone(this);
+
+ options.storeName = this.getStoreName(this);
+
+ options.storeExists = RB.Store.exists(options.storeName);
+
+ error = (options.error) ? options.error : function() {};
+ success = (options.success) ? options.success : function() {};
+
+ var offlineSuccess = function(args) {
+ model.set(_.defaults({
+ id: null,
+ parentObject: parentObject
+ },
+ _.result(model, 'defaults')));
+ model.trigger('destroy', options);
+
+ if (_.isFunction(success)) {
+ success.call(context, args);
+ }
+ };
+ if (this.hasTempId()) {
+ options.ignoreCallbacks = false;
+ return this.localsync('delete', model, options);
+ } else {
+ if (!model.id) {
+ //we need the id set, so get it from the url.
+ var url = model.url();
+ var urlparts = (url) ? url.split('/') : [];
+
+ var _id = (urlparts[urlparts.length - 1] !== '') ? urlparts[urlparts.length - 1] : urlparts[urlparts.length - 2];
+ if (parseInt(_id, 10)) {
+ model.set('id', _id);
+ } else {
+ return false;
+ }
+ }
+ options.success = function(resp, _status, _xhr) {
+ if (model.hasOfflineStatusCode(_xhr)) {
+ return model.useOfflineStorage(model, 'delete', options, offlineSuccess, error, context);
+ }
+ options.ignoreCallbacks = true;
+ model.localsync('delete', model, options);
+ return success.call(context, resp, _status, _xhr);
+ };
+ options.error = function(xhr) {
+ return model.relayErrorCallback(model, 'delete', options, xhr, offlineSuccess, error, context);
+ };
+ return RB.BaseResource.prototype.destroy.call(this, options, context);
+ }
+ },
+ /**
+ * Fetch the object's data from the server.
+ *
+ * An object must have an ID before it can be fetched. Otherwise,
+ * options.error() will be called.
+ *
+ * If this has a parent resource object, we'll ensure that's ready before
+ * fetching this resource.
+ *
+ * The resource must override the parse() function to determine how
+ * the returned resource data is parsed and what data is stored in
+ * this object.
+ *
+ * If we successfully fetch the resource, options.success() will be
+ * called.
+ *
+ * If we fail to fetch the resource, options.error() will be called.
+ *
+ * Args:
+ * options (object):
+ * Object with success and error callbacks.
+ *
+ * context (object):
+ * Context to bind when executing callbacks.
+ */
+ fetch: function(options, context) {
+ options = options || {};
+ var error, success;
+ options.storeName = this.getStoreName(this);
+
+ options.storeExists = RB.Store.exists(options.storeName);
+
+ error = (options.error) ? options.error : function() {};
+ success = (options.success) ? options.success : function() {};
+
+ if (this.localsync('hasDirtyOrDestroyed', this, {
+ ignoreCallbacks: true
+ })) {
+ return this.useOfflineStorage(this, 'fetch', options, success, error, context);
+ } else {
+ var model = this;
+ options.success = function(resp, _status, _xhr) {
+ var responseModel;
+ options.ignoreCallbacks = true;
+ if (model.hasOfflineStatusCode(_xhr)) {
+ return model.useOfflineStorage(model, 'read', options, success, error, context);
+ }
+ resp = RB.parseRemoteResponse(model, resp);
+ responseModel = RB.modelUpdatedWithResponse(model, resp);
+
+ model.localsync('update', responseModel, options);
+
+ return success.call(context, resp, _status, _xhr);
+ };
+ options.error = function(xhr) {
+ model.localsync('update', model, options);
+ return model.relayErrorCallback(model, 'read', options, xhr, success, error, context);
+ };
+ // return options.xhr = onlineSync(method, model, options);
+ var parentObject = this.get('parentObject');
+
+ if (parentObject) {
+ parentObject.ready({
+ ready: function() { Backbone.Model.prototype.fetch.call(this, options, context); },
+ error: options.error
+ }, this);
+ } else {
+ Backbone.Model.prototype.fetch.call(this, options, context);
+ }
+ }
+ },
+ /**
+ * Save the object's data to the server or locally if offline.
+ *
+ * If the object has an ID already, it will be saved to its known
+ * URL using HTTP PUT. If it doesn't have an ID, it will be saved
+ * to its parent list resource using HTTP POST
+ *
+ * If this has a parent resource object, we'll ensure that's created
+ * before saving this resource.
+ *
+ * An object must either be loaded or have a parent resource linking to
+ * this object's list resource URL for an object to be saved.
+ *
+ * The resource must override the toJSON() function to determine what
+ * data is saved to the server.
+ *
+ * If we successfully save the resource, options.success() will be
+ * called, and the "saved" event will be triggered.
+ *
+ * If we fail to save the resource, options.error() will be called.
+ *
+ * Args:
+ * options (object):
+ * Object with success and error callbacks.
+ *
+ * context (object):
+ * Context to bind when executing callbacks.
+ */
+ save: function(options, context) {
+ if(!options) {
+ options = {};
+ }
+ options.storeName = this.getStoreName(this);
+
+ options.storeExists = RB.Store.exists(options.storeName);
+
+ if (!this.id) {
+ //creating a new record
+ return this.__createObject(options, context);
+ } else {
+ //updating a record.
+ return this.__saveObject(options, context);
+ }
+ },
+ __createObject: function(options, context) {
+ var error, success;
+ var model = this;
+
+ error = (options.error) ? options.error : function() {};
+ success = (options.success) ? options.success : function() {};
+
+ var offlineSuccess = function(args) {
+ model.trigger('saved', options);
+
+ if (_.isFunction(success)) {
+ success.call(context, args);
+ }
+ };
+ options.success = function(resp, _status, _xhr) {
+ var updatedModel;
+ if (model.hasOfflineStatusCode(_xhr)) {
+ return model.useOfflineStorage(model, 'create', options, offlineSuccess, error, context);
+ } else {
+ //we are online, so check to sync any items that haven't been saved
+ if (!options.dirty) {
+ model.syncDirtyAndDestroyed();
+ }
+ updatedModel = RB.modelUpdatedWithResponse(model, resp);
+ options.dirty = false;
+ options.ignoreCallbacks = true;
+ model.localsync('update', updatedModel, options);
+ return success.call(context, resp, _status, _xhr);
+ }
+ };
+ options.error = function(context, xhr) {
+ return model.relayErrorCallback(model, 'create', options, xhr, offlineSuccess, error, context);
+ };
+ return RB.BaseResource.prototype.save.call(this, options, context);
+ },
+ __saveObject: function(options, context) {
+ var model = this;
+ var success, error, temporaryId;
+ error = (options.error) ? options.error : function() {};
+ success = (options.success) ? options.success : function() {};
+
+ var parentObject = model.get('parentObject');
+
+ var offlineSuccess = function(args) {
+ model.trigger('saved', options);
+ parentObject.trigger('saved', options);
+ if (_.isFunction(success)) {
+ success.call(context, args);
+ }
+ };
+ if (this.hasTempId()) {
+ temporaryId = this.id;
+ options.success = function(resp, _status, _xhr) {
+ var updatedModel;
+ model.set(model.idAttribute, temporaryId, {
+ silent: true
+ });
+ if (model.hasOfflineStatusCode(_xhr)) {
+ return model.useOfflineStorage(model, 'update', options, offlineSuccess, error, context);
+ }
+
+ //we are online, so check to sync any items that haven't been saved
+ if (!options.dirty) {
+ model.syncDirtyAndDestroyed();
+ }
+ updatedModel = RB.modelUpdatedWithResponse(model, resp);
+ options.dirty = false;
+ options.ignoreCallbacks = true;
+ model.localsync('update', updatedModel, options);
+ return success.call(context, resp, _status, _xhr);
+ };
+ options.error = function(context, xhr) {
+ model.set(model.idAttribute, temporaryId, {
+ silent: true
+ });
+ return model.relayErrorCallback(model, 'update', options, xhr, offlineSuccess, error, context);
+ };
+ this.set(this.idAttribute, null, {
+ silent: true
+ });
+ return RB.BaseResource.prototype.save.call(this, options, context);
+ } else {
+ options.success = function(resp, _status, _xhr) {
+ var updatedModel;
+ if (model.hasOfflineStatusCode(_xhr)) {
+ return model.useOfflineStorage(model, 'update', options, offlineSuccess, error, context);
+ }
+ //we are online, so check to sync any items that haven't been saved
+ if (!options.dirty) {
+ model.syncDirtyAndDestroyed();
+ }
+ updatedModel = RB.modelUpdatedWithResponse(model, resp);
+ options.dirty = false;
+ options.ignoreCallbacks = true;
+ model.localsync('update', updatedModel, options);
+ return success.call(context, resp, _status, _xhr);
+ };
+ options.error = function(context, xhr) {
+ return model.relayErrorCallback(model, 'update', options, xhr, offlineSuccess, error, context);
+ };
+ return RB.BaseResource.prototype.save.call(this, options, context);
+ }
+ }
+});
\ No newline at end of file