Merge 2.1

This commit is contained in:
Mo Bitar
2018-02-13 13:23:20 -06:00
124 changed files with 10196 additions and 8479 deletions

View File

@@ -0,0 +1,159 @@
class ActionsManager {
constructor(httpManager, modelManager, authManager, syncManager) {
this.httpManager = httpManager;
this.modelManager = modelManager;
this.authManager = authManager;
this.syncManager = syncManager;
}
get extensions() {
return this.modelManager.extensions;
}
extensionsInContextOfItem(item) {
return this.extensions.filter(function(ext){
return _.includes(ext.supported_types, item.content_type) || ext.actionsWithContextForItem(item).length > 0;
})
}
/*
Loads an extension in the context of a certain item. The server then has the chance to respond with actions that are
relevant just to this item. The response extension is not saved, just displayed as a one-time thing.
*/
loadExtensionInContextOfItem(extension, item, callback) {
this.httpManager.getAbsolute(extension.url, {content_type: item.content_type, item_uuid: item.uuid}, function(response){
this.updateExtensionFromRemoteResponse(extension, response);
callback && callback(extension);
}.bind(this), function(response){
console.log("Error loading extension", response);
if(callback) {
callback(null);
}
}.bind(this))
}
updateExtensionFromRemoteResponse(extension, response) {
if(response.description) { extension.description = response.description; }
if(response.supported_types) { extension.supported_types = response.supported_types; }
if(response.actions) {
extension.actions = response.actions.map(function(action){
return new Action(action);
})
} else {
extension.actions = [];
}
}
executeAction(action, extension, item, callback) {
var customCallback = function(response) {
action.running = false;
callback(response);
}
action.running = true;
let decrypted = action.access_type == "decrypted";
switch (action.verb) {
case "get": {
this.httpManager.getAbsolute(action.url, {}, function(response){
action.error = false;
var items = response.items || [response.item];
EncryptionHelper.decryptMultipleItems(items, this.authManager.keys());
items = this.modelManager.mapResponseItemsToLocalModels(items, ModelManager.MappingSourceRemoteActionRetrieved);
for(var item of items) {
item.setDirty(true);
}
this.syncManager.sync(null);
customCallback({items: items});
}.bind(this), function(response){
action.error = true;
customCallback(null);
})
break;
}
case "render": {
this.httpManager.getAbsolute(action.url, {}, function(response){
action.error = false;
EncryptionHelper.decryptItem(response.item, this.authManager.keys());
var item = this.modelManager.createItem(response.item, true /* Dont notify observers */);
customCallback({item: item});
}.bind(this), function(response){
action.error = true;
customCallback(null);
})
break;
}
case "show": {
var win = window.open(action.url, '_blank');
win.focus();
customCallback();
break;
}
case "post": {
var params = {};
if(action.all) {
var items = this.modelManager.allItemsMatchingTypes(action.content_types);
params.items = items.map(function(item){
var params = this.outgoingParamsForItem(item, extension, decrypted);
return params;
}.bind(this))
} else {
params.items = [this.outgoingParamsForItem(item, extension, decrypted)];
}
this.performPost(action, extension, params, function(response){
customCallback(response);
});
break;
}
default: {
}
}
action.lastExecuted = new Date();
}
outgoingParamsForItem(item, extension, decrypted = false) {
var keys = this.authManager.keys();
if(decrypted) {
keys = null;
}
var itemParams = new ItemParams(item, keys, this.authManager.protocolVersion());
return itemParams.paramsForExtension();
}
performPost(action, extension, params, callback) {
this.httpManager.postAbsolute(action.url, params, function(response){
action.error = false;
if(callback) {
callback(response);
}
}.bind(this), function(response){
action.error = true;
console.log("Action error response:", response);
if(callback) {
callback({error: "Request error"});
}
})
}
}
angular.module('app').service('actionsManager', ActionsManager);

View File

@@ -1,4 +1,4 @@
angular.module('app.frontend')
angular.module('app')
.provider('authManager', function () {
function domainName() {
@@ -7,11 +7,11 @@ angular.module('app.frontend')
return domain;
}
this.$get = function($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager) {
return new AuthManager($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager);
this.$get = function($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager, singletonManager) {
return new AuthManager($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager, singletonManager);
}
function AuthManager($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager) {
function AuthManager($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager, singletonManager) {
this.loadInitialData = function() {
var userData = storageManager.getItem("user");
@@ -43,11 +43,10 @@ angular.module('app.frontend')
this.ephemeral = ephemeral;
if(ephemeral) {
storageManager.setModelStorageMode(StorageManager.Ephemeral);
storageManager.setItemsMode(storageManager.hasPasscode() ? StorageManager.FixedEncrypted : 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);
}
}
@@ -95,9 +94,9 @@ angular.module('app.frontend')
return supportedVersions.includes(version);
}
this.getAuthParamsForEmail = function(url, email, callback) {
this.getAuthParamsForEmail = function(url, email, extraParams, callback) {
var requestUrl = url + "/auth/params";
httpManager.getAbsolute(requestUrl, {email: email}, function(response){
httpManager.getAbsolute(requestUrl, _.merge({email: email}, extraParams), function(response){
callback(response);
}, function(response){
console.error("Error getting auth params", response);
@@ -120,8 +119,8 @@ angular.module('app.frontend')
}
}
this.login = function(url, email, password, ephemeral, callback) {
this.getAuthParamsForEmail(url, email, function(authParams){
this.login = function(url, email, password, ephemeral, extraParams, callback) {
this.getAuthParamsForEmail(url, email, extraParams, function(authParams){
if(authParams.error) {
callback(authParams);
@@ -134,31 +133,30 @@ angular.module('app.frontend')
}
if(!this.isProtocolVersionSupported(authParams.version)) {
alert("The protocol version associated with your account is outdated and no longer supported by this application. Please visit standardnotes.org/help/security-update for more information.");
callback({didDisplayAlert: true});
let message = "The protocol version associated with your account is outdated and no longer supported by this application. Please visit standardnotes.org/help/security-update for more information.";
callback({error: {message: message}});
return;
}
if(!this.supportsPasswordDerivationCost(authParams.pw_cost)) {
var string = "Your account was created on a platform with higher security capabilities than this browser supports. " +
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 login."
alert(string)
callback({didDisplayAlert: true});
"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 = this.costMinimumForVersion(authParams.version);
if(authParams.pw_cost < minimum) {
alert("Unable to login due to insecure password parameters. Please visit standardnotes.org/help/password-upgrade for more information.");
callback({didDisplayAlert: true});
let message = "Unable to login due to insecure password parameters. Please visit standardnotes.org/help/password-upgrade for more information.";
callback({error: {message: message}});
return;
}
Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: password}, authParams), function(keys){
var requestUrl = url + "/auth/sign_in";
var params = {password: keys.pw, email: email};
var params = _.merge({password: keys.pw, email: email}, extraParams);
httpManager.postAbsolute(requestUrl, params, function(response){
this.setEphemeral(ephemeral);
this.handleAuthResponse(response, email, url, authParams, keys);
@@ -291,5 +289,45 @@ angular.module('app.frontend')
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() {
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();
}
}
}
});

View File

@@ -3,27 +3,45 @@ let ClientDataDomain = "org.standardnotes.sn.components";
class ComponentManager {
constructor($rootScope, modelManager, syncManager, themeManager, $timeout, $compile) {
constructor($rootScope, modelManager, syncManager, desktopManager, nativeExtManager, $timeout, $compile) {
this.$compile = $compile;
this.$rootScope = $rootScope;
this.modelManager = modelManager;
this.syncManager = syncManager;
this.themeManager = themeManager;
this.desktopManager = desktopManager;
this.nativeExtManager = nativeExtManager;
this.timeout = $timeout;
this.streamObservers = [];
this.contextStreamObservers = [];
this.activeComponents = [];
const detectFocusChange = (event) => {
for(var component of this.activeComponents) {
if(document.activeElement == this.iframeForComponent(component)) {
this.timeout(() => {
this.focusChangedForComponent(component);
})
break;
}
}
}
window.addEventListener ? window.addEventListener('focus', detectFocusChange, true) : window.attachEvent('onfocusout', detectFocusChange);
window.addEventListener ? window.addEventListener('blur', detectFocusChange, true) : window.attachEvent('onblur', detectFocusChange);
desktopManager.registerUpdateObserver((component) => {
// Reload theme if active
if(component.active && component.isTheme()) {
this.postActiveThemeToAllComponents();
}
})
// this.loggingEnabled = true;
this.permissionDialogs = [];
this.handlers = [];
$rootScope.$on("theme-changed", function(){
this.postThemeToComponents();
}.bind(this))
window.addEventListener("message", function(event){
if(this.loggingEnabled) {
console.log("Web app: received message", event);
@@ -31,7 +49,7 @@ class ComponentManager {
this.handleMessage(this.componentForSessionKey(event.data.sessionKey), event.data);
}.bind(this), false);
this.modelManager.addItemSyncObserver("component-manager", "*", function(allItems, validItems, deletedItems, source) {
this.modelManager.addItemSyncObserver("component-manager", "*", (allItems, validItems, deletedItems, source) => {
/* 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
@@ -41,7 +59,18 @@ class ComponentManager {
return;
}
var syncedComponents = allItems.filter(function(item){return item.content_type === "SN|Component" });
var syncedComponents = allItems.filter(function(item) {
return item.content_type === "SN|Component" || item.content_type == "SN|Theme"
});
/* 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) {
// Ensure any component in our data is installed by the system
this.desktopManager.syncComponentsInstallation(syncedComponents);
}
for(var component of syncedComponents) {
var activeComponent = _.find(this.activeComponents, {uuid: component.uuid});
if(component.active && !component.deleted && !activeComponent) {
@@ -56,6 +85,10 @@ class ComponentManager {
return observer.contentTypes.indexOf(item.content_type) !== -1;
})
if(relevantItems.length == 0) {
continue;
}
var requiredPermissions = [
{
name: "stream-items",
@@ -63,9 +96,9 @@ class ComponentManager {
}
];
this.runWithPermissions(observer.component, requiredPermissions, observer.originalMessage.permissions, function(){
this.runWithPermissions(observer.component, requiredPermissions, () => {
this.sendItemsInReply(observer.component, relevantItems, observer.originalMessage);
}.bind(this))
})
}
var requiredContextPermissions = [
@@ -75,36 +108,45 @@ class ComponentManager {
];
for(let observer of this.contextStreamObservers) {
this.runWithPermissions(observer.component, requiredContextPermissions, observer.originalMessage.permissions, function(){
for(let handler of this.handlers) {
if(!handler.areas.includes(observer.component.area)) {
continue;
}
for(let handler of this.handlers) {
if(!handler.areas.includes(observer.component.area) && !handler.areas.includes("*")) {
continue;
}
if(handler.contextRequestHandler) {
var itemInContext = handler.contextRequestHandler(observer.component);
if(itemInContext) {
var matchingItem = _.find(allItems, {uuid: itemInContext.uuid});
if(matchingItem) {
this.sendContextItemInReply(observer.component, matchingItem, observer.originalMessage, source);
this.runWithPermissions(observer.component, requiredContextPermissions, () => {
this.sendContextItemInReply(observer.component, matchingItem, observer.originalMessage, source);
})
}
}
}
}.bind(this))
}
}
}.bind(this))
});
}
postThemeToComponents() {
postActiveThemeToAllComponents() {
for(var component of this.components) {
if(!component.active || !component.window) {
// Skip over components that are themes themselves,
// or components that are not active, or components that don't have a window
if(component.isTheme() || !component.active || !component.window) {
continue;
}
this.postThemeToComponent(component);
this.postActiveThemeToComponent(component);
}
}
postThemeToComponent(component) {
getActiveTheme() {
return this.componentsForArea("themes").find((theme) => {return theme.active});
}
postActiveThemeToComponent(component) {
var activeTheme = this.getActiveTheme();
var data = {
themes: [this.themeManager.currentTheme ? this.themeManager.currentTheme.url : null]
themes: [activeTheme ? this.urlForComponent(activeTheme) : null]
}
this.sendMessageToComponent(component, {action: "themes", data: data})
@@ -112,7 +154,7 @@ class ComponentManager {
contextItemDidChangeInArea(area) {
for(let handler of this.handlers) {
if(handler.areas.includes(area) === false) {
if(handler.areas.includes(area) === false && !handler.areas.includes("*")) {
continue;
}
var observers = this.contextStreamObservers.filter(function(observer){
@@ -120,8 +162,10 @@ class ComponentManager {
})
for(let observer of observers) {
var itemInContext = handler.contextRequestHandler(observer.component);
this.sendContextItemInReply(observer.component, itemInContext, observer.originalMessage);
if(handler.contextRequestHandler) {
var itemInContext = handler.contextRequestHandler(observer.component);
this.sendContextItemInReply(observer.component, itemInContext, observer.originalMessage);
}
}
}
}
@@ -129,7 +173,8 @@ 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();
params.clientData = item.getDomainDataItem(component.url, ClientDataDomain) || {};
/* 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) || {};
/* 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
@@ -139,7 +184,7 @@ class ComponentManager {
if(source && source == ModelManager.MappingSourceRemoteSaved) {
params.isMetadataUpdate = true;
}
this.removePrivatePropertiesFromResponseItems([params]);
this.removePrivatePropertiesFromResponseItems([params], component);
return params;
}
@@ -160,8 +205,33 @@ class ComponentManager {
this.replyToMessage(component, originalMessage, response);
}
replyToMessage(component, originalMessage, replyData) {
var reply = {
action: "reply",
original: originalMessage,
data: replyData
}
this.sendMessageToComponent(component, reply);
}
sendMessageToComponent(component, message) {
let permissibleActionsWhileHidden = ["component-registered", "themes"];
if(component.hidden && !permissibleActionsWhileHidden.includes(message.action)) {
if(this.loggingEnabled) {
console.log("Component disabled for current item, not sending any messages.", component.name);
}
return;
}
if(this.loggingEnabled) {
console.log("Web|sendMessageToComponent", component, message);
}
component.window.postMessage(message, "*");
}
get components() {
return this.modelManager.itemsForContentType("SN|Component");
return this.modelManager.allItemsMatchingTypes(["SN|Component", "SN|Theme"]);
}
componentsForArea(area) {
@@ -170,9 +240,17 @@ class ComponentManager {
})
}
urlForComponent(component) {
if(component.offlineOnly || (isDesktopApplication() && component.local_url)) {
return component.local_url && component.local_url.replace("sn://", this.desktopManager.getApplicationDataPath() + "/");
} else {
return component.hosted_url || component.url;
}
}
componentForUrl(url) {
return this.components.filter(function(component){
return component.url === url;
return component.url === url || component.hosted_url === url;
})[0];
}
@@ -191,91 +269,46 @@ class ComponentManager {
/**
Possible Messages:
set-size
stream-items
stream-context-item
save-items
select-item
associate-item
deassociate-item
clear-selection
create-item
delete-items
set-component-data
save-context-client-data
get-context-client-data
set-size
stream-items
stream-context-item
save-items
select-item
associate-item
deassociate-item
clear-selection
create-item
delete-items
set-component-data
install-local-component
toggle-activate-component
request-permissions
*/
if(message.action === "stream-items") {
this.handleStreamItemsMessage(component, message);
}
else if(message.action === "stream-context-item") {
} else if(message.action === "stream-context-item") {
this.handleStreamContextItemMessage(component, message);
} else if(message.action === "set-component-data") {
this.handleSetComponentDataMessage(component, message);
} else if(message.action === "delete-items") {
this.handleDeleteItemsMessage(component, message);
} else if(message.action === "create-item") {
this.handleCreateItemMessage(component, message);
} else if(message.action === "save-items") {
this.handleSaveItemsMessage(component, message);
} else if(message.action === "toggle-activate-component") {
let componentToToggle = this.modelManager.findItem(message.data.uuid);
this.handleToggleComponentMessage(component, componentToToggle, message);
} else if(message.action === "request-permissions") {
this.handleRequestPermissionsMessage(component, message);
} else if(message.action === "install-local-component") {
this.handleInstallLocalComponentMessage(component, message);
}
else if(message.action === "set-component-data") {
component.componentData = message.data.componentData;
component.setDirty(true);
this.syncManager.sync();
}
else if(message.action === "delete-items") {
var items = message.data.items;
var noun = items.length == 1 ? "item" : "items";
if(confirm(`Are you sure you want to delete ${items.length} ${noun}?`)) {
for(var item of items) {
var model = this.modelManager.findItem(item.uuid);
this.modelManager.setItemToBeDeleted(model);
}
this.syncManager.sync();
}
}
else if(message.action === "create-item") {
var responseItem = message.data.item;
this.removePrivatePropertiesFromResponseItems([responseItem]);
var item = this.modelManager.createItem(responseItem);
if(responseItem.clientData) {
item.setDomainDataItem(component.url, responseItem.clientData, ClientDataDomain);
}
this.modelManager.addItem(item);
this.modelManager.resolveReferencesForItem(item);
item.setDirty(true);
this.syncManager.sync();
this.replyToMessage(component, message, {item: this.jsonForItem(item, component)})
}
else if(message.action === "save-items") {
var responseItems = message.data.items;
this.removePrivatePropertiesFromResponseItems(responseItems);
/*
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);
for(var item of localItems) {
var responseItem = _.find(responseItems, {uuid: item.uuid});
_.merge(item.content, responseItem.content);
if(responseItem.clientData) {
item.setDomainDataItem(component.url, responseItem.clientData, ClientDataDomain);
}
item.setDirty(true);
}
this.syncManager.sync((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.handleMessage(component, saveMessage);
});
}
// Notify observers
for(let handler of this.handlers) {
if(handler.areas.includes(component.area)) {
if(handler.areas.includes(component.area) || handler.areas.includes("*")) {
this.timeout(function(){
handler.actionHandler(component, message.action, message.data);
})
@@ -283,9 +316,18 @@ class ComponentManager {
}
}
removePrivatePropertiesFromResponseItems(responseItems) {
removePrivatePropertiesFromResponseItems(responseItems, component, options = {}) {
if(component) {
// System extensions can bypass this step
if(this.nativeExtManager.isSystemExtension(component)) {
return;
}
}
// Don't allow component to overwrite these properties.
let privateProperties = ["appData"];
var privateProperties = ["appData", "autoupdateDisabled", "permissions", "active"];
if(options) {
if(options.includeUrls) { privateProperties = privateProperties.concat(["url", "hosted_url", "local_url"])}
}
for(var responseItem of responseItems) {
// Do not pass in actual items here, otherwise that would be destructive.
@@ -293,7 +335,7 @@ class ComponentManager {
console.assert(typeof responseItem.setDirty !== 'function');
for(var prop of privateProperties) {
delete responseItem[prop];
delete responseItem.content[prop];
}
}
}
@@ -306,25 +348,24 @@ class ComponentManager {
}
];
this.runWithPermissions(component, requiredPermissions, message.permissions, function(){
if(!_.find(this.streamObservers, {identifier: component.url})) {
this.runWithPermissions(component, requiredPermissions, () => {
if(!_.find(this.streamObservers, {identifier: component.uuid})) {
// for pushing laster as changes come in
this.streamObservers.push({
identifier: component.url,
identifier: component.uuid,
component: component,
originalMessage: message,
contentTypes: message.data.content_types
})
}
// push immediately now
var items = [];
for(var contentType of message.data.content_types) {
items = items.concat(this.modelManager.itemsForContentType(contentType));
}
this.sendItemsInReply(component, items, message);
}.bind(this));
});
}
handleStreamContextItemMessage(component, message) {
@@ -335,55 +376,223 @@ class ComponentManager {
}
];
this.runWithPermissions(component, requiredPermissions, message.permissions, function(){
if(!_.find(this.contextStreamObservers, {identifier: component.url})) {
this.runWithPermissions(component, requiredPermissions, function(){
if(!_.find(this.contextStreamObservers, {identifier: component.uuid})) {
// for pushing laster as changes come in
this.contextStreamObservers.push({
identifier: component.url,
identifier: component.uuid,
component: component,
originalMessage: message
})
}
// push immediately now
for(let handler of this.handlers) {
if(handler.areas.includes(component.area) === false) {
continue;
for(let handler of this.handlersForArea(component.area)) {
if(handler.contextRequestHandler) {
var itemInContext = handler.contextRequestHandler(component);
this.sendContextItemInReply(component, itemInContext, message);
}
var itemInContext = handler.contextRequestHandler(component);
this.sendContextItemInReply(component, itemInContext, message);
}
}.bind(this))
}
runWithPermissions(component, requiredPermissions, requestedPermissions, runFunction) {
var acquiredPermissions = component.permissions;
var requestedMatchesRequired = true;
for(var required of requiredPermissions) {
var matching = _.find(requestedPermissions, required);
if(!matching) {
requestedMatchesRequired = false;
break;
isItemWithinComponentContextJurisdiction(item, component) {
for(let handler of this.handlersForArea(component.area)) {
if(handler.contextRequestHandler) {
var itemInContext = handler.contextRequestHandler(component);
if(itemInContext && itemInContext.uuid == item.uuid) {
return true;
}
}
}
return false;
}
if(!requestedMatchesRequired) {
// Error with Component permissions request
console.error("You are requesting permissions", requestedPermissions, "when you need to be requesting", requiredPermissions, ". Component:", component);
handlersForArea(area) {
return this.handlers.filter((candidate) => {return candidate.areas.includes(area)});
}
handleSaveItemsMessage(component, message) {
var responseItems = message.data.items;
var requiredPermissions;
// Check if you're just trying to save the context item, which requires only stream-context-item permissions
if(responseItems.length == 1 && this.isItemWithinComponentContextJurisdiction(responseItems[0], component)) {
requiredPermissions = [
{
name: "stream-context-item"
}
];
} else {
var requiredContentTypes = _.uniq(responseItems.map((i) => {return i.content_type})).sort();
requiredPermissions = [
{
name: "stream-items",
content_types: requiredContentTypes
}
];
}
this.runWithPermissions(component, requiredPermissions, () => {
this.removePrivatePropertiesFromResponseItems(responseItems, component, {includeUrls: true});
/*
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);
for(var item of localItems) {
var responseItem = _.find(responseItems, {uuid: item.uuid});
_.merge(item.content, responseItem.content);
if(responseItem.clientData) {
item.setDomainDataItem(component.url || component.uuid, responseItem.clientData, ClientDataDomain);
}
item.setDirty(true);
}
this.syncManager.sync((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.handleMessage(component, saveMessage);
}, null, "handleSaveItemsMessage");
});
}
handleCreateItemMessage(component, message) {
var requiredPermissions = [
{
name: "stream-items",
content_types: [message.data.item.content_type]
}
];
this.runWithPermissions(component, requiredPermissions, () => {
var responseItem = message.data.item;
this.removePrivatePropertiesFromResponseItems([responseItem], component);
var item = this.modelManager.createItem(responseItem);
if(responseItem.clientData) {
item.setDomainDataItem(component.url || component.uuid, responseItem.clientData, ClientDataDomain);
}
this.modelManager.addItem(item);
this.modelManager.resolveReferencesForItem(item);
item.setDirty(true);
this.syncManager.sync("handleCreateItemMessage");
this.replyToMessage(component, message, {item: this.jsonForItem(item, component)})
});
}
handleDeleteItemsMessage(component, message) {
var requiredContentTypes = _.uniq(message.data.items.map((i) => {return i.content_type})).sort();
var requiredPermissions = [
{
name: "stream-items",
content_types: requiredContentTypes
}
];
this.runWithPermissions(component, requiredPermissions, () => {
var itemsData = message.data.items;
var noun = itemsData.length == 1 ? "item" : "items";
if(confirm(`Are you sure you want to delete ${itemsData.length} ${noun}?`)) {
// Filter for any components and deactivate before deleting
for(var itemData of itemsData) {
var model = this.modelManager.findItem(itemData.uuid);
if(["SN|Component", "SN|Theme"].includes(model.content_type)) {
this.deactivateComponent(model, true);
}
this.modelManager.setItemToBeDeleted(model);
}
this.syncManager.sync("handleDeleteItemsMessage");
}
});
}
handleRequestPermissionsMessage(component, message) {
this.runWithPermissions(component, message.data.permissions, () => {
this.replyToMessage(component, message, {approved: true});
});
}
handleSetComponentDataMessage(component, message) {
// A component setting its own data does not require special permissions
this.runWithPermissions(component, [], () => {
component.componentData = message.data.componentData;
component.setDirty(true);
this.syncManager.sync("handleSetComponentDataMessage");
});
}
handleToggleComponentMessage(sourceComponent, targetComponent, message) {
if(targetComponent.area == "modal") {
this.openModalComponent(targetComponent);
} else {
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);
}
}
this.activateComponent(targetComponent);
}
}
}
handleInstallLocalComponentMessage(sourceComponent, message) {
// Only extensions manager has this permission
if(!this.nativeExtManager.isSystemExtension(sourceComponent)) {
return;
}
let targetComponent = this.modelManager.findItem(message.data.uuid);
this.desktopManager.installComponent(targetComponent);
}
runWithPermissions(component, requiredPermissions, runFunction) {
if(!component.permissions) {
component.permissions = [];
}
var acquiredMatchesRequested = angular.toJson(component.permissions.sort()) === angular.toJson(requestedPermissions.sort());
var acquiredPermissions = component.permissions;
var acquiredMatchesRequired = true;
if(!acquiredMatchesRequested) {
this.promptForPermissions(component, requestedPermissions, function(approved){
for(var required of requiredPermissions) {
var matching = acquiredPermissions.find((candidate) => {
var matchesContentTypes = true;
if(candidate.content_types && required.content_types) {
matchesContentTypes = JSON.stringify(candidate.content_types.sort()) == JSON.stringify(required.content_types.sort());
}
return candidate.name == required.name && matchesContentTypes;
});
if(!matching) {
/* Required permissions can be 1 content type, and requestedPermisisons may send an array of content types.
In the case of an array, we can just check to make sure that requiredPermissions content type is found in the array
*/
matching = acquiredPermissions.find((candidate) => {
return Array.isArray(candidate.content_types)
&& Array.isArray(required.content_types)
&& candidate.content_types.containsPrimitiveSubset(required.content_types);
});
if(!matching) {
acquiredMatchesRequired = false;
break;
}
}
}
if(!acquiredMatchesRequired) {
this.promptForPermissions(component, requiredPermissions, function(approved){
if(approved) {
runFunction();
}
@@ -393,107 +602,86 @@ class ComponentManager {
}
}
promptForPermissions(component, requestedPermissions, callback) {
// since these calls are asyncronous, multiple dialogs may be requested at the same time. We only want to present one and trigger all callbacks based on one modal result
var existingDialog = _.find(this.permissionDialogs, {component: component});
component.trusted = component.url.startsWith("https://standardnotes.org") || component.url.startsWith("https://extensions.standardnotes.org");
promptForPermissions(component, permissions, callback) {
var scope = this.$rootScope.$new(true);
scope.component = component;
scope.permissions = requestedPermissions;
scope.permissions = permissions;
scope.actionBlock = callback;
scope.callback = function(approved) {
if(approved) {
component.permissions = requestedPermissions;
component.setDirty(true);
this.syncManager.sync();
}
for(var existing of this.permissionDialogs) {
if(existing.component === component && existing.actionBlock) {
existing.actionBlock(approved);
for(var permission of permissions) {
if(!component.permissions.includes(permission)) {
component.permissions.push(permission);
}
}
component.setDirty(true);
this.syncManager.sync("promptForPermissions");
}
this.permissionDialogs = this.permissionDialogs.filter(function(dialog){
return dialog.component !== component;
this.permissionDialogs = this.permissionDialogs.filter((pendingDialog) => {
// Remove self
if(pendingDialog == scope) {
pendingDialog.actionBlock && pendingDialog.actionBlock(approved);
return false;
}
if(pendingDialog.component == component) {
// remove pending dialogs that are encapsulated by already approved permissions, and run its function
if(pendingDialog.permissions == permissions || permissions.containsObjectSubset(pendingDialog.permissions)) {
// If approved, run the action block. Otherwise, if canceled, cancel any pending ones as well, since the user was
// explicit in their intentions
if(approved) {
pendingDialog.actionBlock && pendingDialog.actionBlock(approved);
}
return false;
}
}
return true;
})
if(this.permissionDialogs.length > 0) {
this.presentDialog(this.permissionDialogs[0]);
}
}.bind(this);
// since these calls are asyncronous, multiple dialogs may be requested at the same time. We only want to present one and trigger all callbacks based on one modal result
var existingDialog = _.find(this.permissionDialogs, {component: component});
this.permissionDialogs.push(scope);
if(!existingDialog) {
var el = this.$compile( "<permissions-modal component='component' permissions='permissions' callback='callback' class='permissions-modal'></permissions-modal>" )(scope);
angular.element(document.body).append(el);
this.presentDialog(scope);
} else {
console.log("Existing dialog, not presenting.");
}
}
replyToMessage(component, originalMessage, replyData) {
var reply = {
action: "reply",
original: originalMessage,
data: replyData
}
this.sendMessageToComponent(component, reply);
presentDialog(dialog) {
var permissions = dialog.permissions;
var component = dialog.component;
var callback = dialog.callback;
var el = this.$compile( "<permissions-modal component='component' permissions='permissions' callback='callback' class='modal'></permissions-modal>" )(dialog);
angular.element(document.body).append(el);
}
sendMessageToComponent(component, message) {
if(component.ignoreEvents && message.action !== "component-registered") {
if(this.loggingEnabled) {
console.log("Component disabled for current item, not sending any messages.", component.name);
}
return;
}
if(this.loggingEnabled) {
console.log("Web|sendMessageToComponent", component, message);
}
component.window.postMessage(message, "*");
}
installComponent(url) {
var name = getParameterByName("name", url);
var area = getParameterByName("area", url);
var component = this.modelManager.createItem({
content_type: "SN|Component",
url: url,
name: name,
area: area
})
this.modelManager.addItem(component);
component.setDirty(true);
this.syncManager.sync();
}
activateComponent(component) {
var didChange = component.active != true;
component.active = true;
for(var handler of this.handlers) {
if(handler.areas.includes(component.area)) {
handler.activationHandler(component);
}
}
if(didChange) {
component.setDirty(true);
this.syncManager.sync();
}
if(!this.activeComponents.includes(component)) {
this.activeComponents.push(component);
}
openModalComponent(component) {
var scope = this.$rootScope.$new(true);
scope.component = component;
var el = this.$compile( "<component-modal component='component' class='modal'></component-modal>" )(scope);
angular.element(document.body).append(el);
}
registerHandler(handler) {
this.handlers.push(handler);
}
deregisterHandler(identifier) {
var handler = _.find(this.handlers, {identifier: identifier});
this.handlers.splice(this.handlers.indexOf(handler), 1);
}
// Called by other views when the iframe is ready
registerComponentWindow(component, componentWindow) {
if(component.window === componentWindow) {
@@ -507,24 +695,56 @@ class ComponentManager {
}
component.window = componentWindow;
component.sessionKey = Neeto.crypto.generateUUID();
this.sendMessageToComponent(component, {action: "component-registered", sessionKey: component.sessionKey, componentData: component.componentData});
this.postThemeToComponent(component);
this.sendMessageToComponent(component, {
action: "component-registered",
sessionKey: component.sessionKey,
componentData: component.componentData,
data: {
uuid: component.uuid,
environment: isDesktopApplication() ? "desktop" : "web"
}
});
this.postActiveThemeToComponent(component);
}
deactivateComponent(component) {
activateComponent(component, dontSync = false) {
var didChange = component.active != true;
component.active = true;
for(var handler of this.handlers) {
if(handler.areas.includes(component.area) || handler.areas.includes("*")) {
handler.activationHandler(component);
}
}
if(didChange && !dontSync) {
component.setDirty(true);
this.syncManager.sync("activateComponent");
}
if(!this.activeComponents.includes(component)) {
this.activeComponents.push(component);
}
if(component.area == "themes") {
this.postActiveThemeToAllComponents();
}
}
deactivateComponent(component, dontSync = false) {
var didChange = component.active != false;
component.active = false;
component.sessionKey = null;
for(var handler of this.handlers) {
if(handler.areas.includes(component.area)) {
if(handler.areas.includes(component.area) || handler.areas.includes("*")) {
handler.activationHandler(component);
}
}
if(didChange) {
if(didChange && !dontSync) {
component.setDirty(true);
this.syncManager.sync();
this.syncManager.sync("deactivateComponent");
}
_.pull(this.activeComponents, component);
@@ -536,57 +756,23 @@ class ComponentManager {
this.contextStreamObservers = this.contextStreamObservers.filter(function(o){
return o.component !== component;
})
if(component.area == "themes") {
this.postActiveThemeToAllComponents();
}
}
deleteComponent(component) {
this.modelManager.setItemToBeDeleted(component);
this.syncManager.sync();
this.syncManager.sync("deleteComponent");
}
isComponentActive(component) {
return component.active;
}
disassociateComponentWithItem(component, item) {
_.pull(component.associatedItemIds, item.uuid);
if(component.disassociatedItemIds.indexOf(item.uuid) !== -1) {
return;
}
component.disassociatedItemIds.push(item.uuid);
component.setDirty(true);
this.syncManager.sync();
}
associateComponentWithItem(component, item) {
_.pull(component.disassociatedItemIds, item.uuid);
if(component.associatedItemIds.includes(item.uuid)) {
return;
}
component.associatedItemIds.push(item.uuid);
component.setDirty(true);
this.syncManager.sync();
}
enableComponentsForItem(components, item) {
for(var component of components) {
_.pull(component.disassociatedItemIds, item.uuid);
component.setDirty(true);
}
this.syncManager.sync();
}
setEventFlowForComponent(component, on) {
component.ignoreEvents = !on;
}
iframeForComponent(component) {
for(var frame of document.getElementsByTagName("iframe")) {
for(var frame of Array.from(document.getElementsByTagName("iframe"))) {
var componentId = frame.dataset.componentId;
if(componentId === component.uuid) {
return frame;
@@ -594,7 +780,40 @@ class ComponentManager {
}
}
focusChangedForComponent(component) {
let focused = document.activeElement == this.iframeForComponent(component);
for(var handler of this.handlers) {
// Notify all handlers, and not just ones that match this component type
handler.focusHandler && handler.focusHandler(component, focused);
}
}
handleSetSizeEvent(component, data) {
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(data.type === "content") {
var iframe = this.iframeForComponent(component);
var width = data.width;
var height = data.height;
iframe.width = width;
iframe.height = height;
setSize(iframe, data);
} else {
var container = document.getElementById("component-" + component.uuid);
if(container) {
// in the case of Modals, sometimes they may be "active" because they were so in another session,
// but no longer actually visible. So check to make sure the container exists
setSize(container, data);
}
}
}
}
angular.module('app.frontend').service('componentManager', ComponentManager);
angular.module('app').service('componentManager', ComponentManager);

View File

@@ -158,4 +158,4 @@ class DBManager {
}
}
angular.module('app.frontend').service('dbManager', DBManager);
angular.module('app').service('dbManager', DBManager);

View File

@@ -2,11 +2,16 @@
class DesktopManager {
constructor($rootScope, modelManager, authManager, passcodeManager) {
constructor($rootScope, $timeout, modelManager, syncManager, authManager, passcodeManager) {
this.passcodeManager = passcodeManager;
this.modelManager = modelManager;
this.authManager = authManager;
this.syncManager = syncManager;
this.$rootScope = $rootScope;
this.timeout = $timeout;
this.updateObservers = [];
this.isDesktop = isDesktopApplication();
$rootScope.$on("initial-data-loaded", () => {
this.dataLoaded = true;
@@ -22,6 +27,84 @@ class DesktopManager {
})
}
getApplicationDataPath() {
console.assert(this.applicationDataPath, "applicationDataPath is null");
return this.applicationDataPath;
}
/* Sending a component in its raw state is really slow for the desktop app */
convertComponentForTransmission(component) {
return new ItemParams(component).paramsForExportFile(true);
}
// All `components` should be installed
syncComponentsInstallation(components) {
if(!this.isDesktop) return;
var data = components.map((component) => {
return this.convertComponentForTransmission(component);
})
this.installationSyncHandler(data);
}
installComponent(component) {
this.installComponentHandler(this.convertComponentForTransmission(component));
}
registerUpdateObserver(callback) {
var observer = {id: Math.random, callback: callback};
this.updateObservers.push(observer);
return observer;
}
deregisterUpdateObserver(observer) {
_.pull(this.updateObservers, observer);
}
desktop_onComponentInstallationComplete(componentData, error) {
console.log("Web|Component Installation/Update Complete", componentData, error);
// Desktop is only allowed to change these keys:
let permissableKeys = ["package_info", "local_url"];
var component = this.modelManager.findItem(componentData.uuid);
if(!component) {
console.error("desktop_onComponentInstallationComplete component is null for uuid", componentData.uuid);
return;
}
if(error) {
component.setAppDataItem("installError", error);
} else {
for(var key of permissableKeys) {
component[key] = componentData.content[key];
}
this.modelManager.notifySyncObserversOfModels([component], ModelManager.MappingSourceDesktopInstalled);
component.setAppDataItem("installError", null);
}
component.setDirty(true);
this.syncManager.sync("onComponentInstallationComplete");
this.timeout(() => {
for(var observer of this.updateObservers) {
observer.callback(component);
}
})
}
/* Used to resolve "sn://" */
desktop_setApplicationDataPath(path) {
this.applicationDataPath = path;
}
desktop_setComponentInstallationSyncHandler(handler) {
this.installationSyncHandler = handler;
}
desktop_setInstallComponentHandler(handler) {
this.installComponentHandler = handler;
}
desktop_setInitialDataLoadHandler(handler) {
this.dataLoadHandler = handler;
if(this.dataLoaded) {
@@ -56,4 +139,4 @@ class DesktopManager {
}
angular.module('app.frontend').service('desktopManager', DesktopManager);
angular.module('app').service('desktopManager', DesktopManager);

View File

@@ -1,17 +0,0 @@
angular
.module('app.frontend')
.directive('mbAutofocus', ['$timeout', function($timeout) {
return {
restrict: 'A',
scope: {
shouldFocus: "="
},
link : function($scope, $element) {
$timeout(function() {
if($scope.shouldFocus) {
$element[0].focus();
}
});
}
}
}]);

View File

@@ -1,27 +0,0 @@
angular
.module('app.frontend')
.directive('clickOutside', ['$document', function($document) {
return {
restrict: 'A',
replace: false,
link : function($scope, $element, attrs) {
var didApplyClickOutside = false;
$element.bind('click', function(e) {
didApplyClickOutside = false;
if (attrs.isOpen) {
e.stopPropagation();
}
});
$document.bind('click', function() {
if(!didApplyClickOutside) {
$scope.$apply(attrs.clickOutside);
didApplyClickOutside = true;
}
})
}
}
}]);

View File

@@ -1,46 +0,0 @@
angular
.module('app.frontend')
.directive('delayHide', function($timeout) {
return {
restrict: 'A',
scope: {
show: '=',
delay: '@'
},
link: function(scope, elem, attrs) {
var showTimer;
showElement(false);
//This is where all the magic happens!
// Whenever the scope variable updates we simply
// show if it evaluates to 'true' and hide if 'false'
scope.$watch('show', function(newVal){
newVal ? showSpinner() : hideSpinner();
});
function showSpinner() {
if(scope.hidePromise) {
$timeout.cancel(scope.hidePromise);
scope.hidePromise = null;
}
showElement(true);
}
function hideSpinner() {
scope.hidePromise = $timeout(showElement.bind(this, false), getDelay());
}
function showElement(show) {
show ? elem.css({display:''}) : elem.css({display:'none'});
}
function getDelay() {
var delay = parseInt(scope.delay);
return angular.isNumber(delay) ? delay : 200;
}
}
};
});

View File

@@ -1,17 +0,0 @@
angular
.module('app.frontend')
.directive('fileChange', function() {
return {
restrict: 'A',
scope: {
handler: '&'
},
link: function (scope, element) {
element.on('change', function (event) {
scope.$apply(function(){
scope.handler({files: event.target.files});
});
});
}
};
});

View File

@@ -1,19 +0,0 @@
angular.module('app.frontend').directive('infiniteScroll', [
'$rootScope', '$window', '$timeout', function($rootScope, $window, $timeout) {
return {
link: function(scope, elem, attrs) {
// elem.css('overflow-x', 'hidden');
// elem.css('height', 'inherit');
var offset = parseInt(attrs.threshold) || 0;
var e = elem[0]
elem.on('scroll', function(){
if(scope.$eval(attrs.canLoad) && e.scrollTop + e.offsetHeight >= e.scrollHeight - offset) {
scope.$apply(attrs.infiniteScroll);
}
});
}
};
}
]);

View File

@@ -1,20 +0,0 @@
angular
.module('app.frontend')
.directive('lowercase', function() {
return {
require: 'ngModel',
link: function(scope, element, attrs, modelCtrl) {
var lowercase = function(inputValue) {
if (inputValue == undefined) inputValue = '';
var lowercased = inputValue.toLowerCase();
if (lowercased !== inputValue) {
modelCtrl.$setViewValue(lowercased);
modelCtrl.$render();
}
return lowercased;
}
modelCtrl.$parsers.push(lowercase);
lowercase(scope[attrs.ngModel]);
}
};
});

View File

@@ -1,15 +0,0 @@
angular
.module('app.frontend')
.directive('selectOnClick', ['$window', function ($window) {
return {
restrict: 'A',
link: function (scope, element, attrs) {
element.on('focus', function () {
if (!$window.getSelection().toString()) {
// Required for mobile Safari
this.setSelectionRange(0, this.value.length)
}
});
}
};
}]);

View File

@@ -1,580 +0,0 @@
class AccountMenu {
constructor() {
this.restrict = "E";
this.templateUrl = "frontend/directives/account-menu.html";
this.scope = {
"onSuccessfulAuth" : "&"
};
}
controller($scope, $rootScope, authManager, modelManager, syncManager, dbManager, passcodeManager, $timeout, storageManager) {
'ngInject';
$scope.formData = {mergeLocal: true, url: syncManager.serverURL, ephemeral: false};
$scope.user = authManager.user;
$scope.server = syncManager.serverURL;
$scope.encryptedBackupsAvailable = function() {
return authManager.user || passcodeManager.hasPasscode();
}
$scope.syncStatus = syncManager.syncStatus;
$scope.encryptionKey = function() {
return authManager.keys().mk;
}
$scope.authKey = function() {
return authManager.keys().ak;
}
$scope.serverPassword = function() {
return syncManager.serverPassword;
}
$scope.dashboardURL = function() {
return `${$scope.server}/dashboard/#server=${$scope.server}&id=${encodeURIComponent($scope.user.email)}&pw=${$scope.serverPassword()}`;
}
$scope.newPasswordData = {};
$scope.showPasswordChangeForm = function() {
$scope.newPasswordData.showForm = true;
}
$scope.submitPasswordChange = function() {
if($scope.newPasswordData.newPassword != $scope.newPasswordData.newPasswordConfirmation) {
alert("Your new password does not match its confirmation.");
$scope.newPasswordData.status = null;
return;
}
var email = $scope.user.email;
if(!email) {
alert("We don't have your email stored. Please log out then log back in to fix this issue.");
$scope.newPasswordData.status = null;
return;
}
$scope.newPasswordData.status = "Generating New Keys...";
$scope.newPasswordData.showForm = false;
// 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(function(response){
authManager.changePassword(email, $scope.newPasswordData.newPassword, function(response){
if(response.error) {
alert("There was an error changing your password. Please try again.");
$scope.newPasswordData.status = null;
return;
}
// re-encrypt all items
$scope.newPasswordData.status = "Re-encrypting all items with your new key...";
modelManager.setAllItemsDirty();
syncManager.sync(function(response){
if(response.error) {
alert("There was an error re-encrypting your items. Your password was changed, but not all your items were properly re-encrypted and synced. You should try syncing again. If all else fails, you should restore your notes from backup.")
return;
}
$scope.newPasswordData.status = "Successfully changed password and re-encrypted all items.";
$timeout(function(){
alert("Your password has been changed, and your items successfully re-encrypted and synced. You must sign out of all other signed in applications and sign in again, or else you may corrupt your data.")
$scope.newPasswordData = {};
}, 1000)
});
})
})
}
$scope.submitAuthForm = function() {
if($scope.formData.showLogin) {
$scope.login();
} else {
$scope.register();
}
}
$scope.login = function() {
$scope.formData.status = "Generating Login Keys...";
$timeout(function(){
authManager.login($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."}
if(!response || (response && !response.didDisplayAlert)) {
alert(error.message);
}
} else {
$scope.onAuthSuccess();
}
});
})
}
$scope.register = function() {
let confirmation = $scope.formData.password_conf;
if(confirmation !== $scope.formData.user_password) {
alert("The two passwords you entered do not match. Please try again.");
return;
}
$scope.formData.confirmPassword = false;
$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();
}
});
})
}
$scope.mergeLocalChanged = function() {
if(!$scope.formData.mergeLocal) {
if(!confirm("Unchecking this option means any of the notes you have written while you were signed out will be deleted. Are you sure you want to discard these notes?")) {
$scope.formData.mergeLocal = true;
}
}
}
$scope.onAuthSuccess = function() {
var block = function() {
$timeout(function(){
$scope.onSuccessfulAuth()();
syncManager.refreshErroredItems();
syncManager.sync();
})
}
if($scope.formData.mergeLocal) {
// Allows desktop to make backup file
$rootScope.$broadcast("major-data-change");
$scope.clearDatabaseAndRewriteAllItems(true, block);
}
else {
modelManager.resetLocalMemory();
storageManager.clearAllModels(function(){
block();
})
}
}
// Allows indexeddb unencrypted logs to be deleted
// 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(function(){
syncManager.markAllItemsDirtyAndSaveOffline(function(){
callback && callback();
}, alternateUuids)
});
}
$scope.destroyLocalData = function() {
if(!confirm("Are you sure you want to end your session? This will delete all local items and extensions.")) {
return;
}
authManager.signOut();
syncManager.destroyLocalData(function(){
window.location.reload();
})
}
/* Import/Export */
$scope.archiveFormData = {encrypted: $scope.encryptedBackupsAvailable() ? true : false};
$scope.user = authManager.user;
$scope.submitImportPassword = function() {
$scope.performImport($scope.importData.data, $scope.importData.password);
}
$scope.performImport = function(data, password) {
$scope.importData.loading = true;
// allow loading indicator to come up with timeout
$timeout(function(){
$scope.importJSONData(data, password, function(response, errorCount){
$timeout(function(){
$scope.importData.loading = false;
$scope.importData = null;
// Update UI before showing alert
setTimeout(function () {
if(!response) {
alert("There was an error importing your data. Please try again.");
} else {
if(errorCount > 0) {
var message = `Import complete. ${errorCount} items were not imported because there was an error decrypting them. Make sure the password is correct and try again.`;
alert(message);
} else {
alert("Your data was successfully imported.")
}
}
}, 10);
})
})
})
}
$scope.importFileSelected = function(files) {
$scope.importData = {};
var file = files[0];
var reader = new FileReader();
reader.onload = function(e) {
try {
var data = JSON.parse(e.target.result);
$timeout(function(){
if(data.auth_params) {
// request password
$scope.importData.requestPassword = true;
$scope.importData.data = data;
} else {
$scope.performImport(data, null);
}
})
} catch (e) {
alert("Unable to open file. Ensure it is a proper JSON file and try again.");
}
}
reader.readAsText(file);
}
$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);
item.deleted = false;
item.markAllReferencesDirty();
// 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;
}
})
syncManager.sync((response) => {
callback(response, errorCount);
}, {additionalFields: ["created_at", "updated_at"]});
}.bind(this)
if(data.auth_params) {
Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: password}, data.auth_params), function(keys){
try {
EncryptionHelper.decryptMultipleItems(data.items, keys, false); /* throws = false as we don't want to interrupt all decryption if just one fails */
// delete items enc_item_key since the user's actually key will do the encrypting once its passed off
data.items.forEach(function(item){
item.enc_item_key = null;
item.auth_hash = null;
});
var errorCount = 0;
// Don't import items that didn't decrypt properly
data.items = data.items.filter(function(item){
if(item.errorDecrypting) {
errorCount++;
return false;
}
return true;
})
onDataReady(errorCount);
}
catch (e) {
console.log("Error decrypting", e);
alert("There was an error decrypting your items. Make sure the password you entered is correct and try again.");
callback(null);
return;
}
}.bind(this));
} else {
onDataReady();
}
}
/*
Export
*/
function loadZip(callback) {
if(window.zip) {
callback();
return;
}
var scriptTag = document.createElement('script');
scriptTag.src = "/assets/zip/zip.js";
scriptTag.async = false;
var headTag = document.getElementsByTagName('head')[0];
headTag.appendChild(scriptTag);
scriptTag.onload = function() {
zip.workerScriptsPath = "assets/zip/";
callback();
}
}
function downloadZippedNotes(notes) {
loadZip(function(){
zip.createWriter(new zip.BlobWriter("application/zip"), function(zipWriter) {
var index = 0;
function nextFile() {
var note = notes[index];
var blob = new Blob([note.text], {type: 'text/plain'});
zipWriter.add(`${note.title}-${note.uuid}.txt`, new zip.BlobReader(blob), function() {
index++;
if(index < notes.length) {
nextFile();
} else {
zipWriter.close(function(blob) {
downloadData(blob, `Notes Txt Archive - ${new Date()}.zip`)
zipWriter = null;
});
}
});
}
nextFile();
}, onerror);
})
}
var textFile = null;
function hrefForData(data) {
// If we are replacing a previously generated file we need to
// manually revoke the object URL to avoid memory leaks.
if (textFile !== null) {
window.URL.revokeObjectURL(textFile);
}
textFile = window.URL.createObjectURL(data);
// returns a URL you can use as a href
return textFile;
}
function downloadData(data, fileName) {
var link = document.createElement('a');
link.setAttribute('download', fileName);
link.href = hrefForData(data);
document.body.appendChild(link);
link.click();
link.remove();
}
$scope.downloadDataArchive = function() {
// download in Standard File format
var keys, authParams, protocolVersion;
if($scope.archiveFormData.encrypted) {
if(authManager.offline() && passcodeManager.hasPasscode()) {
keys = passcodeManager.keys();
authParams = passcodeManager.passcodeAuthParams();
protocolVersion = authParams.version;
} else {
keys = authManager.keys();
authParams = authManager.getAuthParams();
protocolVersion = authManager.protocolVersion();
}
}
var data = $scope.itemsData(keys, authParams, protocolVersion);
downloadData(data, `SN Archive - ${new Date()}.txt`);
// download as zipped plain text files
if(!keys) {
var notes = modelManager.allItemsMatchingTypes(["Note"]);
downloadZippedNotes(notes);
}
}
$scope.itemsData = function(keys, authParams, protocolVersion) {
let data = modelManager.getAllItemsJSONData(keys, authParams, protocolVersion);
let blobData = new Blob([data], {type: 'text/json'});
return blobData;
}
// Advanced
$scope.reencryptPressed = function() {
if(!confirm("Are you sure you want to re-encrypt and sync all your items? This is useful when updates are made to our encryption specification. You should have been instructed to come here from our website.")) {
return;
}
if(!confirm("It is highly recommended that you download a backup of your data before proceeding. Press cancel to go back. Note that this procedure can take some time, depending on the number of items you have. Do not close the app during process.")) {
return;
}
modelManager.setAllItemsDirty();
syncManager.sync(function(response){
if(response.error) {
alert("There was an error re-encrypting your items. You should try syncing again. If all else fails, you should restore your notes from backup.")
return;
}
$timeout(function(){
alert("Your items have been successfully re-encrypted and synced. You must sign out of all other signed in applications (mobile, desktop, web) and sign in again, or else you may corrupt your data.")
$scope.newPasswordData = {};
}, 1000)
});
}
// 002 Update
$scope.securityUpdateAvailable = function() {
var keys = authManager.keys()
return keys && !keys.ak;
}
$scope.clickedSecurityUpdate = function() {
if(!$scope.securityUpdateData) {
$scope.securityUpdateData = {};
}
$scope.securityUpdateData.showForm = true;
}
$scope.submitSecurityUpdateForm = function() {
$scope.securityUpdateData.processing = true;
var authParams = authManager.getAuthParams();
Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: $scope.securityUpdateData.password}, authParams), function(keys){
if(keys.mk !== authManager.keys().mk) {
alert("Invalid password. Please try again.");
$timeout(function(){
$scope.securityUpdateData.processing = false;
})
return;
}
authManager.saveKeys(keys);
});
}
/*
Encryption Status
*/
$scope.notesAndTagsCount = function() {
var items = modelManager.allItemsMatchingTypes(["Note", "Tag"]);
return items.length;
}
$scope.encryptionStatusForNotes = function() {
var length = $scope.notesAndTagsCount();
return length + "/" + length + " notes and tags encrypted";
}
$scope.encryptionEnabled = function() {
return passcodeManager.hasPasscode() || !authManager.offline();
}
$scope.encryptionSource = function() {
if(!authManager.offline()) {
return "Account keys";
} else if(passcodeManager.hasPasscode()) {
return "Local Passcode";
} else {
return null;
}
}
$scope.encryptionStatusString = function() {
if(!authManager.offline()) {
return "End-to-end encryption is enabled. Your data is encrypted before being synced to your private account.";
} else if(passcodeManager.hasPasscode()) {
return "Encryption is enabled. Your data is encrypted using your passcode before being stored on disk.";
} else {
return "Encryption is not enabled. Sign in, register, or add a passcode lock to enable encryption.";
}
}
/*
Passcode Lock
*/
$scope.passcodeOptionAvailable = function() {
// If you're signed in with an ephemeral session, passcode lock is unavailable
return authManager.offline() || !authManager.isEphemeralSession();
}
$scope.hasPasscode = function() {
return passcodeManager.hasPasscode();
}
$scope.addPasscodeClicked = function() {
$scope.formData.showPasscodeForm = true;
}
$scope.submitPasscodeForm = function() {
var passcode = $scope.formData.passcode;
if(passcode !== $scope.formData.confirmPasscode) {
alert("The two passcodes you entered do not match. Please try again.");
return;
}
passcodeManager.setPasscode(passcode, () => {
$timeout(function(){
$scope.formData.showPasscodeForm = false;
var offline = authManager.offline();
// Allow UI to update before showing alert
setTimeout(function () {
var message = "You've succesfully set an app passcode.";
if(offline) { message += " Your items will now be encrypted using this passcode."; }
alert(message);
}, 10);
if(offline) {
// Allows desktop to make backup file
$rootScope.$broadcast("major-data-change");
$scope.clearDatabaseAndRewriteAllItems(false);
}
})
})
}
$scope.removePasscodePressed = function() {
var signedIn = !authManager.offline();
var message = "Are you sure you want to remove your local passcode?";
if(!signedIn) {
message += " This will remove encryption from your local data.";
}
if(confirm(message)) {
passcodeManager.clearPasscode();
if(authManager.offline()) {
syncManager.markAllItemsDirtyAndSaveOffline();
// Don't create backup here, as if the user is temporarily removing the passcode to change it,
// we don't want to write unencrypted data to disk.
// $rootScope.$broadcast("major-data-change");
}
}
}
$scope.isDesktopApplication = function() {
return isDesktopApplication();
}
}
}
angular.module('app.frontend').directive('accountMenu', () => new AccountMenu);

View File

@@ -1,86 +0,0 @@
class ContextualExtensionsMenu {
constructor() {
this.restrict = "E";
this.templateUrl = "frontend/directives/contextual-menu.html";
this.scope = {
item: "="
};
}
controller($scope, modelManager, extensionManager) {
'ngInject';
$scope.renderData = {};
$scope.extensions = _.map(extensionManager.extensionsInContextOfItem($scope.item), function(ext){
// why are we cloning deep? commenting out because we want original reference so that extension.hide is saved between menu opens
// return _.cloneDeep(ext);
return ext;
});
for(let ext of $scope.extensions) {
ext.loading = true;
extensionManager.loadExtensionInContextOfItem(ext, $scope.item, function(scopedExtension) {
ext.loading = false;
})
}
$scope.executeAction = function(action, extension, parentAction) {
if(!$scope.isActionEnabled(action, extension)) {
alert("This action requires " + action.access_type + " access to this note. You can change this setting in the Extensions menu on the bottom of the app.");
return;
}
if(action.verb == "nested") {
action.showNestedActions = !action.showNestedActions;
return;
}
action.running = true;
extensionManager.executeAction(action, extension, $scope.item, function(response){
action.running = false;
$scope.handleActionResponse(action, response);
// reload extension actions
extensionManager.loadExtensionInContextOfItem(extension, $scope.item, function(ext){
// keep nested state
if(parentAction) {
var matchingAction = _.find(ext.actions, {label: parentAction.label});
matchingAction.showNestedActions = true;
}
});
})
}
$scope.handleActionResponse = function(action, response) {
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;
}
}
}
}
$scope.isActionEnabled = function(action, extension) {
if(action.access_type) {
var extEncryptedAccess = extension.encrypted;
if(action.access_type == "decrypted" && extEncryptedAccess) {
return false;
} else if(action.access_type == "encrypted" && !extEncryptedAccess) {
return false;
}
}
return true;
}
$scope.accessTypeForExtension = function(extension) {
return extension.encrypted ? "encrypted" : "decrypted";
}
}
}
angular.module('app.frontend').directive('contextualExtensionsMenu', () => new ContextualExtensionsMenu);

View File

@@ -1,30 +0,0 @@
class EditorMenu {
constructor() {
this.restrict = "E";
this.templateUrl = "frontend/directives/editor-menu.html";
this.scope = {
callback: "&",
selectedEditor: "="
};
}
controller($scope, componentManager) {
'ngInject';
$scope.formData = {};
$scope.editors = componentManager.componentsForArea("editor-editor");
$scope.selectEditor = function($event, editor) {
if(editor) {
editor.conflict_of = null; // clear conflict if applicable
}
$scope.callback()(editor);
}
}
}
angular.module('app.frontend').directive('editorMenu', () => new EditorMenu);

View File

@@ -1,225 +0,0 @@
class GlobalExtensionsMenu {
constructor() {
this.restrict = "E";
this.templateUrl = "frontend/directives/global-extensions-menu.html";
this.scope = {
};
}
controller($scope, extensionManager, syncManager, modelManager, themeManager, componentManager) {
'ngInject';
$scope.formData = {};
$scope.extensionManager = extensionManager;
$scope.themeManager = themeManager;
$scope.componentManager = componentManager;
$scope.serverExtensions = modelManager.itemsForContentType("SF|Extension");
$scope.selectedAction = function(action, extension) {
extensionManager.executeAction(action, extension, null, function(response){
if(response && response.error) {
action.error = true;
alert("There was an error performing this action. Please try again.");
} else {
action.error = false;
syncManager.sync(null);
}
})
}
$scope.changeExtensionEncryptionFormat = function(encrypted, extension) {
extension.encrypted = encrypted;
extension.setDirty(true);
syncManager.sync();
}
$scope.deleteActionExtension = function(extension) {
if(confirm("Are you sure you want to delete this extension?")) {
extensionManager.deleteExtension(extension);
}
}
$scope.reloadExtensionsPressed = function() {
if(confirm("For your security, reloading extensions will disable any currently enabled repeat actions.")) {
extensionManager.refreshExtensionsFromServer();
}
}
$scope.deleteTheme = function(theme) {
if(confirm("Are you sure you want to delete this theme?")) {
themeManager.deactivateTheme(theme);
modelManager.setItemToBeDeleted(theme);
syncManager.sync();
}
}
$scope.renameExtension = function(extension) {
extension.tempName = extension.name;
extension.rename = true;
}
$scope.submitExtensionRename = function(extension) {
extension.name = extension.tempName;
extension.tempName = null;
extension.setDirty(true);
extension.rename = false;
syncManager.sync();
}
$scope.clickedExtension = function(extension) {
if(extension.rename) {
return;
}
if($scope.currentlyExpandedExtension && $scope.currentlyExpandedExtension !== extension) {
$scope.currentlyExpandedExtension.showDetails = false;
$scope.currentlyExpandedExtension.rename = false;
}
extension.showDetails = !extension.showDetails;
if(extension.showDetails) {
$scope.currentlyExpandedExtension = extension;
}
}
// Server extensions
$scope.deleteServerExt = function(ext) {
if(confirm("Are you sure you want to delete and disable this extension?")) {
_.remove($scope.serverExtensions, {uuid: ext.uuid});
modelManager.setItemToBeDeleted(ext);
syncManager.sync();
}
}
$scope.nameForServerExtension = function(ext) {
var url = ext.url;
if(!url) {
return "Invalid Extension";
}
if(url.includes("gdrive")) {
return "Google Drive Sync";
} else if(url.includes("file_attacher")) {
return "File Attacher";
} else if(url.includes("onedrive")) {
return "OneDrive Sync";
} else if(url.includes("backup.email_archive")) {
return "Daily Email Backups";
} else if(url.includes("dropbox")) {
return "Dropbox Sync";
} else if(url.includes("revisions")) {
return "Revision History";
} else {
return null;
}
}
// Components
$scope.revokePermissions = function(component) {
component.permissions = [];
component.setDirty(true);
syncManager.sync();
}
$scope.deleteComponent = function(component) {
if(confirm("Are you sure you want to delete this component?")) {
componentManager.deleteComponent(component);
}
}
$scope.makeEditorDefault = function(component) {
var currentDefault = componentManager.componentsForArea("editor-editor").filter((e) => {return e.isDefaultEditor()})[0];
if(currentDefault) {
currentDefault.setAppDataItem("defaultEditor", false);
currentDefault.setDirty(true);
}
component.setAppDataItem("defaultEditor", true);
component.setDirty(true);
syncManager.sync();
}
$scope.removeEditorDefault = function(component) {
component.setAppDataItem("defaultEditor", false);
component.setDirty(true);
syncManager.sync();
}
// Installation
$scope.submitInstallLink = function() {
var fullLink = $scope.formData.installLink;
if(!fullLink) {
return;
}
var completion = function() {
$scope.formData.installLink = "";
$scope.formData.successfullyInstalled = true;
}
var links = fullLink.split(",");
for(var link of links) {
var type = getParameterByName("type", link);
if(type == "sf") {
$scope.handleSyncAdapterLink(link, completion);
} else if(type == "editor") {
$scope.handleEditorLink(link, completion);
} else if(link.indexOf(".css") != -1 || type == "theme") {
$scope.handleThemeLink(link, completion);
} else if(type == "component") {
$scope.handleComponentLink(link, completion);
}
else {
$scope.handleActionLink(link, completion);
}
}
}
$scope.handleSyncAdapterLink = function(link, completion) {
var params = parametersFromURL(link);
params["url"] = link;
var ext = new SyncAdapter({content: params});
ext.setDirty(true);
modelManager.addItem(ext);
syncManager.sync();
$scope.serverExtensions.push(ext);
completion();
}
$scope.handleThemeLink = function(link, completion) {
themeManager.submitTheme(link);
completion();
}
$scope.handleComponentLink = function(link, completion) {
componentManager.installComponent(link);
completion();
}
$scope.handleActionLink = function(link, completion) {
if(link) {
extensionManager.addExtension(link, function(response){
if(!response) {
alert("Unable to register this extension. Make sure the link is valid and try again.");
} else {
completion();
}
})
}
}
}
}
angular.module('app.frontend').directive('globalExtensionsMenu', () => new GlobalExtensionsMenu);

View File

@@ -1,70 +0,0 @@
class PermissionsModal {
constructor() {
this.restrict = "E";
this.templateUrl = "frontend/directives/permissions-modal.html";
this.scope = {
show: "=",
component: "=",
permissions: "=",
callback: "="
};
}
link($scope, el, attrs) {
$scope.dismiss = function() {
el.remove();
}
$scope.accept = function() {
$scope.callback(true);
$scope.dismiss();
}
$scope.deny = function() {
$scope.callback(false);
$scope.dismiss();
}
$scope.formattedPermissions = $scope.permissions.map(function(permission){
if(permission.name === "stream-items") {
var title = "Access to ";
var types = permission.content_types.map(function(type){
return (type + "s").toLowerCase();
})
var typesString = "";
var separator = ", ";
for(var i = 0;i < types.length;i++) {
var type = types[i];
if(i == 0) {
// first element
typesString = typesString + type;
} else if(i == types.length - 1) {
// last element
if(types.length > 2) {
typesString += separator + "and " + typesString;
} else if(types.length == 2) {
typesString = typesString + " and " + type;
}
} else {
typesString += separator + type;
}
}
return title + typesString;
} else if(permission.name === "stream-context-item") {
var mapping = {
"editor-stack" : "working note",
"note-tags" : "working note",
"editor-editor": "working note"
}
return "Access to " + mapping[$scope.component.area];
}
})
}
}
angular.module('app.frontend').directive('permissionsModal', () => new PermissionsModal);

View File

@@ -1,337 +0,0 @@
class ExtensionManager {
constructor(httpManager, modelManager, authManager, syncManager, storageManager) {
this.httpManager = httpManager;
this.modelManager = modelManager;
this.authManager = authManager;
this.enabledRepeatActionUrls = JSON.parse(storageManager.getItem("enabledRepeatActionUrls")) || [];
this.syncManager = syncManager;
this.storageManager = storageManager;
modelManager.addItemSyncObserver("extensionManager", "Extension", function(allItems, validItems, deletedItems){
for (var ext of validItems) {
for (var action of ext.actions) {
if(_.includes(this.enabledRepeatActionUrls, action.url)) {
this.enableRepeatAction(action, ext);
}
}
}
}.bind(this))
}
get extensions() {
return this.modelManager.extensions;
}
extensionsInContextOfItem(item) {
return this.extensions.filter(function(ext){
return _.includes(ext.supported_types, item.content_type) || ext.actionsWithContextForItem(item).length > 0;
})
}
actionWithURL(url) {
for (var extension of this.extensions) {
return _.find(extension.actions, {url: url})
}
}
addExtension(url, callback) {
this.retrieveExtensionFromServer(url, callback);
}
deleteExtension(extension) {
for(var action of extension.actions) {
if(action.repeat_mode) {
if(this.isRepeatActionEnabled(action)) {
this.disableRepeatAction(action);
}
}
}
this.modelManager.setItemToBeDeleted(extension);
this.syncManager.sync(null);
}
/*
Loads an extension in the context of a certain item. The server then has the chance to respond with actions that are
relevant just to this item. The response extension is not saved, just displayed as a one-time thing.
*/
loadExtensionInContextOfItem(extension, item, callback) {
this.httpManager.getAbsolute(extension.url, {content_type: item.content_type, item_uuid: item.uuid}, function(response){
this.updateExtensionFromRemoteResponse(extension, response);
callback && callback(extension);
}.bind(this), function(response){
console.log("Error loading extension", response);
if(callback) {
callback(null);
}
}.bind(this))
}
/*
Registers new extension and saves it to user's account
*/
retrieveExtensionFromServer(url, callback) {
this.httpManager.getAbsolute(url, {}, function(response){
if(typeof response !== 'object') {
callback(null);
return;
}
var ext = this.handleExtensionLoadExternalResponseItem(url, response);
if(callback) {
callback(ext);
}
}.bind(this), function(response){
console.error("Error registering extension", response);
callback(null);
})
}
handleExtensionLoadExternalResponseItem(url, externalResponseItem) {
// Don't allow remote response to set these flags
delete externalResponseItem.encrypted;
delete externalResponseItem.uuid;
var extension = _.find(this.extensions, {url: url});
if(extension) {
this.updateExtensionFromRemoteResponse(extension, externalResponseItem);
} else {
extension = new Extension(externalResponseItem);
extension.url = url;
extension.setDirty(true);
this.modelManager.addItem(extension);
this.syncManager.sync(null);
}
return extension;
}
updateExtensionFromRemoteResponse(extension, response) {
if(response.description) {
extension.description = response.description;
}
if(response.supported_types) {
extension.supported_types = response.supported_types;
}
if(response.actions) {
extension.actions = response.actions.map(function(action){
return new Action(action);
})
} else {
extension.actions = [];
}
}
refreshExtensionsFromServer() {
for (var url of this.enabledRepeatActionUrls) {
var action = this.actionWithURL(url);
if(action) {
this.disableRepeatAction(action);
}
}
for(var ext of this.extensions) {
this.retrieveExtensionFromServer(ext.url, function(extension){
extension.setDirty(true);
});
}
}
executeAction(action, extension, item, callback) {
if(extension.encrypted && this.authManager.offline()) {
alert("To send data encrypted, you must have an encryption key, and must therefore be signed in.");
callback(null);
return;
}
var customCallback = function(response) {
action.running = false;
callback(response);
}
action.running = true;
switch (action.verb) {
case "get": {
this.httpManager.getAbsolute(action.url, {}, function(response){
action.error = false;
var items = response.items || [response.item];
EncryptionHelper.decryptMultipleItems(items, this.authManager.keys());
items = this.modelManager.mapResponseItemsToLocalModels(items, ModelManager.MappingSourceRemoteActionRetrieved);
for(var item of items) {
item.setDirty(true);
}
this.syncManager.sync(null);
customCallback({items: items});
}.bind(this), function(response){
action.error = true;
customCallback(null);
})
break;
}
case "render": {
this.httpManager.getAbsolute(action.url, {}, function(response){
action.error = false;
EncryptionHelper.decryptItem(response.item, this.authManager.keys());
var item = this.modelManager.createItem(response.item);
customCallback({item: item});
}.bind(this), function(response){
action.error = true;
customCallback(null);
})
break;
}
case "show": {
var win = window.open(action.url, '_blank');
win.focus();
customCallback();
break;
}
case "post": {
var params = {};
if(action.all) {
var items = this.modelManager.allItemsMatchingTypes(action.content_types);
params.items = items.map(function(item){
var params = this.outgoingParamsForItem(item, extension);
return params;
}.bind(this))
} else {
params.items = [this.outgoingParamsForItem(item, extension)];
}
this.performPost(action, extension, params, function(response){
customCallback(response);
});
break;
}
default: {
}
}
action.lastExecuted = new Date();
}
isRepeatActionEnabled(action) {
return _.includes(this.enabledRepeatActionUrls, action.url);
}
disableRepeatAction(action, extension) {
_.pull(this.enabledRepeatActionUrls, action.url);
this.storageManager.setItem("enabledRepeatActionUrls", JSON.stringify(this.enabledRepeatActionUrls));
this.modelManager.removeItemChangeObserver(action.url);
console.assert(this.isRepeatActionEnabled(action) == false);
}
enableRepeatAction(action, extension) {
if(!_.find(this.enabledRepeatActionUrls, action.url)) {
this.enabledRepeatActionUrls.push(action.url);
this.storageManager.setItem("enabledRepeatActionUrls", JSON.stringify(this.enabledRepeatActionUrls));
}
if(action.repeat_mode) {
if(action.repeat_mode == "watch") {
this.modelManager.addItemChangeObserver(action.url, action.content_types, function(changedItems){
this.triggerWatchAction(action, extension, changedItems);
}.bind(this))
}
if(action.repeat_mode == "loop") {
// todo
}
}
}
queueAction(action, extension, delay, changedItems) {
this.actionQueue = this.actionQueue || [];
if(_.find(this.actionQueue, {url: action.url})) {
return;
}
this.actionQueue.push(action);
setTimeout(function () {
this.triggerWatchAction(action, extension, changedItems);
_.pull(this.actionQueue, action);
}.bind(this), delay * 1000);
}
triggerWatchAction(action, extension, changedItems) {
if(action.repeat_timeout > 0) {
var lastExecuted = action.lastExecuted;
var diffInSeconds = (new Date() - lastExecuted)/1000;
if(diffInSeconds < action.repeat_timeout) {
var delay = action.repeat_timeout - diffInSeconds;
this.queueAction(action, extension, delay, changedItems);
return;
}
}
action.lastExecuted = new Date();
if(action.verb == "post") {
var params = {};
params.items = changedItems.map(function(item){
var params = this.outgoingParamsForItem(item, extension);
return params;
}.bind(this))
action.running = true;
this.performPost(action, extension, params, function(){
action.running = false;
});
} else {
// todo
}
}
outgoingParamsForItem(item, extension) {
var keys = this.authManager.keys();
if(!extension.encrypted) {
keys = null;
}
var itemParams = new ItemParams(item, keys, this.authManager.protocolVersion());
return itemParams.paramsForExtension();
}
performPost(action, extension, params, callback) {
if(extension.encrypted) {
params.auth_params = this.authManager.getAuthParams();
}
this.httpManager.postAbsolute(action.url, params, function(response){
action.error = false;
if(callback) {
callback(response);
}
}.bind(this), function(response){
action.error = true;
console.log("Action error response:", response);
if(callback) {
callback({error: "Request error"});
}
})
}
}
angular.module('app.frontend').service('extensionManager', ExtensionManager);

View File

@@ -1,11 +0,0 @@
angular.module('app.frontend')
.filter('appDate', function ($filter) {
return function (input) {
return input ? $filter('date')(new Date(input), 'MM/dd/yyyy', 'UTC') : '';
};
})
.filter('appDateTime', function ($filter) {
return function (input) {
return input ? $filter('date')(new Date(input), 'MM/dd/yyyy h:mm a') : '';
};
});

View File

@@ -1,42 +0,0 @@
angular.module('app.frontend')
.filter('sortBy', function ($filter) {
return function(items, sortBy) {
let sortValueFn = (a, b, pinCheck = false) => {
if(!pinCheck) {
if(a.pinned && b.pinned) {
return sortValueFn(a, b, true);
}
if(a.pinned) { return -1; }
if(b.pinned) { return 1; }
}
var aValue = a[sortBy] || "";
var bValue = b[sortBy] || "";
let vector = 1;
if(sortBy == "title") {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
if(aValue.length == 0 && bValue.length == 0) {
return 0;
} else if(aValue.length == 0 && bValue.length != 0) {
return 1;
} else if(aValue.length != 0 && bValue.length == 0) {
return -1;
} else {
vector = -1;
}
}
if(aValue > bValue) { return -1 * vector;}
else if(aValue < bValue) { return 1 * vector;}
return 0;
}
items = items || [];
return items.sort(function(a, b){
return sortValueFn(a, b);
})
};
});

View File

@@ -1,5 +0,0 @@
angular.module('app.frontend').filter('startFrom', function() {
return function(input, start) {
return input.slice(start);
};
});

View File

@@ -1,5 +0,0 @@
angular.module('app.frontend').filter('trusted', ['$sce', function ($sce) {
return function(url) {
return $sce.trustAsResourceUrl(url);
};
}]);

View File

@@ -77,4 +77,4 @@ class HttpManager {
}
angular.module('app.frontend').service('httpManager', HttpManager);
angular.module('app').service('httpManager', HttpManager);

View File

@@ -50,7 +50,7 @@ class MigrationManager {
this.modelManager.setItemToBeDeleted(editor);
}
this.syncManager.sync();
this.syncManager.sync("addEditorToComponentMigrator");
}
})
}
@@ -58,4 +58,4 @@ class MigrationManager {
}
angular.module('app.frontend').service('migrationManager', MigrationManager);
angular.module('app').service('migrationManager', MigrationManager);

View File

@@ -3,8 +3,10 @@ class ModelManager {
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";
@@ -18,7 +20,7 @@ class ModelManager {
this._extensions = [];
this.acceptableContentTypes = [
"Note", "Tag", "Extension", "SN|Editor", "SN|Theme",
"SN|Component", "SF|Extension", "SN|UserPreferences"
"SN|Component", "SF|Extension", "SN|UserPreferences", "SF|MFA"
];
}
@@ -52,6 +54,8 @@ class ModelManager {
this.informModelsOfUUIDChangeForItem(newItem, item.uuid, newItem.uuid);
console.log(item.uuid, "-->", newItem.uuid);
var block = () => {
this.addItem(newItem);
newItem.setDirty(true);
@@ -60,9 +64,10 @@ class ModelManager {
}
if(removeOriginal) {
this.removeItemLocally(item, function(){
block();
});
// Set to deleted, then run through mapping function so that observers can be notified
item.deleted = true;
this.mapResponseItemsToLocalModels([item], ModelManager.MappingSourceLocalSaved);
block();
} else {
block();
}
@@ -79,13 +84,13 @@ class ModelManager {
}
allItemsMatchingTypes(contentTypes) {
return this.items.filter(function(item){
return this.allItems.filter(function(item){
return (_.includes(contentTypes, item.content_type) || _.includes(contentTypes, "*")) && !item.dummy;
})
}
itemsForContentType(contentType) {
return this.items.filter(function(item){
return this.allItems.filter(function(item){
return item.content_type == contentType;
});
}
@@ -103,6 +108,10 @@ class ModelManager {
return tag;
}
didSyncModelsOffline(items) {
this.notifySyncObserversOfModels(items, ModelManager.MappingSourceLocalSaved);
}
mapResponseItemsToLocalModels(items, source) {
return this.mapResponseItemsToLocalModelsOmittingFields(items, null, source);
}
@@ -139,7 +148,8 @@ class ModelManager {
continue;
}
var unknownContentType = !_.includes(this.acceptableContentTypes, json_obj["content_type"]);
let contentType = json_obj["content_type"] || (item && item.content_type);
var unknownContentType = !_.includes(this.acceptableContentTypes, contentType);
if(json_obj.deleted == true || unknownContentType) {
if(item && !unknownContentType) {
modelsToNotifyObserversOf.push(item);
@@ -149,7 +159,7 @@ class ModelManager {
}
if(!item) {
item = this.createItem(json_obj);
item = this.createItem(json_obj, true);
}
this.addItem(item);
@@ -172,6 +182,7 @@ class ModelManager {
return models;
}
/* Note that this function is public, and can also be called manually (desktopManager uses it) */
notifySyncObserversOfModels(models, source) {
for(var observer of this.itemSyncObservers) {
var allRelevantItems = models.filter(function(item){return item.content_type == observer.type || observer.type == "*"});
@@ -202,7 +213,7 @@ class ModelManager {
}
}
createItem(json_obj) {
createItem(json_obj, dontNotifyObservers) {
var item;
if(json_obj.content_type == "Note") {
item = new Note(json_obj);
@@ -217,13 +228,24 @@ class ModelManager {
} else if(json_obj.content_type == "SN|Component") {
item = new Component(json_obj);
} else if(json_obj.content_type == "SF|Extension") {
item = new SyncAdapter(json_obj);
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));
@@ -232,7 +254,7 @@ class ModelManager {
}
createDuplicateItem(itemResponse, sourceItem) {
var dup = this.createItem(itemResponse);
var dup = this.createItem(itemResponse, true);
this.resolveReferencesForItem(dup);
return dup;
}
@@ -405,6 +427,25 @@ class ModelManager {
return JSON.stringify(data, null, 2 /* pretty print */);
}
/*
Misc
*/
humanReadableDisplayForContentType(contentType) {
return {
"Note" : "note",
"Tag" : "tag",
"Extension" : "action-based extension",
"SN|Component" : "component",
"SN|Editor" : "editor",
"SN|Theme" : "theme",
"SF|Extension" : "server extension",
"SF|MFA" : "two-factor authentication setting"
}[contentType];
}
}
angular.module('app.frontend').service('modelManager', ModelManager);
angular.module('app').service('modelManager', ModelManager);

View File

@@ -0,0 +1,92 @@
/* A class for handling installation of system extensions */
class NativeExtManager {
constructor(modelManager, syncManager, singletonManager) {
this.modelManager = modelManager;
this.syncManager = syncManager;
this.singletonManager = singletonManager;
this.extensionsIdentifier = "org.standardnotes.extensions-manager";
this.systemExtensions = [];
this.resolveExtensionsManager();
}
isSystemExtension(extension) {
return this.systemExtensions.includes(extension.uuid);
}
resolveExtensionsManager() {
this.singletonManager.registerSingleton({content_type: "SN|Component", package_info: {identifier: this.extensionsIdentifier}}, (resolvedSingleton) => {
// Resolved Singleton
this.systemExtensions.push(resolvedSingleton.uuid);
var needsSync = false;
if(isDesktopApplication()) {
if(!resolvedSingleton.local_url) {
resolvedSingleton.local_url = window._extensions_manager_location;
needsSync = true;
}
} else {
if(!resolvedSingleton.hosted_url) {
resolvedSingleton.hosted_url = window._extensions_manager_location;
needsSync = true;
}
}
if(needsSync) {
resolvedSingleton.setDirty(true);
this.syncManager.sync("resolveExtensionsManager");
}
}, (valueCallback) => {
// Safe to create. Create and return object.
let url = window._extensions_manager_location;
console.log("Installing Extensions Manager from URL", url);
if(!url) {
console.error("window._extensions_manager_location must be set.");
return;
}
let packageInfo = {
name: "Extensions",
identifier: this.extensionsIdentifier
}
var item = {
content_type: "SN|Component",
content: {
name: packageInfo.name,
area: "rooms",
package_info: packageInfo,
permissions: [
{
name: "stream-items",
content_types: ["SN|Component", "SN|Theme", "SF|Extension", "Extension", "SF|MFA", "SN|Editor"]
}
]
}
}
if(isDesktopApplication()) {
item.content.local_url = window._extensions_manager_location;
} else {
item.content.hosted_url = window._extensions_manager_location;
}
var component = this.modelManager.createItem(item);
this.modelManager.addItem(component);
component.setDirty(true);
this.syncManager.sync("resolveExtensionsManager createNew");
this.systemExtensions.push(component.uuid);
valueCallback(component);
});
}
}
angular.module('app').service('nativeExtManager', NativeExtManager);

View File

@@ -1,4 +1,4 @@
angular.module('app.frontend')
angular.module('app')
.provider('passcodeManager', function () {
this.$get = function($rootScope, $timeout, modelManager, dbManager, authManager, storageManager) {
@@ -41,7 +41,7 @@ angular.module('app.frontend')
}.bind(this));
}
this.setPasscode = function(passcode, callback) {
this.setPasscode = (passcode, callback) => {
var cost = Neeto.crypto.defaultPasswordGenerationCost();
var salt = Neeto.crypto.generateRandomKey(512);
var defaultParams = {pw_cost: cost, pw_salt: salt, version: "002"};
@@ -60,6 +60,10 @@ angular.module('app.frontend')
}.bind(this));
}
this.changePasscode = (newPasscode, callback) => {
this.setPasscode(newPasscode, callback);
}
this.clearPasscode = function() {
storageManager.setItemsMode(authManager.isEphemeralSession() ? StorageManager.Ephemeral : StorageManager.Fixed); // Transfer from Ephemeral
storageManager.removeItem("offlineParams", StorageManager.Fixed);
@@ -70,7 +74,8 @@ angular.module('app.frontend')
this.encryptLocalStorage = function(keys) {
storageManager.setKeys(keys);
// Switch to Ephemeral storage, wiping Fixed storage
storageManager.setItemsMode(authManager.isEphemeralSession() ? StorageManager.Ephemeral : StorageManager.FixedEncrypted);
// Last argument is `force`, which we set to true because in the case of changing passcode
storageManager.setItemsMode(authManager.isEphemeralSession() ? StorageManager.Ephemeral : StorageManager.FixedEncrypted, true);
}
this.decryptLocalStorage = function(keys) {

View File

@@ -0,0 +1,174 @@
/*
The SingletonManager allows controllers to register an item as a singleton, which means only one instance of that model
should exist, both on the server and on the client. When the SingletonManager detects multiple items matching the singleton predicate,
the oldest ones will be deleted, leaving the newest ones.
We will treat the model most recently arrived from the server as the most recent one. The reason for this is, if you're offline,
a singleton can be created, as in the case of UserPreferneces. Then when you sign in, you'll retrieve your actual user preferences.
In that case, even though the offline singleton has a more recent updated_at, the server retreived value is the one we care more about.
*/
class SingletonManager {
constructor($rootScope, modelManager) {
this.$rootScope = $rootScope;
this.modelManager = modelManager;
this.singletonHandlers = [];
$rootScope.$on("initial-data-loaded", (event, data) => {
this.resolveSingletons(modelManager.allItems, null, true);
})
$rootScope.$on("sync:completed", (event, data) => {
// The reason we also need to consider savedItems in consolidating singletons is in case of sync conflicts,
// a new item can be created, but is never processed through "retrievedItems" since it is only created locally then saved.
// HOWEVER, by considering savedItems, we are now ruining everything, especially during sign in. A singleton can be created
// offline, and upon sign in, will sync all items to the server, and by combining retrievedItems & savedItems, and only choosing
// the latest, you are now resolving to the most recent one, which is in the savedItems list and not retrieved items, defeating
// the whole purpose of this thing.
// Updated solution: resolveSingletons will now evaluate both of these arrays separately.
this.resolveSingletons(data.retrievedItems, data.savedItems);
})
}
registerSingleton(predicate, 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,
resolutionCallback: resolveCallback,
createBlock: createBlock
});
}
resolveSingletons(retrievedItems, savedItems, initialLoad) {
retrievedItems = retrievedItems || [];
savedItems = savedItems || [];
for(let singletonHandler of this.singletonHandlers) {
var predicate = singletonHandler.predicate;
let retrievedSingletonItems = this.filterItemsWithPredicate(retrievedItems, predicate);
// 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;
if(retrievedSingletonItems.length > 0 || savedSingletonItemsCount > 0) {
/*
Check local inventory and make sure only 1 similar item exists. If more than 1, delete oldest
Note that this local inventory will also contain whatever is in retrievedItems.
However, as stated in the header comment, retrievedItems take precendence over existing items,
even if they have a lower updated_at value
*/
var allExtantItemsMatchingPredicate = this.filterItemsWithPredicate(this.modelManager.allItems, predicate);
/*
If there are more than 1 matches, delete everything not in `retrievedSingletonItems`,
then delete all but the latest in `retrievedSingletonItems`
*/
if(allExtantItemsMatchingPredicate.length >= 2) {
// Items that will be deleted
var toDelete = [];
// The item that will be chosen to be kept
var winningItem, sorted;
if(retrievedSingletonItems.length > 0) {
for(let extantItem of allExtantItemsMatchingPredicate) {
if(!retrievedSingletonItems.includes(extantItem)) {
// Delete it
toDelete.push(extantItem);
}
}
// Sort incoming singleton items by most recently updated first, then delete all the rest
sorted = retrievedSingletonItems.sort((a, b) => {
return a.updated_at < b.updated_at;
})
} else {
// We're in here because of savedItems
// This can be the case if retrievedSingletonItems/retrievedItems length is 0, but savedSingletonItemsCount is non zero.
// In this case, we want to sort by date and delete all but the most recent one
sorted = allExtantItemsMatchingPredicate.sort((a, b) => {
return a.updated_at < b.updated_at;
});
}
winningItem = sorted[0];
// Delete everything but the first one
toDelete = toDelete.concat(sorted.slice(1, sorted.length));
for(var d of toDelete) {
this.modelManager.setItemToBeDeleted(d);
}
this.$rootScope.sync("resolveSingletons");
// Send remaining item to callback
singletonHandler.singleton = winningItem;
singletonHandler.resolutionCallback(winningItem);
} else if(allExtantItemsMatchingPredicate.length == 1) {
if(!singletonHandler.singleton) {
// Not yet notified interested parties of object
var singleton = allExtantItemsMatchingPredicate[0];
singletonHandler.singleton = singleton;
singletonHandler.resolutionCallback(singleton);
}
}
} else {
// Retrieved items does not include any items of interest. If we don't have a singleton registered to this handler,
// we need to create one. Only do this on actual sync completetions and not on initial data load. Because we want
// to get the latest from the server before making the decision to create a new item
if(!singletonHandler.singleton && !initialLoad && !singletonHandler.pendingCreateBlockCallback) {
singletonHandler.pendingCreateBlockCallback = true;
singletonHandler.createBlock((created) => {
singletonHandler.singleton = created;
singletonHandler.pendingCreateBlockCallback = false;
singletonHandler.resolutionCallback(created);
});
}
}
}
}
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

@@ -62,9 +62,9 @@ class StorageManager {
return this._memoryStorage;
}
setItemsMode(mode) {
setItemsMode(mode, force) {
var newStorage = this.getVault(mode);
if(newStorage !== this.storage) {
if(newStorage !== this.storage || mode !== this.itemsStorageMode || force) {
// transfer storages
var length = this.storage.length;
for(var i = 0; i < length; i++) {
@@ -161,7 +161,6 @@ class StorageManager {
for(var key of Object.keys(encryptedStorage.storage)) {
this.setItem(key, encryptedStorage.storage[key]);
}
}
hasPasscode() {
@@ -228,4 +227,4 @@ StorageManager.FixedEncrypted = "FixedEncrypted"; // encrypted memoryStorage + l
StorageManager.Ephemeral = "Ephemeral"; // memoryStorage
StorageManager.Fixed = "Fixed"; // localStorage
angular.module('app.frontend').service('storageManager', StorageManager);
angular.module('app').service('storageManager', StorageManager);

View File

@@ -21,10 +21,6 @@ class SyncManager {
return this.storageManager.getItem("mk");
}
get serverPassword() {
return this.storageManager.getItem("pw");
}
writeItemsToLocalStorage(items, offlineOnly, callback) {
if(items.length == 0) {
callback && callback();
@@ -62,6 +58,11 @@ class SyncManager {
}
}
this.$rootScope.$broadcast("sync:completed", {});
// Required in order for modelManager to notify sync observers
this.modelManager.didSyncModelsOffline(items);
if(callback) {
callback({success: true});
}
@@ -92,7 +93,7 @@ class SyncManager {
let alternateNextItem = () => {
if(index >= originalItems.length) {
// We don't use originalItems as altnerating UUID will have deleted them.
// We don't use originalItems as alternating UUID will have deleted them.
block();
return;
}
@@ -188,7 +189,17 @@ class SyncManager {
this.$interval.cancel(this.syncStatus.checker);
}
sync(callback, options = {}) {
sync(callback, options = {}, source) {
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();
@@ -241,6 +252,11 @@ class SyncManager {
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();
@@ -265,7 +281,17 @@ class SyncManager {
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
= this.handleItemsResponse(response.retrieved_items, null, ModelManager.MappingSourceRemoteRetrieved);
@@ -281,6 +307,9 @@ class SyncManager {
var saved =
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)
@@ -298,12 +327,12 @@ class SyncManager {
if(this.cursorToken || this.syncStatus.needsMoreSync) {
setTimeout(function () {
this.sync(callback, options);
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);
this.sync(callback, options, "onSyncSuccess repeatOnCompletion");
}.bind(this), 10); // wait 10ms to allow UI to update
} else {
this.writeItemsToLocalStorage(this.allRetreivedItems, false, null);
@@ -319,10 +348,11 @@ class SyncManager {
this.$rootScope.$broadcast("major-data-change");
}
this.allRetreivedItems = [];
this.callQueuedCallbacksAndCurrent(callback, response);
this.$rootScope.$broadcast("sync:completed");
this.$rootScope.$broadcast("sync:completed", {retrievedItems: this.allRetreivedItems, savedItems: this.allSavedItems});
this.allRetreivedItems = [];
this.allSavedItems = [];
}
}.bind(this);
@@ -391,14 +421,13 @@ class SyncManager {
console.log("Handle unsaved", unsaved);
var i = 0;
var handleNext = function() {
var handleNext = () => {
if(i >= unsaved.length) {
// Handled all items
this.sync(null, {additionalFields: ["created_at", "updated_at"]});
return;
}
var handled = false;
var mapping = unsaved[i];
var itemResponse = mapping.item;
EncryptionHelper.decryptMultipleItems([itemResponse], this.authManager.keys());
@@ -414,8 +443,10 @@ class SyncManager {
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
handled = true;
this.modelManager.alternateUUIDForItem(item, handleNext, true);
this.modelManager.alternateUUIDForItem(item, () => {
i++;
handleNext();
}, true);
}
else if(error.tag === "sync_conflict") {
@@ -425,20 +456,16 @@ class SyncManager {
itemResponse.uuid = null;
var dup = this.modelManager.createDuplicateItem(itemResponse, item);
if(!itemResponse.deleted && JSON.stringify(item.structureParams()) !== JSON.stringify(dup.structureParams())) {
if(!itemResponse.deleted && !item.isItemContentEqualWith(dup)) {
this.modelManager.addItem(dup);
dup.conflict_of = item.uuid;
dup.setDirty(true);
}
}
++i;
if(!handled) {
i++;
handleNext();
}
}.bind(this);
}
handleNext();
}
@@ -459,4 +486,4 @@ class SyncManager {
}
}
angular.module('app.frontend').service('syncManager', SyncManager);
angular.module('app').service('syncManager', SyncManager);

View File

@@ -1,99 +1,45 @@
class ThemeManager {
constructor(modelManager, syncManager, $rootScope, storageManager) {
this.syncManager = syncManager;
this.modelManager = modelManager;
this.$rootScope = $rootScope;
this.storageManager = storageManager;
}
constructor(componentManager, desktopManager) {
this.componentManager = componentManager;
get themes() {
return this.modelManager.itemsForContentType("SN|Theme");
}
desktopManager.registerUpdateObserver((component) => {
// Reload theme if active
if(component.active && component.isTheme()) {
this.deactivateTheme(component);
setTimeout(() => {
this.activateTheme(component);
}, 10);
}
})
/*
activeTheme: computed property that returns saved theme
currentTheme: stored variable that allows other classes to watch changes
*/
get activeTheme() {
var activeThemeId = this.storageManager.getItem("activeTheme");
if(!activeThemeId) {
return null;
}
var theme = _.find(this.themes, {uuid: activeThemeId});
return theme;
}
activateInitialTheme() {
var theme = this.activeTheme;
if(theme) {
this.activateTheme(theme);
}
}
submitTheme(url) {
var name = this.displayNameForThemeFile(this.fileNameFromPath(url));
var theme = this.modelManager.createItem({content_type: "SN|Theme", url: url, name: name});
this.modelManager.addItem(theme);
theme.setDirty(true);
this.syncManager.sync();
componentManager.registerHandler({identifier: "themeManager", areas: ["themes"], activationHandler: (component) => {
if(component.active) {
this.activateTheme(component);
} else {
this.deactivateTheme(component);
}
}});
}
activateTheme(theme) {
var activeTheme = this.activeTheme;
if(activeTheme) {
this.deactivateTheme(activeTheme);
}
var url = this.componentManager.urlForComponent(theme);
var link = document.createElement("link");
link.href = theme.url;
link.href = url;
link.type = "text/css";
link.rel = "stylesheet";
link.media = "screen,print";
link.id = theme.uuid;
document.getElementsByTagName("head")[0].appendChild(link);
this.storageManager.setItem("activeTheme", theme.uuid);
this.currentTheme = theme;
this.$rootScope.$broadcast("theme-changed");
}
deactivateTheme(theme) {
this.storageManager.removeItem("activeTheme");
var element = document.getElementById(theme.uuid);
if(element) {
element.disabled = true;
element.parentNode.removeChild(element);
}
this.currentTheme = null;
this.$rootScope.$broadcast("theme-changed");
}
isThemeActive(theme) {
return this.storageManager.getItem("activeTheme") === theme.uuid;
}
fileNameFromPath(filePath) {
return filePath.replace(/^.*[\\\/]/, '');
}
capitalizeString(string) {
return string.replace(/(?:^|\s)\S/g, function(a) { return a.toUpperCase(); });
}
displayNameForThemeFile(fileName) {
let fromParam = getParameterByName("name", fileName);
if(fromParam) {
return fromParam;
}
let name = fileName.split(".")[0];
let cleaned = name.split("-").join(" ");
return this.capitalizeString(cleaned);
}
}
angular.module('app.frontend').service('themeManager', ThemeManager);
angular.module('app').service('themeManager', ThemeManager);