diff --git a/app/assets/javascripts/app/frontend/controllers/_base.js b/app/assets/javascripts/app/frontend/controllers/_base.js index 3f802e41d..df67c62ca 100644 --- a/app/assets/javascripts/app/frontend/controllers/_base.js +++ b/app/assets/javascripts/app/frontend/controllers/_base.js @@ -1,5 +1,5 @@ class BaseCtrl { - constructor($rootScope, $scope, syncManager, dbManager, analyticsManager) { + constructor($rootScope, $scope, syncManager, dbManager, analyticsManager, componentManager) { dbManager.openDatabase(null, function(){ // new database, delete syncToken so that items can be refetched entirely from server syncManager.clearSyncToken(); diff --git a/app/assets/javascripts/app/frontend/controllers/editor.js b/app/assets/javascripts/app/frontend/controllers/editor.js index cacb679ae..426642ef7 100644 --- a/app/assets/javascripts/app/frontend/controllers/editor.js +++ b/app/assets/javascripts/app/frontend/controllers/editor.js @@ -17,7 +17,7 @@ angular.module('app.frontend') link:function(scope, elem, attrs, ctrl) { scope.$watch('ctrl.note', function(note, oldNote){ if(note) { - ctrl.setNote(note, oldNote); + ctrl.noteDidChange(note, oldNote); } }); @@ -37,7 +37,10 @@ angular.module('app.frontend') } } }) - .controller('EditorCtrl', function ($sce, $timeout, authManager, $rootScope, extensionManager, syncManager, modelManager, editorManager, themeManager) { + .controller('EditorCtrl', function ($sce, $timeout, authManager, $rootScope, extensionManager, syncManager, modelManager, editorManager, themeManager, componentManager) { + + this.componentManager = componentManager; + this.componentStack = []; $rootScope.$on("theme-changed", function(){ this.postThemeToExternalEditor(); @@ -51,10 +54,76 @@ angular.module('app.frontend') this.loadTagsString(); }.bind(this)); + + componentManager.registerHandler({identifier: "editor", areas: ["note-tags", "editor-stack"], activationHandler: function(component){ + + if(!component.active) { + return; + } + + if(component.area === "note-tags") { + this.tagsComponent = component; + } else { + // stack + if(!_.find(this.componentStack, component)) { + this.componentStack.push(component); + } + } + + $timeout(function(){ + var iframe = componentManager.iframeForComponent(component); + if(iframe) { + iframe.onload = function() { + componentManager.registerComponentWindow(component, iframe.contentWindow); + }.bind(this); + } + }.bind(this)); + + }.bind(this), contextRequestHandler: function(component){ + return this.note; + }.bind(this), actionHandler: function(component, action, data){ + if(action === "set-size") { + 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 = componentManager.iframeForComponent(component); + var width = data.width; + var height = data.height; + iframe.width = width; + iframe.height = height; + + setSize(iframe, data); + } else { + if(component.area == "note-tags") { + var container = document.getElementById("note-tags-component-container"); + setSize(container, data); + } else { + var container = document.getElementById("component-" + component.uuid); + setSize(container, data); + } + } + } + + else if(action === "associate-item") { + var tag = modelManager.findItem(data.item.uuid); + this.addTag(tag); + } + + else if(action === "deassociate-item") { + var tag = modelManager.findItem(data.item.uuid); + this.removeTag(tag); + } + + }.bind(this)}); + window.addEventListener("message", function(event){ if(event.data.status) { this.postNoteToExternalEditor(); - } else { + } else if(!event.data.api) { // console.log("Received message", event.data); var id = event.data.id; var text = event.data.text; @@ -75,8 +144,16 @@ angular.module('app.frontend') } }.bind(this), false); + this.noteDidChange = function(note, oldNote) { + this.setNote(note, oldNote); + for(var component of this.componentStack) { + componentManager.setEventFlowForComponent(component, component.isActiveForItem(this.note)); + } + componentManager.contextItemDidChangeInArea("note-tags"); + componentManager.contextItemDidChangeInArea("editor-stack"); + } + this.setNote = function(note, oldNote) { - this.noteReady = false; var currentEditor = this.editor; this.editor = null; this.showExtensions = false; @@ -90,6 +167,11 @@ angular.module('app.frontend') }.bind(this) var editor = this.editorForNote(note); + if(editor && !editor.systemEditor) { + // setting note to not ready will remove the editor from view in a flash, + // so we only want to do this if switching between external editors + this.noteReady = false; + } if(editor) { if(currentEditor !== editor) { // switch after timeout, so that note data isnt posted to current editor @@ -299,6 +381,27 @@ angular.module('app.frontend') this.tagsString = string; } + this.addTag = function(tag) { + var tags = this.note.tags; + var strings = tags.map(function(_tag){ + return _tag.title; + }) + strings.push(tag.title); + this.updateTags()(this.note, strings); + this.loadTagsString(); + } + + this.removeTag = function(tag) { + var tags = this.note.tags; + var strings = tags.map(function(_tag){ + return _tag.title; + }).filter(function(_tag){ + return _tag !== tag.title; + }) + this.updateTags()(this.note, strings); + this.loadTagsString(); + } + this.updateTagsFromTagsString = function($event) { $event.target.blur(); @@ -314,4 +417,40 @@ angular.module('app.frontend') this.updateTags()(this.note, tags); } + /* Components */ + + let alertKey = "displayed-component-disable-alert"; + + this.disableComponent = function(component) { + componentManager.disableComponentForItem(component, this.note); + componentManager.setEventFlowForComponent(component, false); + if(!localStorage.getItem(alertKey)) { + alert("This component will be disabled for this note. You can re-enable this component in the 'Menu' of the editor pane."); + localStorage.setItem(alertKey, true); + } + } + + this.hasDisabledComponents = function() { + for(var component of this.componentStack) { + if(component.ignoreEvents) { + return true; + } + } + + return false; + } + + this.restoreDisabledComponents = function() { + var relevantComponents = this.componentStack.filter(function(component){ + return component.ignoreEvents; + }) + + componentManager.enableComponentsForItem(relevantComponents, this.note); + + for(var component of relevantComponents) { + componentManager.setEventFlowForComponent(component, true); + componentManager.contextItemDidChangeInArea("editor-stack"); + } + } + }); diff --git a/app/assets/javascripts/app/frontend/controllers/home.js b/app/assets/javascripts/app/frontend/controllers/home.js index 603e2b57f..4f7dc8a93 100644 --- a/app/assets/javascripts/app/frontend/controllers/home.js +++ b/app/assets/javascripts/app/frontend/controllers/home.js @@ -37,6 +37,7 @@ angular.module('app.frontend') themeManager.activateInitialTheme(); $scope.$apply(); + syncManager.sync(null); // refresh every 30s setInterval(function () { @@ -56,6 +57,7 @@ angular.module('app.frontend') */ $scope.updateTagsForNote = function(note, stringTags) { + console.log("Updating tags", stringTags); var toRemove = []; for(var tag of note.tags) { if(stringTags.indexOf(tag.title) === -1) { diff --git a/app/assets/javascripts/app/frontend/controllers/tags.js b/app/assets/javascripts/app/frontend/controllers/tags.js index e6eefbec4..1cf690773 100644 --- a/app/assets/javascripts/app/frontend/controllers/tags.js +++ b/app/assets/javascripts/app/frontend/controllers/tags.js @@ -33,10 +33,39 @@ angular.module('app.frontend') } } }) - .controller('TagsCtrl', function (modelManager, $timeout) { + .controller('TagsCtrl', function ($rootScope, modelManager, $timeout, componentManager) { var initialLoad = true; + componentManager.registerHandler({identifier: "tags", areas: ["tags-list"], activationHandler: function(component){ + this.component = component; + + if(component.active) { + $timeout(function(){ + var iframe = document.getElementById("tags-list-iframe"); + iframe.onload = function() { + componentManager.registerComponentWindow(this.component, iframe.contentWindow); + }.bind(this); + }.bind(this)); + } + + }.bind(this), contextRequestHandler: function(component){ + return null; + }.bind(this), actionHandler: function(component, action, data){ + + if(action === "select-item") { + var tag = modelManager.findItem(data.item.uuid); + if(tag) { + this.selectTag(tag); + } + } + + else if(action === "clear-selection") { + this.selectTag(this.allTag); + } + + }.bind(this)}); + this.setAllTag = function(allTag) { this.selectTag(this.allTag); } diff --git a/app/assets/javascripts/app/frontend/models/app/component.js b/app/assets/javascripts/app/frontend/models/app/component.js new file mode 100644 index 000000000..66d6b5c6e --- /dev/null +++ b/app/assets/javascripts/app/frontend/models/app/component.js @@ -0,0 +1,59 @@ +class Component extends Item { + + constructor(json_obj) { + super(json_obj); + + if(!this.componentData) { + this.componentData = {}; + } + + if(!this.disassociatedItemIds) { + this.disassociatedItemIds = []; + } + } + + mapContentToLocalProperties(contentObject) { + super.mapContentToLocalProperties(contentObject) + this.url = contentObject.url; + this.name = contentObject.name; + + // the location in the view this component is located in. Valid values are currently tags-list, note-tags, and editor-stack` + this.area = contentObject.area; + + this.permissions = contentObject.permissions; + this.active = contentObject.active; + + // custom data that a component can store in itself + this.componentData = contentObject.componentData || {}; + + // items that have requested a component to be disabled in its context + this.disassociatedItemIds = contentObject.disassociatedItemIds || []; + } + + structureParams() { + var params = { + url: this.url, + name: this.name, + area: this.area, + permissions: this.permissions, + active: this.active, + componentData: this.componentData, + disassociatedItemIds: this.disassociatedItemIds + }; + + _.merge(params, super.structureParams()); + return params; + } + + toJSON() { + return {uuid: this.uuid} + } + + get content_type() { + return "SN|Component"; + } + + isActiveForItem(item) { + return this.disassociatedItemIds.indexOf(item.uuid) === -1; + } +} diff --git a/app/assets/javascripts/app/frontend/models/local/itemParams.js b/app/assets/javascripts/app/frontend/models/local/itemParams.js index 790c4c042..61c8b814a 100644 --- a/app/assets/javascripts/app/frontend/models/local/itemParams.js +++ b/app/assets/javascripts/app/frontend/models/local/itemParams.js @@ -22,30 +22,26 @@ class ItemParams { } paramsForSync() { - return this.__params(null, false); + return this.__params(); } __params() { let encryptionVersion = "001"; - var itemCopy = _.cloneDeep(this.item); - console.assert(!this.item.dummy, "Item is dummy, should not have gotten here.", this.item.dummy) var params = {uuid: this.item.uuid, content_type: this.item.content_type, deleted: this.item.deleted, created_at: this.item.created_at}; if(this.keys && !this.item.doNotEncrypt()) { - EncryptionHelper.encryptItem(itemCopy, this.keys, encryptionVersion); - params.content = itemCopy.content; - params.enc_item_key = itemCopy.enc_item_key; - if(encryptionVersion === "001") { - params.auth_hash = itemCopy.auth_hash; - } else { + var encryptedParams = EncryptionHelper.encryptItem(this.item, this.keys, encryptionVersion); + _.merge(params, encryptedParams); + + if(encryptionVersion !== "001") { params.auth_hash = null; } } else { - params.content = this.forExportFile ? itemCopy.createContentJSONFromProperties() : "000" + Neeto.crypto.base64(JSON.stringify(itemCopy.createContentJSONFromProperties())); + params.content = this.forExportFile ? this.item.createContentJSONFromProperties() : "000" + Neeto.crypto.base64(JSON.stringify(this.item.createContentJSONFromProperties())); if(!this.forExportFile) { params.enc_item_key = null; params.auth_hash = null; diff --git a/app/assets/javascripts/app/services/componentManager.js b/app/assets/javascripts/app/services/componentManager.js new file mode 100644 index 000000000..672880623 --- /dev/null +++ b/app/assets/javascripts/app/services/componentManager.js @@ -0,0 +1,499 @@ +class ComponentManager { + + constructor($rootScope, modelManager, syncManager, themeManager, $timeout, $compile) { + this.$compile = $compile; + this.$rootScope = $rootScope; + this.modelManager = modelManager; + this.syncManager = syncManager; + this.themeManager = themeManager; + this.timeout = $timeout; + this.streamObservers = []; + this.contextStreamObservers = []; + this.activeComponents = []; + + 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); + } + this.handleMessage(this.componentForSessionKey(event.data.sessionKey), event.data); + }.bind(this), false); + + this.modelManager.addItemSyncObserver("component-manager", "*", function(items) { + + var syncedComponents = items.filter(function(item){return item.content_type === "SN|Component" }); + for(var component of syncedComponents) { + var activeComponent = _.find(this.activeComponents, {uuid: component.uuid}); + if(component.active && !activeComponent) { + this.activateComponent(component); + } else if(!component.active && activeComponent) { + this.deactivateComponent(component); + } + } + + for(let observer of this.streamObservers) { + var relevantItems = items.filter(function(item){ + return observer.contentTypes.indexOf(item.content_type) !== -1; + }) + + var requiredPermissions = [ + { + name: "stream-items", + content_types: observer.contentTypes.sort() + } + ]; + + this.runWithPermissions(observer.component, requiredPermissions, observer.originalMessage.permissions, function(){ + this.sendItemsInReply(observer.component, relevantItems, observer.originalMessage); + }.bind(this)) + } + + var requiredContextPermissions = [ + { + name: "stream-context-item" + } + ]; + 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) === false) { + continue; + } + var itemInContext = handler.contextRequestHandler(observer.component); + if(itemInContext) { + var matchingItem = _.find(items, {uuid: itemInContext.uuid}); + if(matchingItem) { + this.sendContextItemInReply(observer.component, matchingItem, observer.originalMessage); + } + } + } + }.bind(this)) + } + + }.bind(this)) + } + + postThemeToComponents() { + for(var component of this.components) { + if(!component.active || !component.window) { + continue; + } + this.postThemeToComponent(component); + } + } + + postThemeToComponent(component) { + var data = { + themes: [this.themeManager.currentTheme ? this.themeManager.currentTheme.url : null] + } + + this.sendMessageToComponent(component, {action: "themes", data: data}) + } + + contextItemDidChangeInArea(area) { + for(let handler of this.handlers) { + if(handler.areas.includes(area) === false) { + continue; + } + var observers = this.contextStreamObservers.filter(function(observer){ + return observer.component.area === area; + }) + + for(let observer of observers) { + var itemInContext = handler.contextRequestHandler(observer.component); + this.sendContextItemInReply(observer.component, itemInContext, observer.originalMessage); + } + } + } + + jsonForItem(item) { + 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(); + return params; + } + + sendItemsInReply(component, items, message) { + var response = {items: {}}; + var mapped = items.map(function(item) { + return this.jsonForItem(item); + }.bind(this)); + + response.items = mapped; + this.replyToMessage(component, message, response); + } + + sendContextItemInReply(component, item, originalMessage) { + var response = {item: this.jsonForItem(item)}; + this.replyToMessage(component, originalMessage, response); + } + + get components() { + return this.modelManager.itemsForContentType("SN|Component"); + } + + componentsForStack(stack) { + return this.components.filter(function(component){ + return component.area === stack; + }) + } + + componentForSessionKey(key) { + return _.find(this.components, {sessionKey: key}); + } + + handleMessage(component, message) { + + if(!component) { + if(this.loggingEnabled) { + console.log("Component not defined, returning"); + } + return; + } + + /** + 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 + */ + + if(message.action === "stream-items") { + this.handleStreamItemsMessage(component, message); + } + + else if(message.action === "stream-context-item") { + this.handleStreamContextItemMessage(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 item = this.modelManager.createItem(message.data.item); + this.modelManager.addItem(item); + item.setDirty(true); + this.syncManager.sync(); + this.replyToMessage(component, message, {item: this.jsonForItem(item)}) + } + + else if(message.action === "save-items") { + var responseItems = message.data.items; + var localItems = this.modelManager.mapResponseItemsToLocalModels(responseItems); + + for(var item of localItems) { + var responseItem = _.find(responseItems, {uuid: item.uuid}); + _.merge(item.content, responseItem.content); + item.setDirty(true); + } + this.syncManager.sync(); + } + + for(let handler of this.handlers) { + if(handler.areas.includes(component.area)) { + this.timeout(function(){ + handler.actionHandler(component, message.action, message.data); + }) + } + } + } + + handleStreamItemsMessage(component, message) { + var requiredPermissions = [ + { + name: "stream-items", + content_types: message.data.content_types.sort() + } + ]; + + this.runWithPermissions(component, requiredPermissions, message.permissions, function(){ + if(!_.find(this.streamObservers, {identifier: component.url})) { + // for pushing laster as changes come in + this.streamObservers.push({ + identifier: component.url, + 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) { + + var requiredPermissions = [ + { + name: "stream-context-item" + } + ]; + + this.runWithPermissions(component, requiredPermissions, message.permissions, function(){ + if(!_.find(this.contextStreamObservers, {identifier: component.url})) { + // for pushing laster as changes come in + this.contextStreamObservers.push({ + identifier: component.url, + component: component, + originalMessage: message + }) + } + + // push immediately now + for(let handler of this.handlers) { + if(handler.areas.includes(component.area) === false) { + continue; + } + 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; + } + } + + if(!requestedMatchesRequired) { + // Error with Component permissions request + console.error("You are requesting permissions", requestedPermissions, "when you need to be requesting", requiredPermissions, ". Component:", component); + return; + } + + if(!component.permissions) { + component.permissions = []; + } + + var acquiredMatchesRequested = angular.toJson(component.permissions.sort()) === angular.toJson(requestedPermissions.sort()); + + if(!acquiredMatchesRequested) { + this.promptForPermissions(component, requestedPermissions, function(approved){ + if(approved) { + runFunction(); + } + }); + } else { + runFunction(); + } + } + + 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"); + var scope = this.$rootScope.$new(true); + scope.component = component; + scope.permissions = requestedPermissions; + 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); + } + } + + this.permissionDialogs = this.permissionDialogs.filter(function(dialog){ + return dialog.component !== component; + }) + + }.bind(this); + + this.permissionDialogs.push(scope); + + if(!existingDialog) { + var el = this.$compile( "" )(scope); + angular.element(document.body).append(el); + } else { + console.log("Existing dialog, not presenting."); + } + } + + replyToMessage(component, originalMessage, replyData) { + var reply = { + action: "reply", + original: originalMessage, + data: replyData + } + + this.sendMessageToComponent(component, reply); + } + + sendMessageToComponent(component, message) { + if(component.ignoreEvents && message.action !== "component-registered") { + if(this.loggingEnabled) { + console.log("Component disabled for current item, not sending any messages."); + } + return; + } + 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(); + } + + this.activeComponents.push(component); + } + + registerHandler(handler) { + this.handlers.push(handler); + } + + // Called by other views when the iframe is ready + registerComponentWindow(component, componentWindow) { + component.window = componentWindow; + component.sessionKey = Neeto.crypto.generateUUID(); + this.sendMessageToComponent(component, {action: "component-registered", sessionKey: component.sessionKey, componentData: component.componentData}); + this.postThemeToComponent(component); + } + + deactivateComponent(component) { + var didChange = component.active != false; + component.active = false; + component.sessionKey = null; + + for(var handler of this.handlers) { + if(handler.areas.includes(component.area)) { + handler.activationHandler(component); + } + } + + if(didChange) { + component.setDirty(true); + this.syncManager.sync(); + } + + _.pull(this.activeComponents, component); + + this.streamObservers = this.streamObservers.filter(function(o){ + return o.component !== component; + }) + + this.contextStreamObservers = this.contextStreamObservers.filter(function(o){ + return o.component !== component; + }) + } + + deleteComponent(component) { + this.modelManager.setItemToBeDeleted(component); + this.syncManager.sync(); + } + + isComponentActive(component) { + return component.active; + } + + disableComponentForItem(component, item) { + if(component.disassociatedItemIds.indexOf(item.uuid) !== -1) { + return; + } + component.disassociatedItemIds.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")) { + var componentId = frame.dataset.componentId; + if(componentId === component.uuid) { + return frame; + } + } + } + + +} + +angular.module('app.frontend').service('componentManager', ComponentManager); diff --git a/app/assets/javascripts/app/services/directives/functional/permissionsModal.js b/app/assets/javascripts/app/services/directives/functional/permissionsModal.js new file mode 100644 index 000000000..ce72dfbdb --- /dev/null +++ b/app/assets/javascripts/app/services/directives/functional/permissionsModal.js @@ -0,0 +1,69 @@ +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" + } + return "Access to " + mapping[$scope.component.area]; + } + }) + } + +} + +angular.module('app.frontend').directive('permissionsModal', () => new PermissionsModal); diff --git a/app/assets/javascripts/app/services/directives/views/globalExtensionsMenu.js b/app/assets/javascripts/app/services/directives/views/globalExtensionsMenu.js index 0daa18a82..e48ddf9e4 100644 --- a/app/assets/javascripts/app/services/directives/views/globalExtensionsMenu.js +++ b/app/assets/javascripts/app/services/directives/views/globalExtensionsMenu.js @@ -7,7 +7,7 @@ class GlobalExtensionsMenu { }; } - controller($scope, extensionManager, syncManager, modelManager, themeManager, editorManager) { + controller($scope, extensionManager, syncManager, modelManager, themeManager, editorManager, componentManager) { 'ngInject'; $scope.formData = {}; @@ -15,6 +15,7 @@ class GlobalExtensionsMenu { $scope.extensionManager = extensionManager; $scope.themeManager = themeManager; $scope.editorManager = editorManager; + $scope.componentManager = componentManager; $scope.selectedAction = function(action, extension) { extensionManager.executeAction(action, extension, null, function(response){ @@ -69,6 +70,21 @@ class GlobalExtensionsMenu { editorManager.removeDefaultEditor(editor); } + + // 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); + } + } + // Installation $scope.submitInstallLink = function() { @@ -93,7 +109,11 @@ class GlobalExtensionsMenu { $scope.handleEditorLink(link, completion); } else if(link.indexOf(".css") != -1 || type == "theme") { $scope.handleThemeLink(link, completion); - } else { + } else if(type == "component") { + $scope.handleComponentLink(link, completion); + } + + else { $scope.handleActionLink(link, completion); } } @@ -112,6 +132,11 @@ class GlobalExtensionsMenu { completion(); } + $scope.handleComponentLink = function(link, completion) { + componentManager.installComponent(link); + completion(); + } + $scope.handleActionLink = function(link, completion) { if(link) { extensionManager.addExtension(link, function(response){ diff --git a/app/assets/javascripts/app/services/helpers/encryptionHelper.js b/app/assets/javascripts/app/services/helpers/encryptionHelper.js index 24de8ec7a..88fdb9225 100644 --- a/app/assets/javascripts/app/services/helpers/encryptionHelper.js +++ b/app/assets/javascripts/app/services/helpers/encryptionHelper.js @@ -17,13 +17,14 @@ class EncryptionHelper { } static encryptItem(item, keys, version) { + var params = {}; // encrypt item key var item_key = Neeto.crypto.generateRandomEncryptionKey(); if(version === "001") { // legacy - item.enc_item_key = Neeto.crypto.encryptText(item_key, keys.mk, null); + params.enc_item_key = Neeto.crypto.encryptText(item_key, keys.mk, null); } else { - item.enc_item_key = this._private_encryptString(item_key, keys.encryptionKey, keys.authKey, version); + params.enc_item_key = this._private_encryptString(item_key, keys.encryptionKey, keys.authKey, version); } // encrypt content @@ -32,10 +33,11 @@ class EncryptionHelper { var ciphertext = this._private_encryptString(JSON.stringify(item.createContentJSONFromProperties()), ek, ak, version); if(version === "001") { var authHash = Neeto.crypto.hmac256(ciphertext, ak); - item.auth_hash = authHash; + params.auth_hash = authHash; } - item.content = ciphertext; + params.content = ciphertext; + return params; } static encryptionComponentsFromString(string, baseKey, encryptionKey, authKey) { diff --git a/app/assets/javascripts/app/services/modelManager.js b/app/assets/javascripts/app/services/modelManager.js index a012cc205..6ddc1ac4f 100644 --- a/app/assets/javascripts/app/services/modelManager.js +++ b/app/assets/javascripts/app/services/modelManager.js @@ -8,7 +8,7 @@ class ModelManager { this.itemChangeObservers = []; this.items = []; this._extensions = []; - this.acceptableContentTypes = ["Note", "Tag", "Extension", "SN|Editor", "SN|Theme"]; + this.acceptableContentTypes = ["Note", "Tag", "Extension", "SN|Editor", "SN|Theme", "SN|Component"]; } get allItems() { @@ -71,29 +71,34 @@ class ModelManager { } mapResponseItemsToLocalModelsOmittingFields(items, omitFields) { - var models = [], processedObjects = []; + var models = [], processedObjects = [], allModels = []; // first loop should add and process items for (var json_obj of items) { json_obj = _.omit(json_obj, omitFields || []) var item = this.findItem(json_obj["uuid"]); + + _.omit(json_obj, omitFields); + + if(item) { + item.updateFromJSON(json_obj); + } + if(json_obj["deleted"] == true || !_.includes(this.acceptableContentTypes, json_obj["content_type"])) { if(item) { + allModels.push(item); this.removeItemLocally(item) } continue; } - _.omit(json_obj, omitFields); - if(!item) { item = this.createItem(json_obj); - } else { - item.updateFromJSON(json_obj); } this.addItem(item); + allModels.push(item); models.push(item); processedObjects.push(json_obj); } @@ -106,14 +111,14 @@ class ModelManager { } } - this.notifySyncObserversOfModels(models); + this.notifySyncObserversOfModels(allModels); return models; } notifySyncObserversOfModels(models) { for(var observer of this.itemSyncObservers) { - var relevantItems = models.filter(function(item){return item.content_type == observer.type}); + var relevantItems = models.filter(function(item){return item.content_type == observer.type || observer.type == "*"}); if(relevantItems.length > 0) { observer.callback(relevantItems); } @@ -144,7 +149,11 @@ class ModelManager { item = new Editor(json_obj); } else if(json_obj.content_type == "SN|Theme") { item = new Theme(json_obj); - } else { + } else if(json_obj.content_type == "SN|Component") { + item = new Component(json_obj); + } + + else { item = new Item(json_obj); } diff --git a/app/assets/stylesheets/app/_editor.scss b/app/assets/stylesheets/app/_editor.scss index 12d2a1843..ca1c82d6f 100644 --- a/app/assets/stylesheets/app/_editor.scss +++ b/app/assets/stylesheets/app/_editor.scss @@ -25,14 +25,19 @@ $heading-height: 75px; #editor-title-bar { width: 100%; padding: 20px; + padding-bottom: 10px; background-color: white; border-bottom: none; + z-index: 100; - height: $heading-height; - min-height: $heading-height; + height: auto; + // height: $heading-height; + // min-height: $heading-height; padding-right: 10px; + overflow: visible; + &.fullscreen { position: relative; } @@ -76,7 +81,19 @@ $heading-height: 75px; .editor-tags { clear: left; width: 100%; - height: 25px; + // height: 25px; + overflow: visible; + position: relative; + + #note-tags-component-container { + height: 50px; + #note-tags-iframe { + height: 50px; + width: 100%; + position: absolute; + } + } + .tags-input { background-color: transparent; @@ -125,3 +142,46 @@ $heading-height: 75px; } } } + +#editor-pane-component-stack { + width: 100%; + + .component { + height: 50px; + width: 100%; + position: relative; + &:not(:last-child) { + border-bottom: 1px solid $bg-color; + } + + &:first-child { + border-top: 1px solid $bg-color; + } + + .exit-button { + width: 15px; + height: 100%; + position: absolute; + right: 0; + background-color: transparent; + cursor: pointer; + display: flex; + align-items: center; + color: rgba(black, 0.7); + text-align: center; + padding-left: 2px; + + .content { + + } + + &:hover { + background-color: rgba(gray, 0.3); + } + } + + iframe { + width: 100%; + } + } +} diff --git a/app/assets/stylesheets/app/_permissions-modal.scss b/app/assets/stylesheets/app/_permissions-modal.scss new file mode 100644 index 000000000..6da432ff5 --- /dev/null +++ b/app/assets/stylesheets/app/_permissions-modal.scss @@ -0,0 +1,89 @@ +.permissions-modal { + position: fixed; + margin-left: auto; + margin-right: auto; + left: 0; + right: 0; + top: 0; + bottom: 0; + z-index: 10000; + width: 100vw; + height: 100vh; + background-color: rgba(gray, 0.3); + color: black; + font-size: 16px; + display: flex; + align-items: center; + + .background { + position: absolute; + z-index: -1; + width: 100%; + height: 100%; + } + + .content { + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + background-color: white; + width: 700px; + // height: 500px; + margin: auto; + padding: 10px 30px; + padding-bottom: 30px; + // position: absolute; + // top: 0; left: 0; bottom: 0; right: 0; + overflow-y: scroll; + + p { + margin-bottom: 8px; + margin-top: 0; + } + + h3 { + margin-bottom: 0; + } + + h4 { + margin-bottom: 6px; + } + } + + .learn-more { + margin-top: 20px; + line-height: 1.3; + } + + .status { + color: orange; + &.trusted { + color: $blue-color; + } + } + + .buttons { + margin-top: 35px; + } + + button.standard { + border-radius: 1px; + font-weight: bold; + padding: 6px 20px; + display: inline-block; + + &:hover { + text-decoration: underline; + } + + &.blue { + background-color: $blue-color; + color: white; + } + + &.white { + color: black; + background-color: white; + border: 1px solid gray; + } + } + +} diff --git a/app/assets/stylesheets/app/_tags.scss b/app/assets/stylesheets/app/_tags.scss index 953fc0881..04f69992b 100644 --- a/app/assets/stylesheets/app/_tags.scss +++ b/app/assets/stylesheets/app/_tags.scss @@ -2,6 +2,7 @@ flex: 1 10%; max-width: 180px; min-width: 100px; + width: 180px; $tags-title-bar-height: 55px; diff --git a/app/assets/stylesheets/frontend.css.scss b/app/assets/stylesheets/frontend.css.scss index 81f725378..4f084485d 100644 --- a/app/assets/stylesheets/frontend.css.scss +++ b/app/assets/stylesheets/frontend.css.scss @@ -9,3 +9,4 @@ $dark-gray: #2e2e2e; @import "app/editor"; @import "app/extensions"; @import "app/menus"; +@import "app/permissions-modal"; diff --git a/app/assets/templates/frontend/directives/global-extensions-menu.html.haml b/app/assets/templates/frontend/directives/global-extensions-menu.html.haml index 759672467..a194396a8 100644 --- a/app/assets/templates/frontend/directives/global-extensions-menu.html.haml +++ b/app/assets/templates/frontend/directives/global-extensions-menu.html.haml @@ -100,6 +100,23 @@ %p.wrap.selectable.small{"ng-if" => "extension.showURL"} {{extension.url}} %a.block.mt-5{"ng-click" => "deleteActionExtension(extension); $event.stopPropagation();"} Remove extension + %div{"ng-if" => "componentManager.components.length > 0"} + .container.no-bottom.section-margin + %h2 Components + %ul + %li{"ng-repeat" => "component in componentManager.components", "ng-click" => "component.showDetails = !component.showDetails"} + .container + %h3 {{component.name}} + %a{"ng-if" => "!componentManager.isComponentActive(component)", "ng-click" => "componentManager.activateComponent(component); $event.stopPropagation();"} Activate + %a{"ng-if" => "componentManager.isComponentActive(component)", "ng-click" => "componentManager.deactivateComponent(component); $event.stopPropagation();"} Deactivate + %div{"ng-if" => "component.showDetails"} + .link-group + %a.red{"ng-click" => "deleteComponent(component); $event.stopPropagation();"} Delete + %a{"ng-click" => "component.showLink = !component.showLink; $event.stopPropagation();"} Show Link + %a{"ng-if" => "component.permissions.length", "ng-click" => "revokePermissions(component); $event.stopPropagation();"} Revoke Permissions + %p.small.selectable.wrap{"ng-if" => "component.showLink"} + {{component.url}} + .container.section-margin %h2.blue Install %p.faded Enter an install link diff --git a/app/assets/templates/frontend/directives/permissions-modal.html.haml b/app/assets/templates/frontend/directives/permissions-modal.html.haml new file mode 100644 index 000000000..43262ea21 --- /dev/null +++ b/app/assets/templates/frontend/directives/permissions-modal.html.haml @@ -0,0 +1,25 @@ +.background{"ng-click" => "dismiss()"} + +.content + %h3 The following extension has requested these permissions: + + %h4 Extension + %p Name: {{component.name}} + %p.wrap URL: {{component.url}} + + %h4 Permissions + .permission{"ng-repeat" => "permission in formattedPermissions"} + %p {{permission}} + + %h4 Status + %p.status{"ng-class" => "{'trusted' : component.trusted}"} {{component.trusted ? 'Trusted' : 'Untrusted'}} + + .learn-more + %h4 Details + %p + Extensions use an offline messaging system to communicate. With Trusted extensions, data is never sent remotely without your consent. Learn more about extension permissions at + %a{"href" => "https://standardnotes.org/permissions", "target" => "_blank"} https://standardnotes.org/permissions. + + .buttons + %button.standard.white{"ng-click" => "deny()"} Deny + %button.standard.blue{"ng-click" => "accept()"} Accept diff --git a/app/assets/templates/frontend/editor.html.haml b/app/assets/templates/frontend/editor.html.haml index 8e517d6bf..f4fba1e34 100644 --- a/app/assets/templates/frontend/editor.html.haml +++ b/app/assets/templates/frontend/editor.html.haml @@ -8,7 +8,9 @@ #save-status{"ng-class" => "{'red bold': ctrl.saveError, 'orange bold': ctrl.syncTakingTooLong}", "ng-bind-html" => "ctrl.noteStatus"} .editor-tags - %input.tags-input{"type" => "text", "ng-keyup" => "$event.keyCode == 13 && $event.target.blur();", + #note-tags-component-container{"ng-if" => "ctrl.tagsComponent && ctrl.tagsComponent.active"} + %iframe#note-tags-iframe{"ng-src" => "{{ctrl.tagsComponent.url | trusted}}", "frameBorder" => "0", "sandbox" => "allow-scripts", "data-component-id" => "{{ctrl.tagsComponent.uuid}}"} + %input.tags-input{"ng-if" => "!(ctrl.tagsComponent && ctrl.tagsComponent.active)", "type" => "text", "ng-keyup" => "$event.keyCode == 13 && $event.target.blur();", "ng-model" => "ctrl.tagsString", "placeholder" => "#tags", "ng-blur" => "ctrl.updateTagsFromTagsString($event, ctrl.tagsString)"} %ul.section-menu-bar{"ng-if" => "ctrl.note"} %li{"ng-class" => "{'selected' : ctrl.showMenu}", "click-outside" => "ctrl.showMenu = false;", "is-open" => "ctrl.showMenu"} @@ -19,6 +21,8 @@ %label{"ng-click" => "ctrl.selectedMenuItem($event); ctrl.toggleFullScreen()"} Toggle Fullscreen %li %label{"ng-click" => "ctrl.selectedMenuItem($event); ctrl.deleteNote()"} Delete Note + %li{"ng-if" => "ctrl.hasDisabledComponents()"} + %label{"ng-click" => "ctrl.selectedMenuItem($event); ctrl.restoreDisabledComponents()"} Restore Disabled Components %li{"ng-class" => "{'selected' : ctrl.showEditorMenu}", "click-outside" => "ctrl.showEditorMenu = false;", "is-open" => "ctrl.showEditorMenu"} %label{"ng-click" => "ctrl.showEditorMenu = !ctrl.showEditorMenu; ctrl.showMenu = false; ctrl.showExtensions = false;"} Editor @@ -29,6 +33,12 @@ %contextual-extensions-menu{"ng-if" => "ctrl.showExtensions", "item" => "ctrl.note"} .editor-content{"ng-if" => "ctrl.noteReady", "ng-class" => "{'fullscreen' : ctrl.fullscreen }"} - %iframe#editor-iframe{"ng-if" => "ctrl.editor && !ctrl.editor.systemEditor", "ng-src" => "{{ctrl.editor.url | trusted}}", "frameBorder" => "0", "style" => "width: 100%;"} + %iframe#editor-iframe{"ng-if" => "ctrl.editor && !ctrl.editor.systemEditor", "ng-src" => "{{ctrl.editor.url | trusted}}", "frameBorder" => "0", "style" => "width: 100%;", "sandbox" => "allow-scripts"} + Loading %textarea.editable#note-text-editor{"ng-if" => "!ctrl.editor || ctrl.editor.systemEditor", "ng-class" => "{'fullscreen' : ctrl.fullscreen }", "ng-model" => "ctrl.note.text", "ng-change" => "ctrl.contentChanged()", "ng-click" => "ctrl.clickedTextArea()", "ng-focus" => "ctrl.onContentFocus()"} + + #editor-pane-component-stack + .component.component-stack-border{"ng-repeat" => "component in ctrl.componentStack", "ng-if" => "component.active", "ng-show" => "!component.ignoreEvents", "id" => "{{'component-' + component.uuid}}", "ng-mouseover" => "component.showExit = true", "ng-mouseleave" => "component.showExit = false"} + .exit-button.body-text-color{"ng-if" => "component.showExit", "ng-click" => "ctrl.disableComponent(component)"} × + %iframe#note-tags-iframe{"ng-src" => "{{component.url | trusted}}", "frameBorder" => "0", "sandbox" => "allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-modals", "data-component-id" => "{{component.uuid}}"} diff --git a/app/assets/templates/frontend/tags.html.haml b/app/assets/templates/frontend/tags.html.haml index 607651ab1..e8a061982 100644 --- a/app/assets/templates/frontend/tags.html.haml +++ b/app/assets/templates/frontend/tags.html.haml @@ -1,9 +1,9 @@ -.section.tags - #tags-content.content +.section.tags#tags-column + %iframe#tags-list-iframe{"ng-if" => "ctrl.component && ctrl.component.active", "ng-src" => "{{ctrl.component.url | trusted}}", "frameBorder" => "0", "style" => "width: 100%; height: 100%;", "sandbox" => "allow-scripts"} + #tags-content.content{"ng-if" => "!(ctrl.component && ctrl.component.active)"} #tags-title-bar.section-title-bar .title Tags .add-button#tag-add-button{"ng-click" => "ctrl.clickedAddNewTag()"} + - {{ctrl.test}} .scrollable .tag{"ng-if" => "ctrl.allTag", "ng-click" => "ctrl.selectTag(ctrl.allTag)", "ng-class" => "{'selected' : ctrl.selectedTag == ctrl.allTag}"}