From f9cb631acb922b14ff5319247b6bca240b459209 Mon Sep 17 00:00:00 2001 From: Julian Descottes Date: Sun, 11 Jun 2017 23:57:28 +0200 Subject: [PATCH] Issue #640 - migrate backup service to indexeddb --- .../controller/settings/ImportController.js | 25 ++- src/js/model/Piskel.js | 2 + src/js/service/BackupService.js | 206 ++++++++++++++---- 3 files changed, 184 insertions(+), 49 deletions(-) diff --git a/src/js/controller/settings/ImportController.js b/src/js/controller/settings/ImportController.js index 3f932c5f..85714eb6 100644 --- a/src/js/controller/settings/ImportController.js +++ b/src/js/controller/settings/ImportController.js @@ -29,18 +29,19 @@ ns.ImportController.prototype.initRestoreSession_ = function () { var previousSessionContainer = document.querySelector('.previous-session'); - var previousInfo = pskl.app.backupService.getPreviousPiskelInfo(); - if (previousInfo) { - var previousSessionTemplate_ = pskl.utils.Template.get('previous-session-info-template'); - var date = pskl.utils.DateUtils.format(previousInfo.date, '{{H}}:{{m}} - {{Y}}/{{M}}/{{D}}'); - previousSessionContainer.innerHTML = pskl.utils.Template.replace(previousSessionTemplate_, { - name : previousInfo.name, - date : date - }); - this.addEventListener('.restore-session-button', 'click', this.onRestorePreviousSessionClick_); - } else { - previousSessionContainer.innerHTML = 'No piskel backup was found on this browser.'; - } + pskl.app.backupService.getPreviousPiskelInfo().then(function (previousInfo) { + if (previousInfo) { + var previousSessionTemplate_ = pskl.utils.Template.get('previous-session-info-template'); + var date = pskl.utils.DateUtils.format(previousInfo.date, '{{H}}:{{m}} - {{Y}}/{{M}}/{{D}}'); + previousSessionContainer.innerHTML = pskl.utils.Template.replace(previousSessionTemplate_, { + name : previousInfo.name, + date : date + }); + this.addEventListener('.restore-session-button', 'click', this.onRestorePreviousSessionClick_); + } else { + previousSessionContainer.innerHTML = 'No piskel backup was found on this browser.'; + } + }.bind(this)); }; ns.ImportController.prototype.closeDrawer_ = function () { diff --git a/src/js/model/Piskel.js b/src/js/model/Piskel.js index de896833..75e8af7c 100644 --- a/src/js/model/Piskel.js +++ b/src/js/model/Piskel.js @@ -16,6 +16,8 @@ this.descriptor = descriptor; this.savePath = null; this.fps = fps; + // This id is used to keep track of sessions in the BackupService. + this.sessionId = pskl.utils.Uuid.generate(); } else { throw 'Missing arguments in Piskel constructor : ' + Array.prototype.join.call(arguments, ','); diff --git a/src/js/service/BackupService.js b/src/js/service/BackupService.js index 7541a563..75e98cb0 100644 --- a/src/js/service/BackupService.js +++ b/src/js/service/BackupService.js @@ -1,65 +1,197 @@ (function () { var ns = $.namespace('pskl.service'); - // 1 minute = 1000 * 60 - var BACKUP_INTERVAL = 1000 * 60; + var DB_NAME = 'PiskelSessionsDatabase'; + var DB_VERSION = 1; + + var ONE_SECOND = 1000; + var ONE_MINUTE = 60 * ONE_SECOND; + + // Save every minute = 1000 * 60 + var BACKUP_INTERVAL = ONE_MINUTE; + // Store a new snapshot every 5 minutes. + var SNAPSHOT_INTERVAL = ONE_MINUTE * 5; + // 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; }; ns.BackupService.prototype.init = function () { - var previousPiskel = window.localStorage.getItem('bkp.next.piskel'); - var previousInfo = window.localStorage.getItem('bkp.next.info'); - if (previousPiskel && previousInfo) { - this.savePiskel_('prev', previousPiskel, previousInfo); - } + 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; + }; + ns.BackupService.prototype.backup = function () { var piskel = this.piskelController.getPiskel(); - var descriptor = piskel.getDescriptor(); var hash = piskel.getHash(); - var info = { - name : descriptor.name, - description : descriptor.info, - date : Date.now(), - hash : hash - }; // Do not save an unchanged piskel - if (hash !== this.lastHash) { - this.lastHash = hash; - var serializedPiskel = pskl.utils.serialization.Serializer.serialize(piskel); - this.savePiskel_('next', serializedPiskel, JSON.stringify(info)); + if (hash === this.lastHash) { + return; } - }; - ns.BackupService.prototype.getPreviousPiskelInfo = function () { - var previousInfo = window.localStorage.getItem('bkp.prev.info'); - if (previousInfo) { - return JSON.parse(previousInfo); - } - }; + // Update the hash + // TODO: should only be done after a successfull save. + this.lastHash = hash; - ns.BackupService.prototype.load = function() { - var previousPiskel = window.localStorage.getItem('bkp.prev.piskel'); - previousPiskel = JSON.parse(previousPiskel); + // Prepare the backup snapshot. + var descriptor = piskel.getDescriptor(); + var date = Date.now(); + var snapshot = { + session_id: piskel.sessionId, + date: date, + name: descriptor.name, + description: descriptor.description, + serialized: pskl.utils.serialization.Serializer.serialize(piskel) + }; - pskl.utils.serialization.Deserializer.deserialize(previousPiskel, function (piskel) { - pskl.app.piskelController.setPiskel(piskel); + this.getSnapshotsBySessionId_(piskel.sessionId).then(function (snapshots) { + var latest = snapshots[0]; + + if (latest && date < this.nextSnapshotDate) { + // update the latest snapshot + return this.replaceSnapshot(snapshot, latest); + } else { + // add a new snapshot + this.nextSnapshotDate = date + SNAPSHOT_INTERVAL; + return this.createSnapshot(snapshot).then(function () { + if (snapshots.length >= MAX_SNAPSHOTS_PER_SESSION) { + // remove oldest snapshot + return this.deleteSnapshot(snapshots[snapshots.length - 1]); + } + }.bind(this)); + } + }.bind(this)).catch(function (e) { + console.log('Backup failed'); + console.error(e); }); }; - ns.BackupService.prototype.savePiskel_ = function (type, piskel, info) { - try { - window.localStorage.setItem('bkp.' + type + '.piskel', piskel); - window.localStorage.setItem('bkp.' + type + '.info', info); - } catch (e) { - console.error('Could not save piskel backup in localStorage.', e); - } + 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; + }; + + ns.BackupService.prototype.load = function() { + this.getPreviousPiskelInfo().then(function (snapshot) { + pskl.utils.serialization.Deserializer.deserialize( + JSON.parse(snapshot.serialized), + function (piskel) { + pskl.app.piskelController.setPiskel(piskel); + } + ); + }); }; })();