From 022ff4d1137bd2dfbc315f2f4f5435dd9ab4be02 Mon Sep 17 00:00:00 2001 From: Skylar Ittner Date: Sat, 12 Jan 2019 03:05:00 -0700 Subject: [PATCH] Add better offline sync support --- www/index.html | 2 + www/js/NotePostNotes.class.js | 133 ++++++++++++++++++++++++---------- www/js/Notes.class.js | 84 ++++++++++++++------- www/js/delta.js | 83 +++++++++++++++++++++ www/js/editnote.js | 53 +++++++------- www/js/home.js | 4 +- www/js/main.js | 10 ++- www/js/notes.js | 33 ++++++++- www/pages/home.html | 12 ++- www/routes.js | 42 ++++++----- 10 files changed, 339 insertions(+), 117 deletions(-) create mode 100644 www/js/delta.js diff --git a/www/index.html b/www/index.html index eb9f907..cfa3268 100644 --- a/www/index.html +++ b/www/index.html @@ -31,6 +31,8 @@ + + diff --git a/www/js/NotePostNotes.class.js b/www/js/NotePostNotes.class.js index 05f690f..3717b96 100644 --- a/www/js/NotePostNotes.class.js +++ b/www/js/NotePostNotes.class.js @@ -13,10 +13,10 @@ class NotePostNotes extends Notes { this.password = password; } - del(noteid, callback) { + del(noteid, success, error) { super.del(noteid); var self = this; - $.ajax({ + return $.ajax({ url: this.server + "/api/deletenote", dataType: "json", cache: false, @@ -30,45 +30,51 @@ class NotePostNotes extends Notes { if (val.status == "OK") { self.notes = val.notes; } - if (typeof callback == 'function') { - callback(); + if (typeof success == 'function') { + success(); + } + }, error: function () { + if (typeof error == 'function') { + error(); } } }); } - add(note, callback) { + add(note, success, error) { note.norealid = true; - super.add(note, callback); + this.saveNote(note, success, error); } - fix(note) { - super.fix(note); - note.id = note.noteid; - this.set(note); - } - - load(callback) { - var self = this; - $.ajax({ - url: this.server + "/api/getnotes", + getNote(noteid, success, error) { + return $.ajax({ + url: this.server + "/api/getnote", dataType: "json", - cache: false, method: "POST", + data: { + id: noteid + }, beforeSend: function (xhr) { xhr.setRequestHeader("Authorization", "Basic " + btoa(self.username + ":" + self.password)); }, success: function (val) { if (val.status == "OK") { - self.notes = val.notes; + if (typeof success == 'function') { + success(val.note); + } + } else { + if (typeof error == 'function') { + error(val.msg); + } } - if (typeof callback == 'function') { - callback(); + }, error: function () { + if (typeof error == 'function') { + error(); } } }); } - saveNote(note, callback) { + saveNote(note, success, error) { var self = this; var data = { text: note.content, @@ -78,35 +84,88 @@ class NotePostNotes extends Notes { }; // Don't send ID if it's a locally-made note if (note.norealid != true) { - data.id = note.id; + data.id = note.noteid; } - $.ajax({ + + return $.ajax({ url: this.server + "/api/savenote", dataType: "json", method: "POST", data: data, beforeSend: function (xhr) { xhr.setRequestHeader("Authorization", "Basic " + btoa(self.username + ":" + self.password)); - } - }).always(function () { - if (typeof callback == 'function') { - callback(); + }, success: function (val) { + if (val.status == "OK") { + if (typeof success == 'function') { + success(val.note); + } + } else { + if (typeof error == 'function') { + error(); + } + } + }, error: function () { + if (typeof error == 'function') { + error(); + } } }); } - save(callback) { - this.fixAll(); - super.save(); + /** + * Sync notes with the NotePost server, resolving conflicts in the process. + * + * @param {function} success(notes) called when everything's synced up. + * @param {function} error + * @returns {undefined} + */ + sync(success, error) { + super.sync(); - var ajaxcalls = []; - for (var i = 0; i < this.notes.length; i++) { - ajaxcalls.push(this.saveNote(this.notes[i])); - } + var self = this; + $.ajax({ + url: this.server + "/api/getnotes", + dataType: "json", + cache: false, + method: "POST", + beforeSend: function (xhr) { + xhr.setRequestHeader("Authorization", "Basic " + btoa(self.username + ":" + self.password)); + }, success: function (val) { + if (val.status == "OK") { - $.when(ajaxcalls).always(function () { - if (typeof callback == 'function') { - callback(); + console.log("Comparing notes..."); + console.log("Local copy:", self.notes); + console.log("Remote copy:", val.notes); + var delta = getDelta(self.notes, val.notes); + console.log("Comparison: ", delta); + + var notes = delta.noChange; + notes = notes.concat(delta.addedRemote); + notes = notes.concat(delta.changedRemote); + + // Sync locally-created or modified notes + var notesToUpload = delta.addedLocal; + notesToUpload = notesToUpload.concat(delta.changedLocal); + var addedOrChangedLocallyAjax = []; + for (var i = 0; i < notesToUpload.length; i++) { + addedOrChangedLocallyAjax.push(self.saveNote(self.fix(notesToUpload[i]), function (n) { + notes.push(n); + })); + } + $.when(addedOrChangedLocallyAjax).then(function () { + self.notes = notes; + self.fixAll(); + localStorage.setItem("notes", JSON.stringify(notes)); + console.log(JSON.parse(localStorage.getItem("notes"))); + if (typeof success == 'function') { + success(notes); + } + }); + } + }, error: function () { + if (typeof error == 'function') { + error(); + } } }); } diff --git a/www/js/Notes.class.js b/www/js/Notes.class.js index f5f5652..10f09c0 100644 --- a/www/js/Notes.class.js +++ b/www/js/Notes.class.js @@ -10,9 +10,9 @@ class Notes { this.notes = []; } - get(id) { + get(noteid) { for (var i = 0; i < this.notes.length; i++) { - if (this.notes[i].id == id) { + if (this.notes[i].noteid == noteid) { return this.notes[i]; } } @@ -25,38 +25,51 @@ class Notes { set(note) { for (var i = 0; i < this.notes.length; i++) { - if (this.notes[i].id == note.id) { + if (this.notes[i].noteid == note.noteid) { // Refresh HTML rendering note.html = marked(note.content); this.notes[i] = note; + this.save(); return; } } this.notes.push(note); + this.save(); } - del(noteid, callback) { + del(noteid, success, error) { var newnotearr = []; for (var i = 0; i < this.notes.length; i++) { - if (this.notes[i].id != noteid) { + if (this.notes[i].noteid != noteid) { newnotearr.push(this.notes[i]); } } this.notes = newnotearr; - if (typeof callback == 'function') { - callback(); + this.save(); + if (typeof success == 'function') { + success(); } } - add(note, callback) { + add(note, success, error) { + var noteid = null; + do { + noteid = Math.floor(Math.random() * (9999999999 - 1000000000) + 1000000000); + console.log("Generating random note ID: " + noteid); + } while (this.get(noteid) != null); + note["noteid"] = noteid; this.notes.push(note); - if (typeof callback == 'function') { - callback(); + this.save(); + if (typeof success == 'function') { + success(note); } } fix(note) { + if (typeof note.noteid !== 'number') { + note.noteid = null; + } // Set background color if (typeof note.color !== 'string') { note.color = "FFF59D"; @@ -85,39 +98,58 @@ class Notes { if (typeof note.title !== 'string') { note.title = note.content.split('\n')[0].replace(/[#\-]+/gi, "").trim(); } - if (typeof note.modified !== 'string') { - note.modified = (new Date()).toISOString(); + if (typeof note.modified !== 'number') { + note.modified = Math.round((new Date()).getTime() / 1000); } // Render Markdown to HTML if (typeof note.html !== 'string') { note.html = marked(note.content); } // Save - this.set(note); + return note; } fixAll() { for (var i = 0; i < this.notes.length; i++) { - this.fix(this.notes[i]); + this.notes[i] = this.fix(this.notes[i]); } + this.save(); } - load(callback) { - if (localStorage.getItem("notes") !== null) { - var data = JSON.parse(localStorage.getItem("notes")); - if (data.length > 0) { - this.notes = data; - } + /** + * Sync notes with the storage backend. + * + * @param {type} success + * @param {type} error + * @returns {undefined} + */ + sync(success, error) { + if (localStorage.getItem("notes") !== null) { // There is localstorage + var storage = JSON.parse(localStorage.getItem("notes")); + + console.log("Memory copy:", this.notes); + console.log("Storage copy:", storage); + var delta = getDelta(this.notes, storage); + console.log("Comparison: ", delta); + + var notes = delta.noChange; + notes = notes.concat(delta.addedRemote); + notes = notes.concat(delta.changedRemote); + notes = notes.concat(delta.addedLocal); + notes = notes.concat(delta.changedLocal); + // If localStorage is missing something, we still want it + notes = notes.concat(delta.deletedRemote); + + this.notes = notes; + this.fixAll(); } - if (typeof callback == 'function') { - callback(); + this.save(); + if (typeof success == 'function') { + success(this.notes); } } - save(callback) { + save() { localStorage.setItem("notes", JSON.stringify(this.notes)); - if (typeof callback == 'function') { - callback(); - } } } \ No newline at end of file diff --git a/www/js/delta.js b/www/js/delta.js new file mode 100644 index 0000000..f39d2a0 --- /dev/null +++ b/www/js/delta.js @@ -0,0 +1,83 @@ +/* + * The code in this file is by StackOverflow user Juan Mendes. + * License: Creative Commons Attribution-ShareAlike 3.0 Unported (CC BY-SA 3.0). + * Source: https://stackoverflow.com/a/14966749 + */ + + +/** + * Creates a map out of an array be choosing what property to key by + * @param {object[]} array Array that will be converted into a map + * @param {string} prop Name of property to key by + * @return {object} The mapped array. Example: + * mapFromArray([{a:1,b:2}, {a:3,b:4}], 'a') + * returns {1: {a:1,b:2}, 3: {a:3,b:4}} + */ +function mapFromArray(array, prop) { + var map = {}; + for (var i = 0; i < array.length; i++) { + map[ array[i][prop] ] = array[i]; + } + return map; +} + +/** + * @param {object[]} o old array of notes (local copy) + * @param {object[]} n new array of notes (remote copy) + * @param {object} An object with changes + */ +function getDelta(o, n) { + var delta = { + addedRemote: [], + addedLocal: [], + deletedRemote: [], + deletedLocal: [], + changedRemote: [], + changedLocal: [], + noChange: [] + }; + oSane = []; + for (var i = 0; i < o.length; i++) { + if (o[i].noteid == null) { // Note has no real `noteid` + delta.addedLocal.push(o[i]); + } else { + oSane.push(o[i]); + } + } + var local = mapFromArray(oSane, 'noteid'); + var remote = mapFromArray(n, 'noteid'); + + for (var id in local) { + if (!remote.hasOwnProperty(id)) { // Notes that are only present locally + delta.addedLocal.push(local[id]); + // TODO: Figure out which notes were actually added locally and which were deleted on the server + /*if (local[id].norealid) { // Note hasn't been synced to the remote yet + delta.addedLocal.push(local[id]); + } else { // Note has been synced to remote but isn't there anymore + delta.deletedRemote.push(local[id]); + }*/ + } else { // Notes that are present on both + if (local[id].modified > remote[id].modified) { // Local copy is newer + delta.changedLocal.push(local[id]); + } else if (local[id].modified < remote[id].modified) { // Remote copy is newer + delta.changedRemote.push(remote[id]); + } else { // Modified date is same, let's check content + if (local[id].content == remote[id].content) { + delta.noChange.push(local[id]); + } else if (local[id].content.length > remote[id].content.length) { + delta.changedLocal.push(local[id]); + } else { + delta.changedRemote.push(remote[id]); + } + } + } + } + + // Add notes that are only on the remote + for (var id in remote) { + if (!local.hasOwnProperty(id)) { + delta.addedRemote.push(remote[id]); + } + } + return delta; +} \ No newline at end of file diff --git a/www/js/editnote.js b/www/js/editnote.js index 91ca041..7d41f0e 100644 --- a/www/js/editnote.js +++ b/www/js/editnote.js @@ -4,38 +4,37 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - function saveme(callback) { + function finishSave(note, callback) { + NOTES.fixAll(); + NOTES.sync(function () { + app.toast.create({ + text: 'Note saved.', + closeTimeout: 2000 + }).open(); + $("#orig_content").val(note.content); + }); + if (typeof callback == "function") { + callback(); + } + } + var noteid = $("#note_content").data("noteid"); if (noteid == "") { - // Make a very random ID number and check that it won't collide, - // in case the user is good at winning the lottery - do { - noteid = Math.floor(Math.random() * (9999999999 - 1000000000) + 1000000000); - console.log("Generating random note ID: " + noteid); - } while (notes.get(noteid) != null); - - var note = {id: noteid}; - note.content = $("#note_content").val(); - note.modified = (new Date()).toISOString(); - notes.add(note); - $("#note_content").data("noteid", noteid); + var note = { + content: $("#note_content").val(), + modified: Math.round((new Date()).getTime() / 1000) + }; + NOTES.add(note, function (n) { + $("#note_content").data("noteid", n.noteid); + finishSave(n, callback); + }); } else { - var note = notes.get(noteid); + var note = NOTES.get(noteid); note.content = $("#note_content").val(); - note.modified = (new Date()).toISOString(); - notes.set(note); - } - notes.fix(note); - notes.save(function () { - app.toast.create({ - text: 'Note saved.', - closeTimeout: 2000 - }).open(); - $("#orig_content").val(note.content); - }); - if (typeof callback == "function") { - callback(); + note.modified = Math.round((new Date()).getTime() / 1000); + NOTES.set(note); + finishSave(note, callback); } } diff --git a/www/js/home.js b/www/js/home.js index a756ad6..e3f50d2 100644 --- a/www/js/home.js +++ b/www/js/home.js @@ -10,7 +10,7 @@ $(".view-main").on("ptr:refresh", ".ptr-content", function () { }); function editNote(id) { - var note = notes.get(id); + var note = NOTES.get(id); router.navigate("/editnote", { context: { noteid: id, @@ -31,7 +31,7 @@ function makeList(id) { function deleteNote(id) { app.dialog.confirm('Are you sure?', 'Delete Note', function () { - notes.del(id, function () { + NOTES.del(id, function () { app.ptr.refresh(); }); }); diff --git a/www/js/main.js b/www/js/main.js index 2079ec2..ab3d7cd 100644 --- a/www/js/main.js +++ b/www/js/main.js @@ -19,6 +19,10 @@ var mainView = app.views.create('.view-main', { var router = mainView.router; +var NOTES = null; + +var OFFLINE = false; + /** * Thanks to https://stackoverflow.com/a/13542669 * @param {type} color @@ -54,9 +58,13 @@ router.on("pageInit", function (pagedata) { // Run platform-specific setup code for Cordova or NW.js initPlatform(); + if (localStorage.getItem("configured") == null) { // Open the setup page router.navigate("/setup/0"); } else { - router.navigate("/home"); + createNotesObject(function (n) { + NOTES = n; + router.navigate("/home"); + }); } \ No newline at end of file diff --git a/www/js/notes.js b/www/js/notes.js index 4b334d4..15191a9 100644 --- a/www/js/notes.js +++ b/www/js/notes.js @@ -4,8 +4,33 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -if (localStorage.getItem("serverurl") == null) { - var notes = new Notes(); -} else { - var notes = new NotePostNotes(localStorage.getItem("serverurl"), localStorage.getItem("username"), localStorage.getItem("password")); +function createNotesObject(callback) { + if (localStorage.getItem("serverurl") == null) { + callback(new Notes()); + } else { + var checkurl = localStorage.getItem("serverurl") + "/api/ping"; + $.ajax({ + url: checkurl, + dataType: "json", + cache: false, + method: "POST", + beforeSend: function (xhr) { + xhr.setRequestHeader("Authorization", "Basic " + btoa(localStorage.getItem("username") + ":" + localStorage.getItem("password"))); + }, success: function (data) { + if (data.status == "OK") { + callback(new NotePostNotes(localStorage.getItem("serverurl"), localStorage.getItem("username"), localStorage.getItem("password"))); + } else if (data.status == "ERROR") { + app.dialog.alert(data.msg, "Error"); + OFFLINE = true; + callback(new Notes()); + } else { + OFFLINE = true; + callback(new Notes()); + } + }, error: function () { + OFFLINE = true; + callback(new Notes()); + } + }); + } } \ No newline at end of file diff --git a/www/pages/home.html b/www/pages/home.html index b47aec4..d3a3491 100644 --- a/www/pages/home.html +++ b/www/pages/home.html @@ -28,9 +28,19 @@
+ {{#if offline}} +
+
+
+
cloud_off
+ Offline +
+
+
+ {{/if}} {{#each notecards}}
-
+
diff --git a/www/routes.js b/www/routes.js index 227ebaf..72e6906 100644 --- a/www/routes.js +++ b/www/routes.js @@ -10,21 +10,31 @@ var routes = [ path: '/home', name: 'home', async: function (routeTo, routeFrom, resolve, reject) { - notes.load(function () { - console.log("Loading"); - notes.fixAll(); - var notecards = notes.getAll(); + var pageinfo = { + templateUrl: './pages/home.html', + reloadCurrent: (routeFrom.name == "home") + }; + var context = { + showrefreshbtn: (platform_type != "cordova"), + offline: OFFLINE + }; + NOTES.sync(function (notes) { + context["notecards"] = notes; if (routeFrom.name == "home") { app.ptr.done(); } - resolve({ - templateUrl: './pages/home.html', - reloadCurrent: (routeFrom.name == "home") - }, { - context: { - notecards: notecards, - showrefreshbtn: (platform_type != "cordova") - } + resolve(pageinfo, { + context: context + }); + }, function () { + NOTES.fixAll(); + context["notecards"] = NOTES.getAll(); + if (routeFrom.name == "home") { + app.ptr.done(); + } + context["offline"] = true; + resolve(pageinfo, { + context: context }); }); } @@ -66,15 +76,9 @@ var routes = [ title: "Sign in to a different account", onclick: "router.navigate('/setup/0')" }, - { - setting: "opensource", - title: "Open Source Information", - text: "", - onclick: "router.navigate('/credits')" - }, { setting: "versions", - title: "NotePost app v0.1", + title: "NotePost app v1.0.0", text: "Copyright © 2018-2019 Netsyms Technologies. License: Mozilla Public License 2.0.", onclick: "" },