Add better offline sync support

master
Skylar Ittner 5 years ago
parent 3050862c7f
commit 022ff4d113

@ -31,6 +31,8 @@
<script src="node_modules/easymde/dist/easymde.min.js"></script> <script src="node_modules/easymde/dist/easymde.min.js"></script>
<script src="node_modules/shufflejs/dist/shuffle.min.js"></script> <script src="node_modules/shufflejs/dist/shuffle.min.js"></script>
<script src="js/delta.js"></script>
<script src="js/Notes.class.js"></script> <script src="js/Notes.class.js"></script>
<script src="js/NotePostNotes.class.js"></script> <script src="js/NotePostNotes.class.js"></script>
<script src="js/notes.js"></script> <script src="js/notes.js"></script>

@ -13,10 +13,10 @@ class NotePostNotes extends Notes {
this.password = password; this.password = password;
} }
del(noteid, callback) { del(noteid, success, error) {
super.del(noteid); super.del(noteid);
var self = this; var self = this;
$.ajax({ return $.ajax({
url: this.server + "/api/deletenote", url: this.server + "/api/deletenote",
dataType: "json", dataType: "json",
cache: false, cache: false,
@ -30,45 +30,51 @@ class NotePostNotes extends Notes {
if (val.status == "OK") { if (val.status == "OK") {
self.notes = val.notes; self.notes = val.notes;
} }
if (typeof callback == 'function') { if (typeof success == 'function') {
callback(); success();
}
}, error: function () {
if (typeof error == 'function') {
error();
} }
} }
}); });
} }
add(note, callback) { add(note, success, error) {
note.norealid = true; note.norealid = true;
super.add(note, callback); this.saveNote(note, success, error);
} }
fix(note) { getNote(noteid, success, error) {
super.fix(note); return $.ajax({
note.id = note.noteid; url: this.server + "/api/getnote",
this.set(note);
}
load(callback) {
var self = this;
$.ajax({
url: this.server + "/api/getnotes",
dataType: "json", dataType: "json",
cache: false,
method: "POST", method: "POST",
data: {
id: noteid
},
beforeSend: function (xhr) { beforeSend: function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(self.username + ":" + self.password)); xhr.setRequestHeader("Authorization", "Basic " + btoa(self.username + ":" + self.password));
}, success: function (val) { }, success: function (val) {
if (val.status == "OK") { 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') { }, error: function () {
callback(); if (typeof error == 'function') {
error();
} }
} }
}); });
} }
saveNote(note, callback) { saveNote(note, success, error) {
var self = this; var self = this;
var data = { var data = {
text: note.content, text: note.content,
@ -78,35 +84,88 @@ class NotePostNotes extends Notes {
}; };
// Don't send ID if it's a locally-made note // Don't send ID if it's a locally-made note
if (note.norealid != true) { if (note.norealid != true) {
data.id = note.id; data.id = note.noteid;
} }
$.ajax({
return $.ajax({
url: this.server + "/api/savenote", url: this.server + "/api/savenote",
dataType: "json", dataType: "json",
method: "POST", method: "POST",
data: data, data: data,
beforeSend: function (xhr) { beforeSend: function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(self.username + ":" + self.password)); xhr.setRequestHeader("Authorization", "Basic " + btoa(self.username + ":" + self.password));
} }, success: function (val) {
}).always(function () { if (val.status == "OK") {
if (typeof callback == 'function') { if (typeof success == 'function') {
callback(); success(val.note);
}
} else {
if (typeof error == 'function') {
error();
}
}
}, error: function () {
if (typeof error == 'function') {
error();
}
} }
}); });
} }
save(callback) { /**
this.fixAll(); * Sync notes with the NotePost server, resolving conflicts in the process.
super.save(); *
* @param {function} success(notes) called when everything's synced up.
* @param {function} error
* @returns {undefined}
*/
sync(success, error) {
super.sync();
var ajaxcalls = []; var self = this;
for (var i = 0; i < this.notes.length; i++) { $.ajax({
ajaxcalls.push(this.saveNote(this.notes[i])); 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 () { console.log("Comparing notes...");
if (typeof callback == 'function') { console.log("Local copy:", self.notes);
callback(); 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();
}
} }
}); });
} }

@ -10,9 +10,9 @@ class Notes {
this.notes = []; this.notes = [];
} }
get(id) { get(noteid) {
for (var i = 0; i < this.notes.length; i++) { 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]; return this.notes[i];
} }
} }
@ -25,38 +25,51 @@ class Notes {
set(note) { set(note) {
for (var i = 0; i < this.notes.length; i++) { 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 // Refresh HTML rendering
note.html = marked(note.content); note.html = marked(note.content);
this.notes[i] = note; this.notes[i] = note;
this.save();
return; return;
} }
} }
this.notes.push(note); this.notes.push(note);
this.save();
} }
del(noteid, callback) { del(noteid, success, error) {
var newnotearr = []; var newnotearr = [];
for (var i = 0; i < this.notes.length; i++) { 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]); newnotearr.push(this.notes[i]);
} }
} }
this.notes = newnotearr; this.notes = newnotearr;
if (typeof callback == 'function') { this.save();
callback(); 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); this.notes.push(note);
if (typeof callback == 'function') { this.save();
callback(); if (typeof success == 'function') {
success(note);
} }
} }
fix(note) { fix(note) {
if (typeof note.noteid !== 'number') {
note.noteid = null;
}
// Set background color // Set background color
if (typeof note.color !== 'string') { if (typeof note.color !== 'string') {
note.color = "FFF59D"; note.color = "FFF59D";
@ -85,39 +98,58 @@ class Notes {
if (typeof note.title !== 'string') { if (typeof note.title !== 'string') {
note.title = note.content.split('\n')[0].replace(/[#\-]+/gi, "").trim(); note.title = note.content.split('\n')[0].replace(/[#\-]+/gi, "").trim();
} }
if (typeof note.modified !== 'string') { if (typeof note.modified !== 'number') {
note.modified = (new Date()).toISOString(); note.modified = Math.round((new Date()).getTime() / 1000);
} }
// Render Markdown to HTML // Render Markdown to HTML
if (typeof note.html !== 'string') { if (typeof note.html !== 'string') {
note.html = marked(note.content); note.html = marked(note.content);
} }
// Save // Save
this.set(note); return note;
} }
fixAll() { fixAll() {
for (var i = 0; i < this.notes.length; i++) { 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) { * Sync notes with the storage backend.
var data = JSON.parse(localStorage.getItem("notes")); *
if (data.length > 0) { * @param {type} success
this.notes = data; * @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') { this.save();
callback(); if (typeof success == 'function') {
success(this.notes);
} }
} }
save(callback) { save() {
localStorage.setItem("notes", JSON.stringify(this.notes)); localStorage.setItem("notes", JSON.stringify(this.notes));
if (typeof callback == 'function') {
callback();
}
} }
} }

@ -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;
}

@ -4,38 +4,37 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
function saveme(callback) { 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"); var noteid = $("#note_content").data("noteid");
if (noteid == "") { if (noteid == "") {
// Make a very random ID number and check that it won't collide, var note = {
// in case the user is good at winning the lottery content: $("#note_content").val(),
do { modified: Math.round((new Date()).getTime() / 1000)
noteid = Math.floor(Math.random() * (9999999999 - 1000000000) + 1000000000); };
console.log("Generating random note ID: " + noteid); NOTES.add(note, function (n) {
} while (notes.get(noteid) != null); $("#note_content").data("noteid", n.noteid);
finishSave(n, callback);
var note = {id: noteid}; });
note.content = $("#note_content").val();
note.modified = (new Date()).toISOString();
notes.add(note);
$("#note_content").data("noteid", noteid);
} else { } else {
var note = notes.get(noteid); var note = NOTES.get(noteid);
note.content = $("#note_content").val(); note.content = $("#note_content").val();
note.modified = (new Date()).toISOString(); note.modified = Math.round((new Date()).getTime() / 1000);
notes.set(note); NOTES.set(note);
} finishSave(note, callback);
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();
} }
} }

@ -10,7 +10,7 @@ $(".view-main").on("ptr:refresh", ".ptr-content", function () {
}); });
function editNote(id) { function editNote(id) {
var note = notes.get(id); var note = NOTES.get(id);
router.navigate("/editnote", { router.navigate("/editnote", {
context: { context: {
noteid: id, noteid: id,
@ -31,7 +31,7 @@ function makeList(id) {
function deleteNote(id) { function deleteNote(id) {
app.dialog.confirm('Are you sure?', 'Delete Note', function () { app.dialog.confirm('Are you sure?', 'Delete Note', function () {
notes.del(id, function () { NOTES.del(id, function () {
app.ptr.refresh(); app.ptr.refresh();
}); });
}); });

@ -19,6 +19,10 @@ var mainView = app.views.create('.view-main', {
var router = mainView.router; var router = mainView.router;
var NOTES = null;
var OFFLINE = false;
/** /**
* Thanks to https://stackoverflow.com/a/13542669 * Thanks to https://stackoverflow.com/a/13542669
* @param {type} color * @param {type} color
@ -54,9 +58,13 @@ router.on("pageInit", function (pagedata) {
// Run platform-specific setup code for Cordova or NW.js // Run platform-specific setup code for Cordova or NW.js
initPlatform(); initPlatform();
if (localStorage.getItem("configured") == null) { if (localStorage.getItem("configured") == null) {
// Open the setup page // Open the setup page
router.navigate("/setup/0"); router.navigate("/setup/0");
} else { } else {
router.navigate("/home"); createNotesObject(function (n) {
NOTES = n;
router.navigate("/home");
});
} }

@ -4,8 +4,33 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
if (localStorage.getItem("serverurl") == null) { function createNotesObject(callback) {
var notes = new Notes(); if (localStorage.getItem("serverurl") == null) {
} else { callback(new Notes());
var notes = new NotePostNotes(localStorage.getItem("serverurl"), localStorage.getItem("username"), localStorage.getItem("password")); } 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());
}
});
}
} }

@ -28,9 +28,19 @@
</div> </div>
<div class="row notecards-row" id="notecards-bin"> <div class="row notecards-row" id="notecards-bin">
{{#if offline}}
<div class="col-100 tablet-50 desktop-33 notecard-col">
<div class="card">
<div class="card-content card-content-padding text-align-center text-color-gray">
<div><i class="material-icons" style="font-size: 40pt;">cloud_off</i></div>
Offline
</div>
</div>
</div>
{{/if}}
{{#each notecards}} {{#each notecards}}
<div class="col-100 tablet-50 desktop-33 notecard-col"> <div class="col-100 tablet-50 desktop-33 notecard-col">
<div class="card notecard" id="notecard-{{id}}" data-id="{{id}}" data-bg="{{color}}" data-fg="{{textcolor}}" style="background-color: #{{color}}; color: #{{textcolor}};"> <div class="card notecard" id="notecard-{{noteid}}" data-id="{{noteid}}" data-bg="{{color}}" data-fg="{{textcolor}}" style="background-color: #{{color}}; color: #{{textcolor}};">
<div class="menubtn"> <div class="menubtn">
<i class="material-icons">more_vert</i> <i class="material-icons">more_vert</i>
</div> </div>

@ -10,21 +10,31 @@ var routes = [
path: '/home', path: '/home',
name: 'home', name: 'home',
async: function (routeTo, routeFrom, resolve, reject) { async: function (routeTo, routeFrom, resolve, reject) {
notes.load(function () { var pageinfo = {
console.log("Loading"); templateUrl: './pages/home.html',
notes.fixAll(); reloadCurrent: (routeFrom.name == "home")
var notecards = notes.getAll(); };
var context = {
showrefreshbtn: (platform_type != "cordova"),
offline: OFFLINE
};
NOTES.sync(function (notes) {
context["notecards"] = notes;
if (routeFrom.name == "home") { if (routeFrom.name == "home") {
app.ptr.done(); app.ptr.done();
} }
resolve({ resolve(pageinfo, {
templateUrl: './pages/home.html', context: context
reloadCurrent: (routeFrom.name == "home") });
}, { }, function () {
context: { NOTES.fixAll();
notecards: notecards, context["notecards"] = NOTES.getAll();
showrefreshbtn: (platform_type != "cordova") 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", title: "Sign in to a different account",
onclick: "router.navigate('/setup/0')" onclick: "router.navigate('/setup/0')"
}, },
{
setting: "opensource",
title: "Open Source Information",
text: "",
onclick: "router.navigate('/credits')"
},
{ {
setting: "versions", setting: "versions",
title: "NotePost app v0.1", title: "NotePost app v1.0.0",
text: "Copyright &copy; 2018-2019 Netsyms Technologies. License: <span style=\"text-decoration: underline;\" onclick=\"openBrowser('https://source.netsyms.com/Apps/NotePostApp?pk_campaign=NotePostApp');\">Mozilla Public License 2.0</span>.", text: "Copyright &copy; 2018-2019 Netsyms Technologies. License: <span style=\"text-decoration: underline;\" onclick=\"openBrowser('https://source.netsyms.com/Apps/NotePostApp?pk_campaign=NotePostApp');\">Mozilla Public License 2.0</span>.",
onclick: "" onclick: ""
}, },

Loading…
Cancel
Save