diff --git a/src/js/database/BackupDatabase.js b/src/js/database/BackupDatabase.js new file mode 100644 index 00000000..f73f3e89 --- /dev/null +++ b/src/js/database/BackupDatabase.js @@ -0,0 +1,179 @@ +(function () { + var ns = $.namespace('pskl.database'); + + var DB_NAME = 'PiskelSessionsDatabase'; + var DB_VERSION = 1; + + // Simple wrapper to promisify a request. + var _requestPromise = function (req) { + var deferred = Q.defer(); + req.onsuccess = deferred.resolve.bind(deferred); + req.onerror = deferred.reject.bind(deferred); + return deferred.promise; + }; + + /** + * The BackupDatabase handles all the database interactions related + * to piskel snapshots continuously saved while during the usage of + * Piskel. + * + * @param {Object} options + * - onUpgrade {Function} optional callback called when a DB + * upgrade is performed. + */ + ns.BackupDatabase = function (options) { + options = options || {}; + + this.db = null; + this.onUpgrade = options.onUpgrade; + }; + + /** + * Open and initialize the database. + */ + ns.BackupDatabase.prototype.init = function () { + this.initDeferred_ = Q.defer(); + + var request = window.indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = this.onRequestError_.bind(this); + request.onsuccess = this.onRequestSuccess_.bind(this); + request.onupgradeneeded = this.onUpgradeNeeded_.bind(this); + + return this.initDeferred_.promise; + }; + + ns.BackupDatabase.prototype.onRequestError_ = function (event) { + console.log('Could not initialize the piskel backup database'); + this.initDeferred_.reject(); + }; + + ns.BackupDatabase.prototype.onRequestSuccess_ = function (event) { + this.db = event.target.result; + this.initDeferred_.resolve(this.db); + }; + + ns.BackupDatabase.prototype.onUpgradeNeeded_ = function (event) { + // Set this.db early to allow migration scripts to access it in oncomplete. + this.db = event.target.result; + + // Create an object store "piskels" with the autoIncrement flag set as true. + var objectStore = this.db.createObjectStore('snapshots', { keyPath: 'id', autoIncrement : true }); + + objectStore.createIndex('session_id', 'session_id', { unique: false }); + objectStore.createIndex('date', 'date', { unique: false }); + objectStore.createIndex('session_id, date', ['session_id', 'date'], { unique: false }); + + objectStore.transaction.oncomplete = function(event) { + if (typeof this.onUpgrade == 'function') { + this.onUpgrade(this.db); + } + }.bind(this); + }; + + ns.BackupDatabase.prototype.openObjectStore_ = function () { + return this.db.transaction(['snapshots'], 'readwrite').objectStore('snapshots'); + }; + + /** + * Send an add request for the provided snapshot. + * Returns a promise that resolves the request event. + */ + ns.BackupDatabase.prototype.createSnapshot = function (snapshot) { + var objectStore = this.openObjectStore_(); + var request = objectStore.add(snapshot); + return _requestPromise(request); + }; + + /** + * Send a put request for the provided snapshot. + * Returns a promise that resolves the request event. + */ + ns.BackupDatabase.prototype.updateSnapshot = function (snapshot) { + var objectStore = this.openObjectStore_(); + var request = objectStore.put(snapshot); + return _requestPromise(request); + }; + + /** + * Send a delete request for the provided snapshot. + * Returns a promise that resolves the request event. + */ + ns.BackupDatabase.prototype.deleteSnapshot = function (snapshot) { + var objectStore = this.openObjectStore_(); + var request = objectStore.delete(snapshot.id); + return _requestPromise(request); + }; + + /** + * Get the last (most recent) snapshot that satisfies the accept filter provided. + * Returns a promise that will resolve with the first matching snapshot (or null + * if no valid snapshot is found). + * + * @param {Function} accept: + * Filter method that takes a snapshot as argument and should return true + * if the snapshot is valid. + */ + ns.BackupDatabase.prototype.findLastSnapshot = function (accept) { + // Create the backup promise. + var deferred = Q.defer(); + + // Open a transaction to the snapshots object store. + var objectStore = this.db.transaction(['snapshots']).objectStore('snapshots'); + + var index = objectStore.index('date'); + var range = IDBKeyRange.upperBound(Infinity); + index.openCursor(range, 'prev').onsuccess = function(event) { + var cursor = event.target.result; + var snapshot = cursor && cursor.value; + + // Resolve null if we couldn't find a matching snapshot. + if (!snapshot) { + deferred.resolve(null); + } else if (accept(snapshot)) { + deferred.resolve(snapshot); + } else { + cursor.continue(); + } + }; + + return deferred.promise; + }; + + /** + * Retrieve all the snapshots for a given session id, sorted by descending date order. + * Returns a promise that resolves with an array of snapshots. + * + * @param {String} sessionId + * The session id + */ + ns.BackupDatabase.prototype.getSnapshotsBySessionId = function (sessionId) { + // Create the backup promise. + var deferred = Q.defer(); + + // Open a transaction to the snapshots object store. + var objectStore = this.db.transaction(['snapshots']).objectStore('snapshots'); + + // Loop on all the saved snapshots for the provided piskel id + var index = objectStore.index('session_id, date'); + var keyRange = IDBKeyRange.bound( + [sessionId, 0], + [sessionId, Infinity] + ); + + var snapshots = []; + // Ordered by date in descending order. + index.openCursor(keyRange, 'prev').onsuccess = function(event) { + var cursor = event.target.result; + if (cursor) { + snapshots.push(cursor.value); + cursor.continue(); + } else { + console.log('consumed all piskel snapshots'); + deferred.resolve(snapshots); + } + }; + + return deferred.promise; + }; +})(); diff --git a/src/js/database/PiskelDatabase.js b/src/js/database/PiskelDatabase.js new file mode 100644 index 00000000..6c0a53a5 --- /dev/null +++ b/src/js/database/PiskelDatabase.js @@ -0,0 +1,157 @@ +(function () { + var ns = $.namespace('pskl.database'); + + var DB_NAME = 'PiskelDatabase'; + var DB_VERSION = 1; + + // Simple wrapper to promisify a request. + var _requestPromise = function (req) { + var deferred = Q.defer(); + req.onsuccess = deferred.resolve.bind(deferred); + req.onerror = deferred.reject.bind(deferred); + return deferred.promise; + }; + + /** + * The PiskelDatabase handles all the database interactions related + * to the local piskel saved that can be performed in-browser. + * + * @param {Object} options + * - onUpgrade {Function} optional callback called when a DB + * upgrade is performed. + */ + ns.PiskelDatabase = function (options) { + options = options || {}; + + this.db = null; + this.onUpgrade = options.onUpgrade; + }; + + ns.PiskelDatabase.prototype.init = function () { + this.initDeferred_ = Q.defer(); + + var request = window.indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = this.onRequestError_.bind(this); + request.onsuccess = this.onRequestSuccess_.bind(this); + request.onupgradeneeded = this.onUpgradeNeeded_.bind(this); + + return this.initDeferred_.promise; + }; + + ns.PiskelDatabase.prototype.onRequestError_ = function (event) { + console.log('Failed to initialize IndexedDB, local browser saves will be unavailable.'); + this.initDeferred_.reject(); + }; + + ns.PiskelDatabase.prototype.onRequestSuccess_ = function (event) { + console.log('Successfully initialized IndexedDB, local browser saves are available.'); + this.db = event.target.result; + this.initDeferred_.resolve(this.db); + }; + + ns.PiskelDatabase.prototype.onUpgradeNeeded_ = function (event) { + // Set this.db early to allow migration scripts to access it in oncomplete. + this.db = event.target.result; + + // Create an object store "piskels" with the autoIncrement flag set as true. + var objectStore = this.db.createObjectStore('piskels', { keyPath : 'name' }); + objectStore.transaction.oncomplete = function(event) { + if (typeof this.onUpgrade == 'function') { + this.onUpgrade(this.db); + } + }.bind(this); + }; + + ns.PiskelDatabase.prototype.openObjectStore_ = function () { + return this.db.transaction(['piskels'], 'readwrite').objectStore('piskels'); + }; + + /** + * Send a get request for the provided name. + * Returns a promise that resolves the request event. + */ + ns.PiskelDatabase.prototype.get = function (name) { + var objectStore = this.openObjectStore_(); + return _requestPromise(objectStore.get(name)); + }; + + /** + * List all locally saved piskels. + * Returns a promise that resolves an array of objects: + * - name: name of the piskel + * - description: description of the piskel + * - date: save date + * + * The sprite content is not contained in the object and + * needs to be retrieved with a separate get. + */ + ns.PiskelDatabase.prototype.list = function () { + var deferred = Q.defer(); + + var piskels = []; + var objectStore = this.openObjectStore_(); + var cursor = objectStore.openCursor(); + cursor.onsuccess = function(event) { + var cursor = event.target.result; + if (cursor) { + piskels.push({ + name: cursor.value.name, + date: cursor.value.date, + description: cursor.value.description + }); + cursor.continue(); + } else { + // Cursor consumed all availabled piskels + deferred.resolve(piskels); + } + }; + + cursor.onerror = function () { + deferred.reject(); + }; + + return deferred.promise; + }; + + /** + * Send an put request for the provided args. + * Returns a promise that resolves the request event. + */ + ns.PiskelDatabase.prototype.update = function (name, description, date, serialized) { + var data = {}; + + data.name = name; + data.serialized = serialized; + data.date = date; + data.description = description; + + var objectStore = this.openObjectStore_(); + return _requestPromise(objectStore.put(data)); + }; + + /** + * Send an add request for the provided args. + * Returns a promise that resolves the request event. + */ + ns.PiskelDatabase.prototype.create = function (name, description, date, serialized) { + var data = {}; + + data.name = name; + data.serialized = serialized; + data.date = date; + data.description = description; + + var objectStore = this.openObjectStore_(); + return _requestPromise(objectStore.add(data)); + }; + + /** + * Delete a saved piskel for the provided name. + * Returns a promise that resolves the request event. + */ + ns.PiskelDatabase.prototype.delete = function (name) { + var objectStore = this.openObjectStore_(); + return _requestPromise(objectStore.delete(name)); + }; +})(); diff --git a/src/js/service/BackupService.js b/src/js/service/BackupService.js index 75e98cb0..c8c1f288 100644 --- a/src/js/service/BackupService.js +++ b/src/js/service/BackupService.js @@ -1,9 +1,6 @@ (function () { var ns = $.namespace('pskl.service'); - var DB_NAME = 'PiskelSessionsDatabase'; - var DB_VERSION = 1; - var ONE_SECOND = 1000; var ONE_MINUTE = 60 * ONE_SECOND; @@ -14,104 +11,18 @@ // Store up to 12 snapshots for a piskel session, min. 1 hour of work var MAX_SNAPSHOTS_PER_SESSION = 12; - var _requestPromise = function (req) { - var deferred = Q.defer(); - req.onsuccess = deferred.resolve.bind(deferred); - req.onerror = deferred.reject.bind(deferred); - return deferred.promise; - }; - ns.BackupService = function (piskelController) { this.piskelController = piskelController; this.lastHash = null; this.nextSnapshotDate = -1; + + this.backupDatabase = new pskl.database.BackupDatabase(); }; ns.BackupService.prototype.init = function () { - var request = window.indexedDB.open(DB_NAME, DB_VERSION); - - request.onerror = this.onRequestError_.bind(this); - request.onupgradeneeded = this.onUpgradeNeeded_.bind(this); - request.onsuccess = this.onRequestSuccess_.bind(this); - }; - - ns.BackupService.prototype.onRequestError_ = function (event) { - console.log('Could not initialize the piskel backup database'); - }; - - ns.BackupService.prototype.onUpgradeNeeded_ = function (event) { - // Set this.db early to allow migration scripts to access it in oncomplete. - this.db = event.target.result; - - // Create an object store "piskels" with the autoIncrement flag set as true. - var objectStore = this.db.createObjectStore('snapshots', { keyPath: 'id', autoIncrement : true }); - - objectStore.createIndex('session_id', 'session_id', { unique: false }); - objectStore.createIndex('date', 'date', { unique: false }); - objectStore.createIndex('session_id, date', ['session_id', 'date'], { unique: false }); - - objectStore.transaction.oncomplete = function(event) { - // TODO: Migrate existing data from local storage? - }; - }; - - ns.BackupService.prototype.onRequestSuccess_ = function (event) { - this.db = event.target.result; - window.setInterval(this.backup.bind(this), BACKUP_INTERVAL); - }; - - ns.BackupService.prototype.openObjectStore_ = function () { - return this.db.transaction(['snapshots'], 'readwrite').objectStore('snapshots'); - }; - - ns.BackupService.prototype.createSnapshot = function (snapshot) { - var objectStore = this.openObjectStore_(); - var request = objectStore.add(snapshot); - return _requestPromise(request); - }; - - ns.BackupService.prototype.replaceSnapshot = function (snapshot, replacedSnapshot) { - snapshot.id = replacedSnapshot.id; - - var objectStore = this.openObjectStore_(); - var request = objectStore.put(snapshot); - return _requestPromise(request); - }; - - ns.BackupService.prototype.deleteSnapshot = function (snapshot) { - var objectStore = this.openObjectStore_(); - var request = objectStore.delete(snapshot.id); - return _requestPromise(request); - }; - - ns.BackupService.prototype.getSnapshotsBySessionId_ = function (sessionId) { - // Create the backup promise. - var deferred = Q.defer(); - - // Open a transaction to the snapshots object store. - var objectStore = this.db.transaction(['snapshots']).objectStore('snapshots'); - - // Loop on all the saved snapshots for the provided piskel id - var index = objectStore.index('session_id, date'); - var keyRange = IDBKeyRange.bound( - [sessionId, 0], - [sessionId, Infinity] - ); - - var snapshots = []; - // Ordered by date in descending order. - index.openCursor(keyRange, 'prev').onsuccess = function(event) { - var cursor = event.target.result; - if (cursor) { - snapshots.push(cursor.value); - cursor.continue(); - } else { - console.log('consumed all piskel snapshots'); - deferred.resolve(snapshots); - } - }; - - return deferred.promise; + this.backupDatabase.init().then(function () { + window.setInterval(this.backup.bind(this), BACKUP_INTERVAL); + }.bind(this)); }; ns.BackupService.prototype.backup = function () { @@ -138,19 +49,20 @@ serialized: pskl.utils.serialization.Serializer.serialize(piskel) }; - this.getSnapshotsBySessionId_(piskel.sessionId).then(function (snapshots) { + this.backupDatabase.getSnapshotsBySessionId(piskel.sessionId).then(function (snapshots) { var latest = snapshots[0]; if (latest && date < this.nextSnapshotDate) { // update the latest snapshot - return this.replaceSnapshot(snapshot, latest); + snapshot.id = latest.id; + return this.backupDatabase.updateSnapshot(snapshot); } else { // add a new snapshot this.nextSnapshotDate = date + SNAPSHOT_INTERVAL; - return this.createSnapshot(snapshot).then(function () { + return this.backupDatabase.createSnapshot(snapshot).then(function () { if (snapshots.length >= MAX_SNAPSHOTS_PER_SESSION) { // remove oldest snapshot - return this.deleteSnapshot(snapshots[snapshots.length - 1]); + return this.backupDatabase.deleteSnapshot(snapshots[snapshots.length - 1]); } }.bind(this)); } @@ -161,27 +73,10 @@ }; ns.BackupService.prototype.getPreviousPiskelInfo = function () { - // Create the backup promise. - var deferred = Q.defer(); - - // Open a transaction to the snapshots object store. - var objectStore = this.db.transaction(['snapshots']).objectStore('snapshots'); - var sessionId = this.piskelController.getPiskel().sessionId; - var index = objectStore.index('date'); - var range = IDBKeyRange.upperBound(Infinity); - index.openCursor(range, 'prev').onsuccess = function(event) { - var cursor = event.target.result; - var snapshot = cursor && cursor.value; - if (snapshot && snapshot.session_id === sessionId) { - // Skip snapshots for the current session. - cursor.continue(); - } else { - deferred.resolve(snapshot); - } - }; - - return deferred.promise; + return this.backupDatabase.findLastSnapshot(function (snapshot) { + return snapshot.session_id !== sessionId; + }); }; ns.BackupService.prototype.load = function() { diff --git a/src/js/service/storage/IndexedDbStorageService.js b/src/js/service/storage/IndexedDbStorageService.js index 073854ab..a7367c0a 100644 --- a/src/js/service/storage/IndexedDbStorageService.js +++ b/src/js/service/storage/IndexedDbStorageService.js @@ -5,14 +5,11 @@ ns.IndexedDbStorageService = function (piskelController) { this.piskelController = piskelController; + this.piskelDatabase = new pskl.database.PiskelDatabase(); }; ns.IndexedDbStorageService.prototype.init = function () { - var request = window.indexedDB.open(DB_NAME, DB_VERSION); - - request.onerror = this.onRequestError_.bind(this); - request.onupgradeneeded = this.onUpgradeNeeded_.bind(this); - request.onsuccess = this.onRequestSuccess_.bind(this); + this.piskelDatabase.init(); }; ns.IndexedDbStorageService.prototype.save = function (piskel) { @@ -25,61 +22,18 @@ }; ns.IndexedDbStorageService.prototype.save_ = function (name, description, date, serialized) { - var deferred = Q.defer(); - var objectStore = this.db.transaction(['piskels'], 'readwrite').objectStore('piskels'); - - var getRequest = objectStore.get(name); - getRequest.onsuccess = function (event) { - console.log('get request successful for name: ' + name); + return this.piskelDatabase.get(name).then(function (event) { var data = event.target.result; if (typeof data !== 'undefined') { - - data.serialized = serialized; - data.date = date; - data.description = description; - - var putRequest = objectStore.put(data); - putRequest.onerror = function(event) { - console.log('put request failed for name: ' + name); - deferred.reject(); - }; - putRequest.onsuccess = function(event) { - console.log('put request successful for name: ' + name); - deferred.resolve(); - }; + return this.piskelDatabase.update(name, description, date, serialized); } else { - var request = objectStore.add({ - name: name, - description: description, - serialized: serialized, - date: date - }); - - request.onerror = function(event) { - console.log('Failed to save a piskel'); - deferred.reject(); - }; - request.onsuccess = function(event) { - console.log('Successfully saved a piskel'); - deferred.resolve(); - }; + return this.piskelDatabase.create(name, description, date, serialized); } - }; - - getRequest.onerror = function () { - console.log('get request failed for name: ' + name); - deferred.reject(); - }; - - return deferred.promise; + }.bind(this)); }; ns.IndexedDbStorageService.prototype.load = function (name) { - var objectStore = this.db.transaction(['piskels'], 'readwrite').objectStore('piskels'); - - var getRequest = objectStore.get(name); - getRequest.onsuccess = function (event) { - console.log('get request successful for name: ' + name); + this.piskelDatabase.get(name).then(function (event) { var data = event.target.result; if (typeof data !== 'undefined') { var serialized = data.serialized; @@ -92,80 +46,14 @@ } else { console.log('no local browser save found for name: ' + name); } - }; - - getRequest.onerror = function () { - console.log('get request failed for name: ' + name); - }; + }); }; ns.IndexedDbStorageService.prototype.remove = function (name) { - var objectStore = this.db.transaction(['piskels'], 'readwrite').objectStore('piskels'); - var deleteRequest = objectStore.delete(name); - deleteRequest.onsuccess = function (event) { - console.log('successfully deleted local browser save for name: ' + name); - }; - - deleteRequest.onerror = function (event) { - console.log('failed to delete local browser save for name: ' + name); - }; - }; - - ns.IndexedDbStorageService.prototype.list = function () { - var deferred = Q.defer(); - var piskels = []; - var objectStore = this.db.transaction(['piskels']).objectStore('piskels'); - - var cursor = objectStore.openCursor(); - cursor.onsuccess = function(event) { - var cursor = event.target.result; - if (cursor) { - piskels.push({ - name: cursor.value.name, - date: cursor.value.date, - description: cursor.value.description - }); - cursor.continue(); - } else { - console.log('Cursor consumed all availabled piskels'); - deferred.resolve(piskels); - } - }; - - cursor.onerror = function () { - deferred.reject(); - }; - - return deferred.promise; + this.piskelDatabase.delete(name); }; ns.IndexedDbStorageService.prototype.getKeys = function () { - return this.list(); - }; - - ns.IndexedDbStorageService.prototype.onRequestError_ = function (event) { - console.log('Failed to initialize IndexedDB, local browser saves will be unavailable.'); - }; - - ns.IndexedDbStorageService.prototype.onRequestSuccess_ = function (event) { - this.db = event.target.result; - console.log('Successfully initialized IndexedDB, local browser saves are available.'); - }; - - ns.IndexedDbStorageService.prototype.onUpgradeNeeded_ = function (event) { - // Set this.db early to allow migration scripts to access it in oncomplete. - this.db = event.target.result; - - // Create an object store "piskels" with the autoIncrement flag set as true. - var objectStore = this.db.createObjectStore('piskels', { keyPath : 'name' }); - objectStore.transaction.oncomplete = function(event) { - // Migrate existing sprites from LocalStorage - pskl.service.storage.migrate.MigrateLocalStorageToIndexedDb.migrate().then(function () { - console.log('Successfully migrated local storage data to indexed db'); - }).catch(function (e) { - console.log('Failed to migrate local storage data to indexed db'); - console.error(e); - }); - }; + return this.piskelDatabase.list(); }; })(); diff --git a/src/piskel-script-list.js b/src/piskel-script-list.js index 5f829eb6..c096390e 100644 --- a/src/piskel-script-list.js +++ b/src/piskel-script-list.js @@ -79,6 +79,10 @@ "js/model/Palette.js", "js/model/Piskel.js", + // Database (IndexedDB) + "js/database/BackupDatabase.js", + "js/database/PiskelDatabase.js", + // Selection "js/selection/SelectionManager.js", "js/selection/BaseSelection.js",