diff --git a/background.html b/background.html index 7efe63e1..61440858 100644 --- a/background.html +++ b/background.html @@ -16,6 +16,7 @@
+ @@ -29,7 +30,7 @@ - + diff --git a/bower.json b/bower.json index b15802e7..5f4a96c1 100644 --- a/bower.json +++ b/bower.json @@ -16,7 +16,8 @@ "cryptojs": "svn+http://crypto-js.googlecode.com/svn/#~3.1.2", "libphonenumber-api": "git://github.com/codedust/libphonenumber-api", "backbone.localstorage": "liliakai/Backbone.localStorage#master", - "momentjs": "~2.8.3" + "momentjs": "~2.8.3", + "indexeddb-backbonejs-adapter": "*" }, "devDependencies": { "mocha": "~2.0.1", @@ -79,6 +80,9 @@ ], "momentjs": [ "moment.js" + ], + "indexeddb-backbonejs-adapter": [ + "backbone-indexeddb.js" ] }, "concat": { @@ -90,7 +94,7 @@ "mustache", "underscore", "backbone", - "backbone.localstorage", + "indexeddb-backbonejs-adapter", "qrcode", "libphonenumber-api", "momentjs", diff --git a/components/indexeddb-backbonejs-adapter/backbone-indexeddb.js b/components/indexeddb-backbonejs-adapter/backbone-indexeddb.js new file mode 100644 index 00000000..36735091 --- /dev/null +++ b/components/indexeddb-backbonejs-adapter/backbone-indexeddb.js @@ -0,0 +1,625 @@ +(function (root, factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['backbone', 'underscore'], factory); + } else if (typeof exports === 'object') { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(require('backbone'), require('underscore')); + } else { + // Browser globals (root is window) + root.returnExports = factory(root.Backbone, root._); + } +}(this, function (Backbone, _) { + + // Generate four random hex digits. + function S4() { + return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); + } + + // Generate a pseudo-GUID by concatenating random hexadecimal. + function guid() { + return (S4() + S4() + "-" + S4() + "-" + S4() + "-" + S4() + "-" + S4() + S4() + S4()); + } + + if ( _(indexedDB).isUndefined() ) { return; } + + // Driver object + // That's the interesting part. + // There is a driver for each schema provided. The schema is a te combination of name (for the database), a version as well as migrations to reach that + // version of the database. + function Driver(schema, ready, nolog, onerror) { + this.schema = schema; + this.ready = ready; + this.error = null; + this.transactions = []; // Used to list all transactions and keep track of active ones. + this.db = null; + this.nolog = nolog; + this.onerror = onerror; + var lastMigrationPathVersion = _.last(this.schema.migrations).version; + if (!this.nolog) debugLog("opening database " + this.schema.id + " in version #" + lastMigrationPathVersion); + this.dbRequest = indexedDB.open(this.schema.id,lastMigrationPathVersion); //schema version need to be an unsigned long + + this.launchMigrationPath = function(dbVersion) { + var transaction = this.dbRequest.transaction; + var clonedMigrations = _.clone(schema.migrations); + this.migrate(transaction, clonedMigrations, dbVersion, { + error: function (event) { + this.error = "Database not up to date. " + dbVersion + " expected was " + lastMigrationPathVersion; + }.bind(this) + }); + }; + + this.dbRequest.onblocked = function(event){ + if (!this.nolog) debugLog("connection to database blocked"); + } + + this.dbRequest.onsuccess = function (e) { + this.db = e.target.result; // Attach the connection ot the queue. + var currentIntDBVersion = (parseInt(this.db.version) || 0); // we need convert beacuse chrome store in integer and ie10 DP4+ in int; + var lastMigrationInt = (parseInt(lastMigrationPathVersion) || 0); // And make sure we compare numbers with numbers. + + if (currentIntDBVersion === lastMigrationInt) { //if support new event onupgradeneeded will trigger the ready function + // No migration to perform! + this.ready(); + } else if (currentIntDBVersion < lastMigrationInt ) { + // We need to migrate up to the current migration defined in the database + this.launchMigrationPath(currentIntDBVersion); + } else { + // Looks like the IndexedDB is at a higher version than the current driver schema. + this.error = "Database version is greater than current code " + currentIntDBVersion + " expected was " + lastMigrationInt; + } + }.bind(this); + + + + this.dbRequest.onerror = function (e) { + // Failed to open the database + this.error = "Couldn't not connect to the database" + if (!this.nolog) debugLog("Couldn't not connect to the database"); + this.onerror(); + }.bind(this); + + this.dbRequest.onabort = function (e) { + // Failed to open the database + this.error = "Connection to the database aborted" + if (!this.nolog) debugLog("Connection to the database aborted"); + this.onerror(); + }.bind(this); + + + + this.dbRequest.onupgradeneeded = function(iDBVersionChangeEvent){ + this.db =iDBVersionChangeEvent.target.result; + + var newVersion = iDBVersionChangeEvent.newVersion; + var oldVersion = iDBVersionChangeEvent.oldVersion; + + // Fix Safari 8 and iOS 8 bug + // at the first connection oldVersion is equal to 9223372036854776000 + // but the real value is 0 + if (oldVersion > 99999999999) + oldVersion = 0; + + if (!this.nolog) debugLog("onupgradeneeded = " + oldVersion + " => " + newVersion); + this.launchMigrationPath(oldVersion); + }.bind(this); + } + + function debugLog(str) { + if (typeof window !== "undefined" && typeof window.console !== "undefined" && typeof window.console.log !== "undefined") { + window.console.log(str); + } + else if(console.log !== "undefined") { + console.log(str) + } + } + + // Driver Prototype + Driver.prototype = { + + // Tracks transactions. Mostly for debugging purposes. TO-IMPROVE + _track_transaction: function(transaction) { + this.transactions.push(transaction); + function removeIt() { + var idx = this.transactions.indexOf(transaction); + if (idx !== -1) {this.transactions.splice(idx); } + }; + transaction.oncomplete = removeIt.bind(this); + transaction.onabort = removeIt.bind(this); + transaction.onerror = removeIt.bind(this); + }, + + // Performs all the migrations to reach the right version of the database. + migrate: function (transaction, migrations, version, options) { + transaction.onerror = options.error; + transaction.onabort = options.error; + + if (!this.nolog) debugLog("migrate begin version from #" + version); + var that = this; + var migration = migrations.shift(); + if (migration) { + if (!version || version < migration.version) { + // We need to apply this migration- + if (typeof migration.before == "undefined") { + migration.before = function (next) { + next(); + }; + } + if (typeof migration.after == "undefined") { + migration.after = function (next) { + next(); + }; + } + // First, let's run the before script + if (!this.nolog) debugLog("migrate begin before version #" + migration.version); + migration.before(function () { + if (!this.nolog) debugLog("migrate done before version #" + migration.version); + + if (!this.nolog) debugLog("migrate begin migrate version #" + migration.version); + + migration.migrate(transaction, function () { + if (!this.nolog) debugLog("migrate done migrate version #" + migration.version); + // Migration successfully appliedn let's go to the next one! + if (!this.nolog) debugLog("migrate begin after version #" + migration.version); + migration.after(function () { + if (!this.nolog) debugLog("migrate done after version #" + migration.version); + if (!this.nolog) debugLog("Migrated to " + migration.version); + + //last modification occurred, need finish + if(migrations.length ==0) { + if (!this.nolog) { + debugLog("migrate setting transaction.oncomplete to finish version #" + migration.version); + transaction.oncomplete = function() { + debugLog("migrate done transaction.oncomplete version #" + migration.version); + debugLog("Done migrating"); + } + } + } + else + { + if (!this.nolog) debugLog("migrate end from version #" + version + " to " + migration.version); + that.migrate(transaction, migrations, version, options); + } + + }.bind(this)); + }.bind(this)); + }.bind(this)); + } else { + // No need to apply this migration + if (!this.nolog) debugLog("Skipping migration " + migration.version); + this.migrate(transaction, migrations, version, options); + } + } + }, + + // This is the main method, called by the ExecutionQueue when the driver is ready (database open and migration performed) + execute: function (storeName, method, object, options) { + if (!this.nolog) debugLog("execute : " + method + " on " + storeName + " for " + object.id); + switch (method) { + case "create": + this.create(storeName, object, options); + break; + case "read": + if (object.id || object.cid) { + this.read(storeName, object, options); // It's a model + } else { + this.query(storeName, object, options); // It's a collection + } + break; + case "update": + this.update(storeName, object, options); // We may want to check that this is not a collection. TOFIX + break; + case "delete": + if (object.id || object.cid) { + this.delete(storeName, object, options); + } else { + this.clear(storeName, object, options); + } + break; + default: + // Hum what? + } + }, + + // Writes the json to the storeName in db. It is a create operations, which means it will fail if the key already exists + // options are just success and error callbacks. + create: function (storeName, object, options) { + var writeTransaction = this.db.transaction([storeName], 'readwrite'); + //this._track_transaction(writeTransaction); + var store = writeTransaction.objectStore(storeName); + var json = object.toJSON(); + var idAttribute = _.result(object, 'idAttribute'); + var writeRequest; + + if (json[idAttribute] === undefined && !store.autoIncrement) json[idAttribute] = guid(); + + writeTransaction.onerror = function (e) { + options.error(e); + }; + writeTransaction.oncomplete = function (e) { + options.success(json); + }; + + if (!store.keyPath) + writeRequest = store.add(json, json[idAttribute]); + else + writeRequest = store.add(json); + }, + + // Writes the json to the storeName in db. It is an update operation, which means it will overwrite the value if the key already exist + // options are just success and error callbacks. + update: function (storeName, object, options) { + var writeTransaction = this.db.transaction([storeName], 'readwrite'); + //this._track_transaction(writeTransaction); + var store = writeTransaction.objectStore(storeName); + var json = object.toJSON(); + var idAttribute = _.result(object, 'idAttribute'); + var writeRequest; + + if (!json[idAttribute]) json[idAttribute] = guid(); + + if (!store.keyPath) + writeRequest = store.put(json, json[idAttribute]); + else + writeRequest = store.put(json); + + writeRequest.onerror = function (e) { + options.error(e); + }; + writeTransaction.oncomplete = function (e) { + options.success(json); + }; + }, + + // Reads from storeName in db with json.id if it's there of with any json.xxxx as long as xxx is an index in storeName + read: function (storeName, object, options) { + var readTransaction = this.db.transaction([storeName], "readonly"); + this._track_transaction(readTransaction); + + var store = readTransaction.objectStore(storeName); + var json = object.toJSON(); + var idAttribute = _.result(object, 'idAttribute'); + + var getRequest = null; + if (json[idAttribute]) { + getRequest = store.get(json[idAttribute]); + } else if(options.index) { + var index = store.index(options.index.name); + getRequest = index.get(options.index.value); + } else { + // We need to find which index we have + var cardinality = 0; // try to fit the index with most matches + _.each(store.indexNames, function (key, index) { + index = store.index(key); + if(typeof index.keyPath === 'string' && 1 > cardinality) { + // simple index + if (json[index.keyPath] !== undefined) { + getRequest = index.get(json[index.keyPath]); + cardinality = 1; + } + } else if(typeof index.keyPath === 'object' && index.keyPath.length > cardinality) { + // compound index + var valid = true; + var keyValue = _.map(index.keyPath, function(keyPart) { + valid = valid && json[keyPart] !== undefined; + return json[keyPart]; + }); + if(valid) { + getRequest = index.get(keyValue); + cardinality = index.keyPath.length; + } + } + }); + } + if (getRequest) { + getRequest.onsuccess = function (event) { + if (event.target.result) { + options.success(event.target.result); + } else { + options.error("Not Found"); + } + }; + getRequest.onerror = function () { + options.error("Not Found"); // We couldn't find the record. + } + } else { + options.error("Not Found"); // We couldn't even look for it, as we don't have enough data. + } + }, + + // Deletes the json.id key and value in storeName from db. + delete: function (storeName, object, options) { + var deleteTransaction = this.db.transaction([storeName], 'readwrite'); + //this._track_transaction(deleteTransaction); + + var store = deleteTransaction.objectStore(storeName); + var json = object.toJSON(); + var idAttribute = _.result(object, 'idAttribute'); + + var deleteRequest = store.delete(json[idAttribute]); + + deleteTransaction.oncomplete = function (event) { + options.success(null); + }; + deleteRequest.onerror = function (event) { + options.error("Not Deleted"); + }; + }, + + // Clears all records for storeName from db. + clear: function (storeName, object, options) { + var deleteTransaction = this.db.transaction([storeName], "readwrite"); + //this._track_transaction(deleteTransaction); + + var store = deleteTransaction.objectStore(storeName); + + var deleteRequest = store.clear(); + deleteRequest.onsuccess = function (event) { + options.success(null); + }; + deleteRequest.onerror = function (event) { + options.error("Not Cleared"); + }; + }, + + // Performs a query on storeName in db. + // options may include : + // - conditions : value of an index, or range for an index + // - range : range for the primary key + // - limit : max number of elements to be yielded + // - offset : skipped items. + query: function (storeName, collection, options) { + var elements = []; + var skipped = 0, processed = 0; + var queryTransaction = this.db.transaction([storeName], "readonly"); + //this._track_transaction(queryTransaction); + + var idAttribute = _.result(collection.model.prototype, 'idAttribute'); + var readCursor = null; + var store = queryTransaction.objectStore(storeName); + var index = null, + lower = null, + upper = null, + bounds = null; + + if (options.conditions) { + // We have a condition, we need to use it for the cursor + _.each(store.indexNames, function (key) { + if (!readCursor) { + index = store.index(key); + if (options.conditions[index.keyPath] instanceof Array) { + lower = options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1] ? options.conditions[index.keyPath][1] : options.conditions[index.keyPath][0]; + upper = options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1] ? options.conditions[index.keyPath][0] : options.conditions[index.keyPath][1]; + bounds = IDBKeyRange.bound(lower, upper, true, true); + + if (options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1]) { + // Looks like we want the DESC order + readCursor = index.openCursor(bounds, window.IDBCursor.PREV || "prev"); + } else { + // We want ASC order + readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next"); + } + } else if (typeof options.conditions[index.keyPath] === 'object' && ('$gt' in options.conditions[index.keyPath] || '$gte' in options.conditions[index.keyPath])) { + if('$gt' in options.conditions[index.keyPath]) + bounds = IDBKeyRange.lowerBound(options.conditions[index.keyPath]['$gt'], true); + else + bounds = IDBKeyRange.lowerBound(options.conditions[index.keyPath]['$gte']); + readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next"); + } else if (typeof options.conditions[index.keyPath] === 'object' && ('$lt' in options.conditions[index.keyPath] || '$lte' in options.conditions[index.keyPath])) { + if('$lt' in options.conditions[index.keyPath]) + bounds = IDBKeyRange.upperBound(options.conditions[index.keyPath]['$lt'], true); + else + bounds = IDBKeyRange.upperBound(options.conditions[index.keyPath]['$lte']); + readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next"); + } else if (options.conditions[index.keyPath] != undefined) { + bounds = IDBKeyRange.only(options.conditions[index.keyPath]); + readCursor = index.openCursor(bounds); + } + } + }); + } else { + // No conditions, use the index + if (options.range) { + lower = options.range[0] > options.range[1] ? options.range[1] : options.range[0]; + upper = options.range[0] > options.range[1] ? options.range[0] : options.range[1]; + bounds = IDBKeyRange.bound(lower, upper); + if (options.range[0] > options.range[1]) { + readCursor = store.openCursor(bounds, window.IDBCursor.PREV || "prev"); + } else { + readCursor = store.openCursor(bounds, window.IDBCursor.NEXT || "next"); + } + } else { + readCursor = store.openCursor(); + } + } + + if (typeof (readCursor) == "undefined" || !readCursor) { + options.error("No Cursor"); + } else { + readCursor.onerror = function(e){ + options.error("readCursor error", e); + }; + // Setup a handler for the cursor’s `success` event: + readCursor.onsuccess = function (e) { + var cursor = e.target.result; + if (!cursor) { + if (options.addIndividually || options.clear) { + // nothing! + // We need to indicate that we're done. But, how? + collection.trigger("reset"); + } else { + options.success(elements); // We're done. No more elements. + } + } + else { + // Cursor is not over yet. + if (options.limit && processed >= options.limit) { + // Yet, we have processed enough elements. So, let's just skip. + if (bounds && options.conditions[index.keyPath]) { + cursor.continue(options.conditions[index.keyPath][1] + 1); /* We need to 'terminate' the cursor cleany, by moving to the end */ + } else { + cursor.continue(); /* We need to 'terminate' the cursor cleany, by moving to the end */ + } + } + else if (options.offset && options.offset > skipped) { + skipped++; + cursor.continue(); /* We need to Moving the cursor forward */ + } else { + // This time, it looks like it's good! + if (options.addIndividually) { + collection.add(cursor.value); + } else if (options.clear) { + var deleteRequest = store.delete(cursor.value[idAttribute]); + deleteRequest.onsuccess = function (event) { + elements.push(cursor.value); + }; + deleteRequest.onerror = function (event) { + elements.push(cursor.value); + }; + + } else { + elements.push(cursor.value); + } + processed++; + cursor.continue(); + } + } + }; + } + }, + close :function(){ + if(this.db){ + this.db.close(); + } + } + }; + + // ExecutionQueue object + // The execution queue is an abstraction to buffer up requests to the database. + // It holds a "driver". When the driver is ready, it just fires up the queue and executes in sync. + function ExecutionQueue(schema,next,nolog) { + this.driver = new Driver(schema, this.ready.bind(this), nolog, this.error.bind(this)); + this.started = false; + this.failed = false; + this.stack = []; + this.version = _.last(schema.migrations).version; + this.next = next; + } + + // ExecutionQueue Prototype + ExecutionQueue.prototype = { + // Called when the driver is ready + // It just loops over the elements in the queue and executes them. + ready: function () { + this.started = true; + _.each(this.stack, function (message) { + this.execute(message); + }.bind(this)); + this.stack = []; // fix memory leak + this.next(); + }, + + error: function() { + this.failed = true; + _.each(this.stack, function (message) { + this.execute(message); + }.bind(this)); + this.stack = []; + this.next(); + }, + + // Executes a given command on the driver. If not started, just stacks up one more element. + execute: function (message) { + if (this.started) { + this.driver.execute(message[2].storeName || message[1].storeName, message[0], message[1], message[2]); // Upon messages, we execute the query + } else if (this.failed) { + message[2].error(); + } else { + this.stack.push(message); + } + }, + + close : function(){ + this.driver.close(); + } + }; + + // Method used by Backbone for sync of data with data store. It was initially designed to work with "server side" APIs, This wrapper makes + // it work with the local indexedDB stuff. It uses the schema attribute provided by the object. + // The wrapper keeps an active Executuon Queue for each "schema", and executes querues agains it, based on the object type (collection or + // single model), but also the method... etc. + // Keeps track of the connections + var Databases = {}; + + function sync(method, object, options) { + + if(method == "closeall"){ + _.each(Databases,function(database){ + database.close(); + }); + // Clean up active databases object. + Databases = {}; + return Backbone.$.Deferred().resolve(); + } + + // If a model or a collection does not define a database, fall back on ajaxSync + if (!object || !_.isObject(object.database)) { + return Backbone.ajaxSync(method, object, options); + } + + var schema = object.database; + if (Databases[schema.id]) { + if(Databases[schema.id].version != _.last(schema.migrations).version){ + Databases[schema.id].close(); + delete Databases[schema.id]; + } + } + + var promise; + + if (typeof Backbone.$ === 'undefined' || typeof Backbone.$.Deferred === 'undefined') { + var noop = function() {}; + var resolve = noop; + var reject = noop; + } else { + var dfd = Backbone.$.Deferred(); + var resolve = dfd.resolve; + var reject = dfd.reject; + + promise = dfd.promise(); + } + + var success = options.success; + options.success = function(resp) { + if (success) success(resp); + resolve(); + object.trigger('sync', object, resp, options); + }; + + var error = options.error; + options.error = function(resp) { + if (error) error(resp); + reject(); + object.trigger('error', object, resp, options); + }; + + var next = function(){ + Databases[schema.id].execute([method, object, options]); + }; + + if (!Databases[schema.id]) { + Databases[schema.id] = new ExecutionQueue(schema,next,schema.nolog); + } else { + next(); + } + + return promise; + }; + + Backbone.ajaxSync = Backbone.sync; + Backbone.sync = sync; + + return { sync: sync, debugLog: debugLog}; +})); diff --git a/index.html b/index.html index c1564708..9566067e 100644 --- a/index.html +++ b/index.html @@ -126,6 +126,7 @@ + @@ -143,7 +144,7 @@ - + diff --git a/js/background.js b/js/background.js index 1595971a..cb533395 100644 --- a/js/background.js +++ b/js/background.js @@ -23,8 +23,9 @@ extension.navigator.tabs.create("options.html"); } else { if (textsecure.registration.isDone()) { + var conversations = new Whisper.ConversationCollection(); textsecure.subscribeToPush(function(message) { - Whisper.Threads.addIncomingMessage(message); + conversations.addIncomingMessage(message); console.log("Got message from " + message.pushMessage.source + "." + message.pushMessage.sourceDevice + ': "' + getString(message.message.body) + '"'); var newUnreadCount = textsecure.storage.getUnencrypted("unreadCount", 0) + 1; diff --git a/js/components.js b/js/components.js index f00f467e..74b0f736 100644 --- a/js/components.js +++ b/js/components.js @@ -21356,267 +21356,630 @@ return jQuery; })); -/** - * Backbone localStorage Adapter - * Version 1.1.14 - * - * https://github.com/jeromegn/Backbone.localStorage - */ (function (root, factory) { - if (typeof exports === 'object' && typeof require === 'function') { - module.exports = factory(require("backbone")); - } else if (typeof define === "function" && define.amd) { - // AMD. Register as an anonymous module. - define(["backbone"], function(Backbone) { - // Use global variables if the locals are undefined. - return factory(Backbone || root.Backbone); - }); - } else { - factory(Backbone); - } -}(this, function(Backbone) { -// A simple module to replace `Backbone.sync` with *localStorage*-based -// persistence. Models are given GUIDS, and saved into a JSON object. Simple -// as that. - -// Generate four random hex digits. -function S4() { - return (((1+Math.random())*0x10000)|0).toString(16).substring(1); -}; - -// Generate a pseudo-GUID by concatenating random hexadecimal. -function guid() { - return (S4()+S4()+"-"+S4()+"-"+S4()+"-"+S4()+"-"+S4()+S4()+S4()); -}; - -function isObject(item) { - return item === Object(item); -} - -function contains(array, item) { - var i = array.length; - while (i--) if (array[i] === item) return true; - return false; -} - -function extend(obj, props) { - for (var key in props) obj[key] = props[key] - return obj; -} - -function result(object, property) { - if (object == null) return void 0; - var value = object[property]; - return (typeof value === 'function') ? object[property]() : value; -} - -// Our Store is represented by a single JS object in *localStorage*. Create it -// with a meaningful name, like the name you'd give a table. -// window.Store is deprectated, use Backbone.LocalStorage instead -Backbone.LocalStorage = window.Store = function(name, serializer) { - if( !this.localStorage ) { - throw "Backbone.localStorage: Environment does not support localStorage." - } - this.name = name; - this.serializer = serializer || { - serialize: function(item) { - return isObject(item) ? JSON.stringify(item) : item; - }, - // fix for "illegal access" error on Android when JSON.parse is passed null - deserialize: function (data) { - return data && JSON.parse(data); + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['backbone', 'underscore'], factory); + } else if (typeof exports === 'object') { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(require('backbone'), require('underscore')); + } else { + // Browser globals (root is window) + root.returnExports = factory(root.Backbone, root._); } - }; - var store = this.localStorage().getItem(this.name); - this.records = (store && store.split(",")) || []; -}; +}(this, function (Backbone, _) { -extend(Backbone.LocalStorage.prototype, { - - // Save the current state of the **Store** to *localStorage*. - save: function() { - var store = this.localStorage().getItem(this.name); - this.records = _.union(this.records, store && store.split(",")); - this.localStorage().setItem(this.name, this.records.join(",")); - }, - - // Add a model, giving it a (hopefully)-unique GUID, if it doesn't already - // have an id of it's own. - create: function(model) { - if (!model.id && model.id !== 0) { - model.id = guid(); - model.set(model.idAttribute, model.id); - } - this.localStorage().setItem(this._itemName(model.id), this.serializer.serialize(model)); - this.records.push(model.id.toString()); - this.save(); - return this.find(model); - }, - - // Update a model by replacing its copy in `this.data`. - update: function(model) { - this.localStorage().setItem(this._itemName(model.id), this.serializer.serialize(model)); - var modelId = model.id.toString(); - if (!contains(this.records, modelId)) { - this.records.push(modelId); - this.save(); - } - return this.find(model); - }, - - // Retrieve a model from `this.data` by id. - find: function(model) { - var store = this.localStorage().getItem(this.name); - this.records = (store && store.split(",")) || []; - return this.serializer.deserialize(this.localStorage().getItem(this._itemName(model.id))); - }, - - // Return the array of all models currently in storage. - findAll: function() { - var store = this.localStorage().getItem(this.name); - this.records = (store && store.split(",")) || []; - var result = []; - for (var i = 0, id, data; i < this.records.length; i++) { - id = this.records[i]; - data = this.serializer.deserialize(this.localStorage().getItem(this._itemName(id))); - if (data != null) result.push(data); - } - return result; - }, - - // Delete a model from `this.data`, returning it. - destroy: function(model) { - this.localStorage().removeItem(this._itemName(model.id)); - var modelId = model.id.toString(); - for (var i = 0, id; i < this.records.length; i++) { - if (this.records[i] === modelId) { - this.records.splice(i, 1); - } - } - this.save(); - return model; - }, - - localStorage: function() { - return localStorage; - }, - - // Clear localStorage for specific collection. - _clear: function() { - var local = this.localStorage(), - itemRe = new RegExp("^" + this.name + "-"); - - // Remove id-tracking item (e.g., "foo"). - local.removeItem(this.name); - - // Match all data items (e.g., "foo-ID") and remove. - for (var k in local) { - if (itemRe.test(k)) { - local.removeItem(k); - } + // Generate four random hex digits. + function S4() { + return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); } - this.records.length = 0; - }, - - // Size of localStorage. - _storageSize: function() { - return this.localStorage().length; - }, - - _itemName: function(id) { - return this.name+"-"+id; - } - -}); - -// localSync delegate to the model or collection's -// *localStorage* property, which should be an instance of `Store`. -// window.Store.sync and Backbone.localSync is deprecated, use Backbone.LocalStorage.sync instead -Backbone.LocalStorage.sync = window.Store.sync = Backbone.localSync = function(method, model, options) { - var store = result(model, 'localStorage') || result(model.collection, 'localStorage'); - - var resp, errorMessage; - //If $ is having Deferred - use it. - var syncDfd = Backbone.$ ? - (Backbone.$.Deferred && Backbone.$.Deferred()) : - (Backbone.Deferred && Backbone.Deferred()); - - try { - - switch (method) { - case "read": - resp = model.id != undefined ? store.find(model) : store.findAll(); - break; - case "create": - resp = store.create(model); - break; - case "update": - resp = store.update(model); - break; - case "delete": - resp = store.destroy(model); - break; + // Generate a pseudo-GUID by concatenating random hexadecimal. + function guid() { + return (S4() + S4() + "-" + S4() + "-" + S4() + "-" + S4() + "-" + S4() + S4() + S4()); } - } catch(error) { - if (error.code === 22 && store._storageSize() === 0) - errorMessage = "Private browsing is unsupported"; - else - errorMessage = error.message; - } + if ( _(indexedDB).isUndefined() ) { return; } - if (resp) { - if (options && options.success) { - if (Backbone.VERSION === "0.9.10") { - options.success(model, resp, options); - } else { - options.success(resp); - } - } - if (syncDfd) { - syncDfd.resolve(resp); + // Driver object + // That's the interesting part. + // There is a driver for each schema provided. The schema is a te combination of name (for the database), a version as well as migrations to reach that + // version of the database. + function Driver(schema, ready, nolog, onerror) { + this.schema = schema; + this.ready = ready; + this.error = null; + this.transactions = []; // Used to list all transactions and keep track of active ones. + this.db = null; + this.nolog = nolog; + this.onerror = onerror; + var lastMigrationPathVersion = _.last(this.schema.migrations).version; + if (!this.nolog) debugLog("opening database " + this.schema.id + " in version #" + lastMigrationPathVersion); + this.dbRequest = indexedDB.open(this.schema.id,lastMigrationPathVersion); //schema version need to be an unsigned long + + this.launchMigrationPath = function(dbVersion) { + var transaction = this.dbRequest.transaction; + var clonedMigrations = _.clone(schema.migrations); + this.migrate(transaction, clonedMigrations, dbVersion, { + error: function (event) { + this.error = "Database not up to date. " + dbVersion + " expected was " + lastMigrationPathVersion; + }.bind(this) + }); + }; + + this.dbRequest.onblocked = function(event){ + if (!this.nolog) debugLog("connection to database blocked"); + } + + this.dbRequest.onsuccess = function (e) { + this.db = e.target.result; // Attach the connection ot the queue. + var currentIntDBVersion = (parseInt(this.db.version) || 0); // we need convert beacuse chrome store in integer and ie10 DP4+ in int; + var lastMigrationInt = (parseInt(lastMigrationPathVersion) || 0); // And make sure we compare numbers with numbers. + + if (currentIntDBVersion === lastMigrationInt) { //if support new event onupgradeneeded will trigger the ready function + // No migration to perform! + this.ready(); + } else if (currentIntDBVersion < lastMigrationInt ) { + // We need to migrate up to the current migration defined in the database + this.launchMigrationPath(currentIntDBVersion); + } else { + // Looks like the IndexedDB is at a higher version than the current driver schema. + this.error = "Database version is greater than current code " + currentIntDBVersion + " expected was " + lastMigrationInt; + } + }.bind(this); + + + + this.dbRequest.onerror = function (e) { + // Failed to open the database + this.error = "Couldn't not connect to the database" + if (!this.nolog) debugLog("Couldn't not connect to the database"); + this.onerror(); + }.bind(this); + + this.dbRequest.onabort = function (e) { + // Failed to open the database + this.error = "Connection to the database aborted" + if (!this.nolog) debugLog("Connection to the database aborted"); + this.onerror(); + }.bind(this); + + + + this.dbRequest.onupgradeneeded = function(iDBVersionChangeEvent){ + this.db =iDBVersionChangeEvent.target.result; + + var newVersion = iDBVersionChangeEvent.newVersion; + var oldVersion = iDBVersionChangeEvent.oldVersion; + + // Fix Safari 8 and iOS 8 bug + // at the first connection oldVersion is equal to 9223372036854776000 + // but the real value is 0 + if (oldVersion > 99999999999) + oldVersion = 0; + + if (!this.nolog) debugLog("onupgradeneeded = " + oldVersion + " => " + newVersion); + this.launchMigrationPath(oldVersion); + }.bind(this); } - } else { - errorMessage = errorMessage ? errorMessage - : "Record Not Found"; + function debugLog(str) { + if (typeof window !== "undefined" && typeof window.console !== "undefined" && typeof window.console.log !== "undefined") { + window.console.log(str); + } + else if(console.log !== "undefined") { + console.log(str) + } + } - if (options && options.error) - if (Backbone.VERSION === "0.9.10") { - options.error(model, errorMessage, options); - } else { - options.error(errorMessage); - } + // Driver Prototype + Driver.prototype = { - if (syncDfd) - syncDfd.reject(errorMessage); - } + // Tracks transactions. Mostly for debugging purposes. TO-IMPROVE + _track_transaction: function(transaction) { + this.transactions.push(transaction); + function removeIt() { + var idx = this.transactions.indexOf(transaction); + if (idx !== -1) {this.transactions.splice(idx); } + }; + transaction.oncomplete = removeIt.bind(this); + transaction.onabort = removeIt.bind(this); + transaction.onerror = removeIt.bind(this); + }, - // add compatibility with $.ajax - // always execute callback for success and error - if (options && options.complete) options.complete(resp); + // Performs all the migrations to reach the right version of the database. + migrate: function (transaction, migrations, version, options) { + transaction.onerror = options.error; + transaction.onabort = options.error; - return syncDfd && syncDfd.promise(); -}; + if (!this.nolog) debugLog("migrate begin version from #" + version); + var that = this; + var migration = migrations.shift(); + if (migration) { + if (!version || version < migration.version) { + // We need to apply this migration- + if (typeof migration.before == "undefined") { + migration.before = function (next) { + next(); + }; + } + if (typeof migration.after == "undefined") { + migration.after = function (next) { + next(); + }; + } + // First, let's run the before script + if (!this.nolog) debugLog("migrate begin before version #" + migration.version); + migration.before(function () { + if (!this.nolog) debugLog("migrate done before version #" + migration.version); -Backbone.ajaxSync = Backbone.sync; + if (!this.nolog) debugLog("migrate begin migrate version #" + migration.version); -Backbone.getSyncMethod = function(model) { - if(model.localStorage || (model.collection && model.collection.localStorage)) { - return Backbone.localSync; - } + migration.migrate(transaction, function () { + if (!this.nolog) debugLog("migrate done migrate version #" + migration.version); + // Migration successfully appliedn let's go to the next one! + if (!this.nolog) debugLog("migrate begin after version #" + migration.version); + migration.after(function () { + if (!this.nolog) debugLog("migrate done after version #" + migration.version); + if (!this.nolog) debugLog("Migrated to " + migration.version); - return Backbone.ajaxSync; -}; + //last modification occurred, need finish + if(migrations.length ==0) { + if (!this.nolog) { + debugLog("migrate setting transaction.oncomplete to finish version #" + migration.version); + transaction.oncomplete = function() { + debugLog("migrate done transaction.oncomplete version #" + migration.version); + debugLog("Done migrating"); + } + } + } + else + { + if (!this.nolog) debugLog("migrate end from version #" + version + " to " + migration.version); + that.migrate(transaction, migrations, version, options); + } -// Override 'Backbone.sync' to default to localSync, -// the original 'Backbone.sync' is still available in 'Backbone.ajaxSync' -Backbone.sync = function(method, model, options) { - return Backbone.getSyncMethod(model).apply(this, [method, model, options]); -}; + }.bind(this)); + }.bind(this)); + }.bind(this)); + } else { + // No need to apply this migration + if (!this.nolog) debugLog("Skipping migration " + migration.version); + this.migrate(transaction, migrations, version, options); + } + } + }, -return Backbone.LocalStorage; + // This is the main method, called by the ExecutionQueue when the driver is ready (database open and migration performed) + execute: function (storeName, method, object, options) { + if (!this.nolog) debugLog("execute : " + method + " on " + storeName + " for " + object.id); + switch (method) { + case "create": + this.create(storeName, object, options); + break; + case "read": + if (object.id || object.cid) { + this.read(storeName, object, options); // It's a model + } else { + this.query(storeName, object, options); // It's a collection + } + break; + case "update": + this.update(storeName, object, options); // We may want to check that this is not a collection. TOFIX + break; + case "delete": + if (object.id || object.cid) { + this.delete(storeName, object, options); + } else { + this.clear(storeName, object, options); + } + break; + default: + // Hum what? + } + }, + + // Writes the json to the storeName in db. It is a create operations, which means it will fail if the key already exists + // options are just success and error callbacks. + create: function (storeName, object, options) { + var writeTransaction = this.db.transaction([storeName], 'readwrite'); + //this._track_transaction(writeTransaction); + var store = writeTransaction.objectStore(storeName); + var json = object.toJSON(); + var idAttribute = _.result(object, 'idAttribute'); + var writeRequest; + + if (json[idAttribute] === undefined && !store.autoIncrement) json[idAttribute] = guid(); + + writeTransaction.onerror = function (e) { + options.error(e); + }; + writeTransaction.oncomplete = function (e) { + options.success(json); + }; + + if (!store.keyPath) + writeRequest = store.add(json, json[idAttribute]); + else + writeRequest = store.add(json); + }, + + // Writes the json to the storeName in db. It is an update operation, which means it will overwrite the value if the key already exist + // options are just success and error callbacks. + update: function (storeName, object, options) { + var writeTransaction = this.db.transaction([storeName], 'readwrite'); + //this._track_transaction(writeTransaction); + var store = writeTransaction.objectStore(storeName); + var json = object.toJSON(); + var idAttribute = _.result(object, 'idAttribute'); + var writeRequest; + + if (!json[idAttribute]) json[idAttribute] = guid(); + + if (!store.keyPath) + writeRequest = store.put(json, json[idAttribute]); + else + writeRequest = store.put(json); + + writeRequest.onerror = function (e) { + options.error(e); + }; + writeTransaction.oncomplete = function (e) { + options.success(json); + }; + }, + + // Reads from storeName in db with json.id if it's there of with any json.xxxx as long as xxx is an index in storeName + read: function (storeName, object, options) { + var readTransaction = this.db.transaction([storeName], "readonly"); + this._track_transaction(readTransaction); + + var store = readTransaction.objectStore(storeName); + var json = object.toJSON(); + var idAttribute = _.result(object, 'idAttribute'); + + var getRequest = null; + if (json[idAttribute]) { + getRequest = store.get(json[idAttribute]); + } else if(options.index) { + var index = store.index(options.index.name); + getRequest = index.get(options.index.value); + } else { + // We need to find which index we have + var cardinality = 0; // try to fit the index with most matches + _.each(store.indexNames, function (key, index) { + index = store.index(key); + if(typeof index.keyPath === 'string' && 1 > cardinality) { + // simple index + if (json[index.keyPath] !== undefined) { + getRequest = index.get(json[index.keyPath]); + cardinality = 1; + } + } else if(typeof index.keyPath === 'object' && index.keyPath.length > cardinality) { + // compound index + var valid = true; + var keyValue = _.map(index.keyPath, function(keyPart) { + valid = valid && json[keyPart] !== undefined; + return json[keyPart]; + }); + if(valid) { + getRequest = index.get(keyValue); + cardinality = index.keyPath.length; + } + } + }); + } + if (getRequest) { + getRequest.onsuccess = function (event) { + if (event.target.result) { + options.success(event.target.result); + } else { + options.error("Not Found"); + } + }; + getRequest.onerror = function () { + options.error("Not Found"); // We couldn't find the record. + } + } else { + options.error("Not Found"); // We couldn't even look for it, as we don't have enough data. + } + }, + + // Deletes the json.id key and value in storeName from db. + delete: function (storeName, object, options) { + var deleteTransaction = this.db.transaction([storeName], 'readwrite'); + //this._track_transaction(deleteTransaction); + + var store = deleteTransaction.objectStore(storeName); + var json = object.toJSON(); + var idAttribute = _.result(object, 'idAttribute'); + + var deleteRequest = store.delete(json[idAttribute]); + + deleteTransaction.oncomplete = function (event) { + options.success(null); + }; + deleteRequest.onerror = function (event) { + options.error("Not Deleted"); + }; + }, + + // Clears all records for storeName from db. + clear: function (storeName, object, options) { + var deleteTransaction = this.db.transaction([storeName], "readwrite"); + //this._track_transaction(deleteTransaction); + + var store = deleteTransaction.objectStore(storeName); + + var deleteRequest = store.clear(); + deleteRequest.onsuccess = function (event) { + options.success(null); + }; + deleteRequest.onerror = function (event) { + options.error("Not Cleared"); + }; + }, + + // Performs a query on storeName in db. + // options may include : + // - conditions : value of an index, or range for an index + // - range : range for the primary key + // - limit : max number of elements to be yielded + // - offset : skipped items. + query: function (storeName, collection, options) { + var elements = []; + var skipped = 0, processed = 0; + var queryTransaction = this.db.transaction([storeName], "readonly"); + //this._track_transaction(queryTransaction); + + var idAttribute = _.result(collection.model.prototype, 'idAttribute'); + var readCursor = null; + var store = queryTransaction.objectStore(storeName); + var index = null, + lower = null, + upper = null, + bounds = null; + + if (options.conditions) { + // We have a condition, we need to use it for the cursor + _.each(store.indexNames, function (key) { + if (!readCursor) { + index = store.index(key); + if (options.conditions[index.keyPath] instanceof Array) { + lower = options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1] ? options.conditions[index.keyPath][1] : options.conditions[index.keyPath][0]; + upper = options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1] ? options.conditions[index.keyPath][0] : options.conditions[index.keyPath][1]; + bounds = IDBKeyRange.bound(lower, upper, true, true); + + if (options.conditions[index.keyPath][0] > options.conditions[index.keyPath][1]) { + // Looks like we want the DESC order + readCursor = index.openCursor(bounds, window.IDBCursor.PREV || "prev"); + } else { + // We want ASC order + readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next"); + } + } else if (typeof options.conditions[index.keyPath] === 'object' && ('$gt' in options.conditions[index.keyPath] || '$gte' in options.conditions[index.keyPath])) { + if('$gt' in options.conditions[index.keyPath]) + bounds = IDBKeyRange.lowerBound(options.conditions[index.keyPath]['$gt'], true); + else + bounds = IDBKeyRange.lowerBound(options.conditions[index.keyPath]['$gte']); + readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next"); + } else if (typeof options.conditions[index.keyPath] === 'object' && ('$lt' in options.conditions[index.keyPath] || '$lte' in options.conditions[index.keyPath])) { + if('$lt' in options.conditions[index.keyPath]) + bounds = IDBKeyRange.upperBound(options.conditions[index.keyPath]['$lt'], true); + else + bounds = IDBKeyRange.upperBound(options.conditions[index.keyPath]['$lte']); + readCursor = index.openCursor(bounds, window.IDBCursor.NEXT || "next"); + } else if (options.conditions[index.keyPath] != undefined) { + bounds = IDBKeyRange.only(options.conditions[index.keyPath]); + readCursor = index.openCursor(bounds); + } + } + }); + } else { + // No conditions, use the index + if (options.range) { + lower = options.range[0] > options.range[1] ? options.range[1] : options.range[0]; + upper = options.range[0] > options.range[1] ? options.range[0] : options.range[1]; + bounds = IDBKeyRange.bound(lower, upper); + if (options.range[0] > options.range[1]) { + readCursor = store.openCursor(bounds, window.IDBCursor.PREV || "prev"); + } else { + readCursor = store.openCursor(bounds, window.IDBCursor.NEXT || "next"); + } + } else { + readCursor = store.openCursor(); + } + } + + if (typeof (readCursor) == "undefined" || !readCursor) { + options.error("No Cursor"); + } else { + readCursor.onerror = function(e){ + options.error("readCursor error", e); + }; + // Setup a handler for the cursor’s `success` event: + readCursor.onsuccess = function (e) { + var cursor = e.target.result; + if (!cursor) { + if (options.addIndividually || options.clear) { + // nothing! + // We need to indicate that we're done. But, how? + collection.trigger("reset"); + } else { + options.success(elements); // We're done. No more elements. + } + } + else { + // Cursor is not over yet. + if (options.limit && processed >= options.limit) { + // Yet, we have processed enough elements. So, let's just skip. + if (bounds && options.conditions[index.keyPath]) { + cursor.continue(options.conditions[index.keyPath][1] + 1); /* We need to 'terminate' the cursor cleany, by moving to the end */ + } else { + cursor.continue(); /* We need to 'terminate' the cursor cleany, by moving to the end */ + } + } + else if (options.offset && options.offset > skipped) { + skipped++; + cursor.continue(); /* We need to Moving the cursor forward */ + } else { + // This time, it looks like it's good! + if (options.addIndividually) { + collection.add(cursor.value); + } else if (options.clear) { + var deleteRequest = store.delete(cursor.value[idAttribute]); + deleteRequest.onsuccess = function (event) { + elements.push(cursor.value); + }; + deleteRequest.onerror = function (event) { + elements.push(cursor.value); + }; + + } else { + elements.push(cursor.value); + } + processed++; + cursor.continue(); + } + } + }; + } + }, + close :function(){ + if(this.db){ + this.db.close(); + } + } + }; + + // ExecutionQueue object + // The execution queue is an abstraction to buffer up requests to the database. + // It holds a "driver". When the driver is ready, it just fires up the queue and executes in sync. + function ExecutionQueue(schema,next,nolog) { + this.driver = new Driver(schema, this.ready.bind(this), nolog, this.error.bind(this)); + this.started = false; + this.failed = false; + this.stack = []; + this.version = _.last(schema.migrations).version; + this.next = next; + } + + // ExecutionQueue Prototype + ExecutionQueue.prototype = { + // Called when the driver is ready + // It just loops over the elements in the queue and executes them. + ready: function () { + this.started = true; + _.each(this.stack, function (message) { + this.execute(message); + }.bind(this)); + this.stack = []; // fix memory leak + this.next(); + }, + + error: function() { + this.failed = true; + _.each(this.stack, function (message) { + this.execute(message); + }.bind(this)); + this.stack = []; + this.next(); + }, + + // Executes a given command on the driver. If not started, just stacks up one more element. + execute: function (message) { + if (this.started) { + this.driver.execute(message[2].storeName || message[1].storeName, message[0], message[1], message[2]); // Upon messages, we execute the query + } else if (this.failed) { + message[2].error(); + } else { + this.stack.push(message); + } + }, + + close : function(){ + this.driver.close(); + } + }; + + // Method used by Backbone for sync of data with data store. It was initially designed to work with "server side" APIs, This wrapper makes + // it work with the local indexedDB stuff. It uses the schema attribute provided by the object. + // The wrapper keeps an active Executuon Queue for each "schema", and executes querues agains it, based on the object type (collection or + // single model), but also the method... etc. + // Keeps track of the connections + var Databases = {}; + + function sync(method, object, options) { + + if(method == "closeall"){ + _.each(Databases,function(database){ + database.close(); + }); + // Clean up active databases object. + Databases = {}; + return Backbone.$.Deferred().resolve(); + } + + // If a model or a collection does not define a database, fall back on ajaxSync + if (!object || !_.isObject(object.database)) { + return Backbone.ajaxSync(method, object, options); + } + + var schema = object.database; + if (Databases[schema.id]) { + if(Databases[schema.id].version != _.last(schema.migrations).version){ + Databases[schema.id].close(); + delete Databases[schema.id]; + } + } + + var promise; + + if (typeof Backbone.$ === 'undefined' || typeof Backbone.$.Deferred === 'undefined') { + var noop = function() {}; + var resolve = noop; + var reject = noop; + } else { + var dfd = Backbone.$.Deferred(); + var resolve = dfd.resolve; + var reject = dfd.reject; + + promise = dfd.promise(); + } + + var success = options.success; + options.success = function(resp) { + if (success) success(resp); + resolve(); + object.trigger('sync', object, resp, options); + }; + + var error = options.error; + options.error = function(resp) { + if (error) error(resp); + reject(); + object.trigger('error', object, resp, options); + }; + + var next = function(){ + Databases[schema.id].execute([method, object, options]); + }; + + if (!Databases[schema.id]) { + Databases[schema.id] = new ExecutionQueue(schema,next,schema.nolog); + } else { + next(); + } + + return promise; + }; + + Backbone.ajaxSync = Backbone.sync; + Backbone.sync = sync; + + return { sync: sync, debugLog: debugLog}; })); /** diff --git a/js/database.js b/js/database.js new file mode 100644 index 00000000..b45702a1 --- /dev/null +++ b/js/database.js @@ -0,0 +1,37 @@ +/* vim: ts=4:sw=4:expandtab + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see