diff --git a/app/assets/javascripts/app/app.frontend.js b/app/assets/javascripts/app/app.frontend.js
index f19dfe4fd..da2051625 100644
--- a/app/assets/javascripts/app/app.frontend.js
+++ b/app/assets/javascripts/app/app.frontend.js
@@ -1,6 +1,7 @@
'use strict';
var Neeto = Neeto || {};
+var SN = SN || {};
// detect IE8 and above, and edge.
// IE and Edge do not support pbkdf2 in WebCrypto, therefore we need to use CryptoJS
@@ -17,12 +18,9 @@ angular.module('app.frontend', [
'restangular'
])
-.config(function (RestangularProvider, apiControllerProvider) {
+.config(function (RestangularProvider, authManagerProvider) {
RestangularProvider.setDefaultHeaders({"Content-Type": "application/json"});
- var url = apiControllerProvider.defaultServerURL();
- RestangularProvider.setBaseUrl(url + "/api");
-
RestangularProvider.setFullRequestInterceptor(function(element, operation, route, url, headers, params, httpConfig) {
var token = localStorage.getItem("jwt");
if(token) {
diff --git a/app/assets/javascripts/app/frontend/controllers/_base.js b/app/assets/javascripts/app/frontend/controllers/_base.js
index e6e22df8d..a220696a4 100644
--- a/app/assets/javascripts/app/frontend/controllers/_base.js
+++ b/app/assets/javascripts/app/frontend/controllers/_base.js
@@ -1,9 +1,9 @@
class BaseCtrl {
- constructor($rootScope, modelManager, apiController, dbManager) {
+ constructor(syncManager, dbManager) {
dbManager.openDatabase(null, function(){
// new database, delete syncToken so that items can be refetched entirely from server
- apiController.clearSyncToken();
- apiController.sync();
+ syncManager.clearSyncToken();
+ syncManager.sync();
})
}
}
diff --git a/app/assets/javascripts/app/frontend/controllers/editor.js b/app/assets/javascripts/app/frontend/controllers/editor.js
index a12a75394..c0c8e2909 100644
--- a/app/assets/javascripts/app/frontend/controllers/editor.js
+++ b/app/assets/javascripts/app/frontend/controllers/editor.js
@@ -19,9 +19,9 @@ angular.module('app.frontend')
/**
* Insert 4 spaces when a tab key is pressed,
* only used when inside of the text editor.
- * If the shift key is pressed first, this event is
- * not fired.
- */
+ * If the shift key is pressed first, this event is
+ * not fired.
+ */
var handleTab = function (event) {
if (!event.shiftKey && event.which == 9) {
event.preventDefault();
@@ -29,13 +29,13 @@ angular.module('app.frontend')
var end = this.selectionEnd;
var spaces = " ";
- // Insert 4 spaces
+ // Insert 4 spaces
this.value = this.value.substring(0, start)
+ spaces + this.value.substring(end);
- // Place cursor 4 spaces away from where
- // the tab key was pressed
- this.selectionStart = this.selectionEnd = start + 4;
+ // Place cursor 4 spaces away from where
+ // the tab key was pressed
+ this.selectionStart = this.selectionEnd = start + 4;
}
}
@@ -88,7 +88,7 @@ angular.module('app.frontend')
}
}
})
- .controller('EditorCtrl', function ($sce, $timeout, apiController, markdownRenderer, $rootScope, extensionManager) {
+ .controller('EditorCtrl', function ($sce, $timeout, authManager, markdownRenderer, $rootScope, extensionManager, syncManager) {
this.setNote = function(note, oldNote) {
this.editorMode = 'edit';
@@ -146,16 +146,20 @@ angular.module('app.frontend')
note.dummy = false;
this.save()(note, function(success){
if(success) {
- apiController.clearDraft();
-
if(statusTimeout) $timeout.cancel(statusTimeout);
statusTimeout = $timeout(function(){
- this.noteStatus = "All changes saved"
+ var status = "All changes saved"
+ if(authManager.offline()) {
+ status += " (offline)";
+ }
+ this.saveError = false;
+ this.noteStatus = status;
}.bind(this), 200)
} else {
if(statusTimeout) $timeout.cancel(statusTimeout);
statusTimeout = $timeout(function(){
- this.noteStatus = "(Offline) — All changes saved"
+ this.saveError = true;
+ this.noteStatus = "Error saving"
}.bind(this), 200)
}
}.bind(this));
@@ -171,10 +175,6 @@ angular.module('app.frontend')
this.changesMade = function() {
this.note.hasChanges = true;
this.note.dummy = false;
- if(apiController.isUserSignedIn()) {
- // signed out users have local autosave, dont need draft saving
- apiController.saveDraftToDisk(this.note);
- }
if(saveTimeout) $timeout.cancel(saveTimeout);
if(statusTimeout) $timeout.cancel(statusTimeout);
@@ -236,9 +236,10 @@ angular.module('app.frontend')
}
this.deleteNote = function() {
- apiController.clearDraft();
- this.remove()(this.note);
- this.showMenu = false;
+ if(confirm("Are you sure you want to delete this note?")) {
+ this.remove()(this.note);
+ this.showMenu = false;
+ }
}
this.clickedEditNote = function() {
diff --git a/app/assets/javascripts/app/frontend/controllers/header.js b/app/assets/javascripts/app/frontend/controllers/header.js
index 61ed0f955..624d3b391 100644
--- a/app/assets/javascripts/app/frontend/controllers/header.js
+++ b/app/assets/javascripts/app/frontend/controllers/header.js
@@ -1,5 +1,5 @@
angular.module('app.frontend')
- .directive("header", function(apiController, extensionManager){
+ .directive("header", function(authManager){
return {
restrict: 'E',
scope: {},
@@ -12,119 +12,54 @@ angular.module('app.frontend')
link:function(scope, elem, attrs, ctrl) {
scope.$on("sync:updated_token", function(){
ctrl.syncUpdated();
+ ctrl.findErrors();
+ ctrl.updateOfflineStatus();
+ })
+ scope.$on("sync:error", function(){
+ ctrl.findErrors();
+ ctrl.updateOfflineStatus();
})
}
}
})
- .controller('HeaderCtrl', function ($state, apiController, modelManager, $timeout, extensionManager, dbManager) {
+ .controller('HeaderCtrl', function (authManager, modelManager, $timeout, dbManager, syncManager) {
- this.user = apiController.user;
- this.extensionManager = extensionManager;
- this.loginData = {mergeLocal: true};
+ this.user = authManager.user;
- this.changePasswordPressed = function() {
- this.showNewPasswordForm = !this.showNewPasswordForm;
+ this.updateOfflineStatus = function() {
+ this.offline = authManager.offline();
}
+ this.updateOfflineStatus();
+
+ this.findErrors = function() {
+ this.error = syncManager.syncStatus.error;
+ }
+ this.findErrors();
this.accountMenuPressed = function() {
- this.serverData = {url: apiController.getServer()};
+ this.serverData = {};
this.showAccountMenu = !this.showAccountMenu;
this.showFaq = false;
this.showNewPasswordForm = false;
this.showExtensionsMenu = false;
+ this.showIOMenu = false;
}
this.toggleExtensions = function() {
this.showAccountMenu = false;
+ this.showIOMenu = false;
this.showExtensionsMenu = !this.showExtensionsMenu;
}
- this.toggleExtensionForm = function() {
- this.newExtensionData = {};
- this.showNewExtensionForm = !this.showNewExtensionForm;
- }
-
- this.submitNewExtensionForm = function() {
- if(this.newExtensionData.url) {
- extensionManager.addExtension(this.newExtensionData.url, function(response){
- if(!response) {
- alert("Unable to register this extension. Make sure the link is valid and try again.");
- } else {
- this.newExtensionData.url = "";
- this.showNewExtensionForm = false;
- }
- }.bind(this))
- }
- }
-
- this.selectedAction = function(action, extension) {
- action.running = true;
- extensionManager.executeAction(action, extension, null, function(response){
- action.running = false;
- if(response && response.error) {
- action.error = true;
- alert("There was an error performing this action. Please try again.");
- } else {
- action.error = false;
- apiController.sync(null);
- }
- })
- }
-
- this.deleteExtension = function(extension) {
- if(confirm("Are you sure you want to delete this extension?")) {
- extensionManager.deleteExtension(extension);
- }
- }
-
- this.reloadExtensionsPressed = function() {
- if(confirm("For your security, reloading extensions will disable any currently enabled repeat actions.")) {
- extensionManager.refreshExtensionsFromServer();
- }
- }
-
- this.changeServer = function() {
- apiController.setServer(this.serverData.url, true);
- }
-
- this.signOutPressed = function() {
+ this.toggleIO = function() {
+ this.showIOMenu = !this.showIOMenu;
+ this.showExtensionsMenu = false;
this.showAccountMenu = false;
- apiController.signout(function(){
- window.location.reload();
- })
- }
-
- this.submitPasswordChange = function() {
- this.passwordChangeData.status = "Generating New Keys...";
-
- $timeout(function(){
- if(data.password != data.password_confirmation) {
- alert("Your new password does not match its confirmation.");
- return;
- }
-
- apiController.changePassword(this.passwordChangeData.current_password, this.passwordChangeData.new_password, function(response){
-
- })
-
- }.bind(this))
- }
-
- this.localNotesCount = function() {
- return modelManager.filteredNotes.length;
- }
-
- this.mergeLocalChanged = function() {
- if(!this.loginData.mergeLocal) {
- if(!confirm("Unchecking this option means any of the notes you have written while you were signed out will be deleted. Are you sure you want to discard these notes?")) {
- this.loginData.mergeLocal = true;
- }
- }
}
this.refreshData = function() {
this.isRefreshing = true;
- apiController.sync(function(response){
+ syncManager.sync(function(response){
$timeout(function(){
this.isRefreshing = false;
}.bind(this), 200)
@@ -139,110 +74,4 @@ angular.module('app.frontend')
this.syncUpdated = function() {
this.lastSyncDate = new Date();
}
-
- this.loginSubmitPressed = function() {
- this.loginData.status = "Generating Login Keys...";
-
- $timeout(function(){
- apiController.login(this.loginData.email, this.loginData.user_password, function(response){
- if(!response || response.error) {
- var error = response ? response.error : {message: "An unknown error occured."}
- this.loginData.status = null;
- if(!response || (response && !response.didDisplayAlert)) {
- alert(error.message);
- }
- } else {
- this.onAuthSuccess(response.user);
- }
- }.bind(this));
- }.bind(this))
- }
-
- this.submitRegistrationForm = function() {
- this.loginData.status = "Generating Account Keys...";
-
- $timeout(function(){
- apiController.register(this.loginData.email, this.loginData.user_password, function(response){
- if(!response || response.error) {
- var error = response ? response.error : {message: "An unknown error occured."}
- this.loginData.status = null;
- alert(error.message);
- } else {
- this.onAuthSuccess(response.user);
- }
- }.bind(this));
- }.bind(this))
- }
-
- this.encryptionStatusForNotes = function() {
- var allNotes = modelManager.filteredNotes;
- return allNotes.length + "/" + allNotes.length + " notes encrypted";
- }
-
- this.archiveEncryptionFormat = {encrypted: true};
-
- this.downloadDataArchive = function() {
- var link = document.createElement('a');
- link.setAttribute('download', 'notes.json');
- link.href = apiController.itemsDataFile(this.archiveEncryptionFormat.encrypted);
- link.click();
- }
-
- this.performImport = function(data, password) {
- this.importData.loading = true;
- // allow loading indicator to come up with timeout
- $timeout(function(){
- apiController.importJSONData(data, password, function(success, response){
- console.log("Import response:", success, response);
- this.importData.loading = false;
- if(success) {
- this.importData = null;
- } else {
- alert("There was an error importing your data. Please try again.");
- }
- }.bind(this))
- }.bind(this))
- }
-
- this.submitImportPassword = function() {
- this.performImport(this.importData.data, this.importData.password);
- }
-
- this.importFileSelected = function(files) {
- this.importData = {};
-
- var file = files[0];
- var reader = new FileReader();
- reader.onload = function(e) {
- var data = JSON.parse(e.target.result);
- $timeout(function(){
- if(data.auth_params) {
- // request password
- this.importData.requestPassword = true;
- this.importData.data = data;
- } else {
- this.performImport(data, null);
- }
- }.bind(this))
- }.bind(this)
-
- reader.readAsText(file);
- }
-
- this.onAuthSuccess = function(user) {
- var block = function(){
- window.location.reload();
- this.showLogin = false;
- this.showRegistration = false;
- }.bind(this);
-
- if(!this.loginData.mergeLocal) {
- dbManager.clearAllItems(function(){
- block();
- });
- } else {
- block();
- }
- }
-
- });
+});
diff --git a/app/assets/javascripts/app/frontend/controllers/home.js b/app/assets/javascripts/app/frontend/controllers/home.js
index b03d22838..8d1ebcb03 100644
--- a/app/assets/javascripts/app/frontend/controllers/home.js
+++ b/app/assets/javascripts/app/frontend/controllers/home.js
@@ -1,15 +1,15 @@
angular.module('app.frontend')
-.controller('HomeCtrl', function ($scope, $rootScope, $timeout, apiController, modelManager) {
+.controller('HomeCtrl', function ($scope, $rootScope, $timeout, modelManager, syncManager, authManager) {
$rootScope.bodyClass = "app-body-class";
- apiController.loadLocalItems(function(items){
+ syncManager.loadLocalItems(function(items) {
$scope.$apply();
- apiController.sync(null);
+ syncManager.sync(null);
// refresh every 30s
- setInterval(function () {
- apiController.sync(null);
- }, 30000);
+ // setInterval(function () {
+ // syncManager.sync(null);
+ // }, 30000);
});
$scope.allTag = new Tag({all: true});
@@ -31,7 +31,7 @@ angular.module('app.frontend')
modelManager.createRelationshipBetweenItems(note, tag);
}
- apiController.sync();
+ syncManager.sync();
}
/*
@@ -56,8 +56,12 @@ angular.module('app.frontend')
}
$scope.tagsSave = function(tag, callback) {
+ if(!tag.title || tag.title.length == 0) {
+ $scope.notesRemoveTag(tag);
+ return;
+ }
tag.setDirty(true);
- apiController.sync(callback);
+ syncManager.sync(callback);
}
/*
@@ -69,12 +73,9 @@ angular.module('app.frontend')
if(validNotes == 0) {
modelManager.setItemToBeDeleted(tag);
// if no more notes, delete tag
- apiController.sync(function(){
+ syncManager.sync(function(){
// force scope tags to update on sub directives
- $scope.tags = [];
- $timeout(function(){
- $scope.tags = modelManager.tags;
- })
+ $scope.safeApply();
});
} else {
alert("To delete this tag, remove all its notes first.");
@@ -100,7 +101,7 @@ angular.module('app.frontend')
$scope.saveNote = function(note, callback) {
note.setDirty(true);
- apiController.sync(function(response){
+ syncManager.sync(function(response){
if(response && response.error) {
if(!$scope.didShowErrorAlert) {
$scope.didShowErrorAlert = true;
@@ -137,8 +138,8 @@ angular.module('app.frontend')
return;
}
- apiController.sync(function(){
- if(!apiController.user) {
+ syncManager.sync(function(){
+ if(authManager.offline()) {
// when deleting items while ofline, we need to explictly tell angular to refresh UI
setTimeout(function () {
$scope.safeApply();
diff --git a/app/assets/javascripts/app/frontend/controllers/notes.js b/app/assets/javascripts/app/frontend/controllers/notes.js
index 111eb95d8..5bb7fba5a 100644
--- a/app/assets/javascripts/app/frontend/controllers/notes.js
+++ b/app/assets/javascripts/app/frontend/controllers/notes.js
@@ -24,7 +24,7 @@ angular.module('app.frontend')
}
}
})
- .controller('NotesCtrl', function (apiController, $timeout, $rootScope, modelManager) {
+ .controller('NotesCtrl', function (authManager, $timeout, $rootScope, modelManager) {
$rootScope.$on("editorFocused", function(){
this.showMenu = false;
@@ -32,6 +32,11 @@ angular.module('app.frontend')
var isFirstLoad = true;
+ this.notesToDisplay = 20;
+ this.paginate = function() {
+ this.notesToDisplay += 20
+ }
+
this.tagDidChange = function(tag, oldTag) {
this.showMenu = false;
@@ -48,14 +53,8 @@ angular.module('app.frontend')
if(isFirstLoad) {
$timeout(function(){
- var draft = apiController.getDraft();
- if(draft) {
- var note = draft;
- this.selectNote(note);
- } else {
- this.createNewNote();
- isFirstLoad = false;
- }
+ this.createNewNote();
+ isFirstLoad = false;
}.bind(this))
} else if(tag.notes.length == 0) {
this.createNewNote();
diff --git a/app/assets/javascripts/app/frontend/controllers/tags.js b/app/assets/javascripts/app/frontend/controllers/tags.js
index 5f673c2da..422c308f2 100644
--- a/app/assets/javascripts/app/frontend/controllers/tags.js
+++ b/app/assets/javascripts/app/frontend/controllers/tags.js
@@ -79,19 +79,20 @@ angular.module('app.frontend')
this.saveTag = function($event, tag) {
this.editingTag = null;
- if(tag.title.length == 0) {
- tag.title = originalTagName;
- originalTagName = "";
+ $event.target.blur();
+
+ if(!tag.title || tag.title.length == 0) {
+ if(originalTagName) {
+ tag.title = originalTagName;
+ originalTagName = null;
+ } else {
+ // newly created tag without content
+ modelManager.removeItemLocally(tag);
+ }
return;
}
- $event.target.blur();
- if(!tag.title || tag.title.length == 0) {
- return;
- }
-
this.save()(tag, function(savedTag){
- // _.merge(tag, savedTag);
this.selectTag(tag);
this.newTag = null;
}.bind(this));
diff --git a/app/assets/javascripts/app/frontend/models/api/item.js b/app/assets/javascripts/app/frontend/models/api/item.js
index b87e66c8d..f6129f5a0 100644
--- a/app/assets/javascripts/app/frontend/models/api/item.js
+++ b/app/assets/javascripts/app/frontend/models/api/item.js
@@ -50,10 +50,6 @@ class Item {
}
}
- alternateUUID() {
- this.uuid = Neeto.crypto.generateUUID();
- }
-
setDirty(dirty) {
this.dirty = dirty;
@@ -107,6 +103,10 @@ class Item {
// must override
}
+ isBeingRemovedLocally() {
+
+ }
+
removeAllRelationships() {
// must override
this.setDirty(true);
diff --git a/app/assets/javascripts/app/frontend/models/app/extension.js b/app/assets/javascripts/app/frontend/models/app/extension.js
index acd42b4e2..d6a46aad2 100644
--- a/app/assets/javascripts/app/frontend/models/app/extension.js
+++ b/app/assets/javascripts/app/frontend/models/app/extension.js
@@ -1,18 +1,20 @@
class Action {
constructor(json) {
- _.merge(this, json);
- this.running = false; // in case running=true was synced with server since model is uploaded nondiscriminatory
- this.error = false;
- if(this.lastExecuted) {
- // is string
- this.lastExecuted = new Date(this.lastExecuted);
- }
+ _.merge(this, json);
+ this.running = false; // in case running=true was synced with server since model is uploaded nondiscriminatory
+ this.error = false;
+ if(this.lastExecuted) {
+ // is string
+ this.lastExecuted = new Date(this.lastExecuted);
+ }
}
- get permissionsString() {
+ permissionsString() {
+ console.log("permissions", this.permissions);
if(!this.permissions) {
return "";
}
+
var permission = this.permissions.charAt(0).toUpperCase() + this.permissions.slice(1); // capitalize first letter
permission += ": ";
for(var contentType of this.content_types) {
@@ -28,7 +30,7 @@ class Action {
return permission;
}
- get encryptionModeString() {
+ encryptionModeString() {
if(this.verb != "post") {
return null;
}
@@ -54,6 +56,12 @@ class Extension extends Item {
this.encrypted = true;
this.content_type = "Extension";
+
+ if(json.actions) {
+ this.actions = json.actions.map(function(action){
+ return new Action(action);
+ })
+ }
}
actionsInGlobalContext() {
diff --git a/app/assets/javascripts/app/frontend/models/app/note.js b/app/assets/javascripts/app/frontend/models/app/note.js
index 31d27cf80..b7b4a6c06 100644
--- a/app/assets/javascripts/app/frontend/models/app/note.js
+++ b/app/assets/javascripts/app/frontend/models/app/note.js
@@ -56,6 +56,13 @@ class Note extends Item {
this.tags = [];
}
+ isBeingRemovedLocally() {
+ this.tags.forEach(function(tag){
+ _.pull(tag.notes, this);
+ }.bind(this))
+ super.isBeingRemovedLocally();
+ }
+
static filterDummyNotes(notes) {
var filtered = notes.filter(function(note){return note.dummy == false || note.dummy == null});
return filtered;
diff --git a/app/assets/javascripts/app/frontend/models/app/tag.js b/app/assets/javascripts/app/frontend/models/app/tag.js
index f94c2eba4..f8361ca5a 100644
--- a/app/assets/javascripts/app/frontend/models/app/tag.js
+++ b/app/assets/javascripts/app/frontend/models/app/tag.js
@@ -55,6 +55,13 @@ class Tag extends Item {
this.notes = [];
}
+ isBeingRemovedLocally() {
+ this.notes.forEach(function(note){
+ _.pull(note.tags, this);
+ }.bind(this))
+ super.isBeingRemovedLocally();
+ }
+
get content_type() {
return "Tag";
}
diff --git a/app/assets/javascripts/app/frontend/models/local/itemParams.js b/app/assets/javascripts/app/frontend/models/local/itemParams.js
new file mode 100644
index 000000000..396a51bd1
--- /dev/null
+++ b/app/assets/javascripts/app/frontend/models/local/itemParams.js
@@ -0,0 +1,57 @@
+class ItemParams {
+
+ constructor(item, ek) {
+ this.item = item;
+ this.ek = ek;
+ }
+
+ paramsForExportFile() {
+ this.additionalFields = ["updated_at"];
+ this.forExportFile = true;
+ return _.omit(this.__params(), ["deleted"]);
+ }
+
+ paramsForExtension() {
+ return this.paramsForExportFile();
+ }
+
+ paramsForLocalStorage() {
+ this.additionalFields = ["updated_at", "dirty"];
+ this.forExportFile = true;
+ return this.__params();
+ }
+
+ paramsForSync() {
+ return this.__params(null, false);
+ }
+
+ __params() {
+ var itemCopy = _.cloneDeep(this.item);
+
+ console.assert(!this.item.dummy, "Item is dummy, should not have gotten here.", this.item.dummy)
+
+ var params = {uuid: this.item.uuid, content_type: this.item.content_type, deleted: this.item.deleted, created_at: this.item.created_at};
+
+ if(this.ek) {
+ EncryptionHelper.encryptItem(itemCopy, this.ek);
+ params.content = itemCopy.content;
+ params.enc_item_key = itemCopy.enc_item_key;
+ params.auth_hash = itemCopy.auth_hash;
+ }
+ else {
+ params.content = this.forExportFile ? itemCopy.createContentJSONFromProperties() : "000" + Neeto.crypto.base64(JSON.stringify(itemCopy.createContentJSONFromProperties()));
+ if(!this.forExportFile) {
+ params.enc_item_key = null;
+ params.auth_hash = null;
+ }
+ }
+
+ if(this.additionalFields) {
+ _.merge(params, _.pick(this.item, this.additionalFields));
+ }
+
+ return params;
+ }
+
+
+}
diff --git a/app/assets/javascripts/app/services/apiController.js b/app/assets/javascripts/app/services/apiController.js
deleted file mode 100644
index 7c4e163b1..000000000
--- a/app/assets/javascripts/app/services/apiController.js
+++ /dev/null
@@ -1,607 +0,0 @@
-angular.module('app.frontend')
- .provider('apiController', function () {
-
- function domainName() {
- var domain_comps = location.hostname.split(".");
- var domain = domain_comps[domain_comps.length - 2] + "." + domain_comps[domain_comps.length - 1];
- return domain;
- }
-
- var url;
-
- this.defaultServerURL = function() {
- if(!url) {
- url = localStorage.getItem("server");
- if(!url) {
- url = "https://n3.standardnotes.org";
- }
- }
- return url;
- }
-
-
- this.$get = function($rootScope, Restangular, modelManager, dbManager) {
- return new ApiController($rootScope, Restangular, modelManager, dbManager);
- }
-
- function ApiController($rootScope, Restangular, modelManager, dbManager) {
-
- var userData = localStorage.getItem("user");
- if(userData) {
- this.user = JSON.parse(userData);
- } else {
- // legacy, check for uuid
- var idData = localStorage.getItem("uuid");
- if(idData) {
- this.user = {uuid: idData};
- }
- }
- this.syncToken = localStorage.getItem("syncToken");
-
- /*
- Config
- */
-
- this.getServer = function() {
- if(!url) {
- url = localStorage.getItem("server");
- if(!url) {
- url = "https://n3.standardnotes.org";
- this.setServer(url);
- }
- }
- return url;
- }
-
- this.setServer = function(url, refresh) {
- localStorage.setItem("server", url);
- if(refresh) {
- window.location.reload();
- }
- }
-
-
- /*
- Auth
- */
-
- this.getAuthParams = function() {
- return JSON.parse(localStorage.getItem("auth_params"));
- }
-
- this.isUserSignedIn = function() {
- return localStorage.getItem("jwt");
- }
-
- this.getAuthParamsForEmail = function(email, callback) {
- var request = Restangular.one("auth", "params");
- request.get({email: email}).then(function(response){
- callback(response.plain());
- })
- .catch(function(response){
- console.log("Error getting current user", response);
- callback(response.data);
- })
- }
-
- this.supportsPasswordDerivationCost = function(cost) {
- // some passwords are created on platforms with stronger pbkdf2 capabilities, like iOS,
- // which accidentally used 60,000 iterations (now adjusted), which CryptoJS can't handle here (WebCrypto can however).
- // if user has high password cost and is using browser that doesn't support WebCrypto,
- // we want to tell them that they can't login with this browser.
- if(cost > 5000) {
- return Neeto.crypto instanceof SNCryptoWeb ? true : false;
- } else {
- return true;
- }
- }
-
- this.login = function(email, password, callback) {
- this.getAuthParamsForEmail(email, function(authParams){
- if(!authParams) {
- callback(null);
- return;
- }
-
- if(!this.supportsPasswordDerivationCost(authParams.pw_cost)) {
- var string = "Your account was created on a platform with higher security capabilities than this browser supports. " +
- "If we attempted to generate your login keys here, it would take hours. " +
- "Please use a browser with more up to date security capabilities, like Google Chrome or Firefox, to login."
- alert(string)
- callback({didDisplayAlert: true});
- return;
- }
-
- Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: password}, authParams), function(keys){
- this.setMk(keys.mk);
- var request = Restangular.one("auth/sign_in");
- var params = {password: keys.pw, email: email};
- _.merge(request, params);
- request.post().then(function(response){
- localStorage.setItem("jwt", response.token);
- localStorage.setItem("user", JSON.stringify(response.user));
- localStorage.setItem("auth_params", JSON.stringify(authParams));
- callback(response);
- })
- .catch(function(response){
- callback(response.data);
- })
- }.bind(this));
- }.bind(this))
- }
-
- this.register = function(email, password, callback) {
- Neeto.crypto.generateInitialEncryptionKeysForUser({password: password, email: email}, function(keys, authParams){
- this.setMk(keys.mk);
- keys.mk = null;
- var request = Restangular.one("auth");
- var params = _.merge({password: keys.pw, email: email}, authParams);
- _.merge(request, params);
- request.post().then(function(response){
- localStorage.setItem("jwt", response.token);
- localStorage.setItem("user", JSON.stringify(response.user));
- localStorage.setItem("auth_params", JSON.stringify(_.omit(authParams, ["pw_nonce"])));
- callback(response);
- })
- .catch(function(response){
- callback(response.data);
- })
- }.bind(this));
- }
-
- // this.changePassword = function(current_password, new_password) {
- // this.getAuthParamsForEmail(email, function(authParams){
- // if(!authParams) {
- // callback(null);
- // return;
- // }
- // Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: current_password, email: user.email}, authParams), function(currentKeys) {
- // Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: new_password, email: user.email}, authParams), function(newKeys){
- // var data = {};
- // data.current_password = currentKeys.pw;
- // data.password = newKeys.pw;
- // data.password_confirmation = newKeys.pw;
- //
- // var user = this.user;
- //
- // this._performPasswordChange(currentKeys, newKeys, function(response){
- // if(response && !response.error) {
- // // this.showNewPasswordForm = false;
- // // reencrypt data with new mk
- // this.reencryptAllItemsAndSave(user, newKeys.mk, currentKeys.mk, function(success){
- // if(success) {
- // this.setMk(newKeys.mk);
- // alert("Your password has been changed and your data re-encrypted.");
- // } else {
- // // rollback password
- // this._performPasswordChange(newKeys, currentKeys, function(response){
- // alert("There was an error changing your password. Your password has been rolled back.");
- // window.location.reload();
- // })
- // }
- // }.bind(this));
- // } else {
- // // this.showNewPasswordForm = false;
- // alert("There was an error changing your password. Please try again.");
- // }
- // }.bind(this))
- // }.bind(this));
- // }.bind(this));
- // }.bind(this));
- // }
-
- this._performPasswordChange = function(email, current_keys, new_keys, callback) {
- var request = Restangular.one("auth");
- var params = {password: new_keys.pw, password_confirmation: new_keys.pw, current_password: current_keys.pw, email: email};
- _.merge(request, params);
- request.patch().then(function(response){
- callback(response);
- })
- }
-
-
- /*
- Items
- */
-
- this.setSyncToken = function(syncToken) {
- this.syncToken = syncToken;
- localStorage.setItem("syncToken", this.syncToken);
- }
-
- this.syncWithOptions = function(callback, options = {}) {
-
- if(this.syncOpInProgress) {
- // will perform anoter sync after current completes
- this.repeatSync = true;
- return;
- }
-
- this.syncOpInProgress = true;
-
- var allDirtyItems = modelManager.getDirtyItems();
-
- // we want to write all dirty items to disk only if the user is not signed in, or if the sync op fails
- // if the sync op succeeds, these items will be written to disk by handling the "saved_items" response from the server
- var writeAllDirtyItemsToDisk = function(completion) {
- this.writeItemsToLocalStorage(allDirtyItems, function(responseItems){
- if(completion) {
- completion();
- }
- })
- }.bind(this);
-
- if(!this.isUserSignedIn()) {
- writeAllDirtyItemsToDisk(function(){
- // delete anything needing to be deleted
- allDirtyItems.forEach(function(item){
- if(item.deleted) {
- modelManager.removeItemLocally(item);
- }
- }.bind(this))
-
- modelManager.clearDirtyItems(allDirtyItems);
-
- }.bind(this))
- this.syncOpInProgress = false;
- if(callback) {
- callback();
- }
- return;
- }
-
- let submitLimit = 100;
- var dirtyItems = allDirtyItems.slice(0, submitLimit);
- if(dirtyItems.length < allDirtyItems.length) {
- // more items left to be synced, repeat
- this.repeatSync = true;
- } else {
- this.repeatSync = false;
- }
-
- var request = Restangular.one("items/sync");
- request.limit = 150;
- request.sync_token = this.syncToken;
- request.cursor_token = this.cursorToken;
- request.items = _.map(dirtyItems, function(item){
- return this.createRequestParamsForItem(item, options.additionalFields);
- }.bind(this));
-
- request.post().then(function(response) {
-
- modelManager.clearDirtyItems(dirtyItems);
-
- // handle sync token
- this.setSyncToken(response.sync_token);
- $rootScope.$broadcast("sync:updated_token", this.syncToken);
-
- // handle cursor token (more results waiting, perform another sync)
- this.cursorToken = response.cursor_token;
-
- var retrieved = this.handleItemsResponse(response.retrieved_items, null);
- // merge only metadata for saved items
- var omitFields = ["content", "auth_hash"];
- var saved = this.handleItemsResponse(response.saved_items, omitFields);
-
- this.handleUnsavedItemsResponse(response.unsaved)
-
- this.writeItemsToLocalStorage(saved, null);
- this.writeItemsToLocalStorage(retrieved, null);
-
- this.syncOpInProgress = false;
-
- if(this.cursorToken || this.repeatSync == true) {
- this.syncWithOptions(callback, options);
- } else {
- if(callback) {
- callback(response);
- }
- }
-
- }.bind(this))
- .catch(function(response){
- console.log("Sync error: ", response);
-
- writeAllDirtyItemsToDisk();
- this.syncOpInProgress = false;
-
- if(callback) {
- callback({error: "Sync error"});
- }
- }.bind(this))
- }
-
- this.sync = function(callback) {
- this.syncWithOptions(callback, undefined);
- }
-
- this.handleUnsavedItemsResponse = function(unsaved) {
- if(unsaved.length == 0) {
- return;
- }
-
- console.log("Handle unsaved", unsaved);
- for(var mapping of unsaved) {
- var itemResponse = mapping.item;
- var item = modelManager.findItem(itemResponse.uuid);
- var error = mapping.error;
- if(error.tag == "uuid_conflict") {
- item.alternateUUID();
- item.setDirty(true);
- item.markAllReferencesDirty();
- }
- }
-
- this.syncWithOptions(null, {additionalFields: ["created_at", "updated_at"]});
- }
-
- this.handleItemsResponse = function(responseItems, omitFields) {
- this.decryptItems(responseItems);
- return modelManager.mapResponseItemsToLocalModelsOmittingFields(responseItems, omitFields);
- }
-
- this.createRequestParamsForItem = function(item, additionalFields) {
- return this.paramsForItem(item, true, additionalFields, false);
- }
-
- this.paramsForExportFile = function(item, encrypted) {
- return _.omit(this.paramsForItem(item, encrypted, ["created_at", "updated_at"], true), ["deleted"]);
- }
-
- this.paramsForExtension = function(item, encrypted) {
- return _.omit(this.paramsForItem(item, encrypted, ["created_at", "updated_at"], true), ["deleted"]);
- }
-
- this.paramsForItem = function(item, encrypted, additionalFields, forExportFile) {
- var itemCopy = _.cloneDeep(item);
-
- console.assert(!item.dummy, "Item is dummy, should not have gotten here.", item.dummy)
-
- var params = {uuid: item.uuid, content_type: item.content_type, deleted: item.deleted};
-
- if(encrypted) {
- this.encryptSingleItem(itemCopy, this.retrieveMk());
- params.content = itemCopy.content;
- params.enc_item_key = itemCopy.enc_item_key;
- params.auth_hash = itemCopy.auth_hash;
- }
- else {
- params.content = forExportFile ? itemCopy.createContentJSONFromProperties() : "000" + Neeto.crypto.base64(JSON.stringify(itemCopy.createContentJSONFromProperties()));
- if(!forExportFile) {
- params.enc_item_key = null;
- params.auth_hash = null;
- }
- }
-
- if(additionalFields) {
- _.merge(params, _.pick(item, additionalFields));
- }
-
- return params;
- }
-
- /*
- Import
- */
-
- this.clearSyncToken = function() {
- this.syncToken = null;
- localStorage.removeItem("syncToken");
- }
-
- this.importJSONData = function(data, password, callback) {
- console.log("Importing data", data);
-
- var onDataReady = function() {
- var items = modelManager.mapResponseItemsToLocalModels(data.items);
- items.forEach(function(item){
- item.setDirty(true);
- item.markAllReferencesDirty();
- })
- this.syncWithOptions(callback, {additionalFields: ["created_at", "updated_at"]});
- }.bind(this)
-
- if(data.auth_params) {
- Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: password}, data.auth_params), function(keys){
- var mk = keys.mk;
- try {
- this.decryptItemsWithKey(data.items, mk);
- // delete items enc_item_key since the user's actually key will do the encrypting once its passed off
- data.items.forEach(function(item){
- item.enc_item_key = null;
- item.auth_hash = null;
- })
- onDataReady();
- }
- catch (e) {
- console.log("Error decrypting", e);
- alert("There was an error decrypting your items. Make sure the password you entered is correct and try again.");
- callback(false, null);
- return;
- }
- }.bind(this));
- } else {
- onDataReady();
- }
- }
-
- /*
- Export
- */
-
- this.itemsDataFile = function(encrypted) {
- var textFile = null;
- var makeTextFile = function (text) {
- var data = new Blob([text], {type: 'text/json'});
-
- // If we are replacing a previously generated file we need to
- // manually revoke the object URL to avoid memory leaks.
- if (textFile !== null) {
- window.URL.revokeObjectURL(textFile);
- }
-
- textFile = window.URL.createObjectURL(data);
-
- // returns a URL you can use as a href
- return textFile;
- }.bind(this);
-
- var items = _.map(modelManager.allItemsMatchingTypes(["Tag", "Note"]), function(item){
- return this.paramsForExportFile(item, encrypted);
- }.bind(this));
-
- var data = {
- items: items
- }
-
- if(encrypted) {
- data["auth_params"] = this.getAuthParams();
- }
-
- return makeTextFile(JSON.stringify(data, null, 2 /* pretty print */));
- }
-
- this.staticifyObject = function(object) {
- return JSON.parse(JSON.stringify(object));
- }
-
- this.writeItemsToLocalStorage = function(items, callback) {
- var params = items.map(function(item) {
- return this.paramsForItem(item, false, ["created_at", "updated_at", "dirty"], true)
- }.bind(this));
-
- dbManager.saveItems(params, callback);
- }
-
- this.loadLocalItems = function(callback) {
- var params = dbManager.getAllItems(function(items){
- var items = this.handleItemsResponse(items, null);
- Item.sortItemsByDate(items);
- callback(items);
- }.bind(this))
-
- }
-
- /*
- Drafts
- */
-
- this.saveDraftToDisk = function(draft) {
- localStorage.setItem("draft", JSON.stringify(draft));
- }
-
- this.clearDraft = function() {
- localStorage.removeItem("draft");
- }
-
- this.getDraft = function() {
- var draftString = localStorage.getItem("draft");
- if(!draftString || draftString == 'undefined') {
- return null;
- }
- var jsonObj = _.merge({content_type: "Note"}, JSON.parse(draftString));
- return modelManager.createItem(jsonObj);
- }
-
-
- /*
- Encrpytion
- */
-
- this.retrieveMk = function() {
- if(!this.mk) {
- this.mk = localStorage.getItem("mk");
- }
- return this.mk;
- }
-
- this.setMk = function(mk) {
- localStorage.setItem('mk', mk);
- }
-
- this.signout = function(callback) {
- dbManager.clearAllItems(function(){
- localStorage.clear();
- callback();
- });
- }
-
- this.encryptSingleItem = function(item, masterKey) {
- var item_key = null;
- if(item.enc_item_key) {
- item_key = Neeto.crypto.decryptText(item.enc_item_key, masterKey);
- } else {
- item_key = Neeto.crypto.generateRandomEncryptionKey();
- item.enc_item_key = Neeto.crypto.encryptText(item_key, masterKey);
- }
-
- var ek = Neeto.crypto.firstHalfOfKey(item_key);
- var ak = Neeto.crypto.secondHalfOfKey(item_key);
- var encryptedContent = "001" + Neeto.crypto.encryptText(JSON.stringify(item.createContentJSONFromProperties()), ek);
- var authHash = Neeto.crypto.hmac256(encryptedContent, ak);
-
- item.content = encryptedContent;
- item.auth_hash = authHash;
- item.local_encryption_scheme = "1.0";
- }
-
- this.decryptSingleItem = function(item, masterKey) {
- var item_key = Neeto.crypto.decryptText(item.enc_item_key, masterKey);
-
- var ek = Neeto.crypto.firstHalfOfKey(item_key);
- var ak = Neeto.crypto.secondHalfOfKey(item_key);
- var authHash = Neeto.crypto.hmac256(item.content, ak);
- if(authHash !== item.auth_hash || !item.auth_hash) {
- console.log("Authentication hash does not match.")
- return;
- }
-
- var content = Neeto.crypto.decryptText(item.content.substring(3, item.content.length), ek);
- item.content = content;
- }
-
- this.decryptItems = function(items) {
- var masterKey = this.retrieveMk();
- this.decryptItemsWithKey(items, masterKey);
- }
-
- this.decryptItemsWithKey = function(items, key) {
- for (var item of items) {
- if(item.deleted == true) {
- continue;
- }
- var isString = typeof item.content === 'string' || item.content instanceof String;
- if(isString) {
- try {
- if(item.content.substring(0, 3) == "001" && item.enc_item_key) {
- // is encrypted
- this.decryptSingleItem(item, key);
- } else {
- // is base64 encoded
- item.content = Neeto.crypto.base64Decode(item.content.substring(3, item.content.length))
- }
- } catch (e) {
- console.log("Error decrypting item", item, e);
- continue;
- }
- }
- }
- }
-
- this.reencryptAllItemsAndSave = function(user, newMasterKey, oldMasterKey, callback) {
- var items = modelManager.allItems();
- items.forEach(function(item){
- if(item.content.substring(0, 3) == "001" && item.enc_item_key) {
- // first decrypt item_key with old key
- var item_key = Neeto.crypto.decryptText(item.enc_item_key, oldMasterKey);
- // now encrypt item_key with new key
- item.enc_item_key = Neeto.crypto.encryptText(item_key, newMasterKey);
- }
- });
-
- this.saveBatchItems(user, items, function(success) {
- callback(success);
- }.bind(this));
- }
- }
-});
diff --git a/app/assets/javascripts/app/services/authManager.js b/app/assets/javascripts/app/services/authManager.js
new file mode 100644
index 000000000..cd936b929
--- /dev/null
+++ b/app/assets/javascripts/app/services/authManager.js
@@ -0,0 +1,178 @@
+angular.module('app.frontend')
+ .provider('authManager', function () {
+
+ function domainName() {
+ var domain_comps = location.hostname.split(".");
+ var domain = domain_comps[domain_comps.length - 2] + "." + domain_comps[domain_comps.length - 1];
+ return domain;
+ }
+
+ this.$get = function($rootScope, Restangular, modelManager) {
+ return new AuthManager($rootScope, Restangular, modelManager);
+ }
+
+ function AuthManager($rootScope, Restangular, modelManager) {
+
+ var userData = localStorage.getItem("user");
+ if(userData) {
+ this.user = JSON.parse(userData);
+ } else {
+ // legacy, check for uuid
+ var idData = localStorage.getItem("uuid");
+ if(idData) {
+ this.user = {uuid: idData};
+ }
+ }
+
+ this.offline = function() {
+ return !this.user;
+ }
+
+ this.getAuthParams = function() {
+ return JSON.parse(localStorage.getItem("auth_params"));
+ }
+
+ this.getAuthParamsForEmail = function(url, email, callback) {
+ var requestUrl = url + "/auth/params";
+ var request = Restangular.oneUrl(requestUrl, requestUrl);
+ request.get({email: email}).then(function(response){
+ callback(response.plain());
+ })
+ .catch(function(response){
+ console.log("Error getting auth params", response);
+ callback(null);
+ })
+ }
+
+ this.supportsPasswordDerivationCost = function(cost) {
+ // some passwords are created on platforms with stronger pbkdf2 capabilities, like iOS,
+ // which accidentally used 60,000 iterations (now adjusted), which CryptoJS can't handle here (WebCrypto can however).
+ // if user has high password cost and is using browser that doesn't support WebCrypto,
+ // we want to tell them that they can't login with this browser.
+ if(cost > 5000) {
+ return Neeto.crypto instanceof SNCryptoWeb ? true : false;
+ } else {
+ return true;
+ }
+ }
+
+ this.login = function(url, email, password, callback) {
+ this.getAuthParamsForEmail(url, email, function(authParams){
+ if(!authParams) {
+ callback({error : {message: "Unable to get authentication parameters."}});
+ return;
+ }
+
+ if(!this.supportsPasswordDerivationCost(authParams.pw_cost)) {
+ var string = "Your account was created on a platform with higher security capabilities than this browser supports. " +
+ "If we attempted to generate your login keys here, it would take hours. " +
+ "Please use a browser with more up to date security capabilities, like Google Chrome or Firefox, to login."
+ alert(string)
+ callback({didDisplayAlert: true});
+ return;
+ }
+
+ console.log("compute encryption keys", password, authParams);
+
+ Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: password}, authParams), function(keys){
+ var mk = keys.mk;
+ var requestUrl = url + "/auth/sign_in";
+ var request = Restangular.oneUrl(requestUrl, requestUrl);
+ var params = {password: keys.pw, email: email};
+ _.merge(request, params);
+ request.post().then(function(response){
+ this.handleAuthResponse(response, email, url, authParams, mk, keys.pw);
+ callback(response);
+ }.bind(this))
+ .catch(function(response){
+ console.log("Error logging in", response);
+ callback(response.data);
+ })
+ }.bind(this));
+ }.bind(this))
+ }
+
+ this.handleAuthResponse = function(response, email, url, authParams, mk, pw) {
+ localStorage.setItem("server", url);
+ localStorage.setItem("user", JSON.stringify(response.plain().user));
+ localStorage.setItem("auth_params", JSON.stringify(_.omit(authParams, ["pw_nonce"])));
+ localStorage.setItem("mk", mk);
+ localStorage.setItem("pw", pw);
+ localStorage.setItem("jwt", response.token);
+ }
+
+ this.register = function(url, email, password, callback) {
+ Neeto.crypto.generateInitialEncryptionKeysForUser({password: password, email: email}, function(keys, authParams){
+ var mk = keys.mk;
+ var requestUrl = url + "/auth";
+ var request = Restangular.oneUrl(requestUrl, requestUrl);
+ var params = _.merge({password: keys.pw, email: email}, authParams);
+ _.merge(request, params);
+ request.post().then(function(response){
+ this.handleAuthResponse(response, email, url, authParams, mk, keys.pw);
+ callback(response);
+ }.bind(this))
+ .catch(function(response){
+ console.log("Registration error", response);
+ callback(null);
+ })
+ }.bind(this));
+ }
+
+ // this.changePassword = function(current_password, new_password) {
+ // this.getAuthParamsForEmail(email, function(authParams){
+ // if(!authParams) {
+ // callback(null);
+ // return;
+ // }
+ // Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: current_password, email: user.email}, authParams), function(currentKeys) {
+ // Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: new_password, email: user.email}, authParams), function(newKeys){
+ // var data = {};
+ // data.current_password = currentKeys.pw;
+ // data.password = newKeys.pw;
+ // data.password_confirmation = newKeys.pw;
+ //
+ // var user = this.user;
+ //
+ // this._performPasswordChange(currentKeys, newKeys, function(response){
+ // if(response && !response.error) {
+ // // this.showNewPasswordForm = false;
+ // // reencrypt data with new mk
+ // this.reencryptAllItemsAndSave(user, newKeys.mk, currentKeys.mk, function(success){
+ // if(success) {
+ // this.setMk(newKeys.mk);
+ // alert("Your password has been changed and your data re-encrypted.");
+ // } else {
+ // // rollback password
+ // this._performPasswordChange(newKeys, currentKeys, function(response){
+ // alert("There was an error changing your password. Your password has been rolled back.");
+ // window.location.reload();
+ // })
+ // }
+ // }.bind(this));
+ // } else {
+ // // this.showNewPasswordForm = false;
+ // alert("There was an error changing your password. Please try again.");
+ // }
+ // }.bind(this))
+ // }.bind(this));
+ // }.bind(this));
+ // }.bind(this));
+ // }
+
+ this._performPasswordChange = function(url, email, current_keys, new_keys, callback) {
+ var requestUrl = url + "/auth";
+ var request = Restangular.oneUrl(requestUrl, requestUrl);
+ var params = {password: new_keys.pw, password_confirmation: new_keys.pw, current_password: current_keys.pw, email: email};
+ _.merge(request, params);
+ request.patch().then(function(response){
+ callback(response);
+ })
+ }
+
+ this.staticifyObject = function(object) {
+ return JSON.parse(JSON.stringify(object));
+ }
+
+ }
+});
diff --git a/app/assets/javascripts/app/services/dbManager.js b/app/assets/javascripts/app/services/dbManager.js
index 0d702b032..d7e3edabb 100644
--- a/app/assets/javascripts/app/services/dbManager.js
+++ b/app/assets/javascripts/app/services/dbManager.js
@@ -98,11 +98,14 @@ class DBManager {
}, null)
}
- deleteItem(item) {
+ deleteItem(item, callback) {
this.openDatabase((db) => {
var request = db.transaction("items", "readwrite").objectStore("items").delete(item.uuid);
request.onsuccess = function(event) {
console.log("Successfully deleted item", item.uuid);
+ if(callback) {
+ callback(true);
+ }
};
}, null)
}
diff --git a/app/assets/javascripts/app/services/directives/autofocus.js b/app/assets/javascripts/app/services/directives/functional/autofocus.js
similarity index 100%
rename from app/assets/javascripts/app/services/directives/autofocus.js
rename to app/assets/javascripts/app/services/directives/functional/autofocus.js
diff --git a/app/assets/javascripts/app/services/directives/functional/delay-hide.js b/app/assets/javascripts/app/services/directives/functional/delay-hide.js
new file mode 100644
index 000000000..7467208f4
--- /dev/null
+++ b/app/assets/javascripts/app/services/directives/functional/delay-hide.js
@@ -0,0 +1,42 @@
+angular
+ .module('app.frontend')
+ .directive('delayHide', function($timeout) {
+ return {
+ restrict: 'A',
+ scope: {
+ show: '=',
+ delay: '@'
+ },
+ link: function(scope, elem, attrs) {
+ var showTimer;
+
+ showElement(false);
+
+ //This is where all the magic happens!
+ // Whenever the scope variable updates we simply
+ // show if it evaluates to 'true' and hide if 'false'
+ scope.$watch('show', function(newVal){
+ newVal ? showSpinner() : hideSpinner();
+ });
+
+ function showSpinner() {
+ showElement(true);
+ }
+
+ function hideSpinner() {
+ $timeout(showElement.bind(this, false), getDelay());
+ }
+
+ function showElement(show) {
+ show ? elem.css({display:''}) : elem.css({display:'none'});
+ }
+
+ function getDelay() {
+ var delay = parseInt(scope.delay);
+
+ return angular.isNumber(delay) ? delay : 200;
+ }
+ }
+
+ };
+});
diff --git a/app/assets/javascripts/app/services/directives/file-change.js b/app/assets/javascripts/app/services/directives/functional/file-change.js
similarity index 100%
rename from app/assets/javascripts/app/services/directives/file-change.js
rename to app/assets/javascripts/app/services/directives/functional/file-change.js
diff --git a/app/assets/javascripts/app/services/directives/functional/infiniteScroll.js b/app/assets/javascripts/app/services/directives/functional/infiniteScroll.js
new file mode 100644
index 000000000..a9f565db3
--- /dev/null
+++ b/app/assets/javascripts/app/services/directives/functional/infiniteScroll.js
@@ -0,0 +1,19 @@
+angular.module('app.frontend').directive('infiniteScroll', [
+'$rootScope', '$window', '$timeout', function($rootScope, $window, $timeout) {
+ return {
+ link: function(scope, elem, attrs) {
+ elem.css('overflow-x', 'hidden');
+ elem.css('height', 'inherit');
+
+ var offset = parseInt(attrs.threshold) || 0;
+ var e = elem[0]
+
+ elem.on('scroll', function(){
+ if(scope.$eval(attrs.canLoad) && e.scrollTop + e.offsetHeight >= e.scrollHeight - offset) {
+ scope.$apply(attrs.infiniteScroll);
+ }
+ });
+ }
+ };
+}
+]);
diff --git a/app/assets/javascripts/app/services/directives/lowercase.js b/app/assets/javascripts/app/services/directives/functional/lowercase.js
similarity index 100%
rename from app/assets/javascripts/app/services/directives/lowercase.js
rename to app/assets/javascripts/app/services/directives/functional/lowercase.js
diff --git a/app/assets/javascripts/app/services/directives/selectOnClick.js b/app/assets/javascripts/app/services/directives/functional/selectOnClick.js
similarity index 100%
rename from app/assets/javascripts/app/services/directives/selectOnClick.js
rename to app/assets/javascripts/app/services/directives/functional/selectOnClick.js
diff --git a/app/assets/javascripts/app/services/directives/views/accountMenu.js b/app/assets/javascripts/app/services/directives/views/accountMenu.js
new file mode 100644
index 000000000..5e9d74e62
--- /dev/null
+++ b/app/assets/javascripts/app/services/directives/views/accountMenu.js
@@ -0,0 +1,235 @@
+class AccountMenu {
+
+ constructor() {
+ this.restrict = "E";
+ this.templateUrl = "frontend/directives/account-menu.html";
+ this.scope = {};
+ }
+
+ controller($scope, authManager, modelManager, syncManager, $timeout) {
+ 'ngInject';
+
+ $scope.formData = {url: syncManager.serverURL};
+ $scope.user = authManager.user;
+ $scope.server = syncManager.serverURL;
+
+ $scope.syncStatus = syncManager.syncStatus;
+
+ $scope.changePasswordPressed = function() {
+ $scope.showNewPasswordForm = !$scope.showNewPasswordForm;
+ }
+
+ $scope.encryptionKey = function() {
+ return syncManager.masterKey;
+ }
+
+ $scope.serverPassword = function() {
+ return syncManager.serverPassword;
+ }
+
+ $scope.submitPasswordChange = function() {
+ $scope.passwordChangeData.status = "Generating New Keys...";
+
+ $timeout(function(){
+ if(data.password != data.password_confirmation) {
+ alert("Your new password does not match its confirmation.");
+ return;
+ }
+
+ authManager.changePassword($scope.passwordChangeData.current_password, $scope.passwordChangeData.new_password, function(response){
+
+ })
+
+ })
+ }
+
+ $scope.loginSubmitPressed = function() {
+ $scope.formData.status = "Generating Login Keys...";
+ console.log("logging in with url", $scope.formData.url);
+ $timeout(function(){
+ authManager.login($scope.formData.url, $scope.formData.email, $scope.formData.user_password, function(response){
+ if(!response || response.error) {
+ $scope.formData.status = null;
+ var error = response ? response.error : {message: "An unknown error occured."}
+ if(!response || (response && !response.didDisplayAlert)) {
+ alert(error.message);
+ }
+ } else {
+ $scope.onAuthSuccess();
+ }
+ });
+ })
+ }
+
+ $scope.submitRegistrationForm = function() {
+ $scope.formData.status = "Generating Account Keys...";
+
+ $timeout(function(){
+ authManager.register($scope.formData.url, $scope.formData.email, $scope.formData.user_password, function(response){
+ if(!response || response.error) {
+ $scope.formData.status = null;
+ var error = response ? response.error : {message: "An unknown error occured."}
+ alert(error.message);
+ } else {
+ $scope.onAuthSuccess();
+ }
+ });
+ })
+ }
+
+ $scope.onAuthSuccess = function() {
+ syncManager.markAllItemsDirtyAndSaveOffline(function(){
+ window.location.reload();
+ })
+ }
+
+ $scope.destroyLocalData = function() {
+ if(!confirm("Are you sure you want to end your session? This will delete all local items and extensions.")) {
+ return;
+ }
+
+ syncManager.destroyLocalData(function(){
+ window.location.reload();
+ })
+ }
+
+ /* Import/Export */
+
+ $scope.archiveFormData = {encrypted: $scope.user ? true : false};
+ $scope.user = authManager.user;
+
+ $scope.downloadDataArchive = function() {
+ var link = document.createElement('a');
+ link.setAttribute('download', 'notes.json');
+
+ var ek = $scope.archiveFormData.encrypted ? syncManager.masterKey : null;
+
+ link.href = $scope.itemsDataFile(ek);
+ link.click();
+ }
+
+ $scope.submitImportPassword = function() {
+ $scope.performImport($scope.importData.data, $scope.importData.password);
+ }
+
+ $scope.performImport = function(data, password) {
+ $scope.importData.loading = true;
+ // allow loading indicator to come up with timeout
+ $timeout(function(){
+ $scope.importJSONData(data, password, function(response){
+ $timeout(function(){
+ $scope.importData.loading = false;
+ $scope.importData = null;
+ if(!response) {
+ alert("There was an error importing your data. Please try again.");
+ }
+ })
+ })
+ })
+ }
+
+ $scope.importFileSelected = function(files) {
+ $scope.importData = {};
+
+ var file = files[0];
+ var reader = new FileReader();
+ reader.onload = function(e) {
+ var data = JSON.parse(e.target.result);
+ $timeout(function(){
+ if(data.auth_params) {
+ // request password
+ $scope.importData.requestPassword = true;
+ $scope.importData.data = data;
+ } else {
+ $scope.performImport(data, null);
+ }
+ })
+ }
+
+ reader.readAsText(file);
+ }
+
+ $scope.encryptionStatusForNotes = function() {
+ var allNotes = modelManager.filteredNotes;
+ return allNotes.length + "/" + allNotes.length + " notes encrypted";
+ }
+
+ $scope.importJSONData = function(data, password, callback) {
+ console.log("Importing data", data);
+
+ var onDataReady = function() {
+ var items = modelManager.mapResponseItemsToLocalModels(data.items);
+ items.forEach(function(item){
+ item.setDirty(true);
+ item.markAllReferencesDirty();
+ })
+
+ syncManager.sync(callback, {additionalFields: ["created_at", "updated_at"]});
+ }.bind(this)
+
+ if(data.auth_params) {
+ Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: password}, data.auth_params), function(keys){
+ var mk = keys.mk;
+ try {
+ EncryptionHelper.decryptMultipleItems(data.items, mk, true);
+ // delete items enc_item_key since the user's actually key will do the encrypting once its passed off
+ data.items.forEach(function(item){
+ item.enc_item_key = null;
+ item.auth_hash = null;
+ })
+ onDataReady();
+ }
+ catch (e) {
+ console.log("Error decrypting", e);
+ alert("There was an error decrypting your items. Make sure the password you entered is correct and try again.");
+ callback(null);
+ return;
+ }
+ }.bind(this));
+ } else {
+ onDataReady();
+ }
+ }
+
+ /*
+ Export
+ */
+
+ $scope.itemsDataFile = function(ek) {
+ var textFile = null;
+ var makeTextFile = function (text) {
+ var data = new Blob([text], {type: 'text/json'});
+
+ // If we are replacing a previously generated file we need to
+ // manually revoke the object URL to avoid memory leaks.
+ if (textFile !== null) {
+ window.URL.revokeObjectURL(textFile);
+ }
+
+ textFile = window.URL.createObjectURL(data);
+
+ // returns a URL you can use as a href
+ return textFile;
+ }.bind(this);
+
+ var items = _.map(modelManager.allItemsMatchingTypes(["Tag", "Note"]), function(item){
+ var itemParams = new ItemParams(item, ek);
+ return itemParams.paramsForExportFile();
+ }.bind(this));
+
+ var data = {
+ items: items
+ }
+
+ if(ek) {
+ // auth params are only needed when encrypted with a standard file key
+ data["auth_params"] = authManager.getAuthParams();
+ }
+
+ return makeTextFile(JSON.stringify(data, null, 2 /* pretty print */));
+ }
+
+ }
+}
+
+angular.module('app.frontend').directive('accountMenu', () => new AccountMenu);
diff --git a/app/assets/javascripts/app/services/directives/contextualExtensionsMenu.js b/app/assets/javascripts/app/services/directives/views/contextualExtensionsMenu.js
similarity index 100%
rename from app/assets/javascripts/app/services/directives/contextualExtensionsMenu.js
rename to app/assets/javascripts/app/services/directives/views/contextualExtensionsMenu.js
diff --git a/app/assets/javascripts/app/services/directives/views/globalExtensionsMenu.js b/app/assets/javascripts/app/services/directives/views/globalExtensionsMenu.js
new file mode 100644
index 000000000..9945ac81c
--- /dev/null
+++ b/app/assets/javascripts/app/services/directives/views/globalExtensionsMenu.js
@@ -0,0 +1,64 @@
+class GlobalExtensionsMenu {
+
+ constructor() {
+ this.restrict = "E";
+ this.templateUrl = "frontend/directives/global-extensions-menu.html";
+ this.scope = {
+ };
+ }
+
+ controller($scope, extensionManager, syncManager) {
+ 'ngInject';
+
+ $scope.extensionManager = extensionManager;
+
+ $scope.toggleExtensionForm = function() {
+ $scope.newExtensionData = {};
+ $scope.showNewExtensionForm = !$scope.showNewExtensionForm;
+ }
+
+ $scope.submitNewExtensionForm = function() {
+ if($scope.newExtensionData.url) {
+ extensionManager.addExtension($scope.newExtensionData.url, function(response){
+ if(!response) {
+ alert("Unable to register this extension. Make sure the link is valid and try again.");
+ } else {
+ $scope.newExtensionData.url = "";
+ $scope.showNewExtensionForm = false;
+ }
+ })
+ }
+ }
+
+ $scope.selectedAction = function(action, extension) {
+ extensionManager.executeAction(action, extension, null, function(response){
+ if(response && response.error) {
+ action.error = true;
+ alert("There was an error performing this action. Please try again.");
+ } else {
+ action.error = false;
+ syncManager.sync(null);
+ }
+ })
+ }
+
+ $scope.changeExtensionEncryptionFormat = function(encrypted, extension) {
+ extensionManager.changeExtensionEncryptionFormat(encrypted, extension);
+ }
+
+ $scope.deleteExtension = function(extension) {
+ if(confirm("Are you sure you want to delete this extension?")) {
+ extensionManager.deleteExtension(extension);
+ }
+ }
+
+ $scope.reloadExtensionsPressed = function() {
+ if(confirm("For your security, reloading extensions will disable any currently enabled repeat actions.")) {
+ extensionManager.refreshExtensionsFromServer();
+ }
+ }
+ }
+
+}
+
+angular.module('app.frontend').directive('globalExtensionsMenu', () => new GlobalExtensionsMenu);
diff --git a/app/assets/javascripts/app/services/extensionManager.js b/app/assets/javascripts/app/services/extensionManager.js
index 4be4ecff0..ff44b6350 100644
--- a/app/assets/javascripts/app/services/extensionManager.js
+++ b/app/assets/javascripts/app/services/extensionManager.js
@@ -1,11 +1,12 @@
class ExtensionManager {
- constructor(Restangular, modelManager, apiController) {
+ constructor(Restangular, modelManager, authManager, syncManager) {
this.Restangular = Restangular;
this.modelManager = modelManager;
- this.apiController = apiController;
+ this.authManager = authManager;
this.enabledRepeatActionUrls = JSON.parse(localStorage.getItem("enabledRepeatActionUrls")) || [];
this.decryptedExtensions = JSON.parse(localStorage.getItem("decryptedExtensions")) || [];
+ this.syncManager = syncManager;
modelManager.addItemSyncObserver("extensionManager", "Extension", function(items){
for (var ext of items) {
@@ -42,6 +43,7 @@ class ExtensionManager {
}
changeExtensionEncryptionFormat(encrypted, extension) {
+ console.log("changing encryption status");
if(encrypted) {
_.pull(this.decryptedExtensions, extension.url);
} else {
@@ -68,7 +70,7 @@ class ExtensionManager {
}
this.modelManager.setItemToBeDeleted(extension);
- this.apiController.sync(null);
+ this.syncManager.sync(null);
}
/*
@@ -111,7 +113,7 @@ class ExtensionManager {
extension.url = url;
extension.setDirty(true);
this.modelManager.addItem(extension);
- this.apiController.sync(null);
+ this.syncManager.sync(null);
}
return extension;
@@ -134,22 +136,30 @@ class ExtensionManager {
executeAction(action, extension, item, callback) {
- if(this.extensionUsesEncryptedData(extension) && !this.apiController.isUserSignedIn()) {
+ if(this.extensionUsesEncryptedData(extension) && this.authManager.offline()) {
alert("To send data encrypted, you must have an encryption key, and must therefore be signed in.");
callback(null);
return;
}
+ var customCallback = function(response) {
+ action.running = false;
+ callback(response);
+ }
+
+ action.running = true;
+
switch (action.verb) {
case "get": {
this.Restangular.oneUrl(action.url, action.url).get().then(function(response){
action.error = false;
var items = response.items;
this.modelManager.mapResponseItemsToLocalModels(items);
- callback(items);
+ customCallback(items);
}.bind(this))
.catch(function(response){
action.error = true;
+ customCallback(null);
})
break;
@@ -158,7 +168,7 @@ class ExtensionManager {
case "show": {
var win = window.open(action.url, '_blank');
win.focus();
- callback();
+ customCallback();
break;
}
@@ -177,7 +187,7 @@ class ExtensionManager {
}
this.performPost(action, extension, params, function(response){
- callback(response);
+ customCallback(response);
});
break;
@@ -261,20 +271,25 @@ class ExtensionManager {
var params = this.outgoingParamsForItem(item, extension);
return params;
}.bind(this))
- this.performPost(action, extension, params, null);
+
+ action.running = true;
+ this.performPost(action, extension, params, function(){
+ action.running = false;
+ });
} else {
// todo
}
}
outgoingParamsForItem(item, extension) {
- return this.apiController.paramsForExtension(item, this.extensionUsesEncryptedData(extension));
+ var itemParams = new ItemParams(item, this.syncManager.masterKey);
+ return itemParams.paramsForExtension();
}
performPost(action, extension, params, callback) {
var request = this.Restangular.oneUrl(action.url, action.url);
if(this.extensionUsesEncryptedData(extension)) {
- request.auth_params = this.apiController.getAuthParams();
+ request.auth_params = this.authManager.getAuthParams();
}
_.merge(request, params);
diff --git a/app/assets/javascripts/app/services/filters/startFrom.js b/app/assets/javascripts/app/services/filters/startFrom.js
new file mode 100644
index 000000000..2ebd72e42
--- /dev/null
+++ b/app/assets/javascripts/app/services/filters/startFrom.js
@@ -0,0 +1,6 @@
+// Start from filter
+angular.module('app.frontend').filter('startFrom', function() {
+ return function(input, start) {
+ return input.slice(start);
+ };
+});
diff --git a/app/assets/javascripts/app/services/helpers/encryptionHelper.js b/app/assets/javascripts/app/services/helpers/encryptionHelper.js
new file mode 100644
index 000000000..7cf011ac8
--- /dev/null
+++ b/app/assets/javascripts/app/services/helpers/encryptionHelper.js
@@ -0,0 +1,64 @@
+class EncryptionHelper {
+
+ static encryptItem(item, key) {
+ var item_key = null;
+ if(item.enc_item_key) {
+ // we reuse the key, but this is optional
+ item_key = Neeto.crypto.decryptText(item.enc_item_key, key);
+ } else {
+ item_key = Neeto.crypto.generateRandomEncryptionKey();
+ item.enc_item_key = Neeto.crypto.encryptText(item_key, key);
+ }
+
+ var ek = Neeto.crypto.firstHalfOfKey(item_key);
+ var ak = Neeto.crypto.secondHalfOfKey(item_key);
+ var encryptedContent = "001" + Neeto.crypto.encryptText(JSON.stringify(item.createContentJSONFromProperties()), ek);
+ var authHash = Neeto.crypto.hmac256(encryptedContent, ak);
+
+ item.content = encryptedContent;
+ item.auth_hash = authHash;
+ }
+
+ static decryptItem(item, key) {
+ var item_key = Neeto.crypto.decryptText(item.enc_item_key, key);
+
+ var ek = Neeto.crypto.firstHalfOfKey(item_key);
+ var ak = Neeto.crypto.secondHalfOfKey(item_key);
+ var authHash = Neeto.crypto.hmac256(item.content, ak);
+ if(authHash !== item.auth_hash || !item.auth_hash) {
+ console.log("Authentication hash does not match.")
+ return;
+ }
+
+ var content = Neeto.crypto.decryptText(item.content.substring(3, item.content.length), ek);
+ item.content = content;
+ }
+
+ static decryptMultipleItems(items, key, throws) {
+ for (var item of items) {
+ if(item.deleted == true) {
+ continue;
+ }
+
+ var isString = typeof item.content === 'string' || item.content instanceof String;
+ if(isString) {
+ try {
+ if(item.content.substring(0, 3) == "001" && item.enc_item_key) {
+ // is encrypted
+ this.decryptItem(item, key);
+ } else {
+ // is base64 encoded
+ item.content = Neeto.crypto.base64Decode(item.content.substring(3, item.content.length))
+ }
+ } catch (e) {
+ if(throws) {
+ throw e;
+ }
+ console.log("Error decrypting item", item, e);
+ continue;
+ }
+ }
+ }
+ }
+
+}
diff --git a/app/assets/javascripts/app/services/modelManager.js b/app/assets/javascripts/app/services/modelManager.js
index 9b8d62e70..aa436b6e2 100644
--- a/app/assets/javascripts/app/services/modelManager.js
+++ b/app/assets/javascripts/app/services/modelManager.js
@@ -22,6 +22,18 @@ class ModelManager {
})
}
+ alternateUUIDForItem(item, callback) {
+ // we need to clone this item and give it a new uuid, then delete item with old uuid from db (you can't mofidy uuid's in our indexeddb setup)
+ var newItem = this.createItem(item);
+ newItem.uuid = Neeto.crypto.generateUUID();
+ this.removeItemLocally(item, function(){
+ this.addItem(newItem);
+ newItem.setDirty(true);
+ newItem.markAllReferencesDirty();
+ callback();
+ }.bind(this));
+ }
+
allItemsMatchingTypes(contentTypes) {
return this.items.filter(function(item){
return (_.includes(contentTypes, item.content_type) || _.includes(contentTypes, "*")) && !item.dummy;
@@ -76,7 +88,6 @@ class ModelManager {
this.notifySyncObserversOfModels(models);
- this.sortItems();
return models;
}
@@ -133,7 +144,9 @@ class ModelManager {
}
} else if(item.content_type == "Note") {
if(!_.find(this.notes, {uuid: item.uuid})) {
- this.notes.unshift(item);
+ this.notes.splice(_.sortedLastIndexBy(this.notes, item, function(item){
+ return -item.created_at;
+ }), 0, item);
}
} else if(item.content_type == "Extension") {
if(!_.find(this._extensions, {uuid: item.uuid})) {
@@ -170,14 +183,6 @@ class ModelManager {
}
}
- sortItems() {
- Item.sortItemsByDate(this.notes);
-
- this.tags.forEach(function(tag){
- Item.sortItemsByDate(tag.notes);
- })
- }
-
addItemSyncObserver(id, type, callback) {
this.itemSyncObservers.push({id: id, type: type, callback: callback});
}
@@ -220,18 +225,21 @@ class ModelManager {
item.removeAllRelationships();
}
- removeItemLocally(item) {
+ removeItemLocally(item, callback) {
_.pull(this.items, item);
+ item.isBeingRemovedLocally();
+
if(item.content_type == "Tag") {
_.pull(this.tags, item);
} else if(item.content_type == "Note") {
_.pull(this.notes, item);
+
} else if(item.content_type == "Extension") {
_.pull(this._extensions, item);
}
- this.dbManager.deleteItem(item);
+ this.dbManager.deleteItem(item, callback);
}
/*
diff --git a/app/assets/javascripts/app/services/syncManager.js b/app/assets/javascripts/app/services/syncManager.js
new file mode 100644
index 000000000..66344b15a
--- /dev/null
+++ b/app/assets/javascripts/app/services/syncManager.js
@@ -0,0 +1,245 @@
+class SyncManager {
+
+ constructor($rootScope, modelManager, authManager, dbManager, Restangular) {
+ this.$rootScope = $rootScope;
+ this.modelManager = modelManager;
+ this.authManager = authManager;
+ this.Restangular = Restangular;
+ this.dbManager = dbManager;
+ this.syncStatus = {};
+ }
+
+ get serverURL() {
+ return localStorage.getItem("server") || "https://n3.standardnotes.org";
+ }
+
+ get masterKey() {
+ return localStorage.getItem("mk");
+ }
+
+ get serverPassword() {
+ return localStorage.getItem("pw");
+ }
+
+ writeItemsToLocalStorage(items, offlineOnly, callback) {
+ var params = items.map(function(item) {
+ var itemParams = new ItemParams(item, null);
+ itemParams = itemParams.paramsForLocalStorage();
+ if(offlineOnly) {
+ delete itemParams.dirty;
+ }
+ return itemParams;
+ }.bind(this));
+
+ this.dbManager.saveItems(params, callback);
+ }
+
+ loadLocalItems(callback) {
+ var params = this.dbManager.getAllItems(function(items){
+ var items = this.handleItemsResponse(items, null, null);
+ Item.sortItemsByDate(items);
+ callback(items);
+ }.bind(this))
+ }
+
+ syncOffline(items, callback) {
+ this.writeItemsToLocalStorage(items, true, function(responseItems){
+ // delete anything needing to be deleted
+ for(var item of items) {
+ if(item.deleted) {
+ this.modelManager.removeItemLocally(item);
+ }
+ }
+
+ if(callback) {
+ callback({success: true});
+ }
+ }.bind(this))
+
+ }
+
+ markAllItemsDirtyAndSaveOffline(callback) {
+ var items = this.modelManager.allItems;
+ for(var item of items) {
+ item.setDirty(true);
+ }
+ this.writeItemsToLocalStorage(items, false, callback);
+ }
+
+ get syncURL() {
+ return this.serverURL + "/items/sync";
+ }
+
+ set syncToken(token) {
+ this._syncToken = token;
+ localStorage.setItem("syncToken", token);
+ }
+
+ get syncToken() {
+ if(!this._syncToken) {
+ this._syncToken = localStorage.getItem("syncToken");
+ }
+ return this._syncToken;
+ }
+
+ set cursorToken(token) {
+ this._cursorToken = token;
+ if(token) {
+ localStorage.setItem("cursorToken", token);
+ } else {
+ localStorage.removeItem("cursorToken");
+ }
+ }
+
+ get cursorToken() {
+ if(!this._cursorToken) {
+ this._cursorToken = localStorage.getItem("cursorToken");
+ }
+ return this._cursorToken;
+ }
+
+ sync(callback, options = {}) {
+
+ if(this.syncStatus.syncOpInProgress) {
+ this.repeatOnCompletion = true;
+ console.log("Sync op in progress; returning.");
+ return;
+ }
+
+ var allDirtyItems = this.modelManager.getDirtyItems();
+
+ // we want to write all dirty items to disk only if the user is offline, or if the sync op fails
+ // if the sync op succeeds, these items will be written to disk by handling the "saved_items" response from the server
+ if(this.authManager.offline()) {
+ this.syncOffline(allDirtyItems, callback);
+ this.modelManager.clearDirtyItems(allDirtyItems);
+ return;
+ }
+
+ var isContinuationSync = this.needsMoreSync;
+
+ this.repeatOnCompletion = false;
+ this.syncStatus.syncOpInProgress = true;
+
+ let submitLimit = 100;
+ var subItems = allDirtyItems.slice(0, submitLimit);
+ if(subItems.length < allDirtyItems.length) {
+ // more items left to be synced, repeat
+ this.needsMoreSync = true;
+ } else {
+ this.needsMoreSync = false;
+ }
+
+ if(!isContinuationSync) {
+ this.syncStatus.total = allDirtyItems.length;
+ this.syncStatus.current = 0;
+ }
+
+ var request = this.Restangular.oneUrl(this.syncURL, this.syncURL);
+ request.limit = 150;
+ request.items = _.map(subItems, function(item){
+ var itemParams = new ItemParams(item, localStorage.getItem("mk"));
+ itemParams.additionalFields = options.additionalFields;
+ return itemParams.paramsForSync();
+ }.bind(this));
+
+ request.sync_token = this.syncToken;
+ request.cursor_token = this.cursorToken;
+
+ request.post().then(function(response) {
+ this.modelManager.clearDirtyItems(subItems);
+ this.syncStatus.error = null;
+
+ this.$rootScope.$broadcast("sync:updated_token", this.syncToken);
+
+ var retrieved = this.handleItemsResponse(response.retrieved_items, null);
+ // merge only metadata for saved items
+ var omitFields = ["content", "auth_hash"];
+ var saved = this.handleItemsResponse(response.saved_items, omitFields);
+
+ this.handleUnsavedItemsResponse(response.unsaved)
+
+ this.writeItemsToLocalStorage(saved, false, null);
+ this.writeItemsToLocalStorage(retrieved, false, null);
+
+ this.syncStatus.syncOpInProgress = false;
+ this.syncStatus.current += subItems.length;
+
+ // set the sync token at the end, so that if any errors happen above, you can resync
+ this.syncToken = response.sync_token;
+ this.cursorToken = response.cursor_token;
+
+ if(this.cursorToken || this.repeatOnCompletion || this.needsMoreSync) {
+ setTimeout(function () {
+ this.sync(callback, options);
+ }.bind(this), 10); // wait 10ms to allow UI to update
+ } else {
+ if(callback) {
+ callback(response);
+ }
+ }
+
+ }.bind(this))
+ .catch(function(response){
+ console.log("Sync error: ", response);
+ var error = response.data ? response.data.error : {message: "Could not connect to server."};
+
+ this.syncStatus.syncOpInProgress = false;
+ this.syncStatus.error = error;
+ this.writeItemsToLocalStorage(allDirtyItems, false, null);
+
+ this.$rootScope.$broadcast("sync:error", error);
+
+ if(callback) {
+ callback({error: "Sync error"});
+ }
+ }.bind(this))
+ }
+
+ handleUnsavedItemsResponse(unsaved) {
+ if(unsaved.length == 0) {
+ return;
+ }
+
+ console.log("Handle unsaved", unsaved);
+
+ var i = 0;
+ var handleNext = function() {
+ if (i < unsaved.length) {
+ var mapping = unsaved[i];
+ var itemResponse = mapping.item;
+ var item = this.modelManager.findItem(itemResponse.uuid);
+ var error = mapping.error;
+ if(error.tag == "uuid_conflict") {
+ // uuid conflicts can occur if a user attempts to import an old data archive with uuids form the old account into a new account
+ this.modelManager.alternateUUIDForItem(item, handleNext);
+ }
+ ++i;
+ } else {
+ this.sync(null, {additionalFields: ["created_at", "updated_at"]});
+ }
+ }.bind(this);
+
+ handleNext();
+ }
+
+ handleItemsResponse(responseItems, omitFields) {
+ EncryptionHelper.decryptMultipleItems(responseItems, localStorage.getItem("mk"));
+ return this.modelManager.mapResponseItemsToLocalModelsOmittingFields(responseItems, omitFields);
+ }
+
+ clearSyncToken() {
+ localStorage.removeItem("syncToken");
+ }
+
+ destroyLocalData(callback) {
+ this.dbManager.clearAllItems(function(){
+ localStorage.clear();
+ if(callback) {
+ callback();
+ }
+ });
+ }
+}
+
+angular.module('app.frontend').service('syncManager', SyncManager);
diff --git a/app/assets/stylesheets/app/_directives.scss b/app/assets/stylesheets/app/_directives.scss
index 4ca0da65b..39eb623dc 100644
--- a/app/assets/stylesheets/app/_directives.scss
+++ b/app/assets/stylesheets/app/_directives.scss
@@ -5,7 +5,6 @@
margin-top: 18px;
}
-
.ext-header {
background-color: #ededed;
border-bottom: 1px solid #d3d3d3;
@@ -64,9 +63,7 @@
margin-top: 1px;
font-size: 12px;
}
-
}
-
}
}
}
diff --git a/app/assets/stylesheets/app/_header.scss b/app/assets/stylesheets/app/_header.scss
index 5e5e5208f..74efb241a 100644
--- a/app/assets/stylesheets/app/_header.scss
+++ b/app/assets/stylesheets/app/_header.scss
@@ -1,6 +1,155 @@
-.header {
+.pull-left {
+ float: left !important;
+}
+
+.pull-right {
+ float: right !important;
+}
+
+.mt-1 {
+ margin-top: 1px !important;
+}
+
+.mt-2 {
+ margin-top: 2px !important;
+}
+
+.mt-5 {
+ margin-top: 5px !important;
+}
+
+.mt-10 {
+ margin-top: 10px !important;
+}
+
+.mt-15 {
+ margin-top: 15px !important;
+}
+
+.mt-25 {
+ margin-top: 25px !important;
+}
+
+.mb-0 {
+ margin-bottom: 0px !important;
+}
+
+.mb-5 {
+ margin-bottom: 5px !important;
+}
+
+.mb-10 {
+ margin-bottom: 10px !important;
+}
+
+.mr-5 {
+ margin-right: 5px;
+}
+
+.faded {
+ opacity: 0.5;
+}
+
+.center-align {
+ text-align: center !important;
+}
+
+.center {
+ margin-left: auto !important;
+ margin-right: auto !important;
+}
+
+.block {
+ display: block !important;
+}
+
+.wrap {
+ word-wrap: break-word;
+}
+
+.one-line-overflow {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.small-v-space {
+ height: 6px;
+ display: block;
+}
+
+.medium-v-space {
+ height: 12px;
+ display: block;
+}
+
+.large-v-space {
+ height: 24px;
+ display: block;
+}
+
+.small-padding {
+ padding: 5px !important;
+}
+
+.medium-padding {
+ padding: 10px !important;
+}
+
+.pb-4 {
+ padding-bottom: 4px !important;
+}
+
+.pb-6 {
+ padding-bottom: 6px !important;
+}
+
+.pb-10 {
+ padding-bottom: 10px !important;
+}
+
+.large-padding {
+ padding: 22px !important;
+}
+
+.red {
+ color: red !important;
+}
+
+.blue {
+ color: $blue-color;
+}
+
+.bold {
+ font-weight: bold !important;
+}
+
+.normal {
+ font-weight: normal !important;
+}
+
+.small {
+ font-size: 10px !important;
+}
+
+.inline {
+ display: inline-block;
+}
+
+.fake-link {
+ font-weight: bold;
+ cursor: pointer;
+ color: $blue-color;
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.footer-bar {
position: relative;
width: 100%;
+ padding: 5px;
background-color: #d8d7d9;
height: $header-height;
max-height: $header-height;
@@ -9,90 +158,125 @@
color: $dark-gray;
border-bottom: 1px solid rgba(#979799, 0.4);
+ .medium-text {
+ font-size: 14px;
+ }
+
a {
- color: $dark-gray;
+ font-weight: bold;
+ cursor: pointer;
+ color: $blue-color;
+
+ &.gray {
+ color: $dark-gray !important;
+ }
+
+ &.block {
+ display: block !important;
+ }
+ }
+
+ p {
+ margin: 2px 0px;
+ font-size: 12px;
+ }
+
+ label {
+ font-weight: bold;
+ margin-bottom: 4px;
+ }
+
+ strong {
+ display: block;
+ }
+
+ h2 {
+ margin-bottom: 0px;
+ margin-top: 0px;
+ }
+
+ h3 {
+ font-size: 14px !important;
+ margin-top: 4px !important;
+ margin-bottom: 3px !important;
+ }
+
+ h4 {
+ margin-bottom: 4px !important;
+ font-size: 13px !important;
+ }
+
+ section {
+ padding: 5px;
+ padding-bottom: 2px;
+ margin-top: 5px;
+
+ &.inline-h {
+ padding-top: 5px;
+ padding-left: 0;
+ padding-right: 0;
+ }
+
+ }
+
+ input {
+ margin-bottom: 10px;
+ border-radius: 0px;
}
}
-.header-content {
- margin-bottom: 0px;
- padding-top: 0px;
- border-radius: 0px;
-
- left: 0px;
- right: 0px;
-}
-
-.panel-status-text {
- margin-top: 20px;
- font-style: italic;
- font-size: 14px;
-}
-
-.menu {
- margin-left: 15px;
- padding-top: 5px;
- margin-top: 0px;
- color: #515263;
- z-index: 1000;
- margin-bottom: 0px;
+.footer-bar-link {
font-size: 11px;
+ font-weight: bold;
+ margin-left: 8px;
+ color: #515263;
- &.left {
- float: left;
+ z-index: 1000;
+ display: inline-block;
+ position: relative;
+ cursor: pointer;
+
+ > a {
+ color: #515263;
}
+}
- &.right {
- float: right;
- margin-right: 10px;
- }
+.footer-bar-link .panel {
+ font-weight: normal;
+ font-size: 12px;
- .login-panel .login-input {
- border-radius: 0px;
- }
+ max-height: 85vh;
+ position: absolute;
+ right: 0px;
+ bottom: 20px;
+ min-width: 300px;
+ z-index: 1000;
+ margin-top: 15px;
- .items {
+ box-shadow: 0px 0px 15px rgba(black, 0.2);
+ border: none;
+ cursor: default;
+ overflow: auto;
+ background-color: white;
+}
- .item {
+button.light {
+ font-weight: bold;
+ margin-bottom: 0px;
+ font-size: 12px;
+ height: 30px;
+ padding-top: 3px;
+ text-align: center;
+ margin-bottom: 6px;
+ background-color: white;
+ display: block;
+ width: 100%;
+ border: 1px solid rgba(gray, 0.15);
+ cursor: pointer;
+ color: $blue-color;
- display: inline-block;
- margin-right: 7px;
- position: relative;
- cursor: pointer;
- font-weight: bold;
-
- a {
- color: #515263;
- }
-
- .panel {
- position: absolute;
- right: 0px;
- bottom: 20px;
- min-width: 300px;
- z-index: 1000;
- margin-top: 15px;
- box-shadow: 0px 0px 15px rgba(black, 0.2);
- border: none;
- cursor: default;
- max-height: 85vh;
- overflow: auto;
- background-color: white;
- font-weight: normal;
-
-
- .storage-text {
- font-size: 14px;
- }
-
- .checkbox {
- font-size: 14px;
- font-weight: normal;
- margin-left: auto;
- margin-right: auto;
- }
- }
- }
+ &:hover {
+ background-color: rgba(gray, 0.10);
}
}
@@ -104,162 +288,25 @@
float: left;
}
+.gray-bg {
+ background-color: #f6f6f6;
+ border: 1px solid #f2f2f2;
+}
+
+.white-bg {
+ background-color: white;
+ border: 1px solid rgba(gray, 0.2);
+}
+
.item.last-refreshed {
font-weight: normal !important;
cursor: default !important;
}
-.item.account {
-
- .email {
- font-size: 18px;
- font-weight: bold;
- margin-bottom: 2px;
- }
-
- .server {
- margin-bottom: 10px;
- }
-
- .links {
- margin-bottom: 25px;
- }
-
- .link-item {
- margin-bottom: 8px;
- a {
- font-size: 12px;
- color: $blue-color;
- font-weight: bold;
- }
- }
-
- input {
- border-radius: 0px;
- }
-
- .account-panel {
-
- padding: 12px;
- padding-bottom: 6px;
-
- .account-items {
- margin-top: 0px;
- }
-
- .account-item {
- width: 100%;
- margin-bottom: 34px;
-
- a {
- color: $blue-color;
- font-weight: bold;
- cursor: pointer;
- }
-
- > .icon-container {
- display: block;
- margin-bottom: 10px;
- }
-
- > .meta-container {
- display: block;
- font-size: 10px;
- }
-
- > .action-container {
- font-size: 12px;
- margin-top: 6px;
-
- .status-title {
- font-weight: bold;
- }
-
- .subtext {
- font-size: 10px;
- margin-top: 2px;
- }
-
- a {
- display: block;
- margin-bottom: -10px;
- }
- }
-
- .import-password {
- margin-top: 14px;
-
- > .field {
- display: block;
- margin: 5px 0px;
- }
- }
-
- .encryption-confirmation {
- position: relative;
- .buttons {
- .cancel {
- font-weight: normal;
- margin-right: 3px;
- }
- }
- }
-
- &:last-child {
- margin-bottom: 8px !important;
- }
-
- > .icon-container {
- margin-bottom: 10px;
- .icon {
- height: 35px;
- &.archive {
- height: 30px;
- }
- }
- }
-
- .meta-container {
- > .title {
- font-size: 13px;
- font-weight: bold;
- }
-
- > .desc {
- font-size: 12px;
- margin-top: 3px;
- }
- }
- }
-
- .membership-settings {
- font-size: 14px;
- }
-
- }
-}
-
a.disabled {
pointer-events: none;
}
-.account-form {
- margin-top: 10px;
-}
-
-.registration-login {
-
- .login-forgot {
- margin-top: 20px;
- clear: both;
- a {
- display: block;
- font-size: 13px !important;
- text-align: center;
- }
- }
-}
-
.spinner {
height: 10px;
width: 10px;
@@ -267,138 +314,14 @@ a.disabled {
border: 1px solid #515263;
border-right-color: transparent;
border-radius: 50%;
+
+ &.blue {
+ border: 1px solid $blue-color;
+ border-right-color: transparent;
+ }
}
@keyframes rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
-
-/**
-Extensions
-*/
-
-.extensions-panel {
- font-size: 14px;
-
- .extension-link {
- margin-top: 8px;
-
- a {
- color: $blue-color !important;
- font-weight: bold;
- }
- }
-}
-
-.extension-form {
- margin-top: 8px;
-}
-
-.registered-extensions {
-
-
- .extension {
- margin-bottom: 18px;
- background-color: #f6f6f6;
- border: 1px solid #f2f2f2;
- padding: 14px 6px;
- padding-bottom: 8px;
- color: black;
-
- a {
- color: $blue-color !important;
- font-size: 12px !important;
- font-weight: bold !important;
- }
-
- > .name {
- font-weight: bold;
- font-size: 16px;
- margin-bottom: 6px;
- text-align: center;
- }
-
- .encryption-format {
- margin-top: 4px;
- font-size: 12px;
- text-align: center;
-
- > .title {
- font-size: 13px;
- // font-weight: bold;
- margin-bottom: 2px;
- }
- }
-
- > .subtitle {
- font-size: 14px;
- margin-bottom: 10px;
- }
-
- > .actions {
- margin-top: 15px;
- font-size: 12px;
-
- .action {
- padding: 13px;
- margin-bottom: 10px;
- background-color: rgba(white, 0.9);
- border: 1px solid rgba(gray, 0.15);
-
- > .name {
- font-weight: bold;
- }
-
- > .permissions {
- margin-top: 2px;
- a {
- font-weight: normal !important;
- }
- }
-
- > .execute {
- font-weight: bold;
- margin-bottom: 0px;
- font-size: 12px;
- height: 30px;
- padding-top: 7px;
- text-align: center;
- margin-top: 6px;
- border: 1px solid rgba(gray, 0.15);
- cursor: pointer;
- color: $blue-color;
-
- &:hover {
- background-color: rgba(gray, 0.10);
- }
-
- .execution-spinner {
- margin-left: auto;
- margin-right: auto;
- text-align: center;
- margin-top: 3px;
- }
- }
-
- > .execute-type {
- font-size: 12px;
- margin-bottom: 1px;
- }
-
- > .error {
- color: red;
- margin-top: 6px;
- }
-
- > .last-run {
- opacity: 0.5;
- font-size: 11px;
- margin-top: 6px;
- }
- }
-
- }
- }
-
-}
diff --git a/app/assets/stylesheets/app/_notes.scss b/app/assets/stylesheets/app/_notes.scss
index 6cb3adcce..d71413110 100644
--- a/app/assets/stylesheets/app/_notes.scss
+++ b/app/assets/stylesheets/app/_notes.scss
@@ -41,12 +41,11 @@
.note {
width: 100%;
- // max-width: 100%;
padding: 15px;
- // height: 70px;
border-bottom: 1px solid $bg-color;
cursor: pointer;
background-color: white;
+
> .name {
font-weight: 600;
overflow: hidden;
diff --git a/app/assets/templates/frontend/directives/account-menu.html.haml b/app/assets/templates/frontend/directives/account-menu.html.haml
new file mode 100644
index 000000000..168b1ebf4
--- /dev/null
+++ b/app/assets/templates/frontend/directives/account-menu.html.haml
@@ -0,0 +1,77 @@
+.panel.panel-default.panel-right.account-data-menu
+ .panel-body.large-padding
+ %div{"ng-if" => "!user"}
+ %p Enter your Standard File account information. You can also register for free using the default server address.
+ .small-v-space
+
+ %form.account-form.mt-5{'name' => "loginForm"}
+ %input.form-control{:name => 'server', :placeholder => 'Server URL', :required => true, :type => 'text', 'ng-model' => 'formData.url'}
+ %input.form-control{:autofocus => 'autofocus', :name => 'email', :placeholder => 'Email', :required => true, :type => 'email', 'ng-model' => 'formData.email'}
+ %input.form-control{:placeholder => 'Password', :name => 'password', :required => true, :type => 'password', 'ng-model' => 'formData.user_password'}
+
+ %div{"ng-if" => "!formData.status"}
+ %button.btn.dark-button.half-button{"ng-click" => "loginSubmitPressed()", "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"}
+ %span Sign In
+ %button.btn.dark-button.half-button{"ng-click" => "submitRegistrationForm()", "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"}
+ %span Register
+ %br
+ .block{"style" => "margin-top: 10px; font-size: 14px; font-weight: bold; text-align: center;"}
+ %a.btn{"ng-click" => "showResetForm = !showResetForm"} Passwords cannot be forgotten.
+
+ %em.block.center-align.mt-10{"ng-if" => "formData.status", "style" => "font-size: 14px;"} {{formData.status}}
+
+ %div{"ng-if" => "showResetForm"}
+ %p{"style" => "font-size: 13px; text-align: center;"}
+ Because notes are locally encrypted using a secret key derived from your password, there's no way to decrypt these notes if you forget your password.
+ For this reason, Standard Notes cannot offer a password reset option. You must make sure to store or remember your password.
+
+ %div{"ng-if" => "user"}
+ %h2 {{user.email}}
+ %p {{server}}
+ %a.block.mt-5{"ng-click" => "showCredentials = !showCredentials"} Show Credentials
+ %section.gray-bg.mt-10.medium-padding{"ng-if" => "showCredentials"}
+ %label.block
+ Encryption key:
+ .wrap.normal.mt-1 {{encryptionKey()}}
+ %label.block.mt-5.mb-0
+ Server password:
+ .wrap.normal.mt-1 {{serverPassword() ? serverPassword() : 'Not available. Sign out then sign back in to compute.'}}
+
+ %div.bold.mt-10.blue{"delay-hide" => "true", "show" => "syncStatus.syncOpInProgress", "delay" => "1000"}
+ .spinner.inline.mr-5.blue
+ Syncing
+ %span{"ng-if" => "syncStatus.total > 0"}: {{syncStatus.current}}/{{syncStatus.total}}
+ %p.bold.mt-10.red.block{"ng-if" => "syncStatus.error"} Error syncing: {{syncStatus.error.message}}
+
+ .medium-v-space
+
+ %h4 Local Encryption
+ %p Notes are encrypted locally before being sent to the server. Neither the server owner nor an intrusive entity can decrypt your locally encrypted notes.
+ %div.mt-5
+ %label Status:
+ {{encryptionStatusForNotes()}}
+
+ .mt-25{"ng-if" => "!importData.loading"}
+ %h4 Data Archives
+ .mt-5{"ng-if" => "user"}
+ %label.normal.inline{"ng-if" => "user"}
+ %input{"type" => "radio", "ng-model" => "archiveFormData.encrypted", "ng-value" => "true", "ng-change" => "archiveFormData.encrypted = true"}
+ Encrypted
+ %label.normal.inline
+ %input{"type" => "radio", "ng-model" => "archiveFormData.encrypted", "ng-value" => "false", "ng-change" => "archiveFormData.encrypted = false"}
+ Decrypted
+
+ %a.block{"ng-click" => "downloadDataArchive()"} Download Data Archive
+
+ %label.block.mt-5
+ %input{"type" => "file", "style" => "display: none;", "file-change" => "->", "handler" => "importFileSelected(files)"}
+ .fake-link Import Data from Archive
+
+ %div{"ng-if" => "importData.requestPassword"}
+ Enter the account password associated with the import file.
+ %input{"type" => "text", "ng-model" => "importData.password"}
+ %button{"ng-click" => "submitImportPassword()"} Decrypt & Import
+
+ .spinner.mt-10{"ng-if" => "importData.loading"}
+
+ %a.block.mt-25.red{"ng-click" => "destroyLocalData()"} Destroy all local data
diff --git a/app/assets/templates/frontend/directives/global-extensions-menu.html.haml b/app/assets/templates/frontend/directives/global-extensions-menu.html.haml
new file mode 100644
index 000000000..2dd5df9d1
--- /dev/null
+++ b/app/assets/templates/frontend/directives/global-extensions-menu.html.haml
@@ -0,0 +1,57 @@
+.panel.panel-default.account-panel.panel-right
+ .panel-body
+ %div{"style" => "font-size: 18px;", "ng-if" => "!extensionManager.extensions.length"} No extensions installed
+ %div{"ng-if" => "extensionManager.extensions.length"}
+ %section.gray-bg.inline-h.mb-10.medium-padding{"ng-repeat" => "extension in extensionManager.extensions", "ng-init" => "extension.formData = {}"}
+ %h3.center-align {{extension.name}}
+ .center-align.centered.mt-10
+ %label.block.normal Send data:
+ %label.normal
+ %input{"type" => "radio", "ng-model" => "extension.encrypted", "ng-value" => "true", "ng-change" => "changeExtensionEncryptionFormat(true, extension)"}
+ Encrypted
+ %label.normal
+ %input{"type" => "radio", "ng-model" => "extension.encrypted", "ng-value" => "false", "ng-change" => "changeExtensionEncryptionFormat(false, extension)"}
+ Decrypted
+
+ .small-v-space
+
+ %section.inline-h.white-bg.medium-padding.mb-10.pb-4{"ng-repeat" => "action in extension.actionsInGlobalContext()"}
+ %label.block {{action.label}}
+ %em{"style" => "font-style: italic;"} {{action.desc}}
+ %em{"ng-if" => "action.repeat_mode == 'watch'"}
+ Repeats when a change is made to your items.
+ %em{"ng-if" => "action.repeat_mode == 'loop'"}
+ Repeats at most once every {{action.repeat_timeout}} seconds
+ %div
+ %a{"ng-click" => "action.showPermissions = !action.showPermissions"} {{action.showPermissions ? "Hide permissions" : "Show permissions"}}
+ %div{"ng-if" => "action.showPermissions"}
+ {{action.permissionsString()}}
+ %label.block.normal {{action.encryptionModeString()}}
+
+ %div
+ .mt-5{"ng-if" => "action.repeat_mode"}
+ %button.light{"ng-if" => "extensionManager.isRepeatActionEnabled(action)", "ng-click" => "extensionManager.disableRepeatAction(action, extension)"} Disable
+ %button.light{"ng-if" => "!extensionManager.isRepeatActionEnabled(action)", "ng-click" => "extensionManager.enableRepeatAction(action, extension)"} Enable
+ %button.light.mt-10{"ng-if" => "!action.running && !action.repeat_mode", "ng-click" => "selectedAction(action, extension)"}
+ Perform Action
+ .spinner.mb-5.centered.center-align.block{"ng-if" => "action.running"}
+ %p.mb-5.mt-5.small{"ng-if" => "!action.error && action.lastExecuted && !action.running"}
+ Last run {{action.lastExecuted | appDateTime}}
+ %label.red{"ng-if" => "action.error"}
+ Error performing action.
+
+ %a.block.center-align.mt-10{"ng-click" => "extension.showURL = !extension.showURL"} Show URL
+ %p.center-align.wrap{"ng-if" => "extension.showURL"} {{extension.url}}
+ %a.block.center-align.mt-5{"ng-click" => "deleteExtension(extension)"} Remove extension
+
+ .large-v-space
+
+ %a.block{"ng-click" => "toggleExtensionForm()"} Add new extension
+
+ %form.mt-10.mb-10{"ng-if" => "showNewExtensionForm"}
+ %input.form-control{:autofocus => 'autofocus', :name => 'url', :placeholder => 'Extension URL', :required => true, :type => 'url', 'ng-model' => 'newExtensionData.url'}
+ %button.btn.dark-button.btn-block{"ng-click" => "submitNewExtensionForm()", :type => 'submit', "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"}
+ Add Extension
+
+ %a.block.mt-5{"ng-click" => "reloadExtensionsPressed()", "ng-if" => "extensionManager.extensions.length > 0"} Reload all extensions
+ %a.block.mt-5{"href" => "https://standardnotes.org/extensions", "target" => "_blank"} List of available extensions
diff --git a/app/assets/templates/frontend/editor.html.haml b/app/assets/templates/frontend/editor.html.haml
index 322b42d7f..50461f012 100644
--- a/app/assets/templates/frontend/editor.html.haml
+++ b/app/assets/templates/frontend/editor.html.haml
@@ -5,7 +5,7 @@
%input.input#note-title-editor{"ng-model" => "ctrl.note.title", "ng-keyup" => "$event.keyCode == 13 && ctrl.saveTitle($event)",
"ng-change" => "ctrl.nameChanged()", "ng-focus" => "ctrl.onNameFocus()",
"select-on-click" => "true"}
- .save-status {{ctrl.noteStatus}}
+ .save-status{"ng-class" => "{'red bold': ctrl.saveError}"} {{ctrl.noteStatus}}
.tags
%input.tags-input{"type" => "text", "ng-keyup" => "$event.keyCode == 13 && ctrl.updateTagsFromTagsString($event, ctrl.tagsString)",
"ng-model" => "ctrl.tagsString", "placeholder" => "#tags", "ng-blur" => "ctrl.updateTagsFromTagsString($event, ctrl.tagsString)"}
diff --git a/app/assets/templates/frontend/header.html.haml b/app/assets/templates/frontend/header.html.haml
index 5bdc562cb..f385f293c 100644
--- a/app/assets/templates/frontend/header.html.haml
+++ b/app/assets/templates/frontend/header.html.haml
@@ -1,170 +1,25 @@
-.header
- .header-content
- .menu.left
- .items
- .item.account
- %div{"ng-click" => "ctrl.accountMenuPressed()"}
- %div{"ng-if" => "ctrl.user"} Account
- %div{"ng-if" => "!ctrl.user"} Sign in or Register
- .panel.panel-default.account-panel.panel-right{"ng-if" => "ctrl.showAccountMenu"}
- .panel-body
- .account-items
- .account-item.registration-login{"ng-if" => "!ctrl.user"}
- .account-item
- .meta-container
- .title Server
- .desc Enter your Standard File server address, or use the default.
- .action-container
- %form.account-form{'ng-submit' => 'ctrl.changeServer()', 'name' => "serverChangeForm"}
- .form-tag.has-feedback
- %input.form-control{:name => 'server', :placeholder => 'Server URL', :required => true, :type => 'text', 'ng-model' => 'ctrl.serverData.url'}
- %button.btn.dark-button.btn-block{:type => 'submit', "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"}
- %span.ladda-label Set Server
- .meta-container
- .title Sign in or Register
- .desc
- %form.account-form{'name' => "loginForm"}
- .form-tag.has-feedback
- %input.form-control.login-input{:autofocus => 'autofocus', :name => 'email', :placeholder => 'Email', :required => true, :type => 'email', 'ng-model' => 'ctrl.loginData.email'}
- .form-tag.has-feedback
- %input.form-control.login-input{:placeholder => 'Password', :name => 'password', :required => true, :type => 'password', 'ng-model' => 'ctrl.loginData.user_password'}
- .checkbox{"ng-if" => "ctrl.localNotesCount() > 0"}
- %label
- %input{"type" => "checkbox", "ng-model" => "ctrl.loginData.mergeLocal", "ng-bind" => "true", "ng-change" => "ctrl.mergeLocalChanged()"}
- Merge local notes ({{ctrl.localNotesCount()}} notes)
- %button.btn.dark-button.half-button{"ng-click" => "ctrl.loginSubmitPressed()", "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"}
- %span Sign In
- %button.btn.dark-button.half-button{"ng-click" => "ctrl.submitRegistrationForm()", "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"}
- %span Register
- %br
- .login-forgot{"style" => "padding-top: 4px;"}
- %a.btn.btn-link{"ng-click" => "ctrl.showResetForm = !ctrl.showResetForm"} Passwords cannot be forgotten.
- .panel-status-text{"ng-if" => "ctrl.loginData.status", "style" => "font-size: 14px;"} {{ctrl.loginData.status}}
+.footer-bar
+ .pull-left
+ .footer-bar-link
+ %a{"ng-click" => "ctrl.accountMenuPressed()", "ng-class" => "{red: ctrl.error}"} Account
+ %account-menu{"ng-if" => "ctrl.showAccountMenu"}
- %div{"ng-if" => "ctrl.showResetForm"}
- %p{"style" => "font-size: 13px; text-align: center;"}
- Because notes are locally encrypted using a secret key derived from your password, there's no way to decrypt these notes if you forget your password.
- For this reason, Standard Notes cannot offer a password reset option. You must make sure to store or remember your password.
+ .footer-bar-link
+ %a{"ng-click" => "ctrl.toggleExtensions()"} Extensions
+ %global-extensions-menu{"ng-if" => "ctrl.showExtensionsMenu"}
- .account-item{"ng-if" => "ctrl.user"}
- .email {{ctrl.user.email}}
- .server {{ctrl.serverData.url}}
- .links{"ng-if" => "ctrl.user"}
- -# .link-item
- -# %a{"ng-click" => "ctrl.changePasswordPressed()"} Change Password
- -# %form.account-form{"ng-if" => "ctrl.showNewPasswordForm", 'ng-submit' => 'ctrl.submitPasswordChange()', 'name' => "passwordChangeForm"}
- -# .form-tag.has-feedback
- -# %input.form-control.login-input{:autofocus => 'autofocus', :name => 'current', :placeholder => 'Current password', :required => true, :type => 'password', 'ng-model' => 'ctrl.passwordChangeData.current_password'}
- -# .form-tag.has-feedback
- -# %input.form-control.login-input{:placeholder => 'New password', :name => 'password', :required => true, :type => 'password', 'ng-model' => 'ctrl.passwordChangeData.new_password', "autocomplete" => "new-password"}
- -# .form-tag.has-feedback
- -# %input.form-control.login-input{:placeholder => 'Confirm password', :name => 'password', :required => true, :type => 'password', 'ng-model' => 'ctrl.passwordChangeData.new_password_confirmation', "autocomplete" => "new-password"}
- -# %button.btn.dark-button.btn-block{:type => 'submit', "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"}
- -# %span.ladda-label Change Password
- -# .panel-status-text{"ng-if" => "ctrl.passwordChangeData.status", "style" => "font-size: 14px;"}
- -# {{ctrl.passwordChangeData.status}}
- .link-item
- %a{"ng-click" => "ctrl.signOutPressed()"} Sign Out
- .meta-container
- .title Local Encryption
- .desc Notes are encrypted locally before being sent to the server. Neither the server owner nor an intrusive entity can decrypt your locally encrypted notes.
- .action-container
- %span.status-title Status:
- {{ctrl.encryptionStatusForNotes()}}
- .account-item{"ng-if" => "ctrl.user"}
- .meta-container
- .title Data Archives
- .options{"style" => "font-size: 12px; margin-top: 4px;"}
- %label
- %input{"type" => "radio", "ng-model" => "ctrl.archiveEncryptionFormat.encrypted", "ng-value" => "true", "ng-change" => "ctrl.archiveEncryptionFormat.encrypted = true"}
- Encrypted
- %label
- %input{"type" => "radio", "ng-model" => "ctrl.archiveEncryptionFormat.encrypted", "ng-value" => "false", "ng-change" => "ctrl.archiveEncryptionFormat.encrypted = false"}
- Decrypted
- .action-container
- %a{"ng-click" => "ctrl.downloadDataArchive()"} Download Data Archive
- %br
- %div{"ng-if" => "!ctrl.importData.loading"}
- %label#import-archive
- %input{"type" => "file", "style" => "display: none;", "file-change" => "->", "handler" => "ctrl.importFileSelected(files)"}
- %a.disabled
- %span
- Import Data from Archive
- .import-password{"ng-if" => "ctrl.importData.requestPassword"}
- Enter the account password associated with the import file.
- %input.field{"type" => "text", "ng-model" => "ctrl.importData.password"}
- %button{"ng-click" => "ctrl.submitImportPassword()"} Decrypt & Import
- .spinner{"ng-if" => "ctrl.importData.loading"}
+ .footer-bar-link
+ %a{"href" => "https://standardnotes.org", "target" => "_blank"}
+ Help
- .item
- %a{"ng-click" => "ctrl.toggleExtensions()"} Extensions
- .panel.panel-default.account-panel.panel-right.extensions-panel{"ng-if" => "ctrl.showExtensionsMenu"}
- .panel-body
- %div{"style" => "font-size: 18px;", "ng-if" => "!ctrl.extensionManager.extensions.length"} No extensions installed
- .registered-extensions{"ng-if" => "ctrl.extensionManager.extensions.length"}
- .extension{"ng-repeat" => "extension in ctrl.extensionManager.extensions"}
- .name {{extension.name}}
- .encryption-format
- .title Send data:
- %label
- %input{"type" => "radio", "ng-model" => "extension.encrypted", "ng-value" => "true", "ng-change" => "ctrl.extensionManager.changeExtensionEncryptionFormat(true, extension)"}
- Encrypted
- %label
- %input{"type" => "radio", "ng-model" => "extension.encrypted", "ng-value" => "false", "ng-change" => "ctrl.extensionManager.changeExtensionEncryptionFormat(false, extension)"}
- Decrypted
- .actions
- .action{"ng-repeat" => "action in extension.actionsInGlobalContext()"}
- .name {{action.label}}
- .desc{"style" => "font-style: italic;"} {{action.desc}}
- .execute-type{"ng-if" => "action.repeat_mode == 'watch'"}
- Repeats when a change is made to your items.
- .execute-type{"ng-if" => "action.repeat_mode == 'loop'"}
- Repeats at most once every {{action.repeat_timeout}} seconds
- .permissions
- %a{"ng-click" => "action.showPermissions = !action.showPermissions"} {{action.showPermissions ? "Hide permissions" : "Show permissions"}}
- %div{"ng-if" => "action.showPermissions"}
- {{action.permissionsString}}
- .encryption-type
- %span {{action.encryptionModeString}}
- .execute
- %div{"ng-if" => "action.repeat_mode"}
- %div{"ng-if" => "ctrl.extensionManager.isRepeatActionEnabled(action)", "ng-click" => "ctrl.extensionManager.disableRepeatAction(action, extension)"} Disable
- %div{"ng-if" => "!ctrl.extensionManager.isRepeatActionEnabled(action)", "ng-click" => "ctrl.extensionManager.enableRepeatAction(action, extension)"} Enable
- %div{"ng-if" => "!action.repeat_mode", "ng-click" => "ctrl.selectedAction(action, extension)"}
- %div{"ng-if" => "!action.running"}
- Perform Action
- %div{"ng-if" => "action.running"}
- .spinner.execution-spinner
- .last-run{"ng-if" => "!action.error && action.lastExecuted && !action.running"}
- Last run {{action.lastExecuted | appDateTime}}
- .error{"ng-if" => "action.error"}
- Error performing action.
- %a{"ng-click" => "ctrl.deleteExtension(extension)", "style" => "margin-top: 22px; display: block; text-align: center;"} Remove extension
+ .pull-right
- .extension-link
- %a{"ng-click" => "ctrl.toggleExtensionForm()"} Add new extension
+ .footer-bar-link{"style" => "margin-right: 5px;"}
+ %div{"ng-if" => "ctrl.lastSyncDate", "style" => "float: left; font-weight: normal; margin-right: 8px;"}
+ %span{"ng-if" => "!ctrl.isRefreshing"}
+ Last refreshed {{ctrl.lastSyncDate | appDateTime}}
+ %span{"ng-if" => "ctrl.isRefreshing"}
+ .spinner{"style" => "margin-top: 2px;"}
- %form.extension-form{"ng-if" => "ctrl.showNewExtensionForm"}
- .form-tag.has-feedback
- %input.form-control{:autofocus => 'autofocus', :name => 'url', :placeholder => 'Extension URL', :required => true, :type => 'url', 'ng-model' => 'ctrl.newExtensionData.url'}
- %button.btn.dark-button.btn-block{"ng-click" => "ctrl.submitNewExtensionForm()", :type => 'submit', "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"}
- %span.ladda-label Add Extension
-
- .extension-link
- %a{"ng-click" => "ctrl.reloadExtensionsPressed()", "ng-if" => "ctrl.extensionManager.extensions.length > 0"} Reload all extensions
- .extension-link
- %a{"href" => "https://standardnotes.org/extensions", "target" => "_blank"} List of available extensions
-
- .item
- %a{"href" => "https://standardnotes.org", "target" => "_blank"}
- Help
-
- .menu.right
- .items
- .item.last-refreshed{"ng-if" => "ctrl.lastSyncDate"}
- %span{"ng-if" => "!ctrl.isRefreshing"}
- Last refreshed {{ctrl.lastSyncDate | appDateTime}}
- %span{"ng-if" => "ctrl.isRefreshing"}
- .spinner
- .item{"ng-click" => "ctrl.refreshData()"}
- Refresh
+ %strong{"ng-if" => "ctrl.offline"} Offline
+ %a{"ng-if" => "!ctrl.offline", "ng-click" => "ctrl.refreshData()"} Refresh
diff --git a/app/assets/templates/frontend/home.html.haml b/app/assets/templates/frontend/home.html.haml
index f2f40d0bf..f7057c955 100644
--- a/app/assets/templates/frontend/home.html.haml
+++ b/app/assets/templates/frontend/home.html.haml
@@ -8,4 +8,5 @@
"tag" => "selectedTag", "remove" => "deleteNote"}
%editor-section{"ng-if" => "selectedNote", "note" => "selectedNote", "remove" => "deleteNote", "save" => "saveNote", "update-tags" => "updateTagsForNote"}
- %header{"user" => "defaultUser"}
+
+ %header
diff --git a/app/assets/templates/frontend/notes.html.haml b/app/assets/templates/frontend/notes.html.haml
index 0d5b71103..28fb4654b 100644
--- a/app/assets/templates/frontend/notes.html.haml
+++ b/app/assets/templates/frontend/notes.html.haml
@@ -18,10 +18,11 @@
%li
%a.text{"ng-click" => "ctrl.selectedMenuItem(); ctrl.selectedTagDelete()"} Delete Tag
- .note{"ng-repeat" => "note in ctrl.tag.notes | filter: ctrl.filterNotes",
- "ng-click" => "ctrl.selectNote(note)", "ng-class" => "{'selected' : ctrl.selectedNote == note}"}
- .name{"ng-if" => "note.title"}
- {{note.title}}
- .note-preview
- {{note.text}}
- .date {{(note.created_at | appDateTime) || 'Now'}}
+ %div{"infinite-scroll" => "ctrl.paginate()", "can-load" => "true", "threshold" => "200"}
+ .note{"ng-repeat" => "note in ctrl.tag.notes | limitTo:ctrl.notesToDisplay | filter: ctrl.filterNotes",
+ "ng-click" => "ctrl.selectNote(note)", "ng-class" => "{'selected' : ctrl.selectedNote == note}"}
+ .name{"ng-if" => "note.title"}
+ {{note.title}}
+ .note-preview
+ {{note.text}}
+ .date {{(note.created_at | appDateTime) || 'Now'}}
diff --git a/app/assets/templates/frontend/tags.html.haml b/app/assets/templates/frontend/tags.html.haml
index 69e96b946..abdc92dc5 100644
--- a/app/assets/templates/frontend/tags.html.haml
+++ b/app/assets/templates/frontend/tags.html.haml
@@ -13,5 +13,5 @@
%input.title{"ng-disabled" => "tag != ctrl.selectedTag", "ng-model" => "tag.title",
"ng-keyup" => "$event.keyCode == 13 && ctrl.saveTag($event, tag)", "mb-autofocus" => "true", "should-focus" => "ctrl.newTag",
- "ng-change" => "ctrl.tagTitleDidChange(tag)", "ng-focus" => "ctrl.onTagTitleFocus(tag)"}
+ "ng-change" => "ctrl.tagTitleDidChange(tag)", "ng-focus" => "ctrl.onTagTitleFocus(tag)", "ng-blur" => "ctrl.saveTag($event, tag)"}
.count {{ctrl.noteCount(tag)}}
diff --git a/app/assets/templates/services/.keep b/app/assets/templates/services/.keep
deleted file mode 100644
index e69de29bb..000000000