Merge pull request #218 from standardnotes/flexible-content

Flexible content and session history
This commit is contained in:
Mo Bitar
2018-07-22 18:57:31 -05:00
committed by GitHub
81 changed files with 9020 additions and 2997 deletions

View File

@@ -70,9 +70,10 @@ module.exports = function(grunt) {
},
app: {
src: [
'node_modules/sn-models/dist/sn-models.js',
'app/assets/javascripts/app/*.js',
'app/assets/javascripts/app/controllers/**/*.js',
'app/assets/javascripts/app/models/**/*.js',
'app/assets/javascripts/app/controllers/**/*.js',
'app/assets/javascripts/app/services/**/*.js',
'app/assets/javascripts/app/filters/**/*.js',
'app/assets/javascripts/app/directives/**/*.js',
@@ -161,9 +162,9 @@ module.exports = function(grunt) {
grunt.loadNpmTasks('grunt-babel');
grunt.loadNpmTasks('grunt-browserify');
// grunt.registerTask('default', ['haml', 'ngtemplates', 'sass', 'concat:app',
// 'concat:lib', 'concat:dist', 'concat:css', 'babel', 'browserify', 'uglify']);
grunt.registerTask('default', ['haml', 'ngtemplates', 'sass', 'concat:app', 'babel', 'browserify',
'concat:lib', 'concat:dist', 'ngAnnotate', 'concat:css', 'uglify']);
grunt.registerTask('vendor', ['concat:app', 'babel', 'browserify',
'concat:lib', 'concat:dist', 'ngAnnotate', 'concat:css', 'uglify']);
};

View File

@@ -3,7 +3,6 @@ angular.module('app')
return {
restrict: 'E',
scope: {
save: "&",
remove: "&",
note: "=",
updateTags: "&"
@@ -23,8 +22,8 @@ angular.module('app')
}
}
})
.controller('EditorCtrl', function ($sce, $timeout, authManager, $rootScope, actionsManager, syncManager, modelManager, themeManager, componentManager, storageManager) {
.controller('EditorCtrl', function ($sce, $timeout, authManager, $rootScope, actionsManager, syncManager, modelManager, themeManager, componentManager, storageManager, sessionHistory) {
this.spellcheck = true;
this.componentManager = componentManager;
@@ -36,10 +35,6 @@ angular.module('app')
this.syncTakingTooLong = false;
}.bind(this));
$rootScope.$on("tag-changed", function(){
this.loadTagsString();
}.bind(this));
// Right now this only handles offline saving status changes.
this.syncStatusObserver = syncManager.registerSyncStatusObserver((status) => {
if(status.localError) {
@@ -53,7 +48,7 @@ angular.module('app')
}
})
modelManager.addItemSyncObserver("component-manager", "Note", (allItems, validItems, deletedItems, source) => {
modelManager.addItemSyncObserver("editor-note-observer", "Note", (allItems, validItems, deletedItems, source) => {
if(!this.note) { return; }
// Before checking if isMappingSourceRetrieved, we check if this item was deleted via a local source,
@@ -64,7 +59,7 @@ angular.module('app')
return;
}
if(!ModelManager.isMappingSourceRetrieved(source)) {
if(!SFModelManager.isMappingSourceRetrieved(source)) {
return;
}
@@ -80,6 +75,37 @@ angular.module('app')
this.loadTagsString();
});
modelManager.addItemSyncObserver("editor-tag-observer", "Tag", (allItems, validItems, deletedItems, source) => {
if(!this.note) { return; }
for(var tag of allItems) {
// If a tag is deleted then we'll have lost references to notes. Reload anyway.
if(this.note.savedTagsString == null || tag.deleted || tag.hasRelationshipWithItem(this.note)) {
this.loadTagsString();
return;
}
}
});
// Observe editor changes to see if the current note should update its editor
modelManager.addItemSyncObserver("editor-component-observer", "SN|Component", (allItems, validItems, deletedItems, source) => {
if(!this.note) { return; }
var editors = allItems.filter(function(item) {
return item.isEditor();
});
// If no editors have changed
if(editors.length == 0) {
return;
}
// Look through editors again and find the most proper one
var editor = this.editorForNote(this.note);
this.selectedEditor = editor;
});
this.noteDidChange = function(note, oldNote) {
this.setNote(note, oldNote);
this.reloadComponentContext();
@@ -88,6 +114,7 @@ angular.module('app')
this.setNote = function(note, oldNote) {
this.showExtensions = false;
this.showMenu = false;
this.noteStatus = null;
this.loadTagsString();
let onReady = () => {
@@ -122,32 +149,13 @@ angular.module('app')
if(oldNote && oldNote != note) {
if(oldNote.hasChanges) {
this.save()(oldNote, null);
this.saveNote(oldNote);
} else if(oldNote.dummy) {
this.remove()(oldNote);
}
}
}
// Observe editor changes to see if the current note should update its editor
modelManager.addItemSyncObserver("component-manager", "SN|Component", (allItems, validItems, deletedItems, source) => {
if(!this.note) { return; }
var editors = allItems.filter(function(item) {
return item.isEditor();
});
// If no editors have changed
if(editors.length == 0) {
return;
}
// Look through editors again and find the most proper one
var editor = this.editorForNote(this.note);
this.selectedEditor = editor;
});
this.editorForNote = function(note) {
let editors = componentManager.componentsForArea("editor-editor");
for(var editor of editors) {
@@ -207,7 +215,7 @@ angular.module('app')
}
// Lots of dirtying can happen above, so we'll sync
syncManager.sync("editorMenuOnSelect");
syncManager.sync();
}.bind(this)
this.hasAvailableExtensions = function() {
@@ -235,10 +243,9 @@ angular.module('app')
var statusTimeout;
this.saveNote = function($event) {
this.save = function(dontUpdateClientModified) {
var note = this.note;
note.dummy = false;
// Make sure the note exists. A safety measure, as toggling between tags triggers deletes for dummy notes.
// Race conditions have been fixed, but we'll keep this here just in case.
if(!modelManager.findItem(note.uuid)) {
@@ -246,29 +253,51 @@ angular.module('app')
return;
}
this.save()(note, function(success){
this.saveNote(note, (success) => {
if(success) {
if(statusTimeout) $timeout.cancel(statusTimeout);
statusTimeout = $timeout(function(){
statusTimeout = $timeout(() => {
this.showAllChangesSavedStatus();
}.bind(this), 200)
}, 200)
} else {
if(statusTimeout) $timeout.cancel(statusTimeout);
statusTimeout = $timeout(function(){
statusTimeout = $timeout(() => {
this.showErrorStatus();
}.bind(this), 200)
}, 200)
}
}.bind(this));
}, dontUpdateClientModified);
}
this.saveNote = function(note, callback, dontUpdateClientModified) {
// We don't want to update the client modified date if toggling lock for note.
note.setDirty(true, dontUpdateClientModified);
syncManager.sync().then((response) => {
if(response && response.error) {
if(!this.didShowErrorAlert) {
this.didShowErrorAlert = true;
alert("There was an error saving your note. Please try again.");
}
$timeout(() => {
callback && callback(false);
})
} else {
note.hasChanges = false;
$timeout(() => {
callback && callback(true);
});
}
})
}
this.saveTitle = function($event) {
$event.target.blur();
this.saveNote($event);
this.save($event);
this.focusEditor();
}
var saveTimeout;
this.changesMade = function(bypassDebouncer = false) {
this.changesMade = function({bypassDebouncer, dontUpdateClientModified} = {}) {
this.note.dummy = false;
/* In the case of keystrokes, saving should go through a debouncer to avoid frequent calls.
@@ -283,10 +312,10 @@ angular.module('app')
if(saveTimeout) $timeout.cancel(saveTimeout);
if(statusTimeout) $timeout.cancel(statusTimeout);
saveTimeout = $timeout(function(){
saveTimeout = $timeout(() => {
this.showSavingStatus();
this.saveNote();
}.bind(this), delay)
this.save(dontUpdateClientModified);
}, delay)
}
this.showSavingStatus = function() {
@@ -317,7 +346,8 @@ angular.module('app')
}
this.contentChanged = function() {
this.changesMade();
// content changes should bypass manual debouncer as we use the built in ng-model-options debouncer
this.changesMade({bypassDebouncer: true});
}
this.nameChanged = function() {
@@ -358,12 +388,13 @@ angular.module('app')
this.toggleLockNote = function() {
this.note.setAppDataItem("locked", !this.note.locked);
this.changesMade();
var dontUpdateClientModified = true;
this.changesMade({dontUpdateClientModified});
}
this.toggleArchiveNote = function() {
this.note.setAppDataItem("archived", !this.note.archived);
this.changesMade(true);
this.changesMade({bypassDebouncer: true});
$rootScope.$broadcast("noteArchived");
}
@@ -518,7 +549,7 @@ angular.module('app')
// We also check if the selectedEditor is active. If it's inactive, we want to treat it as an external reference wishing to deactivate this editor (i.e componentView)
if(this.selectedEditor && this.selectedEditor == component && component.active == false) {
this.selectedEditor = null;
}
}
else if(this.selectedEditor) {
if(this.selectedEditor.active) {
if(component.isExplicitlyEnabledForItem(this.note)) {
@@ -568,7 +599,7 @@ angular.module('app')
// Currently extensions are not notified of association until a full server sync completes.
// We need a better system for this, but for now, we'll manually notify observers
modelManager.notifySyncObserversOfModels([this.note], ModelManager.MappingSourceLocalSaved);
modelManager.notifySyncObserversOfModels([tag], SFModelManager.MappingSourceLocalSaved);
}
}
@@ -578,7 +609,7 @@ angular.module('app')
// Currently extensions are not notified of association until a full server sync completes.
// We need a better system for this, but for now, we'll manually notify observers
modelManager.notifySyncObserversOfModels([this.note], ModelManager.MappingSourceLocalSaved);
modelManager.notifySyncObserversOfModels([tag], SFModelManager.MappingSourceLocalSaved);
}
else if(action === "save-items" || action === "save-success" || action == "save-error") {

View File

@@ -10,7 +10,7 @@ angular.module('app')
bindToController: true,
link:function(scope, elem, attrs, ctrl) {
scope.$on("sync:updated_token", function(){
scope.$on("sync:completed", function(){
ctrl.syncUpdated();
ctrl.findErrors();
ctrl.updateOfflineStatus();
@@ -25,7 +25,10 @@ angular.module('app')
.controller('FooterCtrl', function ($rootScope, authManager, modelManager, $timeout, dbManager,
syncManager, storageManager, passcodeManager, componentManager, singletonManager, nativeExtManager) {
this.securityUpdateAvailable = authManager.checkForSecurityUpdate();
authManager.checkForSecurityUpdate().then((available) => {
this.securityUpdateAvailable = available;
})
$rootScope.$on("security-update-status-changed", () => {
this.securityUpdateAvailable = authManager.securityUpdateAvailable;
})
@@ -94,16 +97,16 @@ angular.module('app')
this.refreshData = function() {
this.isRefreshing = true;
syncManager.sync((response) => {
syncManager.sync({force: true}).then((response) => {
$timeout(function(){
this.isRefreshing = false;
}.bind(this), 200)
if(response && response.error) {
alert("There was an error syncing. Please try again. If all else fails, log out and log back in.");
alert("There was an error syncing. Please try again. If all else fails, try signing out and signing back in.");
} else {
this.syncUpdated();
}
}, {force: true}, "refreshData");
});
}
this.syncUpdated = function() {
@@ -133,8 +136,7 @@ angular.module('app')
this.rooms = [];
modelManager.addItemSyncObserver("room-bar", "SN|Component", (allItems, validItems, deletedItems, source) => {
var incomingRooms = allItems.filter((candidate) => {return candidate.area == "rooms"});
this.rooms = _.uniq(this.rooms.concat(incomingRooms)).filter((candidate) => {return !candidate.deleted});
this.rooms = modelManager.components.filter((candidate) => {return candidate.area == "rooms" && !candidate.deleted});
});
componentManager.registerHandler({identifier: "roomBar", areas: ["rooms", "modal"], activationHandler: (component) => {

View File

@@ -26,7 +26,7 @@ angular.module('app')
/* Used to avoid circular dependencies where syncManager cannot be imported but rootScope can */
$rootScope.sync = function(source) {
syncManager.sync("$rootScope.sync - " + source);
syncManager.sync();
}
$rootScope.lockApplication = function() {
@@ -66,28 +66,56 @@ angular.module('app')
dbManager.openDatabase(null, function() {
// new database, delete syncToken so that items can be refetched entirely from server
syncManager.clearSyncToken();
syncManager.sync("openDatabase");
syncManager.sync();
})
}
function initiateSync() {
authManager.loadInitialData();
syncManager.loadLocalItems(function(items) {
$scope.allTag.didLoad = true;
$scope.$apply();
$rootScope.$broadcast("initial-data-loaded");
syncManager.setKeyRequestHandler(async () => {
let offline = authManager.offline();
let auth_params = offline ? passcodeManager.passcodeAuthParams() : await authManager.getAuthParams();
let keys = offline ? passcodeManager.keys() : await authManager.keys();
return {
keys: keys,
offline: offline,
auth_params: auth_params
}
});
syncManager.sync("initiateSync");
syncManager.addEventHandler((syncEvent, data) => {
$rootScope.$broadcast(syncEvent, data || {});
if(syncEvent == "sync-session-invalid") {
alert("Your session has expired. New changes will not be pulled in. Please sign out and sign back in to refresh your session.");
}
});
syncManager.loadLocalItems().then(() => {
$timeout(() => {
$scope.allTag.didLoad = true;
$rootScope.$broadcast("initial-data-loaded");
})
syncManager.sync();
// refresh every 30s
setInterval(function () {
syncManager.sync("timer");
syncManager.sync();
}, 30000);
});
authManager.addEventHandler((event) => {
if(event == SFAuthManager.DidSignOutEvent) {
modelManager.handleSignout();
syncManager.handleSignout();
}
})
}
function loadAllTag() {
var allTag = new Tag({all: true, title: "All"});
var allTag = new SNTag({content: {title: "All"}});
allTag.all = true;
allTag.needsLoad = true;
$scope.allTag = allTag;
$scope.tags = modelManager.tags;
@@ -95,9 +123,13 @@ angular.module('app')
}
function loadArchivedTag() {
var archiveTag = new Tag({archiveTag: true, title: "Archived"});
var archiveTag = new SNSmartTag({content: {title: "Archived", predicate: ["archived", "=", true]}});
Object.defineProperty(archiveTag, "notes", {
get: () => {
return modelManager.notesMatchingPredicate(archiveTag.content.predicate);
}
});
$scope.archiveTag = archiveTag;
$scope.archiveTag.notes = modelManager.notes;
}
/*
@@ -114,7 +146,6 @@ angular.module('app')
}
for(var tagToRemove of toRemove) {
note.removeItemAsRelationship(tagToRemove);
tagToRemove.removeItemAsRelationship(note);
tagToRemove.setDirty(true);
}
@@ -128,25 +159,27 @@ angular.module('app')
}
for(var tag of tags) {
modelManager.createRelationshipBetweenItems(note, tag);
tag.addItemAsRelationship(note);
tag.setDirty(true);
}
note.setDirty(true);
syncManager.sync("updateTagsForNote");
syncManager.sync();
}
/*
Tags Ctrl Callbacks
*/
$scope.tagsWillMakeSelection = function(tag) {
}
$scope.tagsSelectionMade = function(tag) {
// If a tag is selected twice, then the needed dummy note is removed.
// So we perform this check.
if($scope.selectedTag && tag && $scope.selectedTag.uuid == tag.uuid) {
return;
}
if($scope.selectedNote && $scope.selectedNote.dummy) {
modelManager.removeItemLocally($scope.selectedNote);
$scope.selectedNote = null;
}
$scope.selectedTag = tag;
@@ -162,8 +195,7 @@ angular.module('app')
return;
}
tag.setDirty(true);
syncManager.sync(callback, null, "tagsSave");
$rootScope.$broadcast("tag-changed");
syncManager.sync().then(callback);
modelManager.resortTag(tag);
}
@@ -174,11 +206,10 @@ angular.module('app')
$scope.removeTag = function(tag) {
if(confirm("Are you sure you want to delete this tag? Note: deleting a tag will not delete its notes.")) {
modelManager.setItemToBeDeleted(tag);
// if no more notes, delete tag
syncManager.sync(function(){
syncManager.sync().then(() => {
// force scope tags to update on sub directives
$scope.safeApply();
}, null, "removeTag");
});
}
}
@@ -189,8 +220,9 @@ angular.module('app')
$scope.notesAddNew = function(note) {
modelManager.addItem(note);
if(!$scope.selectedTag.all && !$scope.selectedTag.archiveTag) {
modelManager.createRelationshipBetweenItems($scope.selectedTag, note);
if(!$scope.selectedTag.all && !$scope.selectedTag.isSmartTag()) {
$scope.selectedTag.addItemAsRelationship(note);
$scope.selectedTag.setDirty(true);
}
}
@@ -198,27 +230,6 @@ angular.module('app')
Shared Callbacks
*/
$scope.saveNote = function(note, callback) {
note.setDirty(true);
syncManager.sync(function(response){
if(response && response.error) {
if(!$scope.didShowErrorAlert) {
$scope.didShowErrorAlert = true;
alert("There was an error saving your note. Please try again.");
}
if(callback) {
callback(false);
}
} else {
note.hasChanges = false;
if(callback) {
callback(true);
}
}
}, null, "saveNote")
}
$scope.safeApply = function(fn) {
var phase = this.$root.$$phase;
if(phase == '$apply' || phase == '$digest')
@@ -234,7 +245,6 @@ angular.module('app')
}
$scope.deleteNote = function(note) {
modelManager.setItemToBeDeleted(note);
if(note == $scope.selectedNote) {
@@ -247,7 +257,7 @@ angular.module('app')
return;
}
syncManager.sync(function(){
syncManager.sync().then(() => {
if(authManager.offline()) {
// when deleting items while ofline, we need to explictly tell angular to refresh UI
setTimeout(function () {
@@ -255,9 +265,11 @@ angular.module('app')
$scope.safeApply();
}, 50);
} else {
$rootScope.notifyDelete();
$timeout(() => {
$rootScope.notifyDelete();
});
}
}, null, "deleteNote");
});
}
@@ -268,25 +280,24 @@ angular.module('app')
return $location.search()[key];
}
function autoSignInFromParams() {
async function autoSignInFromParams() {
var server = urlParam("server");
var email = urlParam("email");
var pw = urlParam("pw");
if(!authManager.offline()) {
// check if current account
if(syncManager.serverURL === server && authManager.user.email === email) {
if(await syncManager.getServerURL() === server && authManager.user.email === email) {
// already signed in, return
return;
} else {
// sign out
authManager.signOut();
syncManager.destroyLocalData(function(){
authManager.signout(true).then(() => {
window.location.reload();
})
});
}
} else {
authManager.login(server, email, pw, false, false, {}, function(response){
authManager.login(server, email, pw, false, false, {}).then((response) => {
window.location.reload();
})
}

View File

@@ -8,12 +8,15 @@ class LockScreen {
};
}
controller($scope, passcodeManager, authManager, syncManager) {
controller($scope, passcodeManager, authManager, syncManager, storageManager) {
'ngInject';
$scope.formData = {};
$scope.submitPasscodeForm = function() {
if(!$scope.formData.passcode || $scope.formData.passcode.length == 0) {
return;
}
passcodeManager.unlock($scope.formData.passcode, (success) => {
if(!success) {
alert("Invalid passcode. Please try again.");
@@ -33,8 +36,7 @@ class LockScreen {
return;
}
authManager.signOut();
syncManager.destroyLocalData(function(){
authManager.signout(true).then(() => {
window.location.reload();
})
}

View File

@@ -14,7 +14,7 @@ angular.module('app')
bindToController: true,
link:function(scope, elem, attrs, ctrl) {
scope.$watch('ctrl.tag', function(tag, oldTag){
scope.$watch('ctrl.tag', (tag, oldTag) => {
if(tag) {
if(tag.needsLoad) {
scope.$watch('ctrl.tag.didLoad', function(didLoad){
@@ -133,12 +133,14 @@ angular.module('app')
base += " Title";
}
if(this.showArchived && (!this.tag || !this.tag.archiveTag)) {
base += " | + Archived"
}
if(this.hidePinned) {
base += " | Pinned"
if(!this.tag || !this.tag.isSmartTag()) {
// These rules don't apply for smart tags
if(this.showArchived) {
base += " | + Archived"
}
if(this.hidePinned) {
base += " | Pinned"
}
}
return base;
@@ -172,11 +174,11 @@ angular.module('app')
}
this.setNotes = function(notes) {
notes.forEach(function(note){
notes.forEach((note) => {
note.visible = true;
})
var createNew = notes.length == 0;
var createNew = this.visibleNotes().length == 0;
this.selectFirstNote(createNew);
}
@@ -201,6 +203,7 @@ angular.module('app')
this.createNewNote();
return;
}
this.selectedNote = note;
note.conflict_of = null; // clear conflict
this.selectionMade()(note);
@@ -216,9 +219,12 @@ angular.module('app')
}
this.createNewNote = function() {
var title = "New Note" + (this.tag.notes ? (" " + (this.tag.notes.length + 1)) : "");
this.newNote = modelManager.createItem({content_type: "Note", dummy: true, text: ""});
this.newNote.title = title;
// The "Note X" counter is based off this.tag.notes.length, but sometimes, what you see in the list is only a subset.
// We can use this.visibleNotes().length, but that only accounts for non-paginated results, so first 15 or so.
var title = "Note" + (this.tag.notes ? (" " + (this.tag.notes.length + 1)) : "");
let newNote = modelManager.createItem({content_type: "Note", content: {text: "", title: title}});
newNote.dummy = true;
this.newNote = newNote;
this.selectNote(this.newNote);
this.addNew()(this.newNote);
}
@@ -226,7 +232,16 @@ angular.module('app')
this.noteFilter = {text : ''};
this.filterNotes = function(note) {
if((note.archived && !this.showArchived && !this.tag.archiveTag) || (note.pinned && this.hidePinned)) {
var canShowArchived = false, canShowPinned = true;
var isSmartTag = this.tag.isSmartTag();
if(isSmartTag) {
canShowArchived = this.tag.isReferencingArchivedNotes();
} else {
canShowArchived = this.showArchived;
canShowPinned = !this.hidePinned;
}
if((note.archived && !canShowArchived) || (note.pinned && !canShowPinned)) {
note.visible = false;
return note.visible;
}
@@ -241,10 +256,6 @@ angular.module('app')
note.visible = matchesTitle || matchesBody;
}
if(this.tag.archiveTag) {
note.visible = note.visible && note.archived;
}
return note.visible;
}.bind(this)

View File

@@ -5,7 +5,6 @@ angular.module('app')
scope: {
addNew: "&",
selectionMade: "&",
willSelect: "&",
save: "&",
tags: "=",
allTag: "=",
@@ -66,13 +65,21 @@ angular.module('app')
return null;
}.bind(this), actionHandler: function(component, action, data){
if(action === "select-item") {
var tag = modelManager.findItem(data.item.uuid);
if(tag) {
if(data.item.content_type == "Tag") {
var tag = modelManager.findItem(data.item.uuid);
if(tag) {
this.selectTag(tag);
}
} else if(data.item.content_type == "SN|SmartTag") {
var tag = new SNSmartTag(data.item);
Object.defineProperty(tag, "notes", {
get: () => {
return modelManager.notesMatchingPredicate(tag.content.predicate);
}
});
this.selectTag(tag);
}
}
else if(action === "clear-selection") {
} else if(action === "clear-selection") {
this.selectTag(this.allTag);
}
}.bind(this)});
@@ -93,7 +100,6 @@ angular.module('app')
}
this.selectTag = function(tag) {
this.willSelect()(tag);
this.selectedTag = tag;
tag.conflict_of = null; // clear conflict
this.selectionMade()(tag);
@@ -152,10 +158,11 @@ angular.module('app')
this.selectedDeleteTag = function(tag) {
this.removeTag()(tag);
this.selectTag(this.allTag);
}
this.noteCount = function(tag) {
var validNotes = Note.filterDummyNotes(tag.notes).filter(function(note){
var validNotes = SNNote.filterDummyNotes(tag.notes).filter(function(note){
return !note.archived;
});
return validNotes.length;

View File

@@ -9,14 +9,23 @@ class AccountMenu {
};
}
controller($scope, $rootScope, authManager, modelManager, syncManager, dbManager, passcodeManager,
$timeout, storageManager, $compile, archiveManager) {
controller($scope, $rootScope, authManager, modelManager, syncManager, storageManager, dbManager, passcodeManager,
$timeout, $compile, archiveManager) {
'ngInject';
$scope.formData = {mergeLocal: true, url: syncManager.serverURL, ephemeral: false};
$scope.formData = {mergeLocal: true, ephemeral: false};
$scope.user = authManager.user;
$scope.server = syncManager.serverURL;
$scope.securityUpdateAvailable = authManager.checkForSecurityUpdate();
syncManager.getServerURL().then((url) => {
$timeout(() => {
$scope.server = url;
$scope.formData.url = url;
})
})
authManager.checkForSecurityUpdate().then((available) => {
$scope.securityUpdateAvailable = available;
})
$scope.close = function() {
$timeout(() => {
@@ -59,40 +68,35 @@ class AccountMenu {
$scope.formData.status = "Generating Login Keys...";
$timeout(function(){
authManager.login($scope.formData.url, $scope.formData.email, $scope.formData.user_password,
$scope.formData.ephemeral, $scope.formData.strictSignin, extraParams,
(response) => {
if(!response || response.error) {
$scope.formData.ephemeral, $scope.formData.strictSignin, extraParams).then((response) => {
$timeout(() => {
if(!response || response.error) {
syncManager.unlockSyncing();
syncManager.unlockSyncing();
$scope.formData.status = null;
var error = response ? response.error : {message: "An unknown error occured."}
$scope.formData.status = null;
var error = response ? response.error : {message: "An unknown error occured."}
// MFA Error
if(error.tag == "mfa-required" || error.tag == "mfa-invalid") {
$timeout(() => {
// MFA Error
if(error.tag == "mfa-required" || error.tag == "mfa-invalid") {
$scope.formData.showLogin = false;
$scope.formData.mfa = error;
})
}
// General Error
else {
$timeout(() => {
}
// General Error
else {
$scope.formData.showLogin = true;
$scope.formData.mfa = null;
if(error.message) { alert(error.message); }
})
}
}
}
// Success
else {
$scope.onAuthSuccess(() => {
syncManager.unlockSyncing();
syncManager.sync("onLogin");
});
}
// Success
else {
$scope.onAuthSuccess(() => {
syncManager.unlockSyncing();
syncManager.sync();
});
}
})
});
})
}
@@ -108,16 +112,18 @@ class AccountMenu {
$scope.formData.status = "Generating Account Keys...";
$timeout(function(){
authManager.register($scope.formData.url, $scope.formData.email, $scope.formData.user_password, $scope.formData.ephemeral ,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(() => {
syncManager.sync("onRegister");
});
}
authManager.register($scope.formData.url, $scope.formData.email, $scope.formData.user_password, $scope.formData.ephemeral).then((response) => {
$timeout(() => {
if(!response || response.error) {
$scope.formData.status = null;
var error = response ? response.error : {message: "An unknown error occured."}
alert(error.message);
} else {
$scope.onAuthSuccess(() => {
syncManager.sync();
});
}
})
});
})
}
@@ -145,8 +151,8 @@ class AccountMenu {
$scope.clearDatabaseAndRewriteAllItems(true, block);
}
else {
modelManager.resetLocalMemory();
storageManager.clearAllModels(function(){
modelManager.removeAllItemsFromMemory();
storageManager.clearAllModels().then(() => {
block();
})
}
@@ -163,10 +169,10 @@ class AccountMenu {
// clearAllModels will remove data from backing store, but not from working memory
// See: https://github.com/standardnotes/desktop/issues/131
$scope.clearDatabaseAndRewriteAllItems = function(alternateUuids, callback) {
storageManager.clearAllModels(() => {
syncManager.markAllItemsDirtyAndSaveOffline(function(){
storageManager.clearAllModels().then(() => {
syncManager.markAllItemsDirtyAndSaveOffline(alternateUuids).then(() => {
callback && callback();
}, alternateUuids)
})
});
}
@@ -175,8 +181,7 @@ class AccountMenu {
return;
}
authManager.signOut();
syncManager.destroyLocalData(function(){
authManager.signout(true).then(() => {
window.location.reload();
})
}
@@ -201,7 +206,8 @@ class AccountMenu {
// Update UI before showing alert
setTimeout(function () {
if(!response) {
// Response can be null if syncing offline
if(response && response.error) {
alert("There was an error importing your data. Please try again.");
} else {
if(errorCount > 0) {
@@ -243,24 +249,19 @@ class AccountMenu {
}
$scope.importJSONData = function(data, password, callback) {
var onDataReady = function(errorCount) {
var items = modelManager.mapResponseItemsToLocalModels(data.items, ModelManager.MappingSourceFileImport);
items.forEach(function(item){
item.setDirty(true, true);
item.deleted = false;
item.markAllReferencesDirty(true);
var onDataReady = (errorCount) => {
var items = modelManager.importItems(data.items);
for(var item of items) {
// We don't want to activate any components during import process in case of exceptions
// breaking up the import proccess
if(item.content_type == "SN|Component") {
item.active = false;
}
})
if(item.content_type == "SN|Component") { item.active = false; }
}
syncManager.sync((response) => {
syncManager.sync({additionalFields: ["created_at", "updated_at"]}).then((response) => {
// Response can be null if syncing offline
callback(response, errorCount);
}, {additionalFields: ["created_at", "updated_at"]}, "importJSONData");
}.bind(this)
});
}
if(data.auth_params) {
SFJS.crypto.computeEncryptionKeysForUser(password, data.auth_params).then((keys) => {

View File

@@ -11,8 +11,6 @@ class ActionsMenu {
controller($scope, modelManager, actionsManager) {
'ngInject';
$scope.renderData = {};
$scope.extensions = actionsManager.extensions.sort((a, b) => {return a.name.toLowerCase() > b.name.toLowerCase()});
for(let ext of $scope.extensions) {
@@ -51,11 +49,7 @@ class ActionsMenu {
switch (action.verb) {
case "render": {
var item = response.item;
if(item.content_type == "Note") {
$scope.renderData.title = item.title;
$scope.renderData.text = item.text;
$scope.renderData.showRenderModal = true;
}
actionsManager.presentRevisionPreviewModal(item.uuid, item.content);
}
}
}
@@ -77,8 +71,6 @@ class ActionsMenu {
}
})
}
}
}

View File

@@ -18,19 +18,30 @@ class ComponentView {
$scope.el = el;
$scope.identifier = "component-view-" + Math.random();
$scope.componentValid = true;
// console.log("Registering handler", $scope.identifier, $scope.component.name);
this.componentManager.registerHandler({identifier: $scope.identifier, areas: [$scope.component.area], activationHandler: (component) => {
// activationHandlers may be called multiple times, design below to be idempotent
if(component.active) {
this.timeout(() => {
var iframe = this.componentManager.iframeForComponent(component);
if(iframe) {
iframe.onload = function() {
this.componentManager.registerComponentWindow(component, iframe.contentWindow);
}.bind(this);
}
});
$scope.loading = true;
let iframe = this.componentManager.iframeForComponent(component);
if(iframe) {
// begin loading error handler. If onload isn't called in x seconds, display an error
if($scope.loadTimeout) { this.timeout.cancel($scope.loadTimeout);}
$scope.loadTimeout = this.timeout(() => {
if($scope.loading) {
$scope.issueLoading = true;
}
}, 3500)
iframe.onload = function(event) {
this.timeout.cancel($scope.loadTimeout);
$scope.loading = false;
$scope.issueLoading = false;
this.componentManager.registerComponentWindow(component, iframe.contentWindow);
}.bind(this);
}
}
},
actionHandler: (component, action, data) => {
@@ -97,9 +108,9 @@ class ComponentView {
offlineRestricted = component.offlineOnly && !isDesktopApplication();
urlError =
(!isDesktopApplication() && (!component.url && !component.hosted_url))
(!isDesktopApplication() && (!component.hasValidHostedUrl()))
||
(isDesktopApplication() && (!component.local_url && !component.url && !component.hosted_url))
(isDesktopApplication() && (!component.local_url && !component.hasValidHostedUrl()))
expired = component.valid_until && component.valid_until <= new Date();
@@ -112,7 +123,8 @@ class ComponentView {
if($scope.componentValid !== previouslyValid) {
if($scope.componentValid) {
componentManager.activateComponent(component, true);
// We want to reload here, rather than `activateComponent`, because the component will already have attempted to been activated.
componentManager.reloadComponent(component, true);
}
}
@@ -129,7 +141,7 @@ class ComponentView {
$scope.getUrl = function() {
var url = componentManager.urlForComponent($scope.component);
$scope.component.runningLocally = (url !== $scope.component.url) && url !== ($scope.component.hosted_url);
$scope.component.runningLocally = (url == $scope.component.local_url);
return url;
}

View File

@@ -53,7 +53,7 @@ class EditorMenu {
component.setAppDataItem("defaultEditor", true);
component.setDirty(true);
syncManager.sync("makeEditorDefault");
syncManager.sync();
$scope.defaultEditor = component;
}
@@ -61,7 +61,7 @@ class EditorMenu {
$scope.removeEditorDefault = function(component) {
component.setAppDataItem("defaultEditor", false);
component.setDirty(true);
syncManager.sync("removeEditorDefault");
syncManager.sync();
$scope.defaultEditor = null;
}

View File

@@ -7,7 +7,7 @@ class MenuRow {
this.scope = {
circle: "=",
label: "=",
subtite: "=",
subtitle: "=",
hasButton: "=",
buttonText: "=",
buttonClass: "=",

View File

@@ -147,7 +147,7 @@ class PasswordWizard {
}
}
$scope.validateCurrentPassword = function(callback) {
$scope.validateCurrentPassword = async function(callback) {
let currentPassword = $scope.formData.currentPassword;
let newPass = $scope.securityUpdate ? currentPassword : $scope.formData.newPassword;
@@ -173,10 +173,10 @@ class PasswordWizard {
}
// Ensure value for current password matches what's saved
let authParams = authManager.getAuthParams();
let authParams = await authManager.getAuthParams();
let password = $scope.formData.currentPassword;
SFJS.crypto.computeEncryptionKeysForUser(password, authParams).then((keys) => {
let success = keys.mk === authManager.keys().mk;
SFJS.crypto.computeEncryptionKeysForUser(password, authParams).then(async (keys) => {
let success = keys.mk === (await authManager.keys()).mk;
if(success) {
this.currentServerPw = keys.pw;
} else {
@@ -188,8 +188,8 @@ class PasswordWizard {
$scope.resyncData = function(callback) {
modelManager.setAllItemsDirty();
syncManager.sync((response) => {
if(response.error) {
syncManager.sync().then((response) => {
if(!response || response.error) {
alert(FailedSyncMessage)
$timeout(() => callback(false));
} else {
@@ -198,27 +198,25 @@ class PasswordWizard {
});
}
$scope.processPasswordChange = function(callback) {
$scope.processPasswordChange = async function(callback) {
let newUserPassword = $scope.securityUpdate ? $scope.formData.currentPassword : $scope.formData.newPassword;
let currentServerPw = this.currentServerPw;
SFJS.crypto.generateInitialKeysAndAuthParamsForUser(authManager.user.email, newUserPassword).then((results) => {
let newKeys = results.keys;
let newAuthParams = results.authParams;
let results = await SFJS.crypto.generateInitialKeysAndAuthParamsForUser(authManager.user.email, newUserPassword);
let newKeys = results.keys;
let newAuthParams = results.authParams;
// perform a sync beforehand to pull in any last minutes changes before we change the encryption key (and thus cant decrypt new changes)
syncManager.sync((response) => {
authManager.changePassword(currentServerPw, newKeys, newAuthParams, (response) => {
if(response.error) {
alert(response.error.message ? response.error.message : "There was an error changing your password. Please try again.");
$timeout(() => callback(false));
} else {
$timeout(() => callback(true));
}
})
}, null, "submitPasswordChange")
});
// perform a sync beforehand to pull in any last minutes changes before we change the encryption key (and thus cant decrypt new changes)
let syncResponse = await syncManager.sync();
authManager.changePassword(await syncManager.getServerURL(), authManager.user.email, currentServerPw, newKeys, newAuthParams).then((response) => {
if(response.error) {
alert(response.error.message ? response.error.message : "There was an error changing your password. Please try again.");
$timeout(() => callback(false));
} else {
$timeout(() => callback(true));
}
})
}
}

View File

@@ -0,0 +1,48 @@
class RevisionPreviewModal {
constructor() {
this.restrict = "E";
this.templateUrl = "directives/revision-preview-modal.html";
this.scope = {
uuid: "=",
content: "="
};
}
link($scope, el, attrs) {
$scope.dismiss = function() {
el.remove();
}
}
controller($scope, modelManager, syncManager) {
'ngInject';
$scope.restore = function(asCopy) {
if(!asCopy && !confirm("Are you sure you want to replace the current note's contents with what you see in this preview?")) {
return;
}
var item;
if(asCopy) {
var contentCopy = Object.assign({}, $scope.content);
if(contentCopy.title) { contentCopy.title += " (copy)"; }
item = modelManager.createItem({content_type: "Note", content: contentCopy});
modelManager.addItem(item);
} else {
var uuid = $scope.uuid;
item = modelManager.findItem(uuid);
item.content = Object.assign({}, $scope.content);
modelManager.mapResponseItemsToLocalModels([item], SFModelManager.MappingSourceRemoteActionRetrieved);
}
item.setDirty(true);
syncManager.sync();
$scope.dismiss();
}
}
}
angular.module('app').directive('revisionPreviewModal', () => new RevisionPreviewModal);

View File

@@ -0,0 +1,81 @@
class SessionHistoryMenu {
constructor() {
this.restrict = "E";
this.templateUrl = "directives/session-history-menu.html";
this.scope = {
item: "="
};
}
controller($scope, modelManager, sessionHistory, actionsManager, $timeout) {
'ngInject';
$scope.diskEnabled = sessionHistory.diskEnabled;
$scope.autoOptimize = sessionHistory.autoOptimize;
$scope.reloadHistory = function() {
$scope.history = sessionHistory.historyForItem($scope.item);
}
$scope.reloadHistory();
$scope.openRevision = function(revision) {
actionsManager.presentRevisionPreviewModal(revision.item.uuid, revision.item.content);
}
$scope.classForRevision = function(revision) {
var vector = revision.operationVector();
if(vector == 0) {
return "default";
} else if(vector == 1) {
return "success";
} else if(vector == -1) {
return "danger";
}
}
$scope.clearItemHistory = function() {
if(!confirm("Are you sure you want to delete the local session history for this note?")) {
return;
}
sessionHistory.clearItemHistory($scope.item).then(() => {
$timeout(() => {
$scope.reloadHistory();
})
});
}
$scope.clearAllHistory = function() {
if(!confirm("Are you sure you want to delete the local session history for all notes?")) {
return;
}
sessionHistory.clearAllHistory().then(() => {
$timeout(() => {
$scope.reloadHistory();
})
});
}
$scope.toggleDiskSaving = function() {
sessionHistory.toggleDiskSaving().then(() => {
$timeout(() => {
$scope.diskEnabled = sessionHistory.diskEnabled;
})
});
}
$scope.toggleAutoOptimize = function() {
sessionHistory.toggleAutoOptimize().then(() => {
$timeout(() => {
$scope.autoOptimize = sessionHistory.autoOptimize;
})
});
}
}
}
angular.module('app').directive('sessionHistoryMenu', () => new SessionHistoryMenu);

View File

@@ -1,3 +1,6 @@
// reuse
var locale, formatter;
angular.module('app')
.filter('appDate', function ($filter) {
return function (input) {
@@ -6,6 +9,20 @@ angular.module('app')
})
.filter('appDateTime', function ($filter) {
return function (input) {
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
if (!formatter) {
locale = (navigator.languages && navigator.languages.length) ? navigator.languages[0] : navigator.language;
formatter = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'numeric',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
return formatter.format(input);
} else {
return input ? $filter('date')(new Date(input), 'MM/dd/yyyy h:mm a') : '';
};
}
}
});

View File

@@ -35,8 +35,9 @@ angular.module('app')
}
items = items || [];
return items.sort(function(a, b){
var result = items.sort(function(a, b){
return sortValueFn(a, b);
})
return result;
};
});

View File

@@ -1,317 +0,0 @@
let AppDomain = "org.standardnotes.sn";
var dateFormatter;
class Item {
constructor(json_obj = {}) {
this.appData = {};
this.updateFromJSON(json_obj);
this.observers = [];
if(!this.uuid) {
this.uuid = SFJS.crypto.generateUUIDSync();
}
}
static sortItemsByDate(items) {
items.sort(function(a,b){
return new Date(b.created_at) - new Date(a.created_at);
});
}
get contentObject() {
if(!this.content) {
return {};
}
if(this.content !== null && typeof this.content === 'object') {
// this is the case when mapping localStorage content, in which case the content is already parsed
return this.content;
}
try {
return JSON.parse(this.content);
} catch (e) {
console.log("Error parsing json", e, this);
return {};
}
}
updateFromJSON(json) {
_.merge(this, json);
if(this.created_at) {
this.created_at = new Date(this.created_at);
this.updated_at = new Date(this.updated_at);
} else {
this.created_at = new Date();
this.updated_at = new Date();
}
// Allows the getter to be re-invoked
this._client_updated_at = null;
if(json.content) {
this.mapContentToLocalProperties(this.contentObject);
} else if(json.deleted == true) {
this.handleDeletedContent();
}
}
refreshContentObject() {
// Before an item can be duplicated or cloned, we must update this.content (if it is an object) with the object's
// current physical properties, because updateFromJSON, which is what all new items must go through,
// will call this.mapContentToLocalProperties(this.contentObject), which may have stale values if not explicitly updated.
this.content = this.structureParams();
}
/* Allows the item to handle the case where the item is deleted and the content is null */
handleDeletedContent() {
// Subclasses can override
}
setDirty(dirty, dontUpdateClientDate) {
this.dirty = dirty;
// Allows the syncManager to check if an item has been marked dirty after a sync has been started
// This prevents it from clearing it as a dirty item after sync completion, if someone else has marked it dirty
// again after an ongoing sync.
if(!this.dirtyCount) { this.dirtyCount = 0; }
if(dirty) {
this.dirtyCount++;
} else {
this.dirtyCount = 0;
}
if(dirty && !dontUpdateClientDate) {
// Set the client modified date to now if marking the item as dirty
this.client_updated_at = new Date();
} else if(!this.hasRawClientUpdatedAtValue()) {
// copy updated_at
this.client_updated_at = new Date(this.updated_at);
}
if(dirty) {
this.notifyObserversOfChange();
}
}
markAllReferencesDirty(dontUpdateClientDate) {
this.allReferencedObjects().forEach(function(reference){
reference.setDirty(true, dontUpdateClientDate);
})
}
addObserver(observer, callback) {
if(!_.find(this.observers, observer)) {
this.observers.push({observer: observer, callback: callback});
}
}
removeObserver(observer) {
_.remove(this.observers, {observer: observer})
}
notifyObserversOfChange() {
for(var observer of this.observers) {
observer.callback(this);
}
}
mapContentToLocalProperties(contentObj) {
if(contentObj.appData) {
this.appData = contentObj.appData;
}
if(!this.appData) { this.appData = {}; }
}
createContentJSONFromProperties() {
return this.structureParams();
}
referenceParams() {
// must override
}
structureParams() {
return {
references: this.referenceParams(),
appData: this.appData
}
}
addItemAsRelationship(item) {
// must override
}
removeItemAsRelationship(item) {
// must override
}
isBeingRemovedLocally() {
}
removeAndDirtyAllRelationships() {
// must override
this.setDirty(true);
}
removeReferencesNotPresentIn(references) {
}
mergeMetadataFromItem(item) {
_.merge(this, _.omit(item, ["content"]));
}
informReferencesOfUUIDChange(oldUUID, newUUID) {
// optional override
}
potentialItemOfInterestHasChangedItsUUID(newItem, oldUUID, newUUID) {
// optional override
}
allReferencedObjects() {
// must override
return [];
}
doNotEncrypt() {
return false;
}
/*
App Data
*/
setDomainDataItem(key, value, domain) {
var data = this.appData[domain];
if(!data) {
data = {}
}
data[key] = value;
this.appData[domain] = data;
}
getDomainDataItem(key, domain) {
var data = this.appData[domain];
if(data) {
return data[key];
} else {
return null;
}
}
setAppDataItem(key, value) {
this.setDomainDataItem(key, value, AppDomain);
}
getAppDataItem(key) {
return this.getDomainDataItem(key, AppDomain);
}
get pinned() {
return this.getAppDataItem("pinned");
}
get archived() {
return this.getAppDataItem("archived");
}
get locked() {
return this.getAppDataItem("locked");
}
hasRawClientUpdatedAtValue() {
return this.getAppDataItem("client_updated_at") != null;
}
get client_updated_at() {
if(!this._client_updated_at) {
var saved = this.getAppDataItem("client_updated_at");
if(saved) {
this._client_updated_at = new Date(saved);
} else {
this._client_updated_at = new Date(this.updated_at);
}
}
return this._client_updated_at;
}
set client_updated_at(date) {
this._client_updated_at = date;
this.setAppDataItem("client_updated_at", date);
}
/*
During sync conflicts, when determing whether to create a duplicate for an item, we can omit keys that have no
meaningful weight and can be ignored. For example, if one component has active = true and another component has active = false,
it would be silly to duplicate them, so instead we ignore this.
*/
keysToIgnoreWhenCheckingContentEquality() {
return [];
}
// Same as above, but keys inside appData[AppDomain]
appDataKeysToIgnoreWhenCheckingContentEquality() {
return ["client_updated_at"];
}
isItemContentEqualWith(otherItem) {
let omit = (obj, keys) => {
for(var key of keys) {
delete obj[key];
}
return obj;
}
var left = this.structureParams();
left.appData[AppDomain] = omit(left.appData[AppDomain], this.appDataKeysToIgnoreWhenCheckingContentEquality());
left = omit(left, this.keysToIgnoreWhenCheckingContentEquality());
var right = otherItem.structureParams();
right.appData[AppDomain] = omit(right.appData[AppDomain], otherItem.appDataKeysToIgnoreWhenCheckingContentEquality());
right = omit(right, otherItem.keysToIgnoreWhenCheckingContentEquality());
return JSON.stringify(left) === JSON.stringify(right)
}
/*
Dates
*/
createdAtString() {
return this.dateToLocalizedString(this.created_at);
}
updatedAtString() {
return this.dateToLocalizedString(this.client_updated_at);
}
dateToLocalizedString(date) {
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
if (!dateFormatter) {
var locale = (navigator.languages && navigator.languages.length) ? navigator.languages[0] : navigator.language;
dateFormatter = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: '2-digit',
weekday: 'long',
hour: '2-digit',
minute: '2-digit',
});
}
return dateFormatter.format(date);
} else {
// IE < 11, Safari <= 9.0.
// In English, this generates the string most similar to
// the toLocaleDateString() result above.
return date.toDateString() + ' ' + date.toLocaleTimeString();
}
}
}

View File

@@ -1,28 +0,0 @@
class Mfa extends Item {
constructor(json_obj) {
super(json_obj);
}
mapContentToLocalProperties(content) {
super.mapContentToLocalProperties(content)
this.serverContent = content;
}
structureParams() {
return _.merge(this.serverContent, super.structureParams());
}
toJSON() {
return {uuid: this.uuid}
}
get content_type() {
return "SF|MFA";
}
doNotEncrypt() {
return true;
}
}

View File

@@ -1,38 +0,0 @@
class ServerExtension extends Item {
constructor(json_obj) {
super(json_obj);
}
mapContentToLocalProperties(content) {
super.mapContentToLocalProperties(content)
this.url = content.url;
}
structureParams() {
// There was a bug with the way Base64 content was parsed in previous releases related to this item.
// The bug would not parse the JSON behind the base64 string and thus saved data in an invalid format.
// This is the line: https://github.com/standardnotes/web/commit/1ad0bf73d8e995b7588854f1b1e4e4a02303a42f#diff-15753bac364782a3a5876032bcdbf99aR76
// We'll remedy this for affected users by trying to parse the content string
if(typeof this.content !== 'object') {
try {
this.content = JSON.parse(this.content);
} catch (e) {}
}
var params = this.content || {};
_.merge(params, super.structureParams());
return params;
}
toJSON() {
return {uuid: this.uuid}
}
get content_type() {
return "SF|Extension";
}
doNotEncrypt() {
return true;
}
}

View File

@@ -1,145 +0,0 @@
class Component extends Item {
constructor(json_obj) {
// If making a copy of an existing component (usually during sign in if you have a component active in the session),
// which may have window set, you may get a cross-origin exception since you'll be trying to copy the window. So we clear it here.
json_obj.window = null;
super(json_obj);
if(!this.componentData) {
this.componentData = {};
}
if(!this.disassociatedItemIds) {
this.disassociatedItemIds = [];
}
if(!this.associatedItemIds) {
this.associatedItemIds = [];
}
}
mapContentToLocalProperties(content) {
super.mapContentToLocalProperties(content)
/* Legacy */
this.url = content.url || content.hosted_url;
/* New */
this.local_url = content.local_url;
this.hosted_url = content.hosted_url || content.url;
this.offlineOnly = content.offlineOnly;
if(content.valid_until) {
this.valid_until = new Date(content.valid_until);
}
this.name = content.name;
this.autoupdateDisabled = content.autoupdateDisabled;
this.package_info = content.package_info;
// the location in the view this component is located in. Valid values are currently tags-list, note-tags, and editor-stack`
this.area = content.area;
this.permissions = content.permissions;
if(!this.permissions) {
this.permissions = [];
}
this.active = content.active;
// custom data that a component can store in itself
this.componentData = content.componentData || {};
// items that have requested a component to be disabled in its context
this.disassociatedItemIds = content.disassociatedItemIds || [];
// items that have requested a component to be enabled in its context
this.associatedItemIds = content.associatedItemIds || [];
}
handleDeletedContent() {
super.handleDeletedContent();
this.active = false;
}
structureParams() {
var params = {
url: this.url,
hosted_url: this.hosted_url,
local_url: this.local_url,
valid_until: this.valid_until,
offlineOnly: this.offlineOnly,
name: this.name,
area: this.area,
package_info: this.package_info,
permissions: this.permissions,
active: this.active,
autoupdateDisabled: this.autoupdateDisabled,
componentData: this.componentData,
disassociatedItemIds: this.disassociatedItemIds,
associatedItemIds: this.associatedItemIds,
};
_.merge(params, super.structureParams());
return params;
}
toJSON() {
return {uuid: this.uuid}
}
get content_type() {
return "SN|Component";
}
isEditor() {
return this.area == "editor-editor";
}
isTheme() {
return this.content_type == "SN|Theme" || this.area == "themes";
}
isDefaultEditor() {
return this.getAppDataItem("defaultEditor") == true;
}
setLastSize(size) {
this.setAppDataItem("lastSize", size);
}
getLastSize() {
return this.getAppDataItem("lastSize");
}
keysToIgnoreWhenCheckingContentEquality() {
return ["active"].concat(super.keysToIgnoreWhenCheckingContentEquality());
}
/*
An associative component depends on being explicitly activated for a given item, compared to a dissaciative component,
which is enabled by default in areas unrelated to a certain item.
*/
static associativeAreas() {
return ["editor-editor"];
}
isAssociative() {
return Component.associativeAreas().includes(this.area);
}
associateWithItem(item) {
this.associatedItemIds.push(item.uuid);
}
isExplicitlyEnabledForItem(item) {
return this.associatedItemIds.indexOf(item.uuid) !== -1;
}
isExplicitlyDisabledForItem(item) {
return this.disassociatedItemIds.indexOf(item.uuid) !== -1;
}
}

View File

@@ -1,106 +0,0 @@
class Editor extends Item {
constructor(json_obj) {
super(json_obj);
if(!this.notes) {
this.notes = [];
}
if(!this.data) {
this.data = {};
}
}
mapContentToLocalProperties(content) {
super.mapContentToLocalProperties(content)
this.url = content.url;
this.name = content.name;
this.data = content.data || {};
this.default = content.default;
this.systemEditor = content.systemEditor;
}
structureParams() {
var params = {
url: this.url,
name: this.name,
data: this.data,
default: this.default,
systemEditor: this.systemEditor
};
_.merge(params, super.structureParams());
return params;
}
referenceParams() {
var references = _.map(this.notes, function(note){
return {uuid: note.uuid, content_type: note.content_type};
})
return references;
}
addItemAsRelationship(item) {
if(item.content_type == "Note") {
if(!_.find(this.notes, item)) {
this.notes.push(item);
}
}
super.addItemAsRelationship(item);
}
removeItemAsRelationship(item) {
if(item.content_type == "Note") {
_.pull(this.notes, item);
}
super.removeItemAsRelationship(item);
}
removeAndDirtyAllRelationships() {
super.removeAndDirtyAllRelationships();
this.notes = [];
}
removeReferencesNotPresentIn(references) {
super.removeReferencesNotPresentIn(references);
var uuids = references.map(function(ref){return ref.uuid});
this.notes.forEach(function(note){
if(!uuids.includes(note.uuid)) {
_.pull(this.notes, note);
}
}.bind(this))
}
potentialItemOfInterestHasChangedItsUUID(newItem, oldUUID, newUUID) {
if(newItem.content_type === "Note" && _.find(this.notes, {uuid: oldUUID})) {
_.pull(this.notes, {uuid: oldUUID});
this.notes.push(newItem);
}
}
allReferencedObjects() {
return this.notes;
}
toJSON() {
return {uuid: this.uuid}
}
get content_type() {
return "SN|Editor";
}
setData(key, value) {
var dataHasChanged = JSON.stringify(this.data[key]) !== JSON.stringify(value);
if(dataHasChanged) {
this.data[key] = value;
return true;
}
return false;
}
dataForKey(key) {
return this.data[key] || {};
}
}

View File

@@ -1,61 +0,0 @@
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);
}
}
}
class Extension extends Component {
constructor(json) {
super(json);
if(json.actions) {
this.actions = json.actions.map(function(action){
return new Action(action);
})
}
if(!this.actions) {
this.actions = [];
}
}
actionsWithContextForItem(item) {
return this.actions.filter(function(action){
return action.context == item.content_type || action.context == "Item";
})
}
mapContentToLocalProperties(content) {
super.mapContentToLocalProperties(content)
this.description = content.description;
this.supported_types = content.supported_types;
if(content.actions) {
this.actions = content.actions.map(function(action){
return new Action(action);
})
}
}
get content_type() {
return "Extension";
}
structureParams() {
var params = {
description: this.description,
actions: this.actions.map((a) => {return _.omit(a, ["subrows", "subactions"])}),
supported_types: this.supported_types
};
_.merge(params, super.structureParams());
return params;
}
}

View File

@@ -1,129 +0,0 @@
class Note extends Item {
constructor(json_obj) {
super(json_obj);
if(!this.text) {
// Some external editors can't handle a null value for text.
// Notes created on mobile with no text have a null value for it,
// so we'll just set a default here.
this.text = "";
}
if(!this.tags) {
this.tags = [];
}
}
mapContentToLocalProperties(content) {
super.mapContentToLocalProperties(content)
this.title = content.title;
this.text = content.text;
}
referenceParams() {
var references = _.map(this.tags, function(tag){
return {uuid: tag.uuid, content_type: tag.content_type};
})
return references;
}
structureParams() {
var params = {
title: this.title,
text: this.text
};
_.merge(params, super.structureParams());
return params;
}
addItemAsRelationship(item) {
this.savedTagsString = null;
if(item.content_type == "Tag") {
if(!_.find(this.tags, item)) {
this.tags.push(item);
}
}
super.addItemAsRelationship(item);
}
removeItemAsRelationship(item) {
this.savedTagsString = null;
if(item.content_type == "Tag") {
_.pull(this.tags, item);
}
super.removeItemAsRelationship(item);
}
removeAndDirtyAllRelationships() {
this.savedTagsString = null;
this.tags.forEach(function(tag){
_.pull(tag.notes, this);
tag.setDirty(true);
}.bind(this))
this.tags = [];
}
removeReferencesNotPresentIn(references) {
this.savedTagsString = null;
super.removeReferencesNotPresentIn(references);
var uuids = references.map(function(ref){return ref.uuid});
this.tags.slice().forEach(function(tag){
if(!uuids.includes(tag.uuid)) {
_.pull(tag.notes, this);
_.pull(this.tags, tag);
}
}.bind(this))
}
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;
}
informReferencesOfUUIDChange(oldUUID, newUUID) {
for(var tag of this.tags) {
_.pull(tag.notes, {uuid: oldUUID});
tag.notes.push(this);
}
}
allReferencedObjects() {
return this.tags;
}
safeText() {
return this.text || "";
}
safeTitle() {
return this.title || "";
}
toJSON() {
return {uuid: this.uuid}
}
get content_type() {
return "Note";
}
tagsString() {
this.savedTagsString = Tag.arrayToDisplayString(this.tags);
return this.savedTagsString;
}
}

View File

@@ -1,95 +0,0 @@
class Tag extends Item {
constructor(json_obj) {
super(json_obj);
if(!this.notes) {
this.notes = [];
}
}
mapContentToLocalProperties(content) {
super.mapContentToLocalProperties(content)
this.title = content.title;
}
referenceParams() {
var references = _.map(this.notes, function(note){
return {uuid: note.uuid, content_type: note.content_type};
})
return references;
}
structureParams() {
var params = {
title: this.title
};
_.merge(params, super.structureParams());
return params;
}
addItemAsRelationship(item) {
if(item.content_type == "Note") {
if(!_.find(this.notes, item)) {
this.notes.unshift(item);
}
}
super.addItemAsRelationship(item);
}
removeItemAsRelationship(item) {
if(item.content_type == "Note") {
_.pull(this.notes, item);
}
super.removeItemAsRelationship(item);
}
removeAndDirtyAllRelationships() {
this.notes.forEach(function(note){
_.pull(note.tags, this);
note.setDirty(true);
}.bind(this))
this.notes = [];
}
removeReferencesNotPresentIn(references) {
var uuids = references.map(function(ref){return ref.uuid});
this.notes.slice().forEach(function(note){
if(!uuids.includes(note.uuid)) {
_.pull(note.tags, this);
_.pull(this.notes, note);
}
}.bind(this))
}
isBeingRemovedLocally() {
this.notes.forEach(function(note){
_.pull(note.tags, this);
}.bind(this))
super.isBeingRemovedLocally();
}
informReferencesOfUUIDChange(oldUUID, newUUID) {
for(var note of this.notes) {
_.pull(note.tags, {uuid: oldUUID});
note.tags.push(this);
}
}
get content_type() {
return "Tag";
}
allReferencedObjects() {
return this.notes;
}
static arrayToDisplayString(tags) {
return tags.sort((a, b) => {return a.title > b.title}).map(function(tag, i){
return "#" + tag.title;
}).join(" ");
}
}

View File

@@ -1,12 +0,0 @@
class Theme extends Component {
constructor(json_obj) {
super(json_obj);
this.area = "themes";
}
get content_type() {
return "SN|Theme";
}
}

View File

@@ -1,28 +0,0 @@
class EncryptedStorage extends Item {
constructor(json_obj) {
super(json_obj);
}
mapContentToLocalProperties(content) {
super.mapContentToLocalProperties(content)
this.storage = content.storage;
}
structureParams() {
var params = {
storage: this.storage,
};
_.merge(params, super.structureParams());
return params;
}
toJSON() {
return {uuid: this.uuid}
}
get content_type() {
return "SN|EncryptedStorage";
}
}

View File

@@ -1,72 +0,0 @@
class ItemParams {
constructor(item, keys, version) {
this.item = item;
this.keys = keys;
this.version = version || SFJS.version();
}
async paramsForExportFile(includeDeleted) {
this.additionalFields = ["updated_at"];
this.forExportFile = true;
if(includeDeleted) {
return this.__params();
} else {
var result = await this.__params();
return _.omit(result, ["deleted"]);
}
}
async paramsForExtension() {
return this.paramsForExportFile();
}
async paramsForLocalStorage() {
this.additionalFields = ["updated_at", "dirty", "errorDecrypting"];
this.forExportFile = true;
return this.__params();
}
async paramsForSync() {
return this.__params();
}
async __params() {
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.item.errorDecrypting) {
// Items should always be encrypted for export files. Only respect item.doNotEncrypt for remote sync params.
var doNotEncrypt = this.item.doNotEncrypt() && !this.forExportFile;
if(this.keys && !doNotEncrypt) {
var encryptedParams = await SFJS.itemTransformer.encryptItem(this.item, this.keys, this.version);
_.merge(params, encryptedParams);
if(this.version !== "001") {
params.auth_hash = null;
}
}
else {
params.content = this.forExportFile ? this.item.createContentJSONFromProperties() : "000" + await SFJS.crypto.base64(JSON.stringify(this.item.createContentJSONFromProperties()));
if(!this.forExportFile) {
params.enc_item_key = null;
params.auth_hash = null;
}
}
} else {
// Error decrypting, keep "content" and related fields as is (and do not try to encrypt, otherwise that would be undefined behavior)
params.content = this.item.content;
params.enc_item_key = this.item.enc_item_key;
params.auth_hash = this.item.auth_hash;
}
if(this.additionalFields) {
_.merge(params, _.pick(this.item, this.additionalFields));
}
return params;
}
}

View File

@@ -0,0 +1,18 @@
class NoteHistoryEntry extends SFItemHistoryEntry {
previewTitle() {
return this.item.updated_at.toLocaleString();
}
previewSubTitle() {
if(!this.hasPreviousEntry) {
return `${this.textCharDiffLength} characters loaded`
} else if(this.textCharDiffLength < 0) {
return `${this.textCharDiffLength * -1} characters removed`
} else if(this.textCharDiffLength > 0) {
return `${this.textCharDiffLength} characters added`
} else {
return "Title changed"
}
}
}

View File

@@ -14,7 +14,7 @@ class ActionsManager {
}
get extensions() {
return this.modelManager.extensions;
return this.modelManager.validItemsForContentType("Extension");
}
extensionsInContextOfItem(item) {
@@ -74,11 +74,11 @@ class ActionsManager {
if(!item.errorDecrypting) {
if(merge) {
var items = this.modelManager.mapResponseItemsToLocalModels([item], ModelManager.MappingSourceRemoteActionRetrieved);
var items = this.modelManager.mapResponseItemsToLocalModels([item], SFModelManager.MappingSourceRemoteActionRetrieved);
for(var mappedItem of items) {
mappedItem.setDirty(true);
}
this.syncManager.sync(null);
this.syncManager.sync();
customCallback({item: item});
} else {
item = this.modelManager.createItem(item, true /* Dont notify observers */);
@@ -122,9 +122,9 @@ class ActionsManager {
switch (action.verb) {
case "get": {
this.httpManager.getAbsolute(action.url, {}, (response) => {
this.httpManager.getAbsolute(action.url, {}, async (response) => {
action.error = false;
handleResponseDecryption(response, this.authManager.keys(), true);
handleResponseDecryption(response, await this.authManager.keys(), true);
}, (response) => {
action.error = true;
customCallback(null);
@@ -134,9 +134,9 @@ class ActionsManager {
case "render": {
this.httpManager.getAbsolute(action.url, {}, (response) => {
this.httpManager.getAbsolute(action.url, {}, async (response) => {
action.error = false;
handleResponseDecryption(response, this.authManager.keys(), false);
handleResponseDecryption(response, await this.authManager.keys(), false);
}, (response) => {
action.error = true;
customCallback(null);
@@ -175,11 +175,11 @@ class ActionsManager {
}
async outgoingParamsForItem(item, extension, decrypted = false) {
var keys = this.authManager.keys();
var keys = await this.authManager.keys();
if(decrypted) {
keys = null;
}
var itemParams = new ItemParams(item, keys, this.authManager.protocolVersion());
var itemParams = new SFItemParams(item, keys, await this.authManager.getAuthParams());
return itemParams.paramsForExtension();
}
@@ -198,8 +198,15 @@ class ActionsManager {
})
}
presentPasswordModal(callback) {
presentRevisionPreviewModal(uuid, content) {
var scope = this.$rootScope.$new(true);
scope.uuid = uuid;
scope.content = content;
var el = this.$compile( "<revision-preview-modal uuid='uuid' content='content' class='modal'></revision-preview-modal>" )(scope);
angular.element(document.body).append(el);
}
presentPasswordModal(callback) {
var scope = this.$rootScope.$new(true);
scope.type = "password";
scope.title = "Decryption Assistance";

View File

@@ -10,21 +10,19 @@ class ArchiveManager {
Public
*/
downloadBackup(encrypted) {
async downloadBackup(encrypted) {
// download in Standard File format
var keys, authParams, protocolVersion;
var keys, authParams;
if(encrypted) {
if(this.authManager.offline() && this.passcodeManager.hasPasscode()) {
keys = this.passcodeManager.keys();
authParams = this.passcodeManager.passcodeAuthParams();
protocolVersion = authParams.version;
} else {
keys = this.authManager.keys();
authParams = this.authManager.getAuthParams();
protocolVersion = this.authManager.protocolVersion();
keys = await this.authManager.keys();
authParams = await this.authManager.getAuthParams();
}
}
this.__itemsData(keys, authParams, protocolVersion).then((data) => {
this.__itemsData(keys, authParams).then((data) => {
this.__downloadData(data, `SN Archive - ${new Date()}.txt`);
// download as zipped plain text files
@@ -39,8 +37,8 @@ class ArchiveManager {
Private
*/
async __itemsData(keys, authParams, protocolVersion) {
let data = await this.modelManager.getAllItemsJSONData(keys, authParams, protocolVersion);
async __itemsData(keys, authParams) {
let data = await this.modelManager.getAllItemsJSONData(keys, authParams);
let blobData = new Blob([data], {type: 'text/json'});
return blobData;
}

View File

@@ -1,332 +1,184 @@
angular.module('app')
.provider('authManager', function () {
class AuthManager extends SFAuthManager {
function domainName() {
var domain_comps = location.hostname.split(".");
var domain = domain_comps[domain_comps.length - 2] + "." + domain_comps[domain_comps.length - 1];
return domain;
constructor(modelManager, singletonManager, storageManager, dbManager, httpManager, $rootScope, $timeout, $compile) {
super(storageManager, httpManager, $timeout);
this.$rootScope = $rootScope;
this.$compile = $compile;
this.modelManager = modelManager;
this.singletonManager = singletonManager;
this.storageManager = storageManager;
this.dbManager = dbManager;
}
loadInitialData() {
var userData = this.storageManager.getItemSync("user");
if(userData) {
this.user = JSON.parse(userData);
} else {
// legacy, check for uuid
var idData = this.storageManager.getItemSync("uuid");
if(idData) {
this.user = {uuid: idData};
}
}
this.$get = function($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager, singletonManager, $compile) {
return new AuthManager($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager, singletonManager, $compile);
this.configureUserPrefs();
this.checkForSecurityUpdate();
}
offline() {
return !this.user;
}
isEphemeralSession() {
if(this.ephemeral == null || this.ephemeral == undefined) {
this.ephemeral = JSON.parse(this.storageManager.getItemSync("ephemeral", StorageManager.Fixed));
}
return this.ephemeral;
}
setEphemeral(ephemeral) {
this.ephemeral = ephemeral;
if(ephemeral) {
this.storageManager.setModelStorageMode(StorageManager.Ephemeral);
this.storageManager.setItemsMode(StorageManager.Ephemeral);
} else {
this.storageManager.setModelStorageMode(StorageManager.Fixed);
this.storageManager.setItemsMode(this.storageManager.hasPasscode() ? StorageManager.FixedEncrypted : StorageManager.Fixed);
this.storageManager.setItem("ephemeral", JSON.stringify(false), StorageManager.Fixed);
}
}
async protocolVersion() {
var authParams = await this.getAuthParams();
if(authParams && authParams.version) {
return authParams.version;
}
function AuthManager($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager, singletonManager, $compile) {
var keys = await this.keys();
if(keys && keys.ak) {
// If there's no version stored, and there's an ak, it has to be 002. Newer versions would have thier version stored in authParams.
return "002";
} else {
return "001";
}
}
this.loadInitialData = function() {
var userData = storageManager.getItem("user");
if(userData) {
this.user = JSON.parse(userData);
} else {
// legacy, check for uuid
var idData = storageManager.getItem("uuid");
if(idData) {
this.user = {uuid: idData};
}
}
async getAuthParamsForEmail(url, email, extraParams) {
return super.getAuthParamsForEmail(url, email, extraParams);
}
async login(url, email, password, ephemeral, strictSignin, extraParams) {
return super.login(url, email, password, strictSignin, extraParams).then((response) => {
if(!response.error) {
this.setEphemeral(ephemeral);
this.checkForSecurityUpdate();
}
this.offline = function() {
return !this.user;
return response;
})
}
async register(url, email, password, ephemeral) {
return super.register(url, email, password).then((response) => {
if(!response.error) {
this.setEphemeral(ephemeral);
}
return response;
})
}
this.isEphemeralSession = function() {
if(this.ephemeral == null || this.ephemeral == undefined) {
this.ephemeral = JSON.parse(storageManager.getItem("ephemeral", StorageManager.Fixed));
}
return this.ephemeral;
}
this.setEphemeral = function(ephemeral) {
this.ephemeral = ephemeral;
if(ephemeral) {
storageManager.setModelStorageMode(StorageManager.Ephemeral);
storageManager.setItemsMode(StorageManager.Ephemeral);
} else {
storageManager.setModelStorageMode(StorageManager.Fixed);
storageManager.setItemsMode(storageManager.hasPasscode() ? StorageManager.FixedEncrypted : StorageManager.Fixed);
storageManager.setItem("ephemeral", JSON.stringify(false), StorageManager.Fixed);
}
}
this.getAuthParams = function() {
if(!this._authParams) {
this._authParams = JSON.parse(storageManager.getItem("auth_params"));
}
return this._authParams;
}
this.keys = function() {
if(!this._keys) {
var mk = storageManager.getItem("mk");
if(!mk) {
return null;
}
this._keys = {mk: mk, ak: storageManager.getItem("ak")};
}
return this._keys;
}
this.protocolVersion = function() {
var authParams = this.getAuthParams();
if(authParams && authParams.version) {
return authParams.version;
}
var keys = this.keys();
if(keys && keys.ak) {
// If there's no version stored, and there's an ak, it has to be 002. Newer versions would have thier version stored in authParams.
return "002";
} else {
return "001";
}
}
this.isProtocolVersionSupported = function(version) {
return SFJS.supportedVersions().includes(version);
}
this.getAuthParamsForEmail = function(url, email, extraParams, callback) {
var requestUrl = url + "/auth/params";
httpManager.getAbsolute(requestUrl, _.merge({email: email}, extraParams), function(response){
callback(response);
}, function(response){
console.error("Error getting auth params", response);
if(typeof response !== 'object') {
response = {error: {message: "A server error occurred while trying to sign in. Please try again."}};
}
callback(response);
})
}
this.login = function(url, email, password, ephemeral, strictSignin, extraParams, callback) {
this.getAuthParamsForEmail(url, email, extraParams, (authParams) => {
// SF3 requires a unique identifier in the auth params
authParams.identifier = email;
if(authParams.error) {
callback(authParams);
return;
}
if(!authParams || !authParams.pw_cost) {
callback({error : {message: "Invalid email or password."}});
return;
}
if(!this.isProtocolVersionSupported(authParams.version)) {
var message;
if(SFJS.isVersionNewerThanLibraryVersion(authParams.version)) {
// The user has a new account type, but is signing in to an older client.
message = "This version of the application does not support your newer account type. Please upgrade to the latest version of Standard Notes to sign in.";
} else {
// The user has a very old account type, which is no longer supported by this client
message = "The protocol version associated with your account is outdated and no longer supported by this application. Please visit standardnotes.org/help/security for more information.";
}
callback({error: {message: message}});
return;
}
if(SFJS.isProtocolVersionOutdated(authParams.version)) {
let message = `The encryption version for your account, ${authParams.version}, is outdated and requires upgrade. You may proceed with login, but are advised to follow prompts for Security Updates once inside. Please visit standardnotes.org/help/security for more information.\n\nClick 'OK' to proceed with login.`
if(!confirm(message)) {
callback({error: {}});
return;
}
}
if(!SFJS.supportsPasswordDerivationCost(authParams.pw_cost)) {
let message = "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 log in."
callback({error: {message: message}});
return;
}
var minimum = SFJS.costMinimumForVersion(authParams.version);
if(authParams.pw_cost < minimum) {
let message = "Unable to login due to insecure password parameters. Please visit standardnotes.org/help/security for more information.";
callback({error: {message: message}});
return;
}
if(strictSignin) {
// Refuse sign in if authParams.version is anything but the latest version
var latestVersion = SFJS.version();
if(authParams.version !== latestVersion) {
let message = `Strict sign in refused server sign in parameters. The latest security version is ${latestVersion}, but your account is reported to have version ${authParams.version}. If you'd like to proceed with sign in anyway, please disable strict sign in and try again.`;
callback({error: {message: message}});
return;
}
}
SFJS.crypto.computeEncryptionKeysForUser(password, authParams).then((keys) => {
var requestUrl = url + "/auth/sign_in";
var params = _.merge({password: keys.pw, email: email}, extraParams);
httpManager.postAbsolute(requestUrl, params, (response) => {
this.setEphemeral(ephemeral);
this.handleAuthResponse(response, email, url, authParams, keys);
this.checkForSecurityUpdate();
$timeout(() => callback(response));
}, (response) => {
console.error("Error logging in", response);
if(typeof response !== 'object') {
response = {error: {message: "A server error occurred while trying to sign in. Please try again."}};
}
$timeout(() => callback(response));
});
});
})
}
this.handleAuthResponse = function(response, email, url, authParams, keys) {
try {
if(url) {
storageManager.setItem("server", url);
}
this.user = response.user;
storageManager.setItem("user", JSON.stringify(response.user));
this._authParams = authParams;
storageManager.setItem("auth_params", JSON.stringify(authParams));
storageManager.setItem("jwt", response.token);
this.saveKeys(keys);
} catch(e) {
dbManager.displayOfflineAlert();
}
}
this.saveKeys = function(keys) {
this._keys = keys;
// pw doesn't need to be saved.
// storageManager.setItem("pw", keys.pw);
storageManager.setItem("mk", keys.mk);
storageManager.setItem("ak", keys.ak);
}
this.register = function(url, email, password, ephemeral, callback) {
SFJS.crypto.generateInitialKeysAndAuthParamsForUser(email, password).then((results) => {
let keys = results.keys;
let authParams = results.authParams;
var requestUrl = url + "/auth";
var params = _.merge({password: keys.pw, email: email}, authParams);
httpManager.postAbsolute(requestUrl, params, (response) => {
this.setEphemeral(ephemeral);
this.handleAuthResponse(response, email, url, authParams, keys);
callback(response);
}, (response) => {
console.error("Registration error", response);
if(typeof response !== 'object') {
response = {error: {message: "A server error occurred while trying to register. Please try again."}};
}
callback(response);
})
});
}
this.changePassword = function(current_server_pw, newKeys, newAuthParams, callback) {
let email = this.user.email;
let newServerPw = newKeys.pw;
var requestUrl = storageManager.getItem("server") + "/auth/change_pw";
var params = _.merge({new_password: newServerPw, current_password: current_server_pw}, newAuthParams);
httpManager.postAbsolute(requestUrl, params, (response) => {
this.handleAuthResponse(response, email, null, newAuthParams, newKeys);
callback(response);
// Allows security update status to be changed if neccessary
this.checkForSecurityUpdate();
}, (response) => {
if(typeof response !== 'object') {
response = {error: {message: "Something went wrong while changing your password. Your password was not changed. Please try again."}}
}
callback(response);
})
}
this.checkForSecurityUpdate = function() {
if(this.offline()) {
return false;
}
let latest = SFJS.version();
let updateAvailable = this.protocolVersion() !== latest;
if(updateAvailable !== this.securityUpdateAvailable) {
this.securityUpdateAvailable = updateAvailable;
$rootScope.$broadcast("security-update-status-changed");
}
return this.securityUpdateAvailable;
}
this.presentPasswordWizard = function(type) {
var scope = $rootScope.$new(true);
scope.type = type;
var el = $compile( "<password-wizard type='type'></password-wizard>" )(scope);
angular.element(document.body).append(el);
}
this.staticifyObject = function(object) {
return JSON.parse(JSON.stringify(object));
}
this.signOut = function() {
this._keys = null;
this.user = null;
this._authParams = null;
}
/* User Preferences */
let prefsContentType = "SN|UserPreferences";
singletonManager.registerSingleton({content_type: prefsContentType}, (resolvedSingleton) => {
this.userPreferences = resolvedSingleton;
this.userPreferencesDidChange();
}, (valueCallback) => {
// Safe to create. Create and return object.
var prefs = new Item({content_type: prefsContentType});
modelManager.addItem(prefs);
prefs.setDirty(true);
$rootScope.sync("authManager singletonCreate");
valueCallback(prefs);
});
this.userPreferencesDidChange = function() {
$rootScope.$broadcast("user-preferences-changed");
}
this.syncUserPreferences = function() {
if(this.userPreferences) {
this.userPreferences.setDirty(true);
$rootScope.sync("syncUserPreferences");
}
}
this.getUserPrefValue = function(key, defaultValue) {
if(!this.userPreferences) { return defaultValue; }
var value = this.userPreferences.getAppDataItem(key);
return (value !== undefined && value != null) ? value : defaultValue;
}
this.setUserPrefValue = function(key, value, sync) {
if(!this.userPreferences) { console.log("Prefs are null, not setting value", key); return; }
this.userPreferences.setAppDataItem(key, value);
if(sync) {
this.syncUserPreferences();
}
async changePassword(url, email, current_server_pw, newKeys, newAuthParams) {
return super.changePassword(url, email, current_server_pw, newKeys, newAuthParams).then((response) => {
if(!response.error) {
this.checkForSecurityUpdate();
}
return response;
})
}
async handleAuthResponse(response, email, url, authParams, keys) {
try {
await super.handleAuthResponse(response, email, url, authParams, keys);
this.user = response.user;
this.storageManager.setItem("user", JSON.stringify(response.user));
} catch (e) {
this.dbManager.displayOfflineAlert();
}
});
}
async checkForSecurityUpdate() {
if(this.offline()) {
return false;
}
let latest = SFJS.version();
let updateAvailable = await this.protocolVersion() !== latest;
if(updateAvailable !== this.securityUpdateAvailable) {
this.securityUpdateAvailable = updateAvailable;
this.$rootScope.$broadcast("security-update-status-changed");
}
return this.securityUpdateAvailable;
}
presentPasswordWizard(type) {
var scope = this.$rootScope.$new(true);
scope.type = type;
var el = this.$compile( "<password-wizard type='type'></password-wizard>" )(scope);
angular.element(document.body).append(el);
}
signOut() {
super.signout();
this.user = null;
this._authParams = null;
}
/* User Preferences */
configureUserPrefs() {
let prefsContentType = "SN|UserPreferences";
let contentTypePredicate = new SFPredicate("content_type", "=", prefsContentType);
this.singletonManager.registerSingleton([contentTypePredicate], (resolvedSingleton) => {
this.userPreferences = resolvedSingleton;
this.userPreferencesDidChange();
}, (valueCallback) => {
// Safe to create. Create and return object.
var prefs = new SFItem({content_type: prefsContentType});
this.modelManager.addItem(prefs);
prefs.setDirty(true);
this.$rootScope.sync();
valueCallback(prefs);
});
}
userPreferencesDidChange() {
this.$rootScope.$broadcast("user-preferences-changed");
}
syncUserPreferences() {
if(this.userPreferences) {
this.userPreferences.setDirty(true);
this.$rootScope.sync();
}
}
getUserPrefValue(key, defaultValue) {
if(!this.userPreferences) { return defaultValue; }
var value = this.userPreferences.getAppDataItem(key);
return (value !== undefined && value != null) ? value : defaultValue;
}
setUserPrefValue(key, value, sync) {
if(!this.userPreferences) { console.log("Prefs are null, not setting value", key); return; }
this.userPreferences.setAppDataItem(key, value);
if(sync) {
this.syncUserPreferences();
}
}
}
angular.module('app').service('authManager', AuthManager);

View File

@@ -1,9 +1,10 @@
/* This domain will be used to save context item client data */
let ClientDataDomain = "org.standardnotes.sn.components";
class ComponentManager {
constructor($rootScope, modelManager, syncManager, desktopManager, nativeExtManager, $timeout, $compile) {
/* This domain will be used to save context item client data */
ComponentManager.ClientDataDomain = "org.standardnotes.sn.components";
this.$compile = $compile;
this.$rootScope = $rootScope;
this.modelManager = modelManager;
@@ -57,13 +58,13 @@ class ComponentManager {
/* If the source of these new or updated items is from a Component itself saving items, we don't need to notify
components again of the same item. Regarding notifying other components than the issuing component, other mapping sources
will take care of that, like ModelManager.MappingSourceRemoteSaved
will take care of that, like SFModelManager.MappingSourceRemoteSaved
Update: We will now check sourceKey to determine whether the incoming change should be sent to
a component. If sourceKey == component.uuid, it will be skipped. This way, if one component triggers a change,
it's sent to other components.
*/
// if(source == ModelManager.MappingSourceComponentRetrieved) {
// if(source == SFModelManager.MappingSourceComponentRetrieved) {
// return;
// }
@@ -74,7 +75,7 @@ class ComponentManager {
/* We only want to sync if the item source is Retrieved, not MappingSourceRemoteSaved to avoid
recursion caused by the component being modified and saved after it is updated.
*/
if(syncedComponents.length > 0 && source != ModelManager.MappingSourceRemoteSaved) {
if(syncedComponents.length > 0 && source != SFModelManager.MappingSourceRemoteSaved) {
// Ensure any component in our data is installed by the system
this.desktopManager.syncComponentsInstallation(syncedComponents);
}
@@ -157,14 +158,17 @@ class ComponentManager {
}
}
getActiveTheme() {
return this.componentsForArea("themes").find((theme) => {return theme.active});
getActiveThemes() {
return this.componentsForArea("themes").filter((theme) => {return theme.active});
}
postActiveThemeToComponent(component) {
var activeTheme = this.getActiveTheme();
var themes = this.getActiveThemes();
var urls = themes.map((theme) => {
return this.urlForComponent(theme);
})
var data = {
themes: [activeTheme ? this.urlForComponent(activeTheme) : null]
themes: urls
}
this.sendMessageToComponent(component, {action: "themes", data: data})
@@ -193,17 +197,16 @@ class ComponentManager {
jsonForItem(item, component, source) {
var params = {uuid: item.uuid, content_type: item.content_type, created_at: item.created_at, updated_at: item.updated_at, deleted: item.deleted};
params.content = item.createContentJSONFromProperties();
/* Legacy is using component.url key, so if it's present, use it, otherwise use uuid */
params.clientData = item.getDomainDataItem(component.url || component.uuid, ClientDataDomain) || {};
params.clientData = item.getDomainDataItem(component.getClientDataKey(), ComponentManager.ClientDataDomain) || {};
/* This means the this function is being triggered through a remote Saving response, which should not update
actual local content values. The reason is, Save responses may be delayed, and a user may have changed some values
in between the Save was initiated, and the time it completes. So we only want to update actual content values (and not just metadata)
when its another source, like ModelManager.MappingSourceRemoteRetrieved.
when its another source, like SFModelManager.MappingSourceRemoteRetrieved.
3/7/18: Add MappingSourceLocalSaved as well to handle fully offline saving. github.com/standardnotes/forum/issues/169
*/
if(source && (source == ModelManager.MappingSourceRemoteSaved || source == ModelManager.MappingSourceLocalSaved)) {
if(source && (source == SFModelManager.MappingSourceRemoteSaved || source == SFModelManager.MappingSourceLocalSaved)) {
params.isMetadataUpdate = true;
}
this.removePrivatePropertiesFromResponseItems([params], component);
@@ -277,13 +280,13 @@ class ComponentManager {
if(component.offlineOnly || (isDesktopApplication() && component.local_url)) {
return component.local_url && component.local_url.replace("sn://", offlinePrefix + this.desktopManager.getApplicationDataPath() + "/");
} else {
return component.hosted_url || component.url;
return component.hosted_url || component.legacy_url;
}
}
componentForUrl(url) {
return this.components.filter(function(component){
return component.url === url || component.hosted_url === url;
return component.hosted_url === url || component.legacy_url === url;
})[0];
}
@@ -476,24 +479,29 @@ class ComponentManager {
We map the items here because modelManager is what updates the UI. If you were to instead get the items directly,
this would update them server side via sync, but would never make its way back to the UI.
*/
var localItems = this.modelManager.mapResponseItemsToLocalModels(responseItems, ModelManager.MappingSourceComponentRetrieved, component.uuid);
var localItems = this.modelManager.mapResponseItemsToLocalModels(responseItems, SFModelManager.MappingSourceComponentRetrieved, component.uuid);
for(var item of localItems) {
var responseItem = _.find(responseItems, {uuid: item.uuid});
for(var responseItem of responseItems) {
var item = _.find(localItems, {uuid: responseItem.uuid});
if(!item) {
// An item this extension is trying to save was possibly removed locally, notify user
alert(`The extension ${component.name} is trying to save an item with type ${responseItem.content_type}, but that item does not exist. Please restart this extension and try again.`);
continue;
}
_.merge(item.content, responseItem.content);
if(responseItem.clientData) {
item.setDomainDataItem(component.url || component.uuid, responseItem.clientData, ClientDataDomain);
item.setDomainDataItem(component.getClientDataKey(), responseItem.clientData, ComponentManager.ClientDataDomain);
}
item.setDirty(true);
}
this.syncManager.sync((response) => {
this.syncManager.sync().then((response) => {
// Allow handlers to be notified when a save begins and ends, to update the UI
var saveMessage = Object.assign({}, message);
saveMessage.action = response && response.error ? "save-error" : "save-success";
this.replyToMessage(component, message, {error: response.error})
this.replyToMessage(component, message, {error: response && response.error})
this.handleMessage(component, saveMessage);
}, null, "handleSaveItemsMessage");
});
});
}
@@ -513,7 +521,7 @@ class ComponentManager {
for(let responseItem of responseItems) {
var item = this.modelManager.createItem(responseItem);
if(responseItem.clientData) {
item.setDomainDataItem(component.url || component.uuid, responseItem.clientData, ClientDataDomain);
item.setDomainDataItem(component.getClientDataKey(), responseItem.clientData, ComponentManager.ClientDataDomain);
}
this.modelManager.addItem(item);
this.modelManager.resolveReferencesForItem(item, true);
@@ -521,7 +529,7 @@ class ComponentManager {
processedItems.push(item);
}
this.syncManager.sync("handleCreateItemMessage");
this.syncManager.sync();
// "create-item" or "create-items" are possible messages handled here
let reply =
@@ -554,9 +562,12 @@ class ComponentManager {
this.deactivateComponent(model, true);
}
this.modelManager.setItemToBeDeleted(model);
// Currently extensions are not notified of association until a full server sync completes.
// We manually notify observers.
this.modelManager.notifySyncObserversOfModels([model], SFModelManager.MappingSourceRemoteSaved);
}
this.syncManager.sync("handleDeleteItemsMessage");
this.syncManager.sync();
}
});
}
@@ -572,7 +583,7 @@ class ComponentManager {
this.runWithPermissions(component, [], () => {
component.componentData = message.data.componentData;
component.setDirty(true);
this.syncManager.sync("handleSetComponentDataMessage");
this.syncManager.sync();
});
}
@@ -583,11 +594,13 @@ class ComponentManager {
if(targetComponent.active) {
this.deactivateComponent(targetComponent);
} else {
if(targetComponent.content_type == "SN|Theme") {
// Deactive currently active theme
var activeTheme = this.getActiveTheme();
if(activeTheme) {
this.deactivateComponent(activeTheme);
if(targetComponent.content_type == "SN|Theme" && !targetComponent.isLayerable()) {
// Deactive currently active theme if new theme is not layerable
var activeThemes = this.getActiveThemes();
for(var theme of activeThemes) {
if(theme && !theme.isLayerable()) {
this.deactivateComponent(theme);
}
}
}
this.activateComponent(targetComponent);
@@ -665,7 +678,7 @@ class ComponentManager {
}
}
component.setDirty(true);
this.syncManager.sync("promptForPermissions");
this.syncManager.sync();
}
this.permissionDialogs = this.permissionDialogs.filter((pendingDialog) => {
@@ -776,14 +789,14 @@ class ComponentManager {
// We want to run the handler in a $timeout so the UI updates, but we also don't want it to run asyncronously
// so that the steps below this one are run before the handler. So we run in a waitTimeout.
await this.waitTimeout(() => {
handler.activationHandler(component);
handler.activationHandler && handler.activationHandler(component);
})
}
}
if(didChange && !dontSync) {
component.setDirty(true);
this.syncManager.sync("activateComponent");
this.syncManager.sync();
}
if(!this.activeComponents.includes(component)) {
@@ -803,14 +816,14 @@ class ComponentManager {
for(let handler of this.handlers) {
if(handler.areas.includes(component.area) || handler.areas.includes("*")) {
await this.waitTimeout(() => {
handler.activationHandler(component);
handler.activationHandler && handler.activationHandler(component);
})
}
}
if(didChange && !dontSync) {
component.setDirty(true);
this.syncManager.sync("deactivateComponent");
this.syncManager.sync();
}
_.pull(this.activeComponents, component);
@@ -837,7 +850,7 @@ class ComponentManager {
for(let handler of this.handlers) {
if(handler.areas.includes(component.area) || handler.areas.includes("*")) {
await this.waitTimeout(() => {
handler.activationHandler(component);
handler.activationHandler && handler.activationHandler(component);
})
}
}
@@ -863,7 +876,7 @@ class ComponentManager {
for(var handler of this.handlers) {
if(handler.areas.includes(component.area) || handler.areas.includes("*")) {
await this.waitTimeout(() => {
handler.activationHandler(component);
handler.activationHandler && handler.activationHandler(component);
})
}
}
@@ -880,7 +893,7 @@ class ComponentManager {
deleteComponent(component) {
this.modelManager.setItemToBeDeleted(component);
this.syncManager.sync("deleteComponent");
this.syncManager.sync();
}
isComponentActive(component) {
@@ -908,7 +921,9 @@ class ComponentManager {
var setSize = function(element, size) {
var widthString = typeof size.width === 'string' ? size.width : `${data.width}px`;
var heightString = typeof size.height === 'string' ? size.height : `${data.height}px`;
element.setAttribute("style", `width:${widthString}; height:${heightString};`);
if(element) {
element.setAttribute("style", `width:${widthString}; height:${heightString};`);
}
}
if(component.area == "rooms" || component.area == "modal") {
@@ -919,6 +934,9 @@ class ComponentManager {
}
} else {
var iframe = this.iframeForComponent(component);
if(!iframe) {
return;
}
var width = data.width;
var height = data.height;
iframe.width = width;

View File

@@ -47,6 +47,10 @@ class DBManager {
}
};
request.onblocked = (event) => {
console.error("Request blocked error:", event.target.errorCode);
}
request.onupgradeneeded = (event) => {
var db = event.target.result;
@@ -106,7 +110,11 @@ class DBManager {
};
transaction.onerror = function(event) {
console.log("Transaction error:", event.target.errorCode);
console.error("Transaction error:", event.target.errorCode);
};
transaction.onblocked = function(event) {
console.error("Transaction blocked error:", event.target.errorCode);
};
transaction.onabort = function(event) {
@@ -127,12 +135,14 @@ class DBManager {
function putNext() {
if (i < items.length) {
var item = items[i];
itemObjectStore.put(item).onsuccess = putNext;
var request = itemObjectStore.put(item);
request.onerror = (event) => {
console.error("DB put error:", event.target.error);
}
request.onsuccess = putNext;
++i;
} else {
if(onsuccess){
onsuccess();
}
onsuccess && onsuccess();
}
}
}, null)

View File

@@ -37,7 +37,7 @@ class DesktopManager {
Keys are not passed into ItemParams, so the result is not encrypted
*/
async convertComponentForTransmission(component) {
return new ItemParams(component).paramsForExportFile(true);
return new SFItemParams(component).paramsForExportFile(true);
}
// All `components` should be installed
@@ -96,11 +96,11 @@ class DesktopManager {
for(var key of permissableKeys) {
component[key] = componentData.content[key];
}
this.modelManager.notifySyncObserversOfModels([component], ModelManager.MappingSourceDesktopInstalled);
this.modelManager.notifySyncObserversOfModels([component], SFModelManager.MappingSourceDesktopInstalled);
component.setAppDataItem("installError", null);
}
component.setDirty(true);
this.syncManager.sync("onComponentInstallationComplete");
this.syncManager.sync();
this.timeout(() => {
for(var observer of this.updateObservers) {
@@ -129,22 +129,19 @@ class DesktopManager {
}
}
desktop_requestBackupFile(callback) {
var keys, authParams, protocolVersion;
async desktop_requestBackupFile(callback) {
var keys, authParams;
if(this.authManager.offline() && this.passcodeManager.hasPasscode()) {
keys = this.passcodeManager.keys();
authParams = this.passcodeManager.passcodeAuthParams();
protocolVersion = authParams.version;
} else {
keys = this.authManager.keys();
authParams = this.authManager.getAuthParams();
protocolVersion = this.authManager.protocolVersion();
keys = await this.authManager.keys();
authParams = await this.authManager.getAuthParams();
}
this.modelManager.getAllItemsJSONData(
keys,
authParams,
protocolVersion,
true /* return null on empty */
).then((data) => {
callback(data);

View File

@@ -1,80 +1,13 @@
class HttpManager {
class HttpManager extends SFHttpManager {
constructor($timeout, storageManager) {
// calling callbacks in a $timeout allows angular UI to update
this.$timeout = $timeout;
this.storageManager = storageManager;
constructor(storageManager, $timeout) {
// calling callbacks in a $timeout allows UI to update
super($timeout);
this.setJWTRequestHandler(async () => {
return storageManager.getItem("jwt");;
})
}
setAuthHeadersForRequest(request) {
var token = this.storageManager.getItem("jwt");
if(token) {
request.setRequestHeader('Authorization', 'Bearer ' + token);
}
}
postAbsolute(url, params, onsuccess, onerror) {
this.httpRequest("post", url, params, onsuccess, onerror);
}
patchAbsolute(url, params, onsuccess, onerror) {
this.httpRequest("patch", url, params, onsuccess, onerror);
}
getAbsolute(url, params, onsuccess, onerror) {
this.httpRequest("get", url, params, onsuccess, onerror);
}
httpRequest(verb, url, params, onsuccess, onerror) {
var xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function() {
if (xmlhttp.readyState == 4) {
var response = xmlhttp.responseText;
if(response) {
try {
response = JSON.parse(response);
} catch(e) {}
}
if(xmlhttp.status >= 200 && xmlhttp.status <= 299){
this.$timeout(function(){
onsuccess(response);
})
} else {
console.error("Request error:", response);
this.$timeout(function(){
onerror(response, xmlhttp.status)
})
}
}
}.bind(this)
if(verb == "get" && Object.keys(params).length > 0) {
url = url + this.formatParams(params);
}
xmlhttp.open(verb, url, true);
this.setAuthHeadersForRequest(xmlhttp);
xmlhttp.setRequestHeader('Content-type', 'application/json');
if(verb == "post" || verb == "patch") {
xmlhttp.send(JSON.stringify(params));
} else {
xmlhttp.send();
}
}
formatParams(params) {
return "?" + Object
.keys(params)
.map(function(key){
return key+"="+encodeURIComponent(params[key])
})
.join("&")
}
}
angular.module('app').service('httpManager', HttpManager);

View File

@@ -1,23 +1,15 @@
class MigrationManager {
class MigrationManager extends SFMigrationManager {
constructor($rootScope, modelManager, syncManager, componentManager) {
this.$rootScope = $rootScope;
this.modelManager = modelManager;
this.syncManager = syncManager;
constructor($rootScope, modelManager, syncManager, componentManager, storageManager) {
super(modelManager, syncManager, storageManager);
this.componentManager = componentManager;
}
this.migrators = [];
this.addEditorToComponentMigrator();
this.modelManager.addItemSyncObserver("migration-manager", "*", (allItems, validItems, deletedItems) => {
for(var migrator of this.migrators) {
var items = allItems.filter((item) => {return item.content_type == migrator.content_type});
if(items.length > 0) {
migrator.handler(items);
}
}
});
registeredMigrations() {
return [
this.editorToComponentMigration(),
this.componentUrlToHostedUrl()
];
}
/*
@@ -25,10 +17,10 @@ class MigrationManager {
convert to using the new component API.
*/
addEditorToComponentMigrator() {
this.migrators.push({
editorToComponentMigration() {
return {
name: "editor-to-component",
content_type: "SN|Editor",
handler: (editors) => {
// Convert editors to components
for(var editor of editors) {
@@ -36,9 +28,11 @@ class MigrationManager {
if(editor.url && !this.componentManager.componentForUrl(editor.url)) {
var component = this.modelManager.createItem({
content_type: "SN|Component",
url: editor.url,
name: editor.name,
area: "editor-editor"
content: {
url: editor.url,
name: editor.name,
area: "editor-editor"
}
})
component.setAppDataItem("data", editor.data);
component.setDirty(true);
@@ -50,12 +44,38 @@ class MigrationManager {
this.modelManager.setItemToBeDeleted(editor);
}
this.syncManager.sync("addEditorToComponentMigrator");
this.syncManager.sync();
}
})
}
}
/*
Migrate component.url fields to component.hosted_url. This involves rewriting any note data that relied on the
component.url value to store clientData, such as the CodeEditor, which stores the programming language for the note
in the note's clientData[component.url]. We want to rewrite any matching items to transfer that clientData into
clientData[component.uuid].
Created: July 6, 2018
*/
componentUrlToHostedUrl() {
return {
name: "component-url-to-hosted-url",
content_type: "SN|Component",
handler: (components) => {
var notes = this.modelManager.validItemsForContentType("Note");
for(var note of notes) {
for(var component of components) {
var clientData = note.getDomainDataItem(component.hosted_url, ComponentManager.ClientDataDomain);
if(clientData) {
note.setDomainDataItem(component.uuid, clientData, ComponentManager.ClientDataDomain);
note.setDomainDataItem(component.hosted_url, null, ComponentManager.ClientDataDomain);
note.setDirty(true);
}
}
}
this.syncManager.sync();
}
}
}
}
angular.module('app').service('migrationManager', MigrationManager);

View File

@@ -1,302 +1,57 @@
class ModelManager {
SFModelManager.ContentTypeClassMapping = {
"Note" : SNNote,
"Tag" : SNTag,
"SN|SmartTag" : SNSmartTag,
"Extension" : SNExtension,
"SN|Editor" : SNEditor,
"SN|Theme" : SNTheme,
"SN|Component" : SNComponent,
"SF|Extension" : SNServerExtension,
"SF|MFA" : SNMfa
};
constructor(storageManager) {
ModelManager.MappingSourceRemoteRetrieved = "MappingSourceRemoteRetrieved";
ModelManager.MappingSourceRemoteSaved = "MappingSourceRemoteSaved";
ModelManager.MappingSourceLocalSaved = "MappingSourceLocalSaved";
ModelManager.MappingSourceLocalRetrieved = "MappingSourceLocalRetrieved";
ModelManager.MappingSourceComponentRetrieved = "MappingSourceComponentRetrieved";
ModelManager.MappingSourceDesktopInstalled = "MappingSourceDesktopInstalled"; // When a component is installed by the desktop and some of its values change
ModelManager.MappingSourceRemoteActionRetrieved = "MappingSourceRemoteActionRetrieved"; /* aciton-based Extensions like note history */
ModelManager.MappingSourceFileImport = "MappingSourceFileImport";
SFItem.AppDomain = "org.standardnotes.sn";
ModelManager.isMappingSourceRetrieved = (source) => {
return [
ModelManager.MappingSourceRemoteRetrieved,
ModelManager.MappingSourceComponentRetrieved,
ModelManager.MappingSourceRemoteActionRetrieved
].includes(source);
}
class ModelManager extends SFModelManager {
this.storageManager = storageManager;
constructor(storageManager, $timeout) {
super($timeout);
this.notes = [];
this.tags = [];
this.itemSyncObservers = [];
this.itemChangeObservers = [];
this.itemsPendingRemoval = [];
this.items = [];
this._extensions = [];
this.acceptableContentTypes = [
"Note", "Tag", "Extension", "SN|Editor", "SN|Theme",
"SN|Component", "SF|Extension", "SN|UserPreferences", "SF|MFA"
];
this.components = [];
this.storageManager = storageManager;
}
resetLocalMemory() {
handleSignout() {
super.handleSignout();
this.notes.length = 0;
this.tags.length = 0;
this.items.length = 0;
this._extensions.length = 0;
this.components.length = 0;
}
get allItems() {
return this.items.filter(function(item){
return !item.dummy;
})
}
get extensions() {
return this._extensions.filter(function(ext){
return !ext.deleted;
})
}
alternateUUIDForItem(item, callback, removeOriginal) {
// We need to clone this item and give it a new uuid, then delete item with old uuid from db (you can't modify uuid's in our indexeddb setup)
// Collapse in memory properties to item's content object, as the new item will be created based on the content object, and not the physical properties. (like note.text or note.title)
item.refreshContentObject();
var newItem = this.createItem(item);
newItem.uuid = SFJS.crypto.generateUUIDSync();
// Update uuids of relationships
newItem.informReferencesOfUUIDChange(item.uuid, newItem.uuid);
this.informModelsOfUUIDChangeForItem(newItem, item.uuid, newItem.uuid);
console.log(item.uuid, "-->", newItem.uuid);
var block = () => {
this.addItem(newItem);
newItem.setDirty(true);
newItem.markAllReferencesDirty();
callback();
}
if(removeOriginal) {
// Set to deleted, then run through mapping function so that observers can be notified
removeAllItemsFromMemory() {
for(var item of this.items) {
item.deleted = true;
this.mapResponseItemsToLocalModels([item], ModelManager.MappingSourceLocalSaved);
block();
} else {
block();
}
}
informModelsOfUUIDChangeForItem(newItem, oldUUID, newUUID) {
// some models that only have one-way relationships might be interested to hear that an item has changed its uuid
// for example, editors have a one way relationship with notes. When a note changes its UUID, it has no way to inform the editor
// to update its relationships
for(var model of this.items) {
model.potentialItemOfInterestHasChangedItsUUID(newItem, oldUUID, newUUID);
}
}
allItemsMatchingTypes(contentTypes) {
return this.allItems.filter(function(item){
return (_.includes(contentTypes, item.content_type) || _.includes(contentTypes, "*")) && !item.dummy;
})
}
validItemsForContentType(contentType) {
return this.allItems.filter((item) => {
return item.content_type == contentType && !item.errorDecrypting;
});
}
findItem(itemId) {
return _.find(this.items, {uuid: itemId});
this.notifySyncObserversOfModels(this.items);
this.handleSignout();
}
findOrCreateTagByTitle(title) {
var tag = _.find(this.tags, {title: title})
if(!tag) {
tag = this.createItem({content_type: "Tag", title: title});
tag = this.createItem({content_type: "Tag", content: {title: title}});
tag.setDirty(true);
this.addItem(tag);
}
return tag;
}
didSyncModelsOffline(items) {
this.notifySyncObserversOfModels(items, ModelManager.MappingSourceLocalSaved);
}
mapResponseItemsToLocalModels(items, source, sourceKey) {
return this.mapResponseItemsToLocalModelsOmittingFields(items, null, source, sourceKey);
}
mapResponseItemsToLocalModelsOmittingFields(items, omitFields, source, sourceKey) {
var models = [], processedObjects = [], modelsToNotifyObserversOf = [];
// first loop should add and process items
for (var json_obj of items) {
if((!json_obj.content_type || !json_obj.content) && !json_obj.deleted && !json_obj.errorDecrypting) {
// An item that is not deleted should never have empty content
console.error("Server response item is corrupt:", json_obj);
continue;
}
// Lodash's _.omit, which was previously used, seems to cause unexpected behavior
// when json_obj is an ES6 item class. So we instead manually omit each key.
if(Array.isArray(omitFields)) {
for(var key of omitFields) {
delete json_obj[key];
}
}
var item = this.findItem(json_obj.uuid);
if(item) {
item.updateFromJSON(json_obj);
// If an item goes through mapping, it can no longer be a dummy.
item.dummy = false;
}
if(this.itemsPendingRemoval.includes(json_obj.uuid)) {
_.pull(this.itemsPendingRemoval, json_obj.uuid);
continue;
}
let contentType = json_obj["content_type"] || (item && item.content_type);
var unknownContentType = !_.includes(this.acceptableContentTypes, contentType);
var isDirtyItemPendingDelete = false;
if(json_obj.deleted == true || unknownContentType) {
if(json_obj.deleted && json_obj.dirty) {
// Item was marked as deleted but not yet synced
// We need to create this item as usual, but just not add it to individual arrays
// i.e add to this.items but not this.notes (so that it can be retrieved with getDirtyItems)
isDirtyItemPendingDelete = true;
} else {
if(item && !unknownContentType) {
modelsToNotifyObserversOf.push(item);
this.removeItemLocally(item);
}
continue;
}
}
if(!item) {
item = this.createItem(json_obj, true);
}
this.addItem(item, isDirtyItemPendingDelete);
// Observers do not need to handle items that errored while decrypting.
if(!item.errorDecrypting) {
modelsToNotifyObserversOf.push(item);
}
models.push(item);
processedObjects.push(json_obj);
}
// second loop should process references
for (var index in processedObjects) {
var json_obj = processedObjects[index];
if(json_obj.content) {
this.resolveReferencesForItem(models[index]);
}
}
this.notifySyncObserversOfModels(modelsToNotifyObserversOf, source, sourceKey);
return models;
}
/* Note that this function is public, and can also be called manually (desktopManager uses it) */
notifySyncObserversOfModels(models, source, sourceKey) {
for(var observer of this.itemSyncObservers) {
var allRelevantItems = observer.type == "*" ? models : models.filter(function(item){return item.content_type == observer.type});
var validItems = [], deletedItems = [];
for(var item of allRelevantItems) {
if(item.deleted) {
deletedItems.push(item);
} else {
validItems.push(item);
}
}
if(allRelevantItems.length > 0) {
observer.callback(allRelevantItems, validItems, deletedItems, source, sourceKey);
}
}
}
notifyItemChangeObserversOfModels(models) {
for(var observer of this.itemChangeObservers) {
var relevantItems = models.filter(function(item){
return _.includes(observer.content_types, item.content_type) || _.includes(observer.content_types, "*");
});
if(relevantItems.length > 0) {
observer.callback(relevantItems);
}
}
}
createItem(json_obj, dontNotifyObservers) {
var item;
if(json_obj.content_type == "Note") {
item = new Note(json_obj);
} else if(json_obj.content_type == "Tag") {
item = new Tag(json_obj);
} else if(json_obj.content_type == "Extension") {
item = new Extension(json_obj);
} else if(json_obj.content_type == "SN|Editor") {
item = new Editor(json_obj);
} else if(json_obj.content_type == "SN|Theme") {
item = new Theme(json_obj);
} else if(json_obj.content_type == "SN|Component") {
item = new Component(json_obj);
} else if(json_obj.content_type == "SF|Extension") {
item = new ServerExtension(json_obj);
} else if(json_obj.content_type == "SF|MFA") {
item = new Mfa(json_obj);
}
else {
item = new Item(json_obj);
}
// Some observers would be interested to know when an an item is locally created
// If we don't send this out, these observers would have to wait until MappingSourceRemoteSaved
// to hear about it, but sometimes, RemoveSaved is explicitly ignored by the observer to avoid
// recursive callbacks. See componentManager's syncObserver callback.
// dontNotifyObservers is currently only set true by modelManagers mapResponseItemsToLocalModels
if(!dontNotifyObservers) {
this.notifySyncObserversOfModels([item], ModelManager.MappingSourceLocalSaved);
}
item.addObserver(this, function(changedItem){
this.notifyItemChangeObserversOfModels([changedItem]);
}.bind(this));
return item;
}
/*
Be sure itemResponse is a generic Javascript object, and not an Item.
An Item needs to collapse its properties into its content object before it can be duplicated.
Note: the reason we need this function is specificallty for the call to resolveReferencesForItem.
This method creates but does not add the item to the global inventory. It's used by syncManager
to check if this prospective duplicate item is identical to another item, including the references.
*/
createDuplicateItem(itemResponse) {
var dup = this.createItem(itemResponse, true);
this.resolveReferencesForItem(dup);
return dup;
}
addItem(item, globalOnly = false) {
this.addItems([item], globalOnly);
}
addItems(items, globalOnly = false) {
items.forEach(function(item){
super.addItems(items, globalOnly);
items.forEach((item) => {
// In some cases, you just want to add the item to this.items, and not to the individual arrays
// This applies when you want to keep an item syncable, but not display it via the individual arrays
if(!globalOnly) {
@@ -311,17 +66,13 @@ class ModelManager {
if(!_.find(this.notes, {uuid: item.uuid})) {
this.notes.unshift(item);
}
} else if(item.content_type == "Extension") {
if(!_.find(this._extensions, {uuid: item.uuid})) {
this._extensions.unshift(item);
} else if(item.content_type == "SN|Component") {
if(!_.find(this.components, {uuid: item.uuid})) {
this.components.unshift(item);
}
}
}
if(!_.find(this.items, {uuid: item.uuid})) {
this.items.push(item);
}
}.bind(this));
});
}
resortTag(tag) {
@@ -332,158 +83,37 @@ class ModelManager {
}), 0, tag);
}
resolveReferencesForItem(item, markReferencesDirty = false) {
var contentObject = item.contentObject;
// If another client removes an item's references, this client won't pick up the removal unless
// we remove everything not present in the current list of references
item.removeReferencesNotPresentIn(contentObject.references || []);
if(!contentObject.references) {
return;
}
for(var reference of contentObject.references) {
var referencedItem = this.findItem(reference.uuid);
if(referencedItem) {
item.addItemAsRelationship(referencedItem);
referencedItem.addItemAsRelationship(item);
if(markReferencesDirty) {
referencedItem.setDirty(true);
}
} else {
// console.log("Unable to find reference:", reference.uuid, "for item:", item);
}
}
}
addItemSyncObserver(id, type, callback) {
this.itemSyncObservers.push({id: id, type: type, callback: callback});
}
removeItemSyncObserver(id) {
_.remove(this.itemSyncObservers, _.find(this.itemSyncObservers, {id: id}));
}
addItemChangeObserver(id, content_types, callback) {
this.itemChangeObservers.push({id: id, content_types: content_types, callback: callback});
}
removeItemChangeObserver(id) {
_.remove(this.itemChangeObservers, _.find(this.itemChangeObservers, {id: id}));
}
get filteredNotes() {
return Note.filterDummyNotes(this.notes);
}
getDirtyItems() {
return this.items.filter((item) => {
// An item that has an error decrypting can be synced only if it is being deleted.
// Otherwise, we don't want to send corrupt content up to the server.
return item.dirty == true && !item.dummy && (!item.errorDecrypting || item.deleted);
})
}
clearDirtyItems(items) {
for(var item of items) {
item.setDirty(false);
}
}
clearAllDirtyItems() {
this.clearDirtyItems(this.getDirtyItems());
}
setItemToBeDeleted(item) {
item.deleted = true;
if(!item.dummy) {
item.setDirty(true);
}
super.setItemToBeDeleted(item);
// remove from relevant array, but don't remove from all items.
// This way, it's removed from the display, but still synced via get dirty items
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);
}
item.removeAndDirtyAllRelationships();
}
/* Used when changing encryption key */
setAllItemsDirty(dontUpdateClientDates = true) {
var relevantItems = this.allItems.filter(function(item){
return _.includes(this.acceptableContentTypes, item.content_type);
}.bind(this));
for(var item of relevantItems) {
item.setDirty(true, dontUpdateClientDates);
}
this.removeItemFromRespectiveArray(item);
}
removeItemLocally(item, callback) {
_.pull(this.items, item);
super.removeItemLocally(item, callback);
item.isBeingRemovedLocally();
this.removeItemFromRespectiveArray(item);
this.itemsPendingRemoval.push(item.uuid);
this.storageManager.deleteModel(item).then(callback);
}
removeItemFromRespectiveArray(item) {
if(item.content_type == "Tag") {
_.pull(this.tags, item);
_.remove(this.tags, {uuid: item.uuid});
} else if(item.content_type == "Note") {
_.pull(this.notes, item);
} else if(item.content_type == "Extension") {
_.pull(this._extensions, item);
_.remove(this.notes, {uuid: item.uuid});
} else if(item.content_type == "SN|Component") {
_.remove(this.components, {uuid: item.uuid});
}
this.storageManager.deleteModel(item, callback);
}
/*
Relationships
*/
createRelationshipBetweenItems(itemOne, itemTwo) {
itemOne.addItemAsRelationship(itemTwo);
itemTwo.addItemAsRelationship(itemOne);
itemOne.setDirty(true);
itemTwo.setDirty(true);
notesMatchingPredicate(predicate) {
let contentTypePredicate = new SFPredicate("content_type", "=", "Note");
return this.itemsMatchingPredicates([contentTypePredicate, predicate]);
}
/*
Archives
*/
async getAllItemsJSONData(keys, authParams, protocolVersion, returnNullIfEmpty) {
return Promise.all(this.allItems.map((item) => {
var itemParams = new ItemParams(item, keys, protocolVersion);
return itemParams.paramsForExportFile();
})).then((items) => {
if(returnNullIfEmpty && items.length == 0) {
return null;
}
var data = {items: items}
if(keys) {
// auth params are only needed when encrypted with a standard file key
data["auth_params"] = authParams;
}
return JSON.stringify(data, null, 2 /* pretty print */);
})
}
/*
Misc
*/
@@ -492,6 +122,7 @@ class ModelManager {
return {
"Note" : "note",
"Tag" : "tag",
"SN|SmartTag": "smart tag",
"Extension" : "action-based extension",
"SN|Component" : "component",
"SN|Editor" : "editor",

View File

@@ -21,7 +21,10 @@ class NativeExtManager {
resolveExtensionsManager() {
this.singletonManager.registerSingleton({content_type: "SN|Component", package_info: {identifier: this.extensionsManagerIdentifier}}, (resolvedSingleton) => {
let contentTypePredicate = new SFPredicate("content_type", "=", "SN|Component");
let packagePredicate = new SFPredicate("package_info.identifier", "=", this.extensionsManagerIdentifier);
this.singletonManager.registerSingleton([contentTypePredicate, packagePredicate], (resolvedSingleton) => {
// Resolved Singleton
this.systemExtensions.push(resolvedSingleton.uuid);
@@ -40,7 +43,7 @@ class NativeExtManager {
if(needsSync) {
resolvedSingleton.setDirty(true);
this.syncManager.sync("resolveExtensionsManager");
this.syncManager.sync();
}
}, (valueCallback) => {
// Safe to create. Create and return object.
@@ -81,7 +84,7 @@ class NativeExtManager {
this.modelManager.addItem(component);
component.setDirty(true);
this.syncManager.sync("resolveExtensionsManager createNew");
this.syncManager.sync();
this.systemExtensions.push(component.uuid);
@@ -91,7 +94,10 @@ class NativeExtManager {
resolveBatchManager() {
this.singletonManager.registerSingleton({content_type: "SN|Component", package_info: {identifier: this.batchManagerIdentifier}}, (resolvedSingleton) => {
let contentTypePredicate = new SFPredicate("content_type", "=", "SN|Component");
let packagePredicate = new SFPredicate("package_info.identifier", "=", this.batchManagerIdentifier);
this.singletonManager.registerSingleton([contentTypePredicate, packagePredicate], (resolvedSingleton) => {
// Resolved Singleton
this.systemExtensions.push(resolvedSingleton.uuid);
@@ -110,7 +116,7 @@ class NativeExtManager {
if(needsSync) {
resolvedSingleton.setDirty(true);
this.syncManager.sync("resolveExtensionsManager");
this.syncManager.sync();
}
}, (valueCallback) => {
// Safe to create. Create and return object.
@@ -151,7 +157,7 @@ class NativeExtManager {
this.modelManager.addItem(component);
component.setDirty(true);
this.syncManager.sync("resolveBatchManager createNew");
this.syncManager.sync();
this.systemExtensions.push(component.uuid);

View File

@@ -7,7 +7,7 @@ angular.module('app')
function PasscodeManager($rootScope, $timeout, modelManager, dbManager, authManager, storageManager) {
this._hasPasscode = storageManager.getItem("offlineParams", StorageManager.Fixed) != null;
this._hasPasscode = storageManager.getItemSync("offlineParams", StorageManager.Fixed) != null;
this._locked = this._hasPasscode;
this.isLocked = function() {
@@ -23,11 +23,7 @@ angular.module('app')
}
this.passcodeAuthParams = function() {
return JSON.parse(storageManager.getItem("offlineParams", StorageManager.Fixed));
}
this.protocolVersion = function() {
return this._authParams && this._authParams.version;
return JSON.parse(storageManager.getItemSync("offlineParams", StorageManager.Fixed));
}
this.unlock = function(passcode, callback) {

View File

@@ -0,0 +1,25 @@
class SessionHistory extends SFSessionHistoryManager {
constructor(modelManager, storageManager, authManager, passcodeManager, $timeout) {
SFItemHistory.HistoryEntryClassMapping = {
"Note" : NoteHistoryEntry
}
var keyRequestHandler = async () => {
let offline = authManager.offline();
let auth_params = offline ? passcodeManager.passcodeAuthParams() : await authManager.getAuthParams();
let keys = offline ? passcodeManager.keys() : await authManager.keys();
return {
keys: keys,
offline: offline,
auth_params: auth_params
}
}
var contentTypes = ["Note"];
super(modelManager, storageManager, keyRequestHandler, contentTypes, $timeout);
}
}
angular.module('app').service('sessionHistory', SessionHistory);

View File

@@ -40,14 +40,14 @@ class SingletonManager {
})
}
registerSingleton(predicate, resolveCallback, createBlock) {
registerSingleton(predicates, resolveCallback, createBlock) {
/*
predicate: a key/value pair that specifies properties that should match in order for an item to be considered a predicate
resolveCallback: called when one or more items are deleted and a new item becomes the reigning singleton
createBlock: called when a sync is complete and no items are found. The createBlock should create the item and return it.
*/
this.singletonHandlers.push({
predicate: predicate,
predicates: predicates,
resolutionCallback: resolveCallback,
createBlock: createBlock
});
@@ -58,20 +58,20 @@ class SingletonManager {
savedItems = savedItems || [];
for(let singletonHandler of this.singletonHandlers) {
var predicate = singletonHandler.predicate;
let retrievedSingletonItems = this.filterItemsWithPredicate(retrievedItems, predicate);
var predicates = singletonHandler.predicates;
let retrievedSingletonItems = this.modelManager.filterItemsWithPredicates(retrievedItems, predicates);
// We only want to consider saved items count to see if it's more than 0, and do nothing else with it.
// This way we know there was some action and things need to be resolved. The saved items will come up
// in filterItemsWithPredicate(this.modelManager.allItems) and be deleted anyway
let savedSingletonItemsCount = this.filterItemsWithPredicate(savedItems, predicate).length;
let savedSingletonItemsCount = this.modelManager.filterItemsWithPredicates(savedItems, predicates).length;
if(retrievedSingletonItems.length > 0 || savedSingletonItemsCount > 0) {
/*
Check local inventory and make sure only 1 similar item exists. If more than 1, delete newest
Note that this local inventory will also contain whatever is in retrievedItems.
*/
var allExtantItemsMatchingPredicate = this.filterItemsWithPredicate(this.modelManager.allItems, predicate);
var allExtantItemsMatchingPredicate = this.modelManager.itemsMatchingPredicates(predicates);
/*
Delete all but the earliest created
@@ -92,7 +92,7 @@ class SingletonManager {
this.modelManager.setItemToBeDeleted(d);
}
this.$rootScope.sync("resolveSingletons");
this.$rootScope.sync();
// Send remaining item to callback
singletonHandler.singleton = winningItem;
@@ -122,35 +122,6 @@ class SingletonManager {
}
}
}
filterItemsWithPredicate(items, predicate) {
return items.filter((candidate) => {
return this.itemSatisfiesPredicate(candidate, predicate);
})
}
itemSatisfiesPredicate(candidate, predicate) {
for(var key in predicate) {
var predicateValue = predicate[key];
var candidateValue = candidate[key];
if(typeof predicateValue == 'object') {
// Check nested properties
if(!candidateValue) {
// predicateValue is 'object' but candidateValue is null
return false;
}
if(!this.itemSatisfiesPredicate(candidateValue, predicateValue)) {
return false;
}
}
else if(candidateValue != predicateValue) {
return false;
}
}
return true;
}
}
angular.module('app').service('singletonManager', SingletonManager);

View File

@@ -7,6 +7,10 @@ class MemoryStorage {
return this.memory[key] || null;
}
getItemSync(key) {
return this.getItem(key);
}
get length() {
return Object.keys(this.memory).length;
}
@@ -32,9 +36,10 @@ class MemoryStorage {
}
}
class StorageManager {
class StorageManager extends SFStorageManager {
constructor(dbManager) {
super();
this.dbManager = dbManager;
}
@@ -100,7 +105,7 @@ class StorageManager {
}
}
setItem(key, value, vaultKey) {
async setItem(key, value, vaultKey) {
var storage = this.getVault(vaultKey);
storage.setItem(key, value);
@@ -109,25 +114,21 @@ class StorageManager {
}
}
getItem(key, vault) {
async getItem(key, vault) {
return this.getItemSync(key, vault);
}
getItemSync(key, vault) {
var storage = this.getVault(vault);
return storage.getItem(key);
}
setBooleanValue(key, value, vault) {
this.setItem(key, JSON.stringify(value), vault);
}
getBooleanValue(key, vault) {
return JSON.parse(this.getItem(key, vault));
}
removeItem(key, vault) {
async removeItem(key, vault) {
var storage = this.getVault(vault);
storage.removeItem(key);
return storage.removeItem(key);
}
clear() {
async clear() {
this.memoryStorage.clear();
localStorage.clear();
}
@@ -148,29 +149,29 @@ class StorageManager {
}
writeEncryptedStorageToDisk() {
var encryptedStorage = new EncryptedStorage();
var encryptedStorage = new SNEncryptedStorage();
// Copy over totality of current storage
encryptedStorage.storage = this.storageAsHash();
encryptedStorage.content.storage = this.storageAsHash();
// Save new encrypted storage in Fixed storage
var params = new ItemParams(encryptedStorage, this.encryptedStorageKeys, this.encryptedStorageAuthParams.version);
var params = new SFItemParams(encryptedStorage, this.encryptedStorageKeys, this.encryptedStorageAuthParams);
params.paramsForSync().then((syncParams) => {
this.setItem("encryptedStorage", JSON.stringify(syncParams), StorageManager.Fixed);
})
}
async decryptStorage() {
var stored = JSON.parse(this.getItem("encryptedStorage", StorageManager.Fixed));
var stored = JSON.parse(this.getItemSync("encryptedStorage", StorageManager.Fixed));
await SFJS.itemTransformer.decryptItem(stored, this.encryptedStorageKeys);
var encryptedStorage = new EncryptedStorage(stored);
var encryptedStorage = new SNEncryptedStorage(stored);
for(var key of Object.keys(encryptedStorage.storage)) {
for(var key of Object.keys(encryptedStorage.content.storage)) {
this.setItem(key, encryptedStorage.storage[key]);
}
}
hasPasscode() {
return this.getItem("encryptedStorage", StorageManager.Fixed) !== null;
return this.getItemSync("encryptedStorage", StorageManager.Fixed) !== null;
}
@@ -196,36 +197,44 @@ class StorageManager {
this.modelStorageMode = mode;
}
getAllModels(callback) {
if(this.modelStorageMode == StorageManager.Fixed) {
this.dbManager.getAllModels(callback);
} else {
callback && callback();
}
async getAllModels() {
return new Promise((resolve, reject) => {
if(this.modelStorageMode == StorageManager.Fixed) {
this.dbManager.getAllModels(resolve);
} else {
resolve();
}
})
}
saveModel(item) {
this.saveModels([item]);
async saveModel(item) {
return this.saveModels([item]);
}
saveModels(items, onsuccess, onerror) {
if(this.modelStorageMode == StorageManager.Fixed) {
this.dbManager.saveModels(items, onsuccess, onerror);
} else {
onsuccess && onsuccess();
}
async saveModels(items, onsuccess, onerror) {
return new Promise((resolve, reject) => {
if(this.modelStorageMode == StorageManager.Fixed) {
this.dbManager.saveModels(items, resolve, reject);
} else {
resolve();
}
});
}
deleteModel(item, callback) {
if(this.modelStorageMode == StorageManager.Fixed) {
this.dbManager.deleteModel(item, callback);
} else {
callback && callback();
}
async deleteModel(item) {
return new Promise((resolve, reject) => {
if(this.modelStorageMode == StorageManager.Fixed) {
this.dbManager.deleteModel(item, resolve);
} else {
resolve();
}
});
}
clearAllModels(callback) {
this.dbManager.clearAllModels(callback);
async clearAllModels() {
return new Promise((resolve, reject) => {
this.dbManager.clearAllModels(resolve);
});
}
}

View File

@@ -1,583 +1,7 @@
class SyncManager {
class SyncManager extends SFSyncManager {
constructor($rootScope, modelManager, authManager, dbManager, httpManager, $interval, $timeout, storageManager, passcodeManager) {
this.$rootScope = $rootScope;
this.httpManager = httpManager;
this.modelManager = modelManager;
this.authManager = authManager;
this.dbManager = dbManager;
this.$interval = $interval;
this.$timeout = $timeout;
this.storageManager = storageManager;
this.passcodeManager = passcodeManager;
this.syncStatus = {};
this.syncStatusObservers = [];
}
get serverURL() {
return this.storageManager.getItem("server") || window._default_sf_server;
}
get masterKey() {
return this.storageManager.getItem("mk");
}
registerSyncStatusObserver(callback) {
var observer = {key: new Date(), callback: callback};
this.syncStatusObservers.push(observer);
return observer;
}
removeSyncStatusObserver(observer) {
_.pull(this.syncStatusObservers, observer);
}
syncStatusDidChange() {
this.syncStatusObservers.forEach((observer) => {
observer.callback(this.syncStatus);
})
}
writeItemsToLocalStorage(items, offlineOnly, callback) {
if(items.length == 0) {
callback && callback();
return;
}
var version = this.authManager.offline() ? this.passcodeManager.protocolVersion() : this.authManager.protocolVersion();
var keys = this.authManager.offline() ? this.passcodeManager.keys() : this.authManager.keys();
Promise.all(items.map(async (item) => {
var itemParams = new ItemParams(item, keys, version);
itemParams = await itemParams.paramsForLocalStorage();
if(offlineOnly) {
delete itemParams.dirty;
}
return itemParams;
})).then((params) => {
this.storageManager.saveModels(params, () => {
// on success
if(this.syncStatus.localError) {
this.syncStatus.localError = null;
this.syncStatusDidChange();
}
callback && callback();
}, (error) => {
// on error
this.syncStatus.localError = error;
this.syncStatusDidChange();
});
})
}
async loadLocalItems(callback) {
this.storageManager.getAllModels((items) => {
// break it up into chunks to make interface more responsive for large item counts
let total = items.length;
let iteration = 50;
var current = 0;
var processed = [];
var completion = () => {
Item.sortItemsByDate(processed);
callback(processed);
}
var decryptNext = async () => {
var subitems = items.slice(current, current + iteration);
var processedSubitems = await this.handleItemsResponse(subitems, null, ModelManager.MappingSourceLocalRetrieved);
processed.push(processedSubitems);
current += subitems.length;
if(current < total) {
this.$timeout(() => { decryptNext(); });
} else {
completion();
}
}
decryptNext();
})
}
syncOffline(items, callback) {
// Update all items updated_at to now
for(var item of items) {
item.updated_at = new Date();
}
this.writeItemsToLocalStorage(items, true, (responseItems) => {
// delete anything needing to be deleted
for(var item of items) {
if(item.deleted) {
this.modelManager.removeItemLocally(item);
}
}
this.$rootScope.$broadcast("sync:completed", {});
// Required in order for modelManager to notify sync observers
this.modelManager.didSyncModelsOffline(items);
if(callback) {
callback({success: true});
}
})
}
/*
In the case of signing in and merging local data, we alternative UUIDs
to avoid overwriting data a user may retrieve that has the same UUID.
Alternating here forces us to to create duplicates of the items instead.
*/
markAllItemsDirtyAndSaveOffline(callback, alternateUUIDs) {
// use a copy, as alternating uuid will affect array
var originalItems = this.modelManager.allItems.filter((item) => {return !item.errorDecrypting}).slice();
var block = () => {
var allItems = this.modelManager.allItems;
for(var item of allItems) {
item.setDirty(true);
}
this.writeItemsToLocalStorage(allItems, false, callback);
}
if(alternateUUIDs) {
var index = 0;
let alternateNextItem = () => {
if(index >= originalItems.length) {
// We don't use originalItems as alternating UUID will have deleted them.
block();
return;
}
var item = originalItems[index];
index++;
// alternateUUIDForItem last param is a boolean that controls whether the original item
// should be removed locally after new item is created. We set this to true, since during sign in,
// all item ids are alternated, and we only want one final copy of the entire data set.
// Passing false can be desired sometimes, when for example the app has signed out the user,
// but for some reason retained their data (This happens in Firefox when using private mode).
// In this case, we should pass false so that both copies are kept. However, it's difficult to
// detect when the app has entered this state. We will just use true to remove original items for now.
this.modelManager.alternateUUIDForItem(item, alternateNextItem, true);
}
alternateNextItem();
} else {
block();
}
}
get syncURL() {
return this.serverURL + "/items/sync";
}
set syncToken(token) {
this._syncToken = token;
this.storageManager.setItem("syncToken", token);
}
get syncToken() {
if(!this._syncToken) {
this._syncToken = this.storageManager.getItem("syncToken");
}
return this._syncToken;
}
set cursorToken(token) {
this._cursorToken = token;
if(token) {
this.storageManager.setItem("cursorToken", token);
} else {
this.storageManager.removeItem("cursorToken");
}
}
get cursorToken() {
if(!this._cursorToken) {
this._cursorToken = this.storageManager.getItem("cursorToken");
}
return this._cursorToken;
}
get queuedCallbacks() {
if(!this._queuedCallbacks) {
this._queuedCallbacks = [];
}
return this._queuedCallbacks;
}
clearQueuedCallbacks() {
this._queuedCallbacks = [];
}
callQueuedCallbacksAndCurrent(currentCallback, response) {
var allCallbacks = this.queuedCallbacks;
if(currentCallback) {
allCallbacks.push(currentCallback);
}
if(allCallbacks.length) {
for(var eachCallback of allCallbacks) {
eachCallback(response);
}
this.clearQueuedCallbacks();
}
}
beginCheckingIfSyncIsTakingTooLong() {
this.syncStatus.checker = this.$interval(function(){
// check to see if the ongoing sync is taking too long, alert the user
var secondsPassed = (new Date() - this.syncStatus.syncStart) / 1000;
var warningThreshold = 5.0; // seconds
if(secondsPassed > warningThreshold) {
this.$rootScope.$broadcast("sync:taking-too-long");
this.stopCheckingIfSyncIsTakingTooLong();
}
}.bind(this), 500)
}
stopCheckingIfSyncIsTakingTooLong() {
this.$interval.cancel(this.syncStatus.checker);
}
lockSyncing() {
this.syncLocked = true;
}
unlockSyncing() {
this.syncLocked = false;
}
async sync(callback, options = {}, source) {
if(this.syncLocked) {
console.log("Sync Locked, Returning;");
return;
}
if(!options) options = {};
if(typeof callback == 'string') {
// is source string, used to avoid filling parameters on call
source = callback;
callback = null;
}
// console.log("Syncing from", source);
var allDirtyItems = this.modelManager.getDirtyItems();
// When a user hits the physical refresh button, we want to force refresh, in case
// the sync engine is stuck in some inProgress loop.
if(this.syncStatus.syncOpInProgress && !options.force) {
this.repeatOnCompletion = true;
if(callback) {
this.queuedCallbacks.push(callback);
}
// write to local storage nonetheless, since some users may see several second delay in server response.
// if they close the browser before the ongoing sync request completes, local changes will be lost if we dont save here
this.writeItemsToLocalStorage(allDirtyItems, false, null);
console.log("Sync op in progress; returning.");
return;
}
// 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.syncStatus.needsMoreSync;
this.syncStatus.syncOpInProgress = true;
this.syncStatus.syncStart = new Date();
this.beginCheckingIfSyncIsTakingTooLong();
let submitLimit = 100;
var subItems = allDirtyItems.slice(0, submitLimit);
if(subItems.length < allDirtyItems.length) {
// more items left to be synced, repeat
this.syncStatus.needsMoreSync = true;
} else {
this.syncStatus.needsMoreSync = false;
}
if(!isContinuationSync) {
this.syncStatus.total = allDirtyItems.length;
this.syncStatus.current = 0;
}
// If items are marked as dirty during a long running sync request, total isn't updated
// This happens mostly in the case of large imports and sync conflicts where duplicated items are created
if(this.syncStatus.current > this.syncStatus.total) {
this.syncStatus.total = this.syncStatus.current;
}
// when doing a sync request that returns items greater than the limit, and thus subsequent syncs are required,
// we want to keep track of all retreived items, then save to local storage only once all items have been retrieved,
// so that relationships remain intact
if(!this.allRetreivedItems) {
this.allRetreivedItems = [];
}
// We also want to do this for savedItems
if(!this.allSavedItems) {
this.allSavedItems = [];
}
var version = this.authManager.protocolVersion();
var keys = this.authManager.keys();
var params = {};
params.limit = 150;
await Promise.all(subItems.map((item) => {
var itemParams = new ItemParams(item, keys, version);
itemParams.additionalFields = options.additionalFields;
return itemParams.paramsForSync();
})).then((itemsParams) => {
params.items = itemsParams;
})
for(var item of subItems) {
// Reset dirty counter to 0, since we're about to sync it.
// This means anyone marking the item as dirty after this will cause it so sync again and not be cleared on sync completion.
item.dirtyCount = 0;
}
params.sync_token = this.syncToken;
params.cursor_token = this.cursorToken;
var onSyncCompletion = function(response) {
this.stopCheckingIfSyncIsTakingTooLong();
}.bind(this);
var onSyncSuccess = async function(response) {
// Check to make sure any subItem hasn't been marked as dirty again while a sync was ongoing
var itemsToClearAsDirty = [];
for(var item of subItems) {
if(item.dirtyCount == 0) {
// Safe to clear as dirty
itemsToClearAsDirty.push(item);
}
}
this.modelManager.clearDirtyItems(itemsToClearAsDirty);
this.syncStatus.error = null;
this.$rootScope.$broadcast("sync:updated_token", this.syncToken);
// Filter retrieved_items to remove any items that may be in saved_items for this complete sync operation
// When signing in, and a user requires many round trips to complete entire retrieval of data, an item may be saved
// on the first trip, then on subsequent trips using cursor_token, this same item may be returned, since it's date is
// greater than cursor_token. We keep track of all saved items in whole sync operation with this.allSavedItems
// We need this because singletonManager looks at retrievedItems as higher precendence than savedItems, but if it comes in both
// then that's problematic.
let allSavedUUIDs = this.allSavedItems.map((item) => {return item.uuid});
response.retrieved_items = response.retrieved_items.filter((candidate) => {return !allSavedUUIDs.includes(candidate.uuid)});
// Map retrieved items to local data
// Note that deleted items will not be returned
var retrieved = await this.handleItemsResponse(response.retrieved_items, null, ModelManager.MappingSourceRemoteRetrieved);
// Append items to master list of retrieved items for this ongoing sync operation
this.allRetreivedItems = this.allRetreivedItems.concat(retrieved);
// Merge only metadata for saved items
// we write saved items to disk now because it clears their dirty status then saves
// if we saved items before completion, we had have to save them as dirty and save them again on success as clean
var omitFields = ["content", "auth_hash"];
// Map saved items to local data
var saved = await this.handleItemsResponse(response.saved_items, omitFields, ModelManager.MappingSourceRemoteSaved);
// Append items to master list of saved items for this ongoing sync operation
this.allSavedItems = this.allSavedItems.concat(saved);
// Create copies of items or alternate their uuids if neccessary
var unsaved = response.unsaved;
this.handleUnsavedItemsResponse(unsaved)
this.writeItemsToLocalStorage(saved, 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;
onSyncCompletion(response);
if(this.cursorToken || this.syncStatus.needsMoreSync) {
setTimeout(function () {
this.sync(callback, options, "onSyncSuccess cursorToken || needsMoreSync");
}.bind(this), 10); // wait 10ms to allow UI to update
} else if(this.repeatOnCompletion) {
this.repeatOnCompletion = false;
setTimeout(function () {
this.sync(callback, options, "onSyncSuccess repeatOnCompletion");
}.bind(this), 10); // wait 10ms to allow UI to update
} else {
this.writeItemsToLocalStorage(this.allRetreivedItems, false, null);
// The number of changed items that constitute a major change
// This is used by the desktop app to create backups
let majorDataChangeThreshold = 10;
if(
this.allRetreivedItems.length >= majorDataChangeThreshold ||
saved.length >= majorDataChangeThreshold ||
unsaved.length >= majorDataChangeThreshold
) {
this.$rootScope.$broadcast("major-data-change");
}
this.callQueuedCallbacksAndCurrent(callback, response);
this.$rootScope.$broadcast("sync:completed", {retrievedItems: this.allRetreivedItems, savedItems: this.allSavedItems});
this.allRetreivedItems = [];
this.allSavedItems = [];
}
}.bind(this);
try {
this.httpManager.postAbsolute(this.syncURL, params, function(response){
try {
onSyncSuccess(response);
} catch(e) {
console.log("Caught sync success exception:", e);
}
}.bind(this), function(response, statusCode){
if(statusCode == 401) {
alert("Your session has expired. New changes will not be pulled in. Please sign out and sign back in to refresh your session.");
}
console.log("Sync error: ", response);
var error = response ? response.error : {message: "Could not connect to server."};
this.syncStatus.syncOpInProgress = false;
this.syncStatus.error = error;
this.writeItemsToLocalStorage(allDirtyItems, false, null);
onSyncCompletion(response);
this.$rootScope.$broadcast("sync:error", error);
this.callQueuedCallbacksAndCurrent(callback, {error: "Sync error"});
}.bind(this));
}
catch(e) {
console.log("Sync exception caught:", e);
}
}
async handleItemsResponse(responseItems, omitFields, source) {
var keys = this.authManager.keys() || this.passcodeManager.keys();
await SFJS.itemTransformer.decryptMultipleItems(responseItems, keys);
var items = this.modelManager.mapResponseItemsToLocalModelsOmittingFields(responseItems, omitFields, source);
// During the decryption process, items may be marked as "errorDecrypting". If so, we want to be sure
// to persist this new state by writing these items back to local storage. When an item's "errorDecrypting"
// flag is changed, its "errorDecryptingValueChanged" flag will be set, so we can find these items by filtering (then unsetting) below:
var itemsWithErrorStatusChange = items.filter((item) => {
var valueChanged = item.errorDecryptingValueChanged;
// unset after consuming value
item.errorDecryptingValueChanged = false;
return valueChanged;
});
if(itemsWithErrorStatusChange.length > 0) {
this.writeItemsToLocalStorage(itemsWithErrorStatusChange, false, null);
}
return items;
}
refreshErroredItems() {
var erroredItems = this.modelManager.allItems.filter((item) => {return item.errorDecrypting == true});
if(erroredItems.length > 0) {
this.handleItemsResponse(erroredItems, null, ModelManager.MappingSourceLocalRetrieved);
}
}
async handleUnsavedItemsResponse(unsaved) {
if(unsaved.length == 0) {
return;
}
console.log("Handle unsaved", unsaved);
var i = 0;
var handleNext = async () => {
if(i >= unsaved.length) {
// Handled all items
this.sync(null, {additionalFields: ["created_at", "updated_at"]});
return;
}
var mapping = unsaved[i];
var itemResponse = mapping.item;
await SFJS.itemTransformer.decryptMultipleItems([itemResponse], this.authManager.keys());
var item = this.modelManager.findItem(itemResponse.uuid);
if(!item) {
// Could be deleted
return;
}
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 from the old account into a new account
this.modelManager.alternateUUIDForItem(item, () => {
i++;
handleNext();
}, true);
}
else if(error.tag === "sync_conflict") {
// Create a new item with the same contents of this item if the contents differ
// We want a new uuid for the new item. Note that this won't neccessarily adjust references.
itemResponse.uuid = null;
var dup = this.modelManager.createDuplicateItem(itemResponse);
if(!itemResponse.deleted && !item.isItemContentEqualWith(dup)) {
this.modelManager.addItem(dup);
dup.conflict_of = item.uuid;
dup.setDirty(true);
}
i++;
handleNext();
}
}
handleNext();
}
clearSyncToken() {
this.storageManager.removeItem("syncToken");
}
destroyLocalData(callback) {
this.storageManager.clear();
this.storageManager.clearAllModels(function(){
if(callback) {
this.$timeout(function(){
callback();
})
}
}.bind(this));
constructor(modelManager, storageManager, httpManager, $timeout, $interval) {
super(modelManager, storageManager, httpManager, $timeout, $interval);
}
}

View File

@@ -62,7 +62,7 @@ $heading-height: 75px;
text-align: right;
color: rgba(black, 0.23);
.error {
&.error, .error {
color: #f6a200;
}
}

View File

@@ -16,6 +16,13 @@
font-size: 16px;
}
#item-preview-modal {
> .content {
width: 800px;
height: 500px;
}
}
.panel {
background-color: white;
}
@@ -99,6 +106,7 @@
.component-view {
flex-grow: 1;
display: flex;
flex-direction: column;
// not sure why we need this. Removed because it creates unncessary scroll bars. Tested on folders extension, creates horizontal scrollbar at bottom on windows
// overflow: auto;

View File

@@ -87,10 +87,13 @@
height: inherit;
// Autohide scrollbar on Windows.
// Unfortunately must affect every platform since no way to hide just for Windows.
overflow-y: hidden;
&:hover {
overflow-y: scroll;
@at-root {
.windows-web &, .windows-desktop & {
overflow-y: hidden;
&:hover {
overflow-y: scroll;
}
}
}
}

View File

@@ -18,6 +18,11 @@
}
.app-bar {
&.no-top-edge {
border-top: 0;
}
}
}
@@ -48,3 +53,9 @@
color: $blue-color;
}
}
#session-history-menu {
.menu-panel .row .sublabel.opaque {
opacity: 1.0
}
}

View File

@@ -159,8 +159,8 @@
%button.button.info{"type" => "submit"}
.label Decrypt & Import
%p
Importing from backup will overwrite existing notes with matching note from backup. Existing notes not found in the backup will remain as-is and won't be overwritten.
%p If you'd like to import only a selection of notes instead of the whole file, please use the Batch Manager extension instead.
Importing from backup will not overwrite existing data, but instead create a duplicate of any differing data.
%p If you'd like to import only a selection of items instead of the whole file, please use the Batch Manager extension.
.panel-row
.spinner.small.info{"ng-if" => "importData.loading"}
.footer

View File

@@ -19,14 +19,4 @@
%strong {{action.access_type}}
access to this note.
.modal.medium-text.medium{"ng-if" => "renderData.showRenderModal", "ng-click" => "$event.stopPropagation();"}
.content
.sn-component
.panel
.header
%h1.title Preview
%a.close-button.info{"ng-click" => "renderData.showRenderModal = false; $event.stopPropagation();"} Close
.content.selectable
%h2 {{renderData.title}}
%p.normal{"style" => "white-space: pre-wrap; font-size: 16px;"} {{renderData.text}}
%menu-row{"ng-if" => "extension.actionsWithContextForItem(item).length == 0", "label" => "'No Actions Available'", "faded" => "true"}

View File

@@ -1,3 +1,12 @@
.sn-component{"ng-if" => "issueLoading"}
.app-bar.no-edges.no-top-edge
.left
.item
.label.warning There was an issue loading {{component.name}}.
.right
.item{"ng-click" => "reloadComponent()"}
.label Reload
.sn-component{"ng-if" => "error == 'expired'"}
.panel.static
.content

View File

@@ -0,0 +1,13 @@
.modal.medium#item-preview-modal
.content
.sn-component
.panel
.header
%h1.title Preview
.horizontal-group
%a.close-button.info{"ng-click" => "restore(false)"} Restore
%a.close-button.info{"ng-click" => "restore(true)"} Restore as copy
%a.close-button.info{"ng-click" => "dismiss(); $event.stopPropagation()"} Close
.content.selectable
%h2 {{content.title}}
%p.normal{"style" => "white-space: pre-wrap; font-size: 16px;"} {{content.text}}

View File

@@ -0,0 +1,23 @@
.sn-component#session-history-menu
.menu-panel.dropdown-menu
.header
.column
%h4.title {{history.entries.length || 'No'}} revisions
%h4{"ng-click" => "showOptions = !showOptions; $event.stopPropagation();"}
%a Options
%div{"ng-if" => "showOptions"}
%menu-row{"label" => "'Clear note local history'", "ng-click" => "clearItemHistory(); $event.stopPropagation();"}
%menu-row{"label" => "'Clear all local history'", "ng-click" => "clearAllHistory(); $event.stopPropagation();"}
%menu-row{"label" => "(autoOptimize ? 'Disable' : 'Enable') + ' auto cleanup'", "ng-click" => "toggleAutoOptimize(); $event.stopPropagation();"}
.sublabel
Automatically cleans up small revisions to conserve space.
%menu-row{"label" => "(diskEnabled ? 'Disable' : 'Enable') + ' saving history to disk'", "ng-click" => "toggleDiskSaving(); $event.stopPropagation();"}
.sublabel
Saving to disk may increase app loading time and memory footprint.
%menu-row{"ng-repeat" => "revision in history.entries",
"ng-click" => "openRevision(revision); $event.stopPropagation();",
"label" => "revision.previewTitle()"}
.sublabel.opaque{"ng-class" => "classForRevision(revision)"}
{{revision.previewSubTitle()}}

View File

@@ -50,6 +50,10 @@
.label Actions
%actions-menu{"ng-if" => "ctrl.showExtensions", "item" => "ctrl.note"}
.item{"ng-click" => "ctrl.showSessionHistory = !ctrl.showSessionHistory; ctrl.showMenu = false; ctrl.showEditorMenu = false;", "click-outside" => "ctrl.showSessionHistory = false;", "is-open" => "ctrl.showSessionHistory"}
.label Session History
%session-history-menu{"ng-if" => "ctrl.showSessionHistory", "item" => "ctrl.note"}
.editor-content#editor-content{"ng-if" => "ctrl.noteReady && !ctrl.note.errorDecrypting"}
%panel-resizer.left{"panel-id" => "'editor-content'", "on-resize-finish" => "ctrl.onPanelResizeFinish","control" => "ctrl.resizeControl", "min-width" => 300, "property" => "'left'", "hoverable" => "true"}
@@ -58,7 +62,7 @@
%textarea.editable#note-text-editor{"ng-if" => "!ctrl.selectedEditor", "ng-model" => "ctrl.note.text", "ng-readonly" => "ctrl.note.locked",
"ng-change" => "ctrl.contentChanged()", "ng-trim" => "false", "ng-click" => "ctrl.clickedTextArea()",
"ng-focus" => "ctrl.onContentFocus()", "dir" => "auto", "ng-attr-spellcheck" => "{{ctrl.spellcheck}}"}
"ng-focus" => "ctrl.onContentFocus()", "dir" => "auto", "ng-attr-spellcheck" => "{{ctrl.spellcheck}}", "ng-model-options"=>"{ debounce: 200 }"}
{{ctrl.onSystemEditorLoad()}}
%panel-resizer{"panel-id" => "'editor-content'", "on-resize-finish" => "ctrl.onPanelResizeFinish","control" => "ctrl.resizeControl", "min-width" => 300, "hoverable" => "true", "property" => "'right'"}

View File

@@ -1,11 +1,11 @@
.main-ui-view{"ng-class" => "platform"}
%lock-screen{"ng-if" => "needsUnlock", "on-success" => "onSuccessfulUnlock"}
.app#app{"ng-if" => "!needsUnlock"}
%tags-section{"save" => "tagsSave", "add-new" => "tagsAddNew", "will-select" => "tagsWillMakeSelection", "selection-made" => "tagsSelectionMade",
%tags-section{"save" => "tagsSave", "add-new" => "tagsAddNew", "selection-made" => "tagsSelectionMade",
"all-tag" => "allTag", "archive-tag" => "archiveTag", "tags" => "tags", "remove-tag" => "removeTag"}
%notes-section{"add-new" => "notesAddNew", "selection-made" => "notesSelectionMade", "tag" => "selectedTag"}
%editor-section{"note" => "selectedNote", "remove" => "deleteNote", "save" => "saveNote", "update-tags" => "updateTagsForNote"}
%editor-section{"note" => "selectedNote", "remove" => "deleteNote", "update-tags" => "updateTagsForNote"}
%footer{"ng-if" => "!needsUnlock"}

View File

@@ -30,7 +30,7 @@
%menu-row{"label" => "'Date Modified'", "circle" => "ctrl.sortBy == 'client_updated_at' && 'success'", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.selectedSortByUpdated()", "desc" => "'Sort notes with the most recently updated first'"}
%menu-row{"label" => "'Title'", "circle" => "ctrl.sortBy == 'title' && 'success'", "ng-click" => "ctrl.selectedMenuItem($event); ctrl.selectedSortByTitle()", "desc" => "'Sort notes alphabetically by their title'"}
.section{"ng-if" => "!ctrl.tag.archiveTag"}
.section{"ng-if" => "!ctrl.tag.isSmartTag()"}
.header
%h4.title Display
@@ -52,7 +52,7 @@
%i.icon.ion-bookmark
%strong.medium-text Pinned
.archived.tinted{"ng-if" => "note.archived && !ctrl.tag.archiveTag", "ng-class" => "{'tinted-selected' : ctrl.selectedNote == note}"}
.archived.tinted{"ng-if" => "note.archived && !ctrl.tag.isSmartTag()", "ng-class" => "{'tinted-selected' : ctrl.selectedNote == note}"}
%i.icon.ion-ios-box
%strong.medium-text Archived

6609
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,12 @@
"karma-jasmine": "^1.1.0",
"karma-phantomjs-launcher": "^1.0.2",
"sn-stylekit": "1.0.15",
"standard-file-js": "0.3.1"
"standard-file-js": "0.3.4",
"sn-models": "0.1.1",
"connect": "^3.6.6",
"mocha": "^5.2.0",
"serve-static": "^1.13.2",
"chai": "^4.1.2"
},
"license": "GPL-3.0"
}

View File

View File

@@ -1,7 +0,0 @@
require 'test_helper'
class ApikeyControllerTest < ActionController::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -1,7 +0,0 @@
require 'test_helper'
class NamesControllerTest < ActionController::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -1,7 +0,0 @@
require 'test_helper'
class ProtoControllerTest < ActionController::TestCase
# test "the truth" do
# assert true
# end
end

0
test/fixtures/.keep vendored
View File

View File

@@ -1,7 +0,0 @@
# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
access_token: MyString
two:
access_token: MyString

View File

@@ -1,7 +0,0 @@
# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
text: MyString
two:
text: MyString

View File

View File

View File

@@ -6,11 +6,6 @@ describe("date filter", function() {
$filter = _$filter_;
}));
it('returns a defined time', function() {
var date = $filter('appDate');
expect(date(Date())).toBeDefined();
});
it('returns time', function() {
var dateTime = $filter('appDateTime');
expect(dateTime(Date())).toBeDefined();

View File

87
test/mocha/lib/factory.js Normal file
View File

@@ -0,0 +1,87 @@
import '../../../vendor/assets/javascripts/compiled.js';
import '../../../node_modules/chai/chai.js';
import '../vendor/chai-as-promised-built.js';
import '../../../vendor/assets/javascripts/lodash/lodash.custom.js';
import LocalStorageManager from './localStorageManager.js';
const sf_default = new StandardFile();
SFItem.AppDomain = "org.standardnotes.sn";
var _globalStorageManager = null;
var _globalHttpManager = null;
var _globalAuthManager = null;
var _globalModelManager = null;
var _globalStandardFile = null;
export default class Factory {
static initialize() {
this.globalStorageManager();
this.globalHttpManager();
this.globalAuthManager();
this.globalModelManager();
}
static globalStorageManager() {
if(_globalStorageManager == null) { _globalStorageManager = new LocalStorageManager(); }
return _globalStorageManager;
}
static globalHttpManager() {
if(_globalHttpManager == null) {
_globalHttpManager = new SFHttpManager();
_globalHttpManager.setJWTRequestHandler(async () => {
return this.globalStorageManager().getItem("jwt");;
})
}
return _globalHttpManager;
}
static globalAuthManager() {
if(_globalAuthManager == null) { _globalAuthManager = new SFAuthManager(_globalStorageManager, _globalHttpManager); }
return _globalAuthManager;
}
static globalModelManager() {
if(_globalModelManager == null) { _globalModelManager = new SFModelManager(); }
return _globalModelManager;
}
static globalStandardFile() {
if(_globalStandardFile == null) { _globalStandardFile = new StandardFile(); }
return _globalStandardFile;
}
static createModelManager() {
return new SFModelManager();
}
static createItemParams() {
var params = {
uuid: SFJS.crypto.generateUUIDSync(),
content_type: "Note",
content: {
title: "hello",
text: "world"
}
};
return params;
}
static createItem() {
return new SFItem(this.createItemParams());
}
static serverURL() {
return "http://localhost:3000";
}
static async newRegisteredUser(email, password) {
let url = this.serverURL();
if(!email) email = sf_default.crypto.generateUUIDSync();
if(!password) password = sf_default.crypto.generateUUIDSync();
return this.globalAuthManager().register(url, email, password, false);
}
}
Factory.initialize();

View File

@@ -0,0 +1,69 @@
// A test StorageManager class using LocalStorage
export default class LocalStorageManager extends SFStorageManager {
/* Simple Key/Value Storage */
async setItem(key, value, vaultKey) {
localStorage.setItem(key, value);
}
async getItem(key, vault) {
return localStorage.getItem(key)
}
async removeItem(key, vault) {
localStorage.removeItem(key);
}
async clear() {
// clear only simple key/values
localStorage.clear();
}
/*
Model Storage
*/
async getAllModels() {
var models = [];
for(var key in localStorage) {
if(key.startsWith("item-")) {
models.push(JSON.parse(localStorage[key]))
}
}
return models;
}
async saveModel(item) {
return this.saveModels([item]);
}
async saveModels(items) {
return Promise.all(items.map((item) => {
return this.setItem(`item-${item.uuid}`, JSON.stringify(item));
}))
}
async deleteModel(item,) {
return this.removeItem(`item-${item.uuid}`);
}
async clearAllModels() {
// clear only models
for(var key in localStorage) {
if(key.startsWith("item-")) {
this.removeItem(key);
}
}
}
/* General */
clearAllData() {
return Promise.all([
this.clear(),
this.clearAllModels()
])
}
}

548
test/mocha/models.test.js Normal file
View File

@@ -0,0 +1,548 @@
import '../../vendor/assets/javascripts/compiled.js';
import '../../node_modules/chai/chai.js';
import './vendor/chai-as-promised-built.js';
import '../../vendor/assets/javascripts/lodash/lodash.custom.js';
import Factory from './lib/factory.js';
chai.use(chaiAsPromised);
var expect = chai.expect;
const getNoteParams = () => {
var params = {
uuid: SFJS.crypto.generateUUIDSync(),
content_type: "Note",
content: {
title: "hello",
text: "world"
}
};
return params;
}
const createRelatedNoteTagPair = () => {
let noteParams = getNoteParams();
let tagParams = {
uuid: SFJS.crypto.generateUUIDSync(),
content_type: "Tag",
content: {
title: "thoughts",
}
};
tagParams.content.references = [
{
uuid: noteParams.uuid,
content_type: noteParams.content_type
}
]
noteParams.content.references = []
return [noteParams, tagParams];
}
describe("notes and tags", () => {
it('uses proper class for note', () => {
let modelManager = Factory.createModelManager();
let noteParams = getNoteParams();
modelManager.mapResponseItemsToLocalModels([noteParams]);
let note = modelManager.allItemsMatchingTypes(["Note"])[0];
expect(note).to.be.an.instanceOf(SNNote);
});
it('properly handles legacy relationships', () => {
// legacy relationships are when a note has a reference to a tag
let modelManager = Factory.createModelManager();
let pair = createRelatedNoteTagPair();
let noteParams = pair[0];
let tagParams = pair[1];
tagParams.content.references = null;
noteParams.content.references = [
{
uuid: tagParams.uuid,
content_type: tagParams.content_type
}
];
modelManager.mapResponseItemsToLocalModels([noteParams, tagParams]);
let note = modelManager.allItemsMatchingTypes(["Note"])[0];
let tag = modelManager.allItemsMatchingTypes(["Tag"])[0];
expect(note.tags.length).to.equal(1);
expect(tag.notes.length).to.equal(1);
})
it('creates two-way relationship between note and tag', () => {
let modelManager = Factory.createModelManager();
let pair = createRelatedNoteTagPair();
let noteParams = pair[0];
let tagParams = pair[1];
expect(noteParams.content.references.length).to.equal(0);
expect(tagParams.content.references.length).to.equal(1);
modelManager.mapResponseItemsToLocalModels([noteParams, tagParams]);
let note = modelManager.allItemsMatchingTypes(["Note"])[0];
let tag = modelManager.allItemsMatchingTypes(["Tag"])[0];
// expect to be false
expect(note.dirty).to.not.be.ok;
expect(tag.dirty).to.not.be.ok;
expect(note.content.references.length).to.equal(0);
expect(tag.content.references.length).to.equal(1);
expect(note.hasRelationshipWithItem(tag)).to.equal(false);
expect(tag.hasRelationshipWithItem(note)).to.equal(true);
expect(note.tags.length).to.equal(1);
expect(tag.notes.length).to.equal(1);
modelManager.setItemToBeDeleted(note);
expect(note.tags.length).to.equal(0);
expect(tag.notes.length).to.equal(0);
// expect to be true
expect(note.dirty).to.be.ok;
expect(tag.dirty).to.be.ok;
});
it('handles remote deletion of relationship', () => {
let modelManager = Factory.createModelManager();
let pair = createRelatedNoteTagPair();
let noteParams = pair[0];
let tagParams = pair[1];
modelManager.mapResponseItemsToLocalModels([noteParams, tagParams]);
let note = modelManager.allItemsMatchingTypes(["Note"])[0];
let tag = modelManager.allItemsMatchingTypes(["Tag"])[0];
expect(note.content.references.length).to.equal(0);
expect(tag.content.references.length).to.equal(1);
tagParams.content.references = [];
modelManager.mapResponseItemsToLocalModels([tagParams]);
expect(tag.content.references.length).to.equal(0);
expect(note.tags.length).to.equal(0);
expect(tag.notes.length).to.equal(0);
// expect to be false
expect(note.dirty).to.not.be.ok;
expect(tag.dirty).to.not.be.ok;
});
it('resets cached note tags string when tag is deleted from remote source', () => {
let modelManager = Factory.createModelManager();
let pair = createRelatedNoteTagPair();
let noteParams = pair[0];
let tagParams = pair[1];
modelManager.mapResponseItemsToLocalModels([noteParams, tagParams]);
let note = modelManager.allItemsMatchingTypes(["Note"])[0];
let tag = modelManager.allItemsMatchingTypes(["Tag"])[0];
expect(note.tagsString().length).to.not.equal(0);
tagParams.deleted = true;
modelManager.mapResponseItemsToLocalModels([tagParams]);
// should be null
expect(note.savedTagsString).to.not.be.ok;
expect(note.tags.length).to.equal(0);
expect(tag.notes.length).to.equal(0);
});
it('resets cached note tags string when tag reference is removed from remote source', () => {
let modelManager = Factory.createModelManager();
let pair = createRelatedNoteTagPair();
let noteParams = pair[0];
let tagParams = pair[1];
modelManager.mapResponseItemsToLocalModels([noteParams, tagParams]);
let note = modelManager.allItemsMatchingTypes(["Note"])[0];
let tag = modelManager.allItemsMatchingTypes(["Tag"])[0];
expect(note.tagsString().length).to.not.equal(0);
tagParams.content.references = [];
modelManager.mapResponseItemsToLocalModels([tagParams]);
// should be null
expect(note.savedTagsString).to.not.be.ok;
expect(note.tags.length).to.equal(0);
expect(tag.notes.length).to.equal(0);
});
it('resets cached note tags string when tag is renamed', () => {
let modelManager = Factory.createModelManager();
let pair = createRelatedNoteTagPair();
let noteParams = pair[0];
let tagParams = pair[1];
modelManager.mapResponseItemsToLocalModels([noteParams, tagParams]);
let note = modelManager.allItemsMatchingTypes(["Note"])[0];
let tag = modelManager.allItemsMatchingTypes(["Tag"])[0];
expect(note.tagsString()).to.equal(`#${tagParams.content.title}`);
var title = Math.random();
// Saving involves modifying local state first, then syncing with omitting content.
tag.title = title;
tagParams.content.title = title;
// simulate a save, which omits `content`
modelManager.mapResponseItemsToLocalModelsOmittingFields([tagParams], ['content']);
// should be null
expect(note.savedTagsString).to.not.be.ok;
expect(note.tagsString()).to.equal(`#${title}`);
});
it('handles removing relationship between note and tag', () => {
let modelManager = Factory.createModelManager();
let pair = createRelatedNoteTagPair();
let noteParams = pair[0];
let tagParams = pair[1];
modelManager.mapResponseItemsToLocalModels([noteParams, tagParams]);
let note = modelManager.allItemsMatchingTypes(["Note"])[0];
let tag = modelManager.allItemsMatchingTypes(["Tag"])[0];
expect(note.content.references.length).to.equal(0);
expect(tag.content.references.length).to.equal(1);
tag.removeItemAsRelationship(note);
modelManager.mapResponseItemsToLocalModels([tag]);
expect(note.tags.length).to.equal(0);
expect(tag.notes.length).to.equal(0);
});
it('properly handles tag duplication', () => {
let modelManager = Factory.createModelManager();
let pair = createRelatedNoteTagPair();
let noteParams = pair[0];
let tagParams = pair[1];
modelManager.mapResponseItemsToLocalModels([noteParams, tagParams]);
let note = modelManager.allItemsMatchingTypes(["Note"])[0];
let tag = modelManager.allItemsMatchingTypes(["Tag"])[0];
// Usually content_type will be provided by a server response
var duplicateParams = _.merge({content_type: "Tag"}, tag);
duplicateParams.uuid = null;
expect(duplicateParams.content_type).to.equal("Tag");
var duplicateTag = modelManager.createDuplicateItem(duplicateParams);
modelManager.addDuplicatedItem(duplicateTag, tag);
expect(tag.uuid).to.not.equal(duplicateTag.uuid);
expect(tag.content.references.length).to.equal(1);
expect(tag.notes.length).to.equal(1);
expect(duplicateTag.content.references.length).to.equal(1);
expect(duplicateTag.notes.length).to.equal(1);
expect(note.tags.length).to.equal(2);
var noteTag1 = note.tags[0];
var noteTag2 = note.tags[1];
expect(noteTag1.uuid).to.not.equal(noteTag2.uuid);
// expect to be false
expect(note.dirty).to.not.be.ok;
expect(tag.dirty).to.not.be.ok;
});
it('duplicating a note should maintain its tag references', () => {
let modelManager = Factory.createModelManager();
let pair = createRelatedNoteTagPair();
let noteParams = pair[0];
let tagParams = pair[1];
modelManager.mapResponseItemsToLocalModels([noteParams, tagParams]);
let note = modelManager.allItemsMatchingTypes(["Note"])[0];
let tag = modelManager.allItemsMatchingTypes(["Tag"])[0];
// Usually content_type will be provided by a server response
var duplicateParams = _.merge({content_type: "Note"}, note);
duplicateParams.uuid = null;
var duplicateNote = modelManager.createDuplicateItem(duplicateParams);
modelManager.addDuplicatedItem(duplicateNote, note);
expect(note.uuid).to.not.equal(duplicateNote.uuid);
expect(duplicateNote.tags.length).to.equal(note.tags.length);
});
it('deleting a note should update tag references', () => {
let modelManager = Factory.createModelManager();
let pair = createRelatedNoteTagPair();
let noteParams = pair[0];
let tagParams = pair[1];
modelManager.mapResponseItemsToLocalModels([noteParams, tagParams]);
let note = modelManager.allItemsMatchingTypes(["Note"])[0];
let tag = modelManager.allItemsMatchingTypes(["Tag"])[0];
expect(tag.content.references.length).to.equal(1);
expect(tag.notes.length).to.equal(1);
expect(note.content.references.length).to.equal(0);
expect(note.tags.length).to.equal(1);
modelManager.setItemToBeDeleted(tag);
modelManager.mapResponseItemsToLocalModels([tag]);
expect(tag.content.references.length).to.equal(0);
expect(tag.notes.length).to.equal(0);
});
it('importing existing data should keep relationships valid', () => {
let modelManager = Factory.createModelManager();
let pair = createRelatedNoteTagPair();
let noteParams = pair[0];
let tagParams = pair[1];
modelManager.mapResponseItemsToLocalModels([noteParams, tagParams]);
let note = modelManager.allItemsMatchingTypes(["Note"])[0];
let tag = modelManager.allItemsMatchingTypes(["Tag"])[0];
expect(tag.content.references.length).to.equal(1);
expect(tag.notes.length).to.equal(1);
expect(note.content.references.length).to.equal(0);
expect(note.tags.length).to.equal(1);
modelManager.importItems([noteParams, tagParams]);
expect(modelManager.allItems.length).to.equal(2);
expect(tag.content.references.length).to.equal(1);
expect(tag.notes.length).to.equal(1);
expect(note.content.references.length).to.equal(0);
expect(note.referencingObjects.length).to.equal(1);
expect(note.tags.length).to.equal(1);
});
it('importing data with differing content should create duplicates', () => {
let modelManager = Factory.createModelManager();
let pair = createRelatedNoteTagPair();
let noteParams = pair[0];
let tagParams = pair[1];
modelManager.mapResponseItemsToLocalModels([noteParams, tagParams]);
let note = modelManager.allItemsMatchingTypes(["Note"])[0];
let tag = modelManager.allItemsMatchingTypes(["Tag"])[0];
noteParams.content.title = Math.random();
tagParams.content.title = Math.random();
modelManager.importItems([noteParams, tagParams]);
expect(modelManager.allItems.length).to.equal(4);
var newNote = modelManager.allItemsMatchingTypes(["Note"])[1];
var newTag = modelManager.allItemsMatchingTypes(["Tag"])[1];
expect(newNote.uuid).to.not.equal(note.uuid);
expect(newTag.uuid).to.not.equal(tag.uuid);
expect(tag.content.references.length).to.equal(2);
expect(tag.notes.length).to.equal(2);
expect(note.content.references.length).to.equal(0);
expect(note.referencingObjects.length).to.equal(2);
expect(note.tags.length).to.equal(2);
expect(newTag.content.references.length).to.equal(1);
expect(newTag.notes.length).to.equal(1);
expect(newNote.content.references.length).to.equal(0);
expect(newNote.referencingObjects.length).to.equal(1);
expect(newNote.tags.length).to.equal(1);
});
});
describe("syncing", () => {
var totalItemCount = 0;
beforeEach((done) => {
var email = Factory.globalStandardFile().crypto.generateUUIDSync();
var password = Factory.globalStandardFile().crypto.generateUUIDSync();
Factory.globalStorageManager().clearAllData().then(() => {
Factory.newRegisteredUser(email, password).then((user) => {
done();
})
})
})
let modelManager = Factory.createModelManager();
let authManager = Factory.globalAuthManager();
let syncManager = new SFSyncManager(modelManager, Factory.globalStorageManager(), Factory.globalHttpManager());
syncManager.setKeyRequestHandler(async () => {
return {
keys: await authManager.keys(),
auth_params: await authManager.getAuthParams(),
offline: false
};
})
const wait = (secs) => {
return new Promise((resolve, reject) => {
setTimeout(function () {
resolve();
}, secs * 1000);
})
}
it('syncing a note many times does not cause duplication', async () => {
modelManager.handleSignout();
let pair = createRelatedNoteTagPair();
let noteParams = pair[0];
let tagParams = pair[1];
modelManager.mapResponseItemsToLocalModels([noteParams, tagParams]);
let note = modelManager.allItemsMatchingTypes(["Note"])[0];
let tag = modelManager.allItemsMatchingTypes(["Tag"])[0];
for(var i = 0; i < 9; i++) {
note.setDirty(true);
tag.setDirty(true);
await syncManager.sync();
syncManager.clearSyncToken();
expect(tag.content.references.length).to.equal(1);
expect(note.tags.length).to.equal(1);
expect(tag.notes.length).to.equal(1);
expect(modelManager.allItems.length).to.equal(2);
console.log("Waiting 1.1s...");
await wait(1.1);
}
}).timeout(20000);
it("handles signing in and merging data", async () => {
let syncManager = new SFSyncManager(modelManager, Factory.globalStorageManager(), Factory.globalHttpManager());
// be offline
syncManager.setKeyRequestHandler(async () => {
return {
offline: true
};
})
modelManager.handleSignout();
let pair = createRelatedNoteTagPair();
let noteParams = pair[0];
let tagParams = pair[1];
modelManager.mapResponseItemsToLocalModels([noteParams, tagParams]);
let originalNote = modelManager.allItemsMatchingTypes(["Note"])[0];
let originalTag = modelManager.allItemsMatchingTypes(["Tag"])[0];
originalNote.setDirty(true);
originalTag.setDirty(true);
await syncManager.sync();
expect(originalTag.content.references.length).to.equal(1);
expect(originalTag.notes.length).to.equal(1);
expect(originalNote.tags.length).to.equal(1);
// go online
syncManager.setKeyRequestHandler(async () => {
return {
keys: await authManager.keys(),
auth_params: await authManager.getAuthParams(),
offline: false
};
})
// when signing in, all local items are cleared from storage (but kept in memory; to clear desktop logs),
// then resaved with alternated uuids.
await Factory.globalStorageManager().clearAllModels();
return expect(syncManager.markAllItemsDirtyAndSaveOffline(true)).to.be.fulfilled.then(() => {
let note = modelManager.allItemsMatchingTypes(["Note"])[0];
let tag = modelManager.allItemsMatchingTypes(["Tag"])[0];
expect(modelManager.allItems.length).to.equal(2);
expect(note.uuid).to.not.equal(originalNote.uuid);
expect(tag.uuid).to.not.equal(originalTag.uuid);
expect(tag.content.references.length).to.equal(1);
expect(note.content.references.length).to.equal(0);
expect(note.referencingObjects.length).to.equal(1);
expect(tag.notes.length).to.equal(1);
expect(note.tags.length).to.equal(1);
});
})
it('duplicating a tag should maintian its relationships', async () => {
modelManager.handleSignout();
let pair = createRelatedNoteTagPair();
let noteParams = pair[0];
let tagParams = pair[1];
modelManager.mapResponseItemsToLocalModels([noteParams, tagParams]);
let note = modelManager.allItemsMatchingTypes(["Note"])[0];
let tag = modelManager.allItemsMatchingTypes(["Tag"])[0];
note.setDirty(true);
tag.setDirty(true);
await syncManager.sync();
await syncManager.clearSyncToken();
expect(modelManager.allItems.length).to.equal(2);
tag.title = `${Math.random()}`
tag.setDirty(true);
expect(note.referencingObjects.length).to.equal(1);
// wait about 1s, which is the value the dev server will ignore conflicting changes
return expect(new Promise((resolve, reject) => {
setTimeout(function () {
resolve();
}, 1100);
})).to.be.fulfilled.then(async () => {
return expect(syncManager.sync()).to.be.fulfilled.then(async (response) => {
// tag should now be conflicted and a copy created
let models = modelManager.allItems;
expect(modelManager.allItems.length).to.equal(3);
var tags = modelManager.allItemsMatchingTypes(["Tag"]);
var tag1 = tags[0];
var tag2 = tags[1];
expect(tag1.uuid).to.not.equal(tag2.uuid);
expect(tag1.uuid).to.equal(tag.uuid);
expect(tag2.conflict_of).to.equal(tag1.uuid);
expect(tag1.notes.length).to.equal(tag2.notes.length);
expect(tag1.referencingObjects.length).to.equal(0);
expect(tag2.referencingObjects.length).to.equal(0);
// Two tags now link to this note
expect(note.referencingObjects.length).to.equal(2);
expect(note.referencingObjects[0]).to.not.equal(note.referencingObjects[1]);
})
})
}).timeout(10000);
})

23
test/mocha/test.html Normal file
View File

@@ -0,0 +1,23 @@
<html>
<head>
<meta charset="utf-8">
<title>Mocha Tests</title>
<link href="https://cdn.rawgit.com/mochajs/mocha/2.2.5/mocha.css" rel="stylesheet" />
<style>
body {
/* background-color: rgb(195, 195, 195); */
}
</style>
</head>
<body>
<div id="mocha"></div>
<script src="../../node_modules/mocha/mocha.js"></script>
<script>mocha.setup('bdd')</script>
<script type="module" src="models.test.js"></script>
<script type="module">
mocha.checkLeaks();
mocha.run();
</script>
</body>
</html>

View File

@@ -0,0 +1,539 @@
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.chaiAsPromised = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
"use strict";
/* eslint-disable no-invalid-this */
let checkError = require("check-error");
module.exports = (chai, utils) => {
const Assertion = chai.Assertion;
const assert = chai.assert;
const proxify = utils.proxify;
// If we are using a version of Chai that has checkError on it,
// we want to use that version to be consistent. Otherwise, we use
// what was passed to the factory.
if (utils.checkError) {
checkError = utils.checkError;
}
function isLegacyJQueryPromise(thenable) {
// jQuery promises are Promises/A+-compatible since 3.0.0. jQuery 3.0.0 is also the first version
// to define the catch method.
return typeof thenable.catch !== "function" &&
typeof thenable.always === "function" &&
typeof thenable.done === "function" &&
typeof thenable.fail === "function" &&
typeof thenable.pipe === "function" &&
typeof thenable.progress === "function" &&
typeof thenable.state === "function";
}
function assertIsAboutPromise(assertion) {
if (typeof assertion._obj.then !== "function") {
throw new TypeError(utils.inspect(assertion._obj) + " is not a thenable.");
}
if (isLegacyJQueryPromise(assertion._obj)) {
throw new TypeError("Chai as Promised is incompatible with thenables of jQuery<3.0.0, sorry! Please " +
"upgrade jQuery or use another Promises/A+ compatible library (see " +
"http://promisesaplus.com/).");
}
}
function proxifyIfSupported(assertion) {
return proxify === undefined ? assertion : proxify(assertion);
}
function method(name, asserter) {
utils.addMethod(Assertion.prototype, name, function () {
assertIsAboutPromise(this);
return asserter.apply(this, arguments);
});
}
function property(name, asserter) {
utils.addProperty(Assertion.prototype, name, function () {
assertIsAboutPromise(this);
return proxifyIfSupported(asserter.apply(this, arguments));
});
}
function doNotify(promise, done) {
promise.then(() => done(), done);
}
// These are for clarity and to bypass Chai refusing to allow `undefined` as actual when used with `assert`.
function assertIfNegated(assertion, message, extra) {
assertion.assert(true, null, message, extra.expected, extra.actual);
}
function assertIfNotNegated(assertion, message, extra) {
assertion.assert(false, message, null, extra.expected, extra.actual);
}
function getBasePromise(assertion) {
// We need to chain subsequent asserters on top of ones in the chain already (consider
// `eventually.have.property("foo").that.equals("bar")`), only running them after the existing ones pass.
// So the first base-promise is `assertion._obj`, but after that we use the assertions themselves, i.e.
// previously derived promises, to chain off of.
return typeof assertion.then === "function" ? assertion : assertion._obj;
}
function getReasonName(reason) {
return reason instanceof Error ? reason.toString() : checkError.getConstructorName(reason);
}
// Grab these first, before we modify `Assertion.prototype`.
const propertyNames = Object.getOwnPropertyNames(Assertion.prototype);
const propertyDescs = {};
for (const name of propertyNames) {
propertyDescs[name] = Object.getOwnPropertyDescriptor(Assertion.prototype, name);
}
property("fulfilled", function () {
const derivedPromise = getBasePromise(this).then(
value => {
assertIfNegated(this,
"expected promise not to be fulfilled but it was fulfilled with #{act}",
{ actual: value });
return value;
},
reason => {
assertIfNotNegated(this,
"expected promise to be fulfilled but it was rejected with #{act}",
{ actual: getReasonName(reason) });
return reason;
}
);
module.exports.transferPromiseness(this, derivedPromise);
return this;
});
property("rejected", function () {
const derivedPromise = getBasePromise(this).then(
value => {
assertIfNotNegated(this,
"expected promise to be rejected but it was fulfilled with #{act}",
{ actual: value });
return value;
},
reason => {
assertIfNegated(this,
"expected promise not to be rejected but it was rejected with #{act}",
{ actual: getReasonName(reason) });
// Return the reason, transforming this into a fulfillment, to allow further assertions, e.g.
// `promise.should.be.rejected.and.eventually.equal("reason")`.
return reason;
}
);
module.exports.transferPromiseness(this, derivedPromise);
return this;
});
method("rejectedWith", function (errorLike, errMsgMatcher, message) {
let errorLikeName = null;
const negate = utils.flag(this, "negate") || false;
// rejectedWith with that is called without arguments is
// the same as a plain ".rejected" use.
if (errorLike === undefined && errMsgMatcher === undefined &&
message === undefined) {
/* eslint-disable no-unused-expressions */
return this.rejected;
/* eslint-enable no-unused-expressions */
}
if (message !== undefined) {
utils.flag(this, "message", message);
}
if (errorLike instanceof RegExp || typeof errorLike === "string") {
errMsgMatcher = errorLike;
errorLike = null;
} else if (errorLike && errorLike instanceof Error) {
errorLikeName = errorLike.toString();
} else if (typeof errorLike === "function") {
errorLikeName = checkError.getConstructorName(errorLike);
} else {
errorLike = null;
}
const everyArgIsDefined = Boolean(errorLike && errMsgMatcher);
let matcherRelation = "including";
if (errMsgMatcher instanceof RegExp) {
matcherRelation = "matching";
}
const derivedPromise = getBasePromise(this).then(
value => {
let assertionMessage = null;
let expected = null;
if (errorLike) {
assertionMessage = "expected promise to be rejected with #{exp} but it was fulfilled with #{act}";
expected = errorLikeName;
} else if (errMsgMatcher) {
assertionMessage = `expected promise to be rejected with an error ${matcherRelation} #{exp} but ` +
`it was fulfilled with #{act}`;
expected = errMsgMatcher;
}
assertIfNotNegated(this, assertionMessage, { expected, actual: value });
return value;
},
reason => {
const errorLikeCompatible = errorLike && (errorLike instanceof Error ?
checkError.compatibleInstance(reason, errorLike) :
checkError.compatibleConstructor(reason, errorLike));
const errMsgMatcherCompatible = errMsgMatcher && checkError.compatibleMessage(reason, errMsgMatcher);
const reasonName = getReasonName(reason);
if (negate && everyArgIsDefined) {
if (errorLikeCompatible && errMsgMatcherCompatible) {
this.assert(true,
null,
"expected promise not to be rejected with #{exp} but it was rejected " +
"with #{act}",
errorLikeName,
reasonName);
}
} else {
if (errorLike) {
this.assert(errorLikeCompatible,
"expected promise to be rejected with #{exp} but it was rejected with #{act}",
"expected promise not to be rejected with #{exp} but it was rejected " +
"with #{act}",
errorLikeName,
reasonName);
}
if (errMsgMatcher) {
this.assert(errMsgMatcherCompatible,
`expected promise to be rejected with an error ${matcherRelation} #{exp} but got ` +
`#{act}`,
`expected promise not to be rejected with an error ${matcherRelation} #{exp}`,
errMsgMatcher,
checkError.getMessage(reason));
}
}
return reason;
}
);
module.exports.transferPromiseness(this, derivedPromise);
return this;
});
property("eventually", function () {
utils.flag(this, "eventually", true);
return this;
});
method("notify", function (done) {
doNotify(getBasePromise(this), done);
return this;
});
method("become", function (value, message) {
return this.eventually.deep.equal(value, message);
});
// ### `eventually`
// We need to be careful not to trigger any getters, thus `Object.getOwnPropertyDescriptor` usage.
const methodNames = propertyNames.filter(name => {
return name !== "assert" && typeof propertyDescs[name].value === "function";
});
methodNames.forEach(methodName => {
Assertion.overwriteMethod(methodName, originalMethod => function () {
return doAsserterAsyncAndAddThen(originalMethod, this, arguments);
});
});
const getterNames = propertyNames.filter(name => {
return name !== "_obj" && typeof propertyDescs[name].get === "function";
});
getterNames.forEach(getterName => {
// Chainable methods are things like `an`, which can work both for `.should.be.an.instanceOf` and as
// `should.be.an("object")`. We need to handle those specially.
const isChainableMethod = Assertion.prototype.__methods.hasOwnProperty(getterName);
if (isChainableMethod) {
Assertion.overwriteChainableMethod(
getterName,
originalMethod => function () {
return doAsserterAsyncAndAddThen(originalMethod, this, arguments);
},
originalGetter => function () {
return doAsserterAsyncAndAddThen(originalGetter, this);
}
);
} else {
Assertion.overwriteProperty(getterName, originalGetter => function () {
return proxifyIfSupported(doAsserterAsyncAndAddThen(originalGetter, this));
});
}
});
function doAsserterAsyncAndAddThen(asserter, assertion, args) {
// Since we're intercepting all methods/properties, we need to just pass through if they don't want
// `eventually`, or if we've already fulfilled the promise (see below).
if (!utils.flag(assertion, "eventually")) {
asserter.apply(assertion, args);
return assertion;
}
const derivedPromise = getBasePromise(assertion).then(value => {
// Set up the environment for the asserter to actually run: `_obj` should be the fulfillment value, and
// now that we have the value, we're no longer in "eventually" mode, so we won't run any of this code,
// just the base Chai code that we get to via the short-circuit above.
assertion._obj = value;
utils.flag(assertion, "eventually", false);
return args ? module.exports.transformAsserterArgs(args) : args;
}).then(newArgs => {
asserter.apply(assertion, newArgs);
// Because asserters, for example `property`, can change the value of `_obj` (i.e. change the "object"
// flag), we need to communicate this value change to subsequent chained asserters. Since we build a
// promise chain paralleling the asserter chain, we can use it to communicate such changes.
return assertion._obj;
});
module.exports.transferPromiseness(assertion, derivedPromise);
return assertion;
}
// ### Now use the `Assertion` framework to build an `assert` interface.
const originalAssertMethods = Object.getOwnPropertyNames(assert).filter(propName => {
return typeof assert[propName] === "function";
});
assert.isFulfilled = (promise, message) => (new Assertion(promise, message)).to.be.fulfilled;
assert.isRejected = (promise, errorLike, errMsgMatcher, message) => {
const assertion = new Assertion(promise, message);
return assertion.to.be.rejectedWith(errorLike, errMsgMatcher, message);
};
assert.becomes = (promise, value, message) => assert.eventually.deepEqual(promise, value, message);
assert.doesNotBecome = (promise, value, message) => assert.eventually.notDeepEqual(promise, value, message);
assert.eventually = {};
originalAssertMethods.forEach(assertMethodName => {
assert.eventually[assertMethodName] = function (promise) {
const otherArgs = Array.prototype.slice.call(arguments, 1);
let customRejectionHandler;
const message = arguments[assert[assertMethodName].length - 1];
if (typeof message === "string") {
customRejectionHandler = reason => {
throw new chai.AssertionError(`${message}\n\nOriginal reason: ${utils.inspect(reason)}`);
};
}
const returnedPromise = promise.then(
fulfillmentValue => assert[assertMethodName].apply(assert, [fulfillmentValue].concat(otherArgs)),
customRejectionHandler
);
returnedPromise.notify = done => {
doNotify(returnedPromise, done);
};
return returnedPromise;
};
});
};
module.exports.transferPromiseness = (assertion, promise) => {
assertion.then = promise.then.bind(promise);
};
module.exports.transformAsserterArgs = values => values;
},{"check-error":2}],2:[function(require,module,exports){
'use strict';
/* !
* Chai - checkError utility
* Copyright(c) 2012-2016 Jake Luer <jake@alogicalparadox.com>
* MIT Licensed
*/
/**
* ### .checkError
*
* Checks that an error conforms to a given set of criteria and/or retrieves information about it.
*
* @api public
*/
/**
* ### .compatibleInstance(thrown, errorLike)
*
* Checks if two instances are compatible (strict equal).
* Returns false if errorLike is not an instance of Error, because instances
* can only be compatible if they're both error instances.
*
* @name compatibleInstance
* @param {Error} thrown error
* @param {Error|ErrorConstructor} errorLike object to compare against
* @namespace Utils
* @api public
*/
function compatibleInstance(thrown, errorLike) {
return errorLike instanceof Error && thrown === errorLike;
}
/**
* ### .compatibleConstructor(thrown, errorLike)
*
* Checks if two constructors are compatible.
* This function can receive either an error constructor or
* an error instance as the `errorLike` argument.
* Constructors are compatible if they're the same or if one is
* an instance of another.
*
* @name compatibleConstructor
* @param {Error} thrown error
* @param {Error|ErrorConstructor} errorLike object to compare against
* @namespace Utils
* @api public
*/
function compatibleConstructor(thrown, errorLike) {
if (errorLike instanceof Error) {
// If `errorLike` is an instance of any error we compare their constructors
return thrown.constructor === errorLike.constructor || thrown instanceof errorLike.constructor;
} else if (errorLike.prototype instanceof Error || errorLike === Error) {
// If `errorLike` is a constructor that inherits from Error, we compare `thrown` to `errorLike` directly
return thrown.constructor === errorLike || thrown instanceof errorLike;
}
return false;
}
/**
* ### .compatibleMessage(thrown, errMatcher)
*
* Checks if an error's message is compatible with a matcher (String or RegExp).
* If the message contains the String or passes the RegExp test,
* it is considered compatible.
*
* @name compatibleMessage
* @param {Error} thrown error
* @param {String|RegExp} errMatcher to look for into the message
* @namespace Utils
* @api public
*/
function compatibleMessage(thrown, errMatcher) {
var comparisonString = typeof thrown === 'string' ? thrown : thrown.message;
if (errMatcher instanceof RegExp) {
return errMatcher.test(comparisonString);
} else if (typeof errMatcher === 'string') {
return comparisonString.indexOf(errMatcher) !== -1; // eslint-disable-line no-magic-numbers
}
return false;
}
/**
* ### .getFunctionName(constructorFn)
*
* Returns the name of a function.
* This also includes a polyfill function if `constructorFn.name` is not defined.
*
* @name getFunctionName
* @param {Function} constructorFn
* @namespace Utils
* @api private
*/
var functionNameMatch = /\s*function(?:\s|\s*\/\*[^(?:*\/)]+\*\/\s*)*([^\(\/]+)/;
function getFunctionName(constructorFn) {
var name = '';
if (typeof constructorFn.name === 'undefined') {
// Here we run a polyfill if constructorFn.name is not defined
var match = String(constructorFn).match(functionNameMatch);
if (match) {
name = match[1];
}
} else {
name = constructorFn.name;
}
return name;
}
/**
* ### .getConstructorName(errorLike)
*
* Gets the constructor name for an Error instance or constructor itself.
*
* @name getConstructorName
* @param {Error|ErrorConstructor} errorLike
* @namespace Utils
* @api public
*/
function getConstructorName(errorLike) {
var constructorName = errorLike;
if (errorLike instanceof Error) {
constructorName = getFunctionName(errorLike.constructor);
} else if (typeof errorLike === 'function') {
// If `err` is not an instance of Error it is an error constructor itself or another function.
// If we've got a common function we get its name, otherwise we may need to create a new instance
// of the error just in case it's a poorly-constructed error. Please see chaijs/chai/issues/45 to know more.
constructorName = getFunctionName(errorLike).trim() ||
getFunctionName(new errorLike()); // eslint-disable-line new-cap
}
return constructorName;
}
/**
* ### .getMessage(errorLike)
*
* Gets the error message from an error.
* If `err` is a String itself, we return it.
* If the error has no message, we return an empty string.
*
* @name getMessage
* @param {Error|String} errorLike
* @namespace Utils
* @api public
*/
function getMessage(errorLike) {
var msg = '';
if (errorLike && errorLike.message) {
msg = errorLike.message;
} else if (typeof errorLike === 'string') {
msg = errorLike;
}
return msg;
}
module.exports = {
compatibleInstance: compatibleInstance,
compatibleConstructor: compatibleConstructor,
compatibleMessage: compatibleMessage,
getMessage: getMessage,
getConstructorName: getConstructorName,
};
},{}]},{},[1])(1)
});

View File

View File

@@ -1,7 +0,0 @@
require 'test_helper'
class ApiKeyTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -1,7 +0,0 @@
require 'test_helper'
class NameTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

8
testing-server.js Normal file
View File

@@ -0,0 +1,8 @@
// Used for running mocha tests
var connect = require('connect');
var serveStatic = require('serve-static');
var port = 7000;
connect().use(serveStatic(__dirname)).listen(port, function(){
console.log(`Server running on ${port}...`);
});

View File

@@ -1,7 +1,7 @@
/**
* @license
* Lodash (Custom Build) <https://lodash.com/>
* Build: `lodash include="includes,merge,filter,map,remove,find,omit,pull,cloneDeep,pick,uniq,sortedIndexBy"`
* Build: `lodash include="includes,merge,filter,map,remove,find,omit,pull,cloneDeep,pick,uniq,sortedIndexBy,mergeWith"`
* Copyright JS Foundation and other contributors <https://js.foundation/>
* Released under MIT license <https://lodash.com/license>
* Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE>
@@ -4890,6 +4890,41 @@
baseMerge(object, source, srcIndex);
});
/**
* This method is like `_.merge` except that it accepts `customizer` which
* is invoked to produce the merged values of the destination and source
* properties. If `customizer` returns `undefined`, merging is handled by the
* method instead. The `customizer` is invoked with six arguments:
* (objValue, srcValue, key, object, source, stack).
*
* **Note:** This method mutates `object`.
*
* @static
* @memberOf _
* @since 4.0.0
* @category Object
* @param {Object} object The destination object.
* @param {...Object} sources The source objects.
* @param {Function} customizer The function to customize assigned values.
* @returns {Object} Returns `object`.
* @example
*
* function customizer(objValue, srcValue) {
* if (_.isArray(objValue)) {
* return objValue.concat(srcValue);
* }
* }
*
* var object = { 'a': [1], 'b': [2] };
* var other = { 'a': [3], 'b': [4] };
*
* _.mergeWith(object, other, customizer);
* // => { 'a': [1, 3], 'b': [2, 4] }
*/
var mergeWith = createAssigner(function(object, source, srcIndex, customizer) {
baseMerge(object, source, srcIndex, customizer);
});
/**
* The opposite of `_.pick`; this method creates an object composed of the
* own and inherited enumerable property paths of `object` that are not omitted.
@@ -5169,6 +5204,7 @@
lodash.map = map;
lodash.memoize = memoize;
lodash.merge = merge;
lodash.mergeWith = mergeWith;
lodash.omit = omit;
lodash.pick = pick;
lodash.property = property;

View File

@@ -1,50 +1,51 @@
/**
* @license
* Lodash (Custom Build) lodash.com/license | Underscore.js 1.8.3 underscorejs.org/LICENSE
* Build: `lodash include="includes,merge,filter,map,remove,find,omit,pull,cloneDeep,pick,uniq,sortedIndexBy"`
* Build: `lodash include="includes,merge,filter,map,remove,find,omit,pull,cloneDeep,pick,uniq,sortedIndexBy,mergeWith"`
*/
;(function(){function t(t,n){return t.set(n[0],n[1]),t}function n(t,n){return t.add(n),t}function r(t,n,r){switch(r.length){case 0:return t.call(n);case 1:return t.call(n,r[0]);case 2:return t.call(n,r[0],r[1]);case 3:return t.call(n,r[0],r[1],r[2])}return t.apply(n,r)}function e(t,n){for(var r=-1,e=null==t?0:t.length;++r<e&&n(t[r],r,t)!==false;);return t}function u(t,n){for(var r=-1,e=null==t?0:t.length,u=0,o=[];++r<e;){var i=t[r];n(i,r,t)&&(o[u++]=i)}return o}function o(t,n){return!!(null==t?0:t.length)&&h(t,n,0)>-1;
}function i(t,n,r){for(var e=-1,u=null==t?0:t.length;++e<u;)if(r(n,t[e]))return true;return false}function c(t,n){for(var r=-1,e=null==t?0:t.length,u=Array(e);++r<e;)u[r]=n(t[r],r,t);return u}function f(t,n){for(var r=-1,e=n.length,u=t.length;++r<e;)t[u+r]=n[r];return t}function a(t,n,r,e){var u=-1,o=null==t?0:t.length;for(e&&o&&(r=t[++u]);++u<o;)r=n(r,t[u],u,t);return r}function l(t,n){for(var r=-1,e=null==t?0:t.length;++r<e;)if(n(t[r],r,t))return true;return false}function s(t,n,r,e){for(var u=t.length,o=r+(e?1:-1);e?o--:++o<u;)if(n(t[o],o,t))return o;
return-1}function h(t,n,r){return n===n?A(t,n,r):s(t,p,r)}function v(t,n,r,e){for(var u=r-1,o=t.length;++u<o;)if(e(t[u],n))return u;return-1}function p(t){return t!==t}function y(t){return function(n){return null==n?Or:n[t]}}function g(t,n){for(var r=-1,e=Array(t);++r<t;)e[r]=n(r);return e}function b(t){return function(n){return t(n)}}function _(t,n){return c(n,function(n){return t[n]})}function d(t,n){return t.has(n)}function j(t,n){return null==t?Or:t[n]}function w(t){var n=-1,r=Array(t.size);return t.forEach(function(t,e){
r[++n]=[e,t]}),r}function O(t,n){return function(r){return t(n(r))}}function m(t){var n=-1,r=Array(t.size);return t.forEach(function(t){r[++n]=t}),r}function A(t,n,r){for(var e=r-1,u=t.length;++e<u;)if(t[e]===n)return e;return-1}function z(){}function x(t){var n=-1,r=null==t?0:t.length;for(this.clear();++n<r;){var e=t[n];this.set(e[0],e[1])}}function S(){this.__data__=Ou?Ou(null):{},this.size=0}function k(t){var n=this.has(t)&&delete this.__data__[t];return this.size-=n?1:0,n}function $(t){var n=this.__data__;
if(Ou){var r=n[t];return r===xr?Or:r}return Je.call(n,t)?n[t]:Or}function I(t){var n=this.__data__;return Ou?n[t]!==Or:Je.call(n,t)}function L(t,n){var r=this.__data__;return this.size+=this.has(t)?0:1,r[t]=Ou&&n===Or?xr:n,this}function P(t){var n=-1,r=null==t?0:t.length;for(this.clear();++n<r;){var e=t[n];this.set(e[0],e[1])}}function E(){this.__data__=[],this.size=0}function F(t){var n=this.__data__,r=rt(n,t);return!(r<0)&&(r==n.length-1?n.pop():iu.call(n,r,1),--this.size,true)}function B(t){var n=this.__data__,r=rt(n,t);
return r<0?Or:n[r][1]}function M(t){return rt(this.__data__,t)>-1}function T(t,n){var r=this.__data__,e=rt(r,t);return e<0?(++this.size,r.push([t,n])):r[e][1]=n,this}function U(t){var n=-1,r=null==t?0:t.length;for(this.clear();++n<r;){var e=t[n];this.set(e[0],e[1])}}function N(){this.size=0,this.__data__={hash:new x,map:new(_u||P),string:new x}}function C(t){var n=hn(this,t).delete(t);return this.size-=n?1:0,n}function D(t){return hn(this,t).get(t)}function R(t){return hn(this,t).has(t)}function V(t,n){
var r=hn(this,t),e=r.size;return r.set(t,n),this.size+=r.size==e?0:1,this}function q(t){var n=-1,r=null==t?0:t.length;for(this.__data__=new U;++n<r;)this.add(t[n])}function W(t){return this.__data__.set(t,xr),this}function G(t){return this.__data__.has(t)}function H(t){this.size=(this.__data__=new P(t)).size}function J(){this.__data__=new P,this.size=0}function K(t){var n=this.__data__,r=n.delete(t);return this.size=n.size,r}function Q(t){return this.__data__.get(t)}function X(t){return this.__data__.has(t);
}function Y(t,n){var r=this.__data__;if(r instanceof P){var e=r.__data__;if(!_u||e.length<Ar-1)return e.push([t,n]),this.size=++r.size,this;r=this.__data__=new U(e)}return r.set(t,n),this.size=r.size,this}function Z(t,n){var r=qu(t),e=!r&&Vu(t),u=!r&&!e&&Wu(t),o=!r&&!e&&!u&&Gu(t),i=r||e||u||o,c=i?g(t.length,String):[],f=c.length;for(var a in t)!n&&!Je.call(t,a)||i&&("length"==a||u&&("offset"==a||"parent"==a)||o&&("buffer"==a||"byteLength"==a||"byteOffset"==a)||wn(a,f))||c.push(a);return c}function tt(t,n,r){
(r===Or||Kn(t[n],r))&&(r!==Or||n in t)||ot(t,n,r)}function nt(t,n,r){var e=t[n];Je.call(t,n)&&Kn(e,r)&&(r!==Or||n in t)||ot(t,n,r)}function rt(t,n){for(var r=t.length;r--;)if(Kn(t[r][0],n))return r;return-1}function et(t,n){return t&&Qt(n,hr(n),t)}function ut(t,n){return t&&Qt(n,vr(n),t)}function ot(t,n,r){"__proto__"==n&&au?au(t,n,{configurable:true,enumerable:true,value:r,writable:true}):t[n]=r}function it(t,n,r,u,o,i){var c,f=n&kr,a=n&$r,l=n&Ir;if(r&&(c=o?r(t,u,o,i):r(t)),c!==Or)return c;if(!tr(t))return t;
var s=qu(t);if(s){if(c=bn(t),!f)return Kt(t,c)}else{var h=Uu(t),v=h==Hr||h==Jr;if(Wu(t))return Dt(t,f);if(h==Yr||h==Dr||v&&!o){if(c=a||v?{}:_n(t),!f)return a?Yt(t,ut(c,t)):Xt(t,et(c,t))}else{if(!Pe[h])return o?t:{};c=dn(t,h,it,f)}}i||(i=new H);var p=i.get(t);if(p)return p;i.set(t,c);var y=l?a?ln:an:a?vr:hr,g=s?Or:y(t);return e(g||t,function(e,u){g&&(u=e,e=t[u]),nt(c,u,it(e,n,r,u,t,i))}),c}function ct(t,n){var r=[];return Pu(t,function(t,e,u){n(t,e,u)&&r.push(t)}),r}function ft(t,n,r,e,u){var o=-1,i=t.length;
for(r||(r=jn),u||(u=[]);++o<i;){var c=t[o];n>0&&r(c)?n>1?ft(c,n-1,r,e,u):f(u,c):e||(u[u.length]=c)}return u}function at(t,n){return t&&Eu(t,n,hr)}function lt(t,n){n=Ct(n,t);for(var r=0,e=n.length;null!=t&&r<e;)t=t[Bn(n[r++])];return r&&r==e?t:Or}function st(t,n,r){var e=n(t);return qu(t)?e:f(e,r(t))}function ht(t){return null==t?t===Or?oe:Xr:fu&&fu in Object(t)?yn(t):Ln(t)}function vt(t,n){return null!=t&&n in Object(t)}function pt(t){return nr(t)&&ht(t)==Dr}function yt(t,n,r,e,u){return t===n||(null==t||null==n||!nr(t)&&!nr(n)?t!==t&&n!==n:gt(t,n,r,e,yt,u));
}function gt(t,n,r,e,u,o){var i=qu(t),c=qu(n),f=i?Rr:Uu(t),a=c?Rr:Uu(n);f=f==Dr?Yr:f,a=a==Dr?Yr:a;var l=f==Yr,s=a==Yr,h=f==a;if(h&&Wu(t)){if(!Wu(n))return false;i=true,l=false}if(h&&!l)return o||(o=new H),i||Gu(t)?un(t,n,r,e,u,o):on(t,n,f,r,e,u,o);if(!(r&Lr)){var v=l&&Je.call(t,"__wrapped__"),p=s&&Je.call(n,"__wrapped__");if(v||p){var y=v?t.value():t,g=p?n.value():n;return o||(o=new H),u(y,g,r,e,o)}}return!!h&&(o||(o=new H),cn(t,n,r,e,u,o))}function bt(t,n,r,e){var u=r.length,o=u,i=!e;if(null==t)return!o;for(t=Object(t);u--;){
var c=r[u];if(i&&c[2]?c[1]!==t[c[0]]:!(c[0]in t))return false}for(;++u<o;){c=r[u];var f=c[0],a=t[f],l=c[1];if(i&&c[2]){if(a===Or&&!(f in t))return false}else{var s=new H;if(e)var h=e(a,l,f,t,n,s);if(!(h===Or?yt(l,a,Lr|Pr,e,s):h))return false}}return true}function _t(t){return!(!tr(t)||zn(t))&&(Yn(t)?Ye:ke).test(Mn(t))}function dt(t){return nr(t)&&Zn(t.length)&&!!Le[ht(t)]}function jt(t){return typeof t=="function"?t:null==t?gr:typeof t=="object"?qu(t)?zt(t[0],t[1]):At(t):dr(t)}function wt(t){if(!xn(t))return vu(t);
var n=[];for(var r in Object(t))Je.call(t,r)&&"constructor"!=r&&n.push(r);return n}function Ot(t){if(!tr(t))return In(t);var n=xn(t),r=[];for(var e in t)("constructor"!=e||!n&&Je.call(t,e))&&r.push(e);return r}function mt(t,n){var r=-1,e=Qn(t)?Array(t.length):[];return Pu(t,function(t,u,o){e[++r]=n(t,u,o)}),e}function At(t){var n=vn(t);return 1==n.length&&n[0][2]?kn(n[0][0],n[0][1]):function(r){return r===t||bt(r,t,n)}}function zt(t,n){return mn(t)&&Sn(n)?kn(Bn(t),n):function(r){var e=lr(r,t);return e===Or&&e===n?sr(r,t):yt(n,e,Lr|Pr);
}}function xt(t,n,r,e,u){t!==n&&Eu(n,function(o,i){if(tr(o))u||(u=new H),St(t,n,i,r,xt,e,u);else{var c=e?e(t[i],o,i+"",t,n,u):Or;c===Or&&(c=o),tt(t,i,c)}},vr)}function St(t,n,r,e,u,o,i){var c=t[r],f=n[r],a=i.get(f);if(a)return tt(t,r,a),Or;var l=o?o(c,f,r+"",t,n,i):Or,s=l===Or;if(s){var h=qu(f),v=!h&&Wu(f),p=!h&&!v&&Gu(f);l=f,h||v||p?qu(c)?l=c:Xn(c)?l=Kt(c):v?(s=false,l=Dt(f,true)):p?(s=false,l=Jt(f,true)):l=[]:rr(f)||Vu(f)?(l=c,Vu(c)?l=fr(c):(!tr(c)||e&&Yn(c))&&(l=_n(f))):s=false}s&&(i.set(f,l),u(l,f,e,o,i),
i.delete(f)),tt(t,r,l)}function kt(t,n){return $t(t,n,function(n,r){return sr(t,r)})}function $t(t,n,r){for(var e=-1,u=n.length,o={};++e<u;){var i=n[e],c=lt(t,i);r(c,i)&&Ft(o,Ct(i,t),c)}return o}function It(t){return function(n){return lt(n,t)}}function Lt(t,n,r,e){var u=e?v:h,o=-1,i=n.length,f=t;for(t===n&&(n=Kt(n)),r&&(f=c(t,b(r)));++o<i;)for(var a=0,l=n[o],s=r?r(l):l;(a=u(f,s,a,e))>-1;)f!==t&&iu.call(f,a,1),iu.call(t,a,1);return t}function Pt(t,n){for(var r=t?n.length:0,e=r-1;r--;){var u=n[r];if(r==e||u!==o){
var o=u;wn(u)?iu.call(t,u,1):Nt(t,u)}}return t}function Et(t,n){return Nu(Pn(t,n,gr),t+"")}function Ft(t,n,r,e){if(!tr(t))return t;n=Ct(n,t);for(var u=-1,o=n.length,i=o-1,c=t;null!=c&&++u<o;){var f=Bn(n[u]),a=r;if(u!=i){var l=c[f];a=e?e(l,f,c):Or,a===Or&&(a=tr(l)?l:wn(n[u+1])?[]:{})}nt(c,f,a),c=c[f]}return t}function Bt(t,n,r){var e=-1,u=t.length;n<0&&(n=-n>u?0:u+n),r=r>u?u:r,r<0&&(r+=u),u=n>r?0:r-n>>>0,n>>>=0;for(var o=Array(u);++e<u;)o[e]=t[e+n];return o}function Mt(t,n,r,e){n=r(n);for(var u=0,o=null==t?0:t.length,i=n!==n,c=null===n,f=ur(n),a=n===Or;u<o;){
var l=lu((u+o)/2),s=r(t[l]),h=s!==Or,v=null===s,p=s===s,y=ur(s);if(i)var g=e||p;else g=a?p&&(e||h):c?p&&h&&(e||!v):f?p&&h&&!v&&(e||!y):!v&&!y&&(e?s<=n:s<n);g?u=l+1:o=l}return yu(o,Cr)}function Tt(t){if(typeof t=="string")return t;if(qu(t))return c(t,Tt)+"";if(ur(t))return Iu?Iu.call(t):"";var n=t+"";return"0"==n&&1/t==-Br?"-0":n}function Ut(t,n,r){var e=-1,u=o,c=t.length,f=true,a=[],l=a;if(r)f=false,u=i;else if(c>=Ar){var s=n?null:Bu(t);if(s)return m(s);f=false,u=d,l=new q}else l=n?[]:a;t:for(;++e<c;){var h=t[e],v=n?n(h):h;
if(h=r||0!==h?h:0,f&&v===v){for(var p=l.length;p--;)if(l[p]===v)continue t;n&&l.push(v),a.push(h)}else u(l,v,r)||(l!==a&&l.push(v),a.push(h))}return a}function Nt(t,n){return n=Ct(n,t),t=En(t,n),null==t||delete t[Bn(Nn(n))]}function Ct(t,n){return qu(t)?t:mn(t,n)?[t]:Cu(ar(t))}function Dt(t,n){if(n)return t.slice();var r=t.length,e=ru?ru(r):new t.constructor(r);return t.copy(e),e}function Rt(t){var n=new t.constructor(t.byteLength);return new nu(n).set(new nu(t)),n}function Vt(t,n){return new t.constructor(n?Rt(t.buffer):t.buffer,t.byteOffset,t.byteLength);
}function qt(n,r,e){return a(r?e(w(n),kr):w(n),t,new n.constructor)}function Wt(t){var n=new t.constructor(t.source,ze.exec(t));return n.lastIndex=t.lastIndex,n}function Gt(t,r,e){return a(r?e(m(t),kr):m(t),n,new t.constructor)}function Ht(t){return $u?Object($u.call(t)):{}}function Jt(t,n){return new t.constructor(n?Rt(t.buffer):t.buffer,t.byteOffset,t.length)}function Kt(t,n){var r=-1,e=t.length;for(n||(n=Array(e));++r<e;)n[r]=t[r];return n}function Qt(t,n,r,e){var u=!r;r||(r={});for(var o=-1,i=n.length;++o<i;){
var c=n[o],f=e?e(r[c],t[c],c,r,t):Or;f===Or&&(f=t[c]),u?ot(r,c,f):nt(r,c,f)}return r}function Xt(t,n){return Qt(t,Mu(t),n)}function Yt(t,n){return Qt(t,Tu(t),n)}function Zt(t){return Et(function(n,r){var e=-1,u=r.length,o=u>1?r[u-1]:Or,i=u>2?r[2]:Or;for(o=t.length>3&&typeof o=="function"?(u--,o):Or,i&&On(r[0],r[1],i)&&(o=u<3?Or:o,u=1),n=Object(n);++e<u;){var c=r[e];c&&t(n,c,e,o)}return n})}function tn(t,n){return function(r,e){if(null==r)return r;if(!Qn(r))return t(r,e);for(var u=r.length,o=n?u:-1,i=Object(r);(n?o--:++o<u)&&e(i[o],o,i)!==false;);
return r}}function nn(t){return function(n,r,e){for(var u=-1,o=Object(n),i=e(n),c=i.length;c--;){var f=i[t?c:++u];if(r(o[f],f,o)===false)break}return n}}function rn(t){return function(n,r,e){var u=Object(n);if(!Qn(n)){var o=sn(r,3);n=hr(n),r=function(t){return o(u[t],t,u)}}var i=t(n,r,e);return i>-1?u[o?n[i]:i]:Or}}function en(t){return rr(t)?Or:t}function un(t,n,r,e,u,o){var i=r&Lr,c=t.length,f=n.length;if(c!=f&&!(i&&f>c))return false;var a=o.get(t);if(a&&o.get(n))return a==n;var s=-1,h=true,v=r&Pr?new q:Or;
for(o.set(t,n),o.set(n,t);++s<c;){var p=t[s],y=n[s];if(e)var g=i?e(y,p,s,n,t,o):e(p,y,s,t,n,o);if(g!==Or){if(g)continue;h=false;break}if(v){if(!l(n,function(t,n){if(!d(v,n)&&(p===t||u(p,t,r,e,o)))return v.push(n)})){h=false;break}}else if(p!==y&&!u(p,y,r,e,o)){h=false;break}}return o.delete(t),o.delete(n),h}function on(t,n,r,e,u,o,i){switch(r){case fe:if(t.byteLength!=n.byteLength||t.byteOffset!=n.byteOffset)return false;t=t.buffer,n=n.buffer;case ce:return!(t.byteLength!=n.byteLength||!o(new nu(t),new nu(n)));
case qr:case Wr:case Qr:return Kn(+t,+n);case Gr:return t.name==n.name&&t.message==n.message;case ne:case ee:return t==n+"";case Kr:var c=w;case re:var f=e&Lr;if(c||(c=m),t.size!=n.size&&!f)return false;var a=i.get(t);if(a)return a==n;e|=Pr,i.set(t,n);var l=un(c(t),c(n),e,u,o,i);return i.delete(t),l;case ue:if($u)return $u.call(t)==$u.call(n)}return false}function cn(t,n,r,e,u,o){var i=r&Lr,c=an(t),f=c.length;if(f!=an(n).length&&!i)return false;for(var a=f;a--;){var l=c[a];if(!(i?l in n:Je.call(n,l)))return false;
}var s=o.get(t);if(s&&o.get(n))return s==n;var h=true;o.set(t,n),o.set(n,t);for(var v=i;++a<f;){l=c[a];var p=t[l],y=n[l];if(e)var g=i?e(y,p,l,n,t,o):e(p,y,l,t,n,o);if(!(g===Or?p===y||u(p,y,r,e,o):g)){h=false;break}v||(v="constructor"==l)}if(h&&!v){var b=t.constructor,_=n.constructor;b!=_&&"constructor"in t&&"constructor"in n&&!(typeof b=="function"&&b instanceof b&&typeof _=="function"&&_ instanceof _)&&(h=false)}return o.delete(t),o.delete(n),h}function fn(t){return Nu(Pn(t,Or,Un),t+"")}function an(t){return st(t,hr,Mu);
}function ln(t){return st(t,vr,Tu)}function sn(){var t=z.iteratee||br;return t=t===br?jt:t,arguments.length?t(arguments[0],arguments[1]):t}function hn(t,n){var r=t.__data__;return An(n)?r[typeof n=="string"?"string":"hash"]:r.map}function vn(t){for(var n=hr(t),r=n.length;r--;){var e=n[r],u=t[e];n[r]=[e,u,Sn(u)]}return n}function pn(t,n){var r=j(t,n);return _t(r)?r:Or}function yn(t){var n=Je.call(t,fu),r=t[fu];try{t[fu]=Or;var e=true}catch(t){}var u=Qe.call(t);return e&&(n?t[fu]=r:delete t[fu]),u}function gn(t,n,r){
n=Ct(n,t);for(var e=-1,u=n.length,o=false;++e<u;){var i=Bn(n[e]);if(!(o=null!=t&&r(t,i)))break;t=t[i]}return o||++e!=u?o:(u=null==t?0:t.length,!!u&&Zn(u)&&wn(i,u)&&(qu(t)||Vu(t)))}function bn(t){var n=t.length,r=t.constructor(n);return n&&"string"==typeof t[0]&&Je.call(t,"index")&&(r.index=t.index,r.input=t.input),r}function _n(t){return typeof t.constructor!="function"||xn(t)?{}:Lu(eu(t))}function dn(t,n,r,e){var u=t.constructor;switch(n){case ce:return Rt(t);case qr:case Wr:return new u(+t);case fe:
return Vt(t,e);case ae:case le:case se:case he:case ve:case pe:case ye:case ge:case be:return Jt(t,e);case Kr:return qt(t,e,r);case Qr:case ee:return new u(t);case ne:return Wt(t);case re:return Gt(t,e,r);case ue:return Ht(t)}}function jn(t){return qu(t)||Vu(t)||!!(cu&&t&&t[cu])}function wn(t,n){return n=null==n?Mr:n,!!n&&(typeof t=="number"||Ie.test(t))&&t>-1&&t%1==0&&t<n}function On(t,n,r){if(!tr(r))return false;var e=typeof n;return!!("number"==e?Qn(r)&&wn(n,r.length):"string"==e&&n in r)&&Kn(r[n],t);
}function mn(t,n){if(qu(t))return false;var r=typeof t;return!("number"!=r&&"symbol"!=r&&"boolean"!=r&&null!=t&&!ur(t))||(de.test(t)||!_e.test(t)||null!=n&&t in Object(n))}function An(t){var n=typeof t;return"string"==n||"number"==n||"symbol"==n||"boolean"==n?"__proto__"!==t:null===t}function zn(t){return!!Ke&&Ke in t}function xn(t){var n=t&&t.constructor;return t===(typeof n=="function"&&n.prototype||We)}function Sn(t){return t===t&&!tr(t)}function kn(t,n){return function(r){return null!=r&&(r[t]===n&&(n!==Or||t in Object(r)));
}}function $n(t){var n=Hn(t,function(t){return r.size===Sr&&r.clear(),t}),r=n.cache;return n}function In(t){var n=[];if(null!=t)for(var r in Object(t))n.push(r);return n}function Ln(t){return Qe.call(t)}function Pn(t,n,e){return n=pu(n===Or?t.length-1:n,0),function(){for(var u=arguments,o=-1,i=pu(u.length-n,0),c=Array(i);++o<i;)c[o]=u[n+o];o=-1;for(var f=Array(n+1);++o<n;)f[o]=u[o];return f[n]=e(c),r(t,this,f)}}function En(t,n){return n.length<2?t:lt(t,Bt(n,0,-1))}function Fn(t){var n=0,r=0;return function(){
var e=gu(),u=Fr-(e-r);if(r=e,u>0){if(++n>=Er)return arguments[0]}else n=0;return t.apply(Or,arguments)}}function Bn(t){if(typeof t=="string"||ur(t))return t;var n=t+"";return"0"==n&&1/t==-Br?"-0":n}function Mn(t){if(null!=t){try{return He.call(t)}catch(t){}try{return t+""}catch(t){}}return""}function Tn(t,n,r){var e=null==t?0:t.length;if(!e)return-1;var u=null==r?0:ir(r);return u<0&&(u=pu(e+u,0)),s(t,sn(n,3),u)}function Un(t){return(null==t?0:t.length)?ft(t,1):[]}function Nn(t){var n=null==t?0:t.length;
return n?t[n-1]:Or}function Cn(t,n){return t&&t.length&&n&&n.length?Lt(t,n):t}function Dn(t,n){var r=[];if(!t||!t.length)return r;var e=-1,u=[],o=t.length;for(n=sn(n,3);++e<o;){var i=t[e];n(i,e,t)&&(r.push(i),u.push(e))}return Pt(t,u),r}function Rn(t,n,r){return Mt(t,n,sn(r,2))}function Vn(t){return t&&t.length?Ut(t):[]}function qn(t,n){return(qu(t)?u:ct)(t,sn(n,3))}function Wn(t,n,r,e){t=Qn(t)?t:pr(t),r=r&&!e?ir(r):0;var u=t.length;return r<0&&(r=pu(u+r,0)),er(t)?r<=u&&t.indexOf(n,r)>-1:!!u&&h(t,n,r)>-1;
}function Gn(t,n){return(qu(t)?c:mt)(t,sn(n,3))}function Hn(t,n){if(typeof t!="function"||null!=n&&typeof n!="function")throw new TypeError(zr);var r=function(){var e=arguments,u=n?n.apply(this,e):e[0],o=r.cache;if(o.has(u))return o.get(u);var i=t.apply(this,e);return r.cache=o.set(u,i)||o,i};return r.cache=new(Hn.Cache||U),r}function Jn(t){return it(t,kr|Ir)}function Kn(t,n){return t===n||t!==t&&n!==n}function Qn(t){return null!=t&&Zn(t.length)&&!Yn(t)}function Xn(t){return nr(t)&&Qn(t)}function Yn(t){
if(!tr(t))return false;var n=ht(t);return n==Hr||n==Jr||n==Vr||n==te}function Zn(t){return typeof t=="number"&&t>-1&&t%1==0&&t<=Mr}function tr(t){var n=typeof t;return null!=t&&("object"==n||"function"==n)}function nr(t){return null!=t&&typeof t=="object"}function rr(t){if(!nr(t)||ht(t)!=Yr)return false;var n=eu(t);if(null===n)return true;var r=Je.call(n,"constructor")&&n.constructor;return typeof r=="function"&&r instanceof r&&He.call(r)==Xe}function er(t){return typeof t=="string"||!qu(t)&&nr(t)&&ht(t)==ee}function ur(t){
return typeof t=="symbol"||nr(t)&&ht(t)==ue}function or(t){if(!t)return 0===t?t:0;if(t=cr(t),t===Br||t===-Br){return(t<0?-1:1)*Tr}return t===t?t:0}function ir(t){var n=or(t),r=n%1;return n===n?r?n-r:n:0}function cr(t){if(typeof t=="number")return t;if(ur(t))return Ur;if(tr(t)){var n=typeof t.valueOf=="function"?t.valueOf():t;t=tr(n)?n+"":n}if(typeof t!="string")return 0===t?t:+t;t=t.replace(me,"");var r=Se.test(t);return r||$e.test(t)?Ee(t.slice(2),r?2:8):xe.test(t)?Ur:+t}function fr(t){return Qt(t,vr(t));
}function ar(t){return null==t?"":Tt(t)}function lr(t,n,r){var e=null==t?Or:lt(t,n);return e===Or?r:e}function sr(t,n){return null!=t&&gn(t,n,vt)}function hr(t){return Qn(t)?Z(t):wt(t)}function vr(t){return Qn(t)?Z(t,true):Ot(t)}function pr(t){return null==t?[]:_(t,hr(t))}function yr(t){return function(){return t}}function gr(t){return t}function br(t){return jt(typeof t=="function"?t:it(t,kr))}function _r(){}function dr(t){return mn(t)?y(Bn(t)):It(t)}function jr(){return[]}function wr(){return false}var Or,mr="4.17.4",Ar=200,zr="Expected a function",xr="__lodash_hash_undefined__",Sr=500,kr=1,$r=2,Ir=4,Lr=1,Pr=2,Er=800,Fr=16,Br=1/0,Mr=9007199254740991,Tr=1.7976931348623157e308,Ur=NaN,Nr=4294967295,Cr=Nr-1,Dr="[object Arguments]",Rr="[object Array]",Vr="[object AsyncFunction]",qr="[object Boolean]",Wr="[object Date]",Gr="[object Error]",Hr="[object Function]",Jr="[object GeneratorFunction]",Kr="[object Map]",Qr="[object Number]",Xr="[object Null]",Yr="[object Object]",Zr="[object Promise]",te="[object Proxy]",ne="[object RegExp]",re="[object Set]",ee="[object String]",ue="[object Symbol]",oe="[object Undefined]",ie="[object WeakMap]",ce="[object ArrayBuffer]",fe="[object DataView]",ae="[object Float32Array]",le="[object Float64Array]",se="[object Int8Array]",he="[object Int16Array]",ve="[object Int32Array]",pe="[object Uint8Array]",ye="[object Uint8ClampedArray]",ge="[object Uint16Array]",be="[object Uint32Array]",_e=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,de=/^\w*$/,je=/^\./,we=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,Oe=/[\\^$.*+?()[\]{}|]/g,me=/^\s+|\s+$/g,Ae=/\\(\\)?/g,ze=/\w*$/,xe=/^[-+]0x[0-9a-f]+$/i,Se=/^0b[01]+$/i,ke=/^\[object .+?Constructor\]$/,$e=/^0o[0-7]+$/i,Ie=/^(?:0|[1-9]\d*)$/,Le={};
Le[ae]=Le[le]=Le[se]=Le[he]=Le[ve]=Le[pe]=Le[ye]=Le[ge]=Le[be]=true,Le[Dr]=Le[Rr]=Le[ce]=Le[qr]=Le[fe]=Le[Wr]=Le[Gr]=Le[Hr]=Le[Kr]=Le[Qr]=Le[Yr]=Le[ne]=Le[re]=Le[ee]=Le[ie]=false;var Pe={};Pe[Dr]=Pe[Rr]=Pe[ce]=Pe[fe]=Pe[qr]=Pe[Wr]=Pe[ae]=Pe[le]=Pe[se]=Pe[he]=Pe[ve]=Pe[Kr]=Pe[Qr]=Pe[Yr]=Pe[ne]=Pe[re]=Pe[ee]=Pe[ue]=Pe[pe]=Pe[ye]=Pe[ge]=Pe[be]=true,Pe[Gr]=Pe[Hr]=Pe[ie]=false;var Ee=parseInt,Fe=typeof global=="object"&&global&&global.Object===Object&&global,Be=typeof self=="object"&&self&&self.Object===Object&&self,Me=Fe||Be||Function("return this")(),Te=typeof exports=="object"&&exports&&!exports.nodeType&&exports,Ue=Te&&typeof module=="object"&&module&&!module.nodeType&&module,Ne=Ue&&Ue.exports===Te,Ce=Ne&&Fe.process,De=function(){
try{return Ce&&Ce.binding&&Ce.binding("util")}catch(t){}}(),Re=De&&De.isTypedArray,Ve=Array.prototype,qe=Function.prototype,We=Object.prototype,Ge=Me["__core-js_shared__"],He=qe.toString,Je=We.hasOwnProperty,Ke=function(){var t=/[^.]+$/.exec(Ge&&Ge.keys&&Ge.keys.IE_PROTO||"");return t?"Symbol(src)_1."+t:""}(),Qe=We.toString,Xe=He.call(Object),Ye=RegExp("^"+He.call(Je).replace(Oe,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$"),Ze=Ne?Me.Buffer:Or,tu=Me.Symbol,nu=Me.Uint8Array,ru=Ze?Ze.allocUnsafe:Or,eu=O(Object.getPrototypeOf,Object),uu=Object.create,ou=We.propertyIsEnumerable,iu=Ve.splice,cu=tu?tu.isConcatSpreadable:Or,fu=tu?tu.toStringTag:Or,au=function(){
try{var t=pn(Object,"defineProperty");return t({},"",{}),t}catch(t){}}(),lu=Math.floor,su=Object.getOwnPropertySymbols,hu=Ze?Ze.isBuffer:Or,vu=O(Object.keys,Object),pu=Math.max,yu=Math.min,gu=Date.now,bu=pn(Me,"DataView"),_u=pn(Me,"Map"),du=pn(Me,"Promise"),ju=pn(Me,"Set"),wu=pn(Me,"WeakMap"),Ou=pn(Object,"create"),mu=Mn(bu),Au=Mn(_u),zu=Mn(du),xu=Mn(ju),Su=Mn(wu),ku=tu?tu.prototype:Or,$u=ku?ku.valueOf:Or,Iu=ku?ku.toString:Or,Lu=function(){function t(){}return function(n){if(!tr(n))return{};if(uu)return uu(n);
t.prototype=n;var r=new t;return t.prototype=Or,r}}();x.prototype.clear=S,x.prototype.delete=k,x.prototype.get=$,x.prototype.has=I,x.prototype.set=L,P.prototype.clear=E,P.prototype.delete=F,P.prototype.get=B,P.prototype.has=M,P.prototype.set=T,U.prototype.clear=N,U.prototype.delete=C,U.prototype.get=D,U.prototype.has=R,U.prototype.set=V,q.prototype.add=q.prototype.push=W,q.prototype.has=G,H.prototype.clear=J,H.prototype.delete=K,H.prototype.get=Q,H.prototype.has=X,H.prototype.set=Y;var Pu=tn(at),Eu=nn(),Fu=au?function(t,n){
return au(t,"toString",{configurable:true,enumerable:false,value:yr(n),writable:true})}:gr,Bu=ju&&1/m(new ju([,-0]))[1]==Br?function(t){return new ju(t)}:_r,Mu=su?function(t){return null==t?[]:(t=Object(t),u(su(t),function(n){return ou.call(t,n)}))}:jr,Tu=su?function(t){for(var n=[];t;)f(n,Mu(t)),t=eu(t);return n}:jr,Uu=ht;(bu&&Uu(new bu(new ArrayBuffer(1)))!=fe||_u&&Uu(new _u)!=Kr||du&&Uu(du.resolve())!=Zr||ju&&Uu(new ju)!=re||wu&&Uu(new wu)!=ie)&&(Uu=function(t){var n=ht(t),r=n==Yr?t.constructor:Or,e=r?Mn(r):"";
if(e)switch(e){case mu:return fe;case Au:return Kr;case zu:return Zr;case xu:return re;case Su:return ie}return n});var Nu=Fn(Fu),Cu=$n(function(t){var n=[];return je.test(t)&&n.push(""),t.replace(we,function(t,r,e,u){n.push(e?u.replace(Ae,"$1"):r||t)}),n}),Du=Et(Cn),Ru=rn(Tn);Hn.Cache=U;var Vu=pt(function(){return arguments}())?pt:function(t){return nr(t)&&Je.call(t,"callee")&&!ou.call(t,"callee")},qu=Array.isArray,Wu=hu||wr,Gu=Re?b(Re):dt,Hu=Zt(function(t,n,r){xt(t,n,r)}),Ju=fn(function(t,n){var r={};
if(null==t)return r;var e=false;n=c(n,function(n){return n=Ct(n,t),e||(e=n.length>1),n}),Qt(t,ln(t),r),e&&(r=it(r,kr|$r|Ir,en));for(var u=n.length;u--;)Nt(r,n[u]);return r}),Ku=fn(function(t,n){return null==t?{}:kt(t,n)});z.constant=yr,z.filter=qn,z.flatten=Un,z.iteratee=br,z.keys=hr,z.keysIn=vr,z.map=Gn,z.memoize=Hn,z.merge=Hu,z.omit=Ju,z.pick=Ku,z.property=dr,z.pull=Du,z.pullAll=Cn,z.remove=Dn,z.toPlainObject=fr,z.uniq=Vn,z.values=pr,z.cloneDeep=Jn,z.eq=Kn,z.find=Ru,z.findIndex=Tn,z.get=lr,z.hasIn=sr,
z.identity=gr,z.includes=Wn,z.isArguments=Vu,z.isArray=qu,z.isArrayLike=Qn,z.isArrayLikeObject=Xn,z.isBuffer=Wu,z.isFunction=Yn,z.isLength=Zn,z.isObject=tr,z.isObjectLike=nr,z.isPlainObject=rr,z.isString=er,z.isSymbol=ur,z.isTypedArray=Gu,z.last=Nn,z.stubArray=jr,z.stubFalse=wr,z.noop=_r,z.sortedIndexBy=Rn,z.toFinite=or,z.toInteger=ir,z.toNumber=cr,z.toString=ar,z.VERSION=mr,typeof define=="function"&&typeof define.amd=="object"&&define.amd?(Me._=z, define(function(){return z})):Ue?((Ue.exports=z)._=z,
Te._=z):Me._=z}).call(this);
;(function(){function t(t,e){return t.set(e[0],e[1]),t}function e(t,e){return t.add(e),t}function n(t,e,n){switch(n.length){case 0:return t.call(e);case 1:return t.call(e,n[0]);case 2:return t.call(e,n[0],n[1]);case 3:return t.call(e,n[0],n[1],n[2])}return t.apply(e,n)}function r(t,e){for(var n=-1,r=null==t?0:t.length;++n<r&&false!==e(t[n],n,t););}function o(t,e){for(var n=-1,r=null==t?0:t.length,o=0,u=[];++n<r;){var c=t[n];e(c,n,t)&&(u[o++]=c)}return u}function u(t,e){return!(null==t||!t.length)&&-1<s(t,e,0);
}function c(t,e){for(var n=-1,r=null==t?0:t.length,o=Array(r);++n<r;)o[n]=e(t[n],n,t);return o}function i(t,e){for(var n=-1,r=e.length,o=t.length;++n<r;)t[o+n]=e[n];return t}function a(t,e,n){for(var r=-1,o=null==t?0:t.length;++r<o;)n=e(n,t[r],r,t);return n}function f(t,e){for(var n=-1,r=null==t?0:t.length;++n<r;)if(e(t[n],n,t))return true;return false}function l(t,e,n){var r=t.length;for(n+=-1;++n<r;)if(e(t[n],n,t))return n;return-1}function s(t,e,n){if(e===e)t:{--n;for(var r=t.length;++n<r;)if(t[n]===e){
t=n;break t}t=-1}else t=l(t,b,n);return t}function b(t){return t!==t}function h(t){return function(e){return null==e?ae:e[t]}}function p(t){return function(e){return t(e)}}function y(t,e){return c(e,function(e){return t[e]})}function j(t,e){return t.has(e)}function v(t){var e=-1,n=Array(t.size);return t.forEach(function(t,r){n[++e]=[r,t]}),n}function g(t){var e=Object;return function(n){return t(e(n))}}function _(t){var e=-1,n=Array(t.size);return t.forEach(function(t){n[++e]=t}),n}function d(){}
function A(t){var e=-1,n=null==t?0:t.length;for(this.clear();++e<n;){var r=t[e];this.set(r[0],r[1])}}function w(t){var e=-1,n=null==t?0:t.length;for(this.clear();++e<n;){var r=t[e];this.set(r[0],r[1])}}function m(t){var e=-1,n=null==t?0:t.length;for(this.clear();++e<n;){var r=t[e];this.set(r[0],r[1])}}function O(t){var e=-1,n=null==t?0:t.length;for(this.__data__=new m;++e<n;)this.add(t[e])}function S(t){this.size=(this.__data__=new w(t)).size}function k(t,e){var n=Dn(t),r=!n&&Bn(t),o=!n&&!r&&Pn(t),u=!n&&!r&&!o&&Ln(t);
if(n=n||r||o||u){for(var r=t.length,c=String,i=-1,a=Array(r);++i<r;)a[i]=c(i);r=a}else r=[];var f,c=r.length;for(f in t)!e&&!Ne.call(t,f)||n&&("length"==f||o&&("offset"==f||"parent"==f)||u&&("buffer"==f||"byteLength"==f||"byteOffset"==f)||mt(f,c))||r.push(f);return r}function z(t,e,n){(n===ae||Bt(t[e],n))&&(n!==ae||e in t)||M(t,e,n)}function x(t,e,n){var r=t[e];Ne.call(t,e)&&Bt(r,n)&&(n!==ae||e in t)||M(t,e,n)}function I(t,e){for(var n=t.length;n--;)if(Bt(t[n][0],e))return n;return-1}function F(t,e){
return t&&ut(e,Yt(e),t)}function E(t,e){return t&&ut(e,Zt(e),t)}function M(t,e,n){"__proto__"==e&&tn?tn(t,e,{configurable:true,enumerable:true,value:n,writable:true}):t[e]=n}function $(t,e,n,o,u,c){var i,a=1&e,f=2&e,l=4&e;if(n&&(i=u?n(t,o,u,c):n(t)),i!==ae)return i;if(!Vt(t))return t;if(o=Dn(t)){if(i=_t(t),!a)return ot(t,i)}else{var s=Fn(t),b="[object Function]"==s||"[object GeneratorFunction]"==s;if(Pn(t))return et(t,a);if("[object Object]"==s||"[object Arguments]"==s||b&&!u){if(i=f||b?{}:dt(t),!a)return f?it(t,E(i,t)):ct(t,F(i,t));
}else{if(!Oe[s])return u?t:{};i=At(t,s,$,a)}}if(c||(c=new S),u=c.get(t))return u;c.set(t,i);var f=l?f?pt:ht:f?Zt:Yt,h=o?ae:f(t);return r(h||t,function(r,o){h&&(o=r,r=t[o]),x(i,o,$(r,e,n,o,t,c))}),i}function U(t,e){var n=[];return On(t,function(t,r,o){e(t,r,o)&&n.push(t)}),n}function B(t,e,n,r,o){var u=-1,c=t.length;for(n||(n=wt),o||(o=[]);++u<c;){var a=t[u];0<e&&n(a)?1<e?B(a,e-1,n,r,o):i(o,a):r||(o[o.length]=a)}return o}function D(t,e){e=tt(e,t);for(var n=0,r=e.length;null!=t&&n<r;)t=t[xt(e[n++])];
return n&&n==r?t:ae}function P(t,e,n){return e=e(t),Dn(t)?e:i(e,n(t))}function L(t){if(null==t)t=t===ae?"[object Undefined]":"[object Null]";else if(Ze&&Ze in Object(t)){var e=Ne.call(t,Ze),n=t[Ze];try{t[Ze]=ae;var r=true}catch(t){}var o=Ce.call(t);r&&(e?t[Ze]=n:delete t[Ze]),t=o}else t=Ce.call(t);return t}function N(t){return Ct(t)&&"[object Arguments]"==L(t)}function V(t,e,n,r,o){if(t===e)e=true;else if(null==t||null==e||!Ct(t)&&!Ct(e))e=t!==t&&e!==e;else t:{var u=Dn(t),c=Dn(e),i=u?"[object Array]":Fn(t),a=c?"[object Array]":Fn(e),i="[object Arguments]"==i?"[object Object]":i,a="[object Arguments]"==a?"[object Object]":a,f="[object Object]"==i,c="[object Object]"==a;
if((a=i==a)&&Pn(t)){if(!Pn(e)){e=false;break t}u=true,f=false}if(a&&!f)o||(o=new S),e=u||Ln(t)?lt(t,e,n,r,V,o):st(t,e,i,n,r,V,o);else{if(!(1&n)&&(u=f&&Ne.call(t,"__wrapped__"),i=c&&Ne.call(e,"__wrapped__"),u||i)){t=u?t.value():t,e=i?e.value():e,o||(o=new S),e=V(t,e,n,r,o);break t}if(a)e:if(o||(o=new S),u=1&n,i=ht(t),c=i.length,a=ht(e).length,c==a||u){for(f=c;f--;){var l=i[f];if(!(u?l in e:Ne.call(e,l))){e=false;break e}}if((a=o.get(t))&&o.get(e))e=a==e;else{a=true,o.set(t,e),o.set(e,t);for(var s=u;++f<c;){var l=i[f],b=t[l],h=e[l];
if(r)var p=u?r(h,b,l,e,t,o):r(b,h,l,t,e,o);if(p===ae?b!==h&&!V(b,h,n,r,o):!p){a=false;break}s||(s="constructor"==l)}a&&!s&&(n=t.constructor,r=e.constructor,n!=r&&"constructor"in t&&"constructor"in e&&!(typeof n=="function"&&n instanceof n&&typeof r=="function"&&r instanceof r)&&(a=false)),o.delete(t),o.delete(e),e=a}}else e=false;else e=false}}return e}function C(t,e){var n=e.length,r=n;if(null==t)return!r;for(t=Object(t);n--;){var o=e[n];if(o[2]?o[1]!==t[o[0]]:!(o[0]in t))return false}for(;++n<r;){var o=e[n],u=o[0],c=t[u],i=o[1];
if(o[2]){if(c===ae&&!(u in t))return false}else if(o=new S,void 0===ae?!V(i,c,3,void 0,o):1)return false}return true}function R(t){return Ct(t)&&Nt(t.length)&&!!me[L(t)]}function T(t){return typeof t=="function"?t:null==t?ne:typeof t=="object"?Dn(t)?G(t[0],t[1]):q(t):ue(t)}function W(t,e){var n=-1,r=Dt(t)?Array(t.length):[];return On(t,function(t,o,u){r[++n]=e(t,o,u)}),r}function q(t){var e=vt(t);return 1==e.length&&e[0][2]?kt(e[0][0],e[0][1]):function(n){return n===t||C(n,e)}}function G(t,e){return Ot(t)&&e===e&&!Vt(e)?kt(xt(t),e):function(n){
var r=Qt(n,t);return r===ae&&r===e?Xt(n,t):V(e,r,3)}}function H(t,e,n,r,o){t!==e&&Sn(e,function(u,c){if(Vt(u)){o||(o=new S);var i=o,a=t[c],f=e[c],l=i.get(f);if(l)z(t,c,l);else{var l=r?r(a,f,c+"",t,e,i):ae,s=l===ae;if(s){var b=Dn(f),h=!b&&Pn(f),p=!b&&!h&&Ln(f),l=f;b||h||p?Dn(a)?l=a:Pt(a)?l=ot(a):h?(s=false,l=et(f,true)):p?(s=false,l=rt(f,true)):l=[]:Rt(f)||Bn(f)?(l=a,Bn(a)?l=Jt(a):(!Vt(a)||n&&Lt(a))&&(l=dt(f))):s=false}s&&(i.set(f,l),H(l,f,n,r,i),i.delete(f)),z(t,c,l)}}else i=r?r(t[c],u,c+"",t,e,o):ae,i===ae&&(i=u),
z(t,c,i)},Zt)}function J(t,e){return K(t,e,function(e,n){return Xt(t,n)})}function K(t,e,n){for(var r=-1,o=e.length,u={};++r<o;){var c=e[r],i=D(t,c);if(n(i,c)){var a=u,c=tt(c,t);if(Vt(a))for(var c=tt(c,a),f=-1,l=c.length,s=l-1;null!=a&&++f<l;){var b=xt(c[f]),h=i;if(f!=s){var p=a[b],h=ae;h===ae&&(h=Vt(p)?p:mt(c[f+1])?[]:{})}x(a,b,h),a=a[b]}}}return u}function Q(t){return function(e){return D(e,t)}}function X(t){return En(zt(t,void 0,ne),t+"")}function Y(t){if(typeof t=="string")return t;if(Dn(t))return c(t,Y)+"";
if(Wt(t))return wn?wn.call(t):"";var e=t+"";return"0"==e&&1/t==-fe?"-0":e}function Z(t,e){e=tt(e,t);var n;if(2>e.length)n=t;else{n=e;var r=0,o=-1,u=-1,c=n.length;for(0>r&&(r=-r>c?0:c+r),o=o>c?c:o,0>o&&(o+=c),c=r>o?0:o-r>>>0,r>>>=0,o=Array(c);++u<c;)o[u]=n[u+r];n=D(t,o)}t=n,null==t||delete t[xt(Mt(e))]}function tt(t,e){return Dn(t)?t:Ot(t,e)?[t]:Mn(Kt(t))}function et(t,e){if(e)return t.slice();var n=t.length,n=He?He(n):new t.constructor(n);return t.copy(n),n}function nt(t){var e=new t.constructor(t.byteLength);
return new Ge(e).set(new Ge(t)),e}function rt(t,e){return new t.constructor(e?nt(t.buffer):t.buffer,t.byteOffset,t.length)}function ot(t,e){var n=-1,r=t.length;for(e||(e=Array(r));++n<r;)e[n]=t[n];return e}function ut(t,e,n){var r=!n;n||(n={});for(var o=-1,u=e.length;++o<u;){var c=e[o],i=ae;i===ae&&(i=t[c]),r?M(n,c,i):x(n,c,i)}return n}function ct(t,e){return ut(t,xn(t),e)}function it(t,e){return ut(t,In(t),e)}function at(t){return X(function(e,n){var r,o=-1,u=n.length,c=1<u?n[u-1]:ae,i=2<u?n[2]:ae,c=3<t.length&&typeof c=="function"?(u--,
c):ae;if(r=i){r=n[0];var a=n[1];if(Vt(i)){var f=typeof a;r=!!("number"==f?Dt(i)&&mt(a,i.length):"string"==f&&a in i)&&Bt(i[a],r)}else r=false}for(r&&(c=3>u?ae:c,u=1),e=Object(e);++o<u;)(i=n[o])&&t(e,i,o,c);return e})}function ft(t){return Rt(t)?ae:t}function lt(t,e,n,r,o,u){var c=1&n,i=t.length,a=e.length;if(i!=a&&!(c&&a>i))return false;if((a=u.get(t))&&u.get(e))return a==e;var a=-1,l=true,s=2&n?new O:ae;for(u.set(t,e),u.set(e,t);++a<i;){var b=t[a],h=e[a];if(r)var p=c?r(h,b,a,e,t,u):r(b,h,a,t,e,u);if(p!==ae){
if(p)continue;l=false;break}if(s){if(!f(e,function(t,e){if(!j(s,e)&&(b===t||o(b,t,n,r,u)))return s.push(e)})){l=false;break}}else if(b!==h&&!o(b,h,n,r,u)){l=false;break}}return u.delete(t),u.delete(e),l}function st(t,e,n,r,o,u,c){switch(n){case"[object DataView]":if(t.byteLength!=e.byteLength||t.byteOffset!=e.byteOffset)break;t=t.buffer,e=e.buffer;case"[object ArrayBuffer]":if(t.byteLength!=e.byteLength||!u(new Ge(t),new Ge(e)))break;return true;case"[object Boolean]":case"[object Date]":case"[object Number]":
return Bt(+t,+e);case"[object Error]":return t.name==e.name&&t.message==e.message;case"[object RegExp]":case"[object String]":return t==e+"";case"[object Map]":var i=v;case"[object Set]":if(i||(i=_),t.size!=e.size&&!(1&r))break;return(n=c.get(t))?n==e:(r|=2,c.set(t,e),e=lt(i(t),i(e),r,o,u,c),c.delete(t),e);case"[object Symbol]":if(An)return An.call(t)==An.call(e)}return false}function bt(t){return En(zt(t,ae,Et),t+"")}function ht(t){return P(t,Yt,xn)}function pt(t){return P(t,Zt,In)}function yt(){var t=d.iteratee||re,t=t===re?T:t;
return arguments.length?t(arguments[0],arguments[1]):t}function jt(t,e){var n=t.__data__,r=typeof e;return("string"==r||"number"==r||"symbol"==r||"boolean"==r?"__proto__"!==e:null===e)?n[typeof e=="string"?"string":"hash"]:n.map}function vt(t){for(var e=Yt(t),n=e.length;n--;){var r=e[n],o=t[r];e[n]=[r,o,o===o&&!Vt(o)]}return e}function gt(t,e){var n=null==t?ae:t[e];return(!Vt(n)||Ve&&Ve in n?0:(Lt(n)?Te:de).test(It(n)))?n:ae}function _t(t){var e=t.length,n=t.constructor(e);return e&&"string"==typeof t[0]&&Ne.call(t,"index")&&(n.index=t.index,
n.input=t.input),n}function dt(t){return typeof t.constructor!="function"||St(t)?{}:mn(Je(t))}function At(n,r,o,u){var c=n.constructor;switch(r){case"[object ArrayBuffer]":return nt(n);case"[object Boolean]":case"[object Date]":return new c(+n);case"[object DataView]":return r=u?nt(n.buffer):n.buffer,new n.constructor(r,n.byteOffset,n.byteLength);case"[object Float32Array]":case"[object Float64Array]":case"[object Int8Array]":case"[object Int16Array]":case"[object Int32Array]":case"[object Uint8Array]":
case"[object Uint8ClampedArray]":case"[object Uint16Array]":case"[object Uint32Array]":return rt(n,u);case"[object Map]":return r=u?o(v(n),1):v(n),a(r,t,new n.constructor);case"[object Number]":case"[object String]":return new c(n);case"[object RegExp]":return r=new n.constructor(n.source,ve.exec(n)),r.lastIndex=n.lastIndex,r;case"[object Set]":return r=u?o(_(n),1):_(n),a(r,e,new n.constructor);case"[object Symbol]":return An?Object(An.call(n)):{}}}function wt(t){return Dn(t)||Bn(t)||!!(Ye&&t&&t[Ye]);
}function mt(t,e){return e=null==e?9007199254740991:e,!!e&&(typeof t=="number"||we.test(t))&&-1<t&&0==t%1&&t<e}function Ot(t,e){if(Dn(t))return false;var n=typeof t;return!("number"!=n&&"symbol"!=n&&"boolean"!=n&&null!=t&&!Wt(t))||(be.test(t)||!se.test(t)||null!=e&&t in Object(e))}function St(t){var e=t&&t.constructor;return t===(typeof e=="function"&&e.prototype||De)}function kt(t,e){return function(n){return null!=n&&(n[t]===e&&(e!==ae||t in Object(n)))}}function zt(t,e,r){return e=un(e===ae?t.length-1:e,0),
function(){for(var o=arguments,u=-1,c=un(o.length-e,0),i=Array(c);++u<c;)i[u]=o[e+u];for(u=-1,c=Array(e+1);++u<e;)c[u]=o[u];return c[e]=r(i),n(t,this,c)}}function xt(t){if(typeof t=="string"||Wt(t))return t;var e=t+"";return"0"==e&&1/t==-fe?"-0":e}function It(t){if(null!=t){try{return Le.call(t)}catch(t){}return t+""}return""}function Ft(t,e,n){var r=null==t?0:t.length;return r?(n=null==n?0:Gt(n),0>n&&(n=un(r+n,0)),l(t,yt(e,3),n)):-1}function Et(t){return(null==t?0:t.length)?B(t,1):[]}function Mt(t){
var e=null==t?0:t.length;return e?t[e-1]:ae}function $t(t,e){var n;if(t&&t.length&&e&&e.length){n=e;var r=s,o=-1,u=n.length;for(t===n&&(n=ot(n));++o<u;)for(var c=0,i=n[o];-1<(c=r(t,i,c,void 0));)t!==t&&Xe.call(t,c,1),Xe.call(t,c,1);n=t}else n=t;return n}function Ut(t,e){function n(){var r=arguments,o=e?e.apply(this,r):r[0],u=n.cache;return u.has(o)?u.get(o):(r=t.apply(this,r),n.cache=u.set(o,r)||u,r)}if(typeof t!="function"||null!=e&&typeof e!="function")throw new TypeError("Expected a function");
return n.cache=new(Ut.Cache||m),n}function Bt(t,e){return t===e||t!==t&&e!==e}function Dt(t){return null!=t&&Nt(t.length)&&!Lt(t)}function Pt(t){return Ct(t)&&Dt(t)}function Lt(t){return!!Vt(t)&&(t=L(t),"[object Function]"==t||"[object GeneratorFunction]"==t||"[object AsyncFunction]"==t||"[object Proxy]"==t)}function Nt(t){return typeof t=="number"&&-1<t&&0==t%1&&9007199254740991>=t}function Vt(t){var e=typeof t;return null!=t&&("object"==e||"function"==e)}function Ct(t){return null!=t&&typeof t=="object";
}function Rt(t){return!(!Ct(t)||"[object Object]"!=L(t))&&(t=Je(t),null===t||(t=Ne.call(t,"constructor")&&t.constructor,typeof t=="function"&&t instanceof t&&Le.call(t)==Re))}function Tt(t){return typeof t=="string"||!Dn(t)&&Ct(t)&&"[object String]"==L(t)}function Wt(t){return typeof t=="symbol"||Ct(t)&&"[object Symbol]"==L(t)}function qt(t){return t?(t=Ht(t),t===fe||t===-fe?1.7976931348623157e308*(0>t?-1:1):t===t?t:0):0===t?t:0}function Gt(t){t=qt(t);var e=t%1;return t===t?e?t-e:t:0}function Ht(t){
if(typeof t=="number")return t;if(Wt(t))return le;if(Vt(t)&&(t=typeof t.valueOf=="function"?t.valueOf():t,t=Vt(t)?t+"":t),typeof t!="string")return 0===t?t:+t;t=t.replace(ye,"");var e=_e.test(t);return e||Ae.test(t)?ke(t.slice(2),e?2:8):ge.test(t)?le:+t}function Jt(t){return ut(t,Zt(t))}function Kt(t){return null==t?"":Y(t)}function Qt(t,e,n){return t=null==t?ae:D(t,e),t===ae?n:t}function Xt(t,e){var n;if(n=null!=t){n=t;var r;r=tt(e,n);for(var o=-1,u=r.length,c=false;++o<u;){var i=xt(r[o]);if(!(c=null!=n&&null!=n&&i in Object(n)))break;
n=n[i]}c||++o!=u?n=c:(u=null==n?0:n.length,n=!!u&&Nt(u)&&mt(i,u)&&(Dn(n)||Bn(n)))}return n}function Yt(t){if(Dt(t))t=k(t);else if(St(t)){var e,n=[];for(e in Object(t))Ne.call(t,e)&&"constructor"!=e&&n.push(e);t=n}else t=on(t);return t}function Zt(t){if(Dt(t))t=k(t,true);else if(Vt(t)){var e,n=St(t),r=[];for(e in t)("constructor"!=e||!n&&Ne.call(t,e))&&r.push(e);t=r}else{if(e=[],null!=t)for(n in Object(t))e.push(n);t=e}return t}function te(t){return null==t?[]:y(t,Yt(t))}function ee(t){return function(){
return t}}function ne(t){return t}function re(t){return T(typeof t=="function"?t:$(t,1))}function oe(){}function ue(t){return Ot(t)?h(xt(t)):Q(t)}function ce(){return[]}function ie(){return false}var ae,fe=1/0,le=NaN,se=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,be=/^\w*$/,he=/^\./,pe=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,ye=/^\s+|\s+$/g,je=/\\(\\)?/g,ve=/\w*$/,ge=/^[-+]0x[0-9a-f]+$/i,_e=/^0b[01]+$/i,de=/^\[object .+?Constructor\]$/,Ae=/^0o[0-7]+$/i,we=/^(?:0|[1-9]\d*)$/,me={};
me["[object Float32Array]"]=me["[object Float64Array]"]=me["[object Int8Array]"]=me["[object Int16Array]"]=me["[object Int32Array]"]=me["[object Uint8Array]"]=me["[object Uint8ClampedArray]"]=me["[object Uint16Array]"]=me["[object Uint32Array]"]=true,me["[object Arguments]"]=me["[object Array]"]=me["[object ArrayBuffer]"]=me["[object Boolean]"]=me["[object DataView]"]=me["[object Date]"]=me["[object Error]"]=me["[object Function]"]=me["[object Map]"]=me["[object Number]"]=me["[object Object]"]=me["[object RegExp]"]=me["[object Set]"]=me["[object String]"]=me["[object WeakMap]"]=false;
var Oe={};Oe["[object Arguments]"]=Oe["[object Array]"]=Oe["[object ArrayBuffer]"]=Oe["[object DataView]"]=Oe["[object Boolean]"]=Oe["[object Date]"]=Oe["[object Float32Array]"]=Oe["[object Float64Array]"]=Oe["[object Int8Array]"]=Oe["[object Int16Array]"]=Oe["[object Int32Array]"]=Oe["[object Map]"]=Oe["[object Number]"]=Oe["[object Object]"]=Oe["[object RegExp]"]=Oe["[object Set]"]=Oe["[object String]"]=Oe["[object Symbol]"]=Oe["[object Uint8Array]"]=Oe["[object Uint8ClampedArray]"]=Oe["[object Uint16Array]"]=Oe["[object Uint32Array]"]=true,
Oe["[object Error]"]=Oe["[object Function]"]=Oe["[object WeakMap]"]=false;var Se,ke=parseInt,ze=typeof global=="object"&&global&&global.Object===Object&&global,xe=typeof self=="object"&&self&&self.Object===Object&&self,Ie=ze||xe||Function("return this")(),Fe=typeof exports=="object"&&exports&&!exports.nodeType&&exports,Ee=Fe&&typeof module=="object"&&module&&!module.nodeType&&module,Me=Ee&&Ee.exports===Fe,$e=Me&&ze.process;t:{try{Se=$e&&$e.binding&&$e.binding("util");break t}catch(t){}Se=void 0}var Ue=Se&&Se.isTypedArray,Be=Array.prototype,De=Object.prototype,Pe=Ie["__core-js_shared__"],Le=Function.prototype.toString,Ne=De.hasOwnProperty,Ve=function(){
var t=/[^.]+$/.exec(Pe&&Pe.keys&&Pe.keys.IE_PROTO||"");return t?"Symbol(src)_1."+t:""}(),Ce=De.toString,Re=Le.call(Object),Te=RegExp("^"+Le.call(Ne).replace(/[\\^$.*+?()[\]{}|]/g,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$"),We=Me?Ie.Buffer:ae,qe=Ie.Symbol,Ge=Ie.Uint8Array,He=We?We.a:ae,Je=g(Object.getPrototypeOf),Ke=Object.create,Qe=De.propertyIsEnumerable,Xe=Be.splice,Ye=qe?qe.isConcatSpreadable:ae,Ze=qe?qe.toStringTag:ae,tn=function(){try{var t=gt(Object,"defineProperty");
return t({},"",{}),t}catch(t){}}(),en=Math.floor,nn=Object.getOwnPropertySymbols,rn=We?We.isBuffer:ae,on=g(Object.keys),un=Math.max,cn=Math.min,an=Date.now,fn=gt(Ie,"DataView"),ln=gt(Ie,"Map"),sn=gt(Ie,"Promise"),bn=gt(Ie,"Set"),hn=gt(Ie,"WeakMap"),pn=gt(Object,"create"),yn=It(fn),jn=It(ln),vn=It(sn),gn=It(bn),_n=It(hn),dn=qe?qe.prototype:ae,An=dn?dn.valueOf:ae,wn=dn?dn.toString:ae,mn=function(){function t(){}return function(e){return Vt(e)?Ke?Ke(e):(t.prototype=e,e=new t,t.prototype=ae,e):{}}}();
A.prototype.clear=function(){this.__data__=pn?pn(null):{},this.size=0},A.prototype.delete=function(t){return t=this.has(t)&&delete this.__data__[t],this.size-=t?1:0,t},A.prototype.get=function(t){var e=this.__data__;return pn?(t=e[t],"__lodash_hash_undefined__"===t?ae:t):Ne.call(e,t)?e[t]:ae},A.prototype.has=function(t){var e=this.__data__;return pn?e[t]!==ae:Ne.call(e,t)},A.prototype.set=function(t,e){var n=this.__data__;return this.size+=this.has(t)?0:1,n[t]=pn&&e===ae?"__lodash_hash_undefined__":e,
this},w.prototype.clear=function(){this.__data__=[],this.size=0},w.prototype.delete=function(t){var e=this.__data__;return t=I(e,t),!(0>t)&&(t==e.length-1?e.pop():Xe.call(e,t,1),--this.size,true)},w.prototype.get=function(t){var e=this.__data__;return t=I(e,t),0>t?ae:e[t][1]},w.prototype.has=function(t){return-1<I(this.__data__,t)},w.prototype.set=function(t,e){var n=this.__data__,r=I(n,t);return 0>r?(++this.size,n.push([t,e])):n[r][1]=e,this},m.prototype.clear=function(){this.size=0,this.__data__={
hash:new A,map:new(ln||w),string:new A}},m.prototype.delete=function(t){return t=jt(this,t).delete(t),this.size-=t?1:0,t},m.prototype.get=function(t){return jt(this,t).get(t)},m.prototype.has=function(t){return jt(this,t).has(t)},m.prototype.set=function(t,e){var n=jt(this,t),r=n.size;return n.set(t,e),this.size+=n.size==r?0:1,this},O.prototype.add=O.prototype.push=function(t){return this.__data__.set(t,"__lodash_hash_undefined__"),this},O.prototype.has=function(t){return this.__data__.has(t)},S.prototype.clear=function(){
this.__data__=new w,this.size=0},S.prototype.delete=function(t){var e=this.__data__;return t=e.delete(t),this.size=e.size,t},S.prototype.get=function(t){return this.__data__.get(t)},S.prototype.has=function(t){return this.__data__.has(t)},S.prototype.set=function(t,e){var n=this.__data__;if(n instanceof w){var r=n.__data__;if(!ln||199>r.length)return r.push([t,e]),this.size=++n.size,this;n=this.__data__=new m(r)}return n.set(t,e),this.size=n.size,this};var On=function(t,e){return function(n,r){if(null==n)return n;
if(!Dt(n))return t(n,r);for(var o=n.length,u=e?o:-1,c=Object(n);(e?u--:++u<o)&&false!==r(c[u],u,c););return n}}(function(t,e){return t&&Sn(t,e,Yt)}),Sn=function(t){return function(e,n,r){var o=-1,u=Object(e);r=r(e);for(var c=r.length;c--;){var i=r[t?c:++o];if(false===n(u[i],i,u))break}return e}}(),kn=tn?function(t,e){return tn(t,"toString",{configurable:true,enumerable:false,value:ee(e),writable:true})}:ne,zn=bn&&1/_(new bn([,-0]))[1]==fe?function(t){return new bn(t)}:oe,xn=nn?function(t){return null==t?[]:(t=Object(t),
o(nn(t),function(e){return Qe.call(t,e)}))}:ce,In=nn?function(t){for(var e=[];t;)i(e,xn(t)),t=Je(t);return e}:ce,Fn=L;(fn&&"[object DataView]"!=Fn(new fn(new ArrayBuffer(1)))||ln&&"[object Map]"!=Fn(new ln)||sn&&"[object Promise]"!=Fn(sn.resolve())||bn&&"[object Set]"!=Fn(new bn)||hn&&"[object WeakMap]"!=Fn(new hn))&&(Fn=function(t){var e=L(t);if(t=(t="[object Object]"==e?t.constructor:ae)?It(t):"")switch(t){case yn:return"[object DataView]";case jn:return"[object Map]";case vn:return"[object Promise]";
case gn:return"[object Set]";case _n:return"[object WeakMap]"}return e});var En=function(t){var e=0,n=0;return function(){var r=an(),o=16-(r-n);if(n=r,0<o){if(800<=++e)return arguments[0]}else e=0;return t.apply(ae,arguments)}}(kn),Mn=function(t){t=Ut(t,function(t){return 500===e.size&&e.clear(),t});var e=t.cache;return t}(function(t){var e=[];return he.test(t)&&e.push(""),t.replace(pe,function(t,n,r,o){e.push(r?o.replace(je,"$1"):n||t)}),e}),$n=X($t),Un=function(t){return function(e,n,r){var o=Object(e);
if(!Dt(e)){var u=yt(n,3);e=Yt(e),n=function(t){return u(o[t],t,o)}}return n=t(e,n,r),-1<n?o[u?e[n]:n]:ae}}(Ft);Ut.Cache=m;var Bn=N(function(){return arguments}())?N:function(t){return Ct(t)&&Ne.call(t,"callee")&&!Qe.call(t,"callee")},Dn=Array.isArray,Pn=rn||ie,Ln=Ue?p(Ue):R,Nn=at(function(t,e,n){H(t,e,n)}),Vn=at(function(t,e,n,r){H(t,e,n,r)}),Cn=bt(function(t,e){var n={};if(null==t)return n;var r=false;e=c(e,function(e){return e=tt(e,t),r||(r=1<e.length),e}),ut(t,pt(t),n),r&&(n=$(n,7,ft));for(var o=e.length;o--;)Z(n,e[o]);
return n}),Rn=bt(function(t,e){return null==t?{}:J(t,e)});d.constant=ee,d.filter=function(t,e){return(Dn(t)?o:U)(t,yt(e,3))},d.flatten=Et,d.iteratee=re,d.keys=Yt,d.keysIn=Zt,d.map=function(t,e){return(Dn(t)?c:W)(t,yt(e,3))},d.memoize=Ut,d.merge=Nn,d.mergeWith=Vn,d.omit=Cn,d.pick=Rn,d.property=ue,d.pull=$n,d.pullAll=$t,d.remove=function(t,e){var n=[];if(!t||!t.length)return n;var r=-1,o=[],u=t.length;for(e=yt(e,3);++r<u;){var c=t[r];e(c,r,t)&&(n.push(c),o.push(r))}for(r=t?o.length:0,u=r-1;r--;)if(c=o[r],
r==u||c!==i){var i=c;mt(c)?Xe.call(t,c,1):Z(t,c)}return n},d.toPlainObject=Jt,d.uniq=function(t){if(t&&t.length)t:{var e=-1,n=u,r=t.length,o=true,c=[],i=c;if(200<=r){if(n=zn(t)){t=_(n);break t}o=false,n=j,i=new O}else i=c;e:for(;++e<r;){var a=t[e],f=a,a=0!==a?a:0;if(o&&f===f){for(var l=i.length;l--;)if(i[l]===f)continue e;c.push(a)}else n(i,f,void 0)||(i!==c&&i.push(f),c.push(a))}t=c}else t=[];return t},d.values=te,d.cloneDeep=function(t){return $(t,5)},d.eq=Bt,d.find=Un,d.findIndex=Ft,d.get=Qt,d.hasIn=Xt,
d.identity=ne,d.includes=function(t,e,n,r){return t=Dt(t)?t:te(t),n=n&&!r?Gt(n):0,r=t.length,0>n&&(n=un(r+n,0)),Tt(t)?n<=r&&-1<t.indexOf(e,n):!!r&&-1<s(t,e,n)},d.isArguments=Bn,d.isArray=Dn,d.isArrayLike=Dt,d.isArrayLikeObject=Pt,d.isBuffer=Pn,d.isFunction=Lt,d.isLength=Nt,d.isObject=Vt,d.isObjectLike=Ct,d.isPlainObject=Rt,d.isString=Tt,d.isSymbol=Wt,d.isTypedArray=Ln,d.last=Mt,d.stubArray=ce,d.stubFalse=ie,d.noop=oe,d.sortedIndexBy=function(t,e,n){n=yt(n,2),e=n(e);for(var r=0,o=null==t?0:t.length,u=e!==e,c=null===e,i=Wt(e),a=e===ae;r<o;){
var f=en((r+o)/2),l=n(t[f]),s=l!==ae,b=null===l,h=l===l,p=Wt(l);(u?h:a?h&&s:c?h&&s&&!b:i?h&&s&&!b&&!p:b||p?0:l<e)?r=f+1:o=f}return cn(o,4294967294)},d.toFinite=qt,d.toInteger=Gt,d.toNumber=Ht,d.toString=Kt,d.VERSION="4.17.4",typeof define=="function"&&typeof define.amd=="object"&&define.amd?(Ie._=d, define(function(){return d})):Ee?((Ee.exports=d)._=d,Fe._=d):Ie._=d}).call(this);