diff --git a/guacamole/src/main/frontend/src/app/import/controllers/importConnectionsController.js b/guacamole/src/main/frontend/src/app/import/controllers/importConnectionsController.js index 178d6c69d..73164a08c 100644 --- a/guacamole/src/main/frontend/src/app/import/controllers/importConnectionsController.js +++ b/guacamole/src/main/frontend/src/app/import/controllers/importConnectionsController.js @@ -25,42 +25,81 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$ // Required services const connectionParseService = $injector.get('connectionParseService'); - const connectionService = $injector.get('connectionService'); + const connectionService = $injector.get('connectionService'); + + // Required types + const ParseError = $injector.get('ParseError'); + const TranslatableMessage = $injector.get('TranslatableMessage'); + + function handleSuccess(data) { + console.log("OMG SUCCESS: ", data) + } + + // Set any caught error message to the scope for display + const handleError = error => { + console.error(error); + $scope.error = error; + } + + // Clear the current error + const clearError = () => delete $scope.error; function processData(type, data) { - - let requestBody; + + // The function that will process all the raw data and return a list of + // patches to be submitted to the API + let processDataCallback; // Parse the data based on the provided mimetype switch(type) { case "application/json": case "text/json": - requestBody = connectionParseService.parseJSON(data); + processDataCallback = connectionParseService.parseJSON; break; case "text/csv": - requestBody = connectionParseService.parseCSV(data); + processDataCallback = connectionParseService.parseCSV; break; case "application/yaml": case "application/x-yaml": case "text/yaml": case "text/x-yaml": - requestBody = connectionParseService.parseYAML(data); + processDataCallback = connectionParseService.parseYAML; break; - + + default: + handleError(new ParseError({ + message: 'Invalid file type: ' + type, + key: 'CONNECTION_IMPORT.INVALID_FILE_TYPE', + variables: { TYPE: type } + })); + return; } + + // Make the call to process the data into a series of patches + processDataCallback(data) - console.log(requestBody); + // Send the data off to be imported if parsing is successful + .then(handleSuccess) + + // Display any error found while parsing the file + .catch(handleError); } $scope.upload = function() { + + // Clear any error message from the previous upload attempt + clearError(); const files = angular.element('#file')[0].files; if (files.length <= 0) { - console.error("TODO: This should be a proper error tho"); + handleError(new ParseError({ + message: 'No file supplied', + key: 'CONNECTION_IMPORT.ERROR_NO_FILE_SUPPLIED' + })); return; } diff --git a/guacamole/src/main/frontend/src/app/import/services/connectionCSVService.js b/guacamole/src/main/frontend/src/app/import/services/connectionCSVService.js index 361a15283..ee14f809b 100644 --- a/guacamole/src/main/frontend/src/app/import/services/connectionCSVService.js +++ b/guacamole/src/main/frontend/src/app/import/services/connectionCSVService.js @@ -19,16 +19,26 @@ /* global _ */ +// A suffix that indicates that a particular header refers to a parameter +const PARAMETER_SUFFIX = ' (parameter)'; + +// A suffix that indicates that a particular header refers to an attribute +const ATTRIBUTE_SUFFIX = ' (attribute)'; + /** * A service for parsing user-provided CSV connection data for bulk import. */ angular.module('import').factory('connectionCSVService', ['$injector', function connectionCSVService($injector) { + + // Required types + const ParseError = $injector.get('ParseError'); + const TranslatableMessage = $injector.get('TranslatableMessage'); // Required services - const $q = $injector.get('$q'); - const $routeParams = $injector.get('$routeParams'); - const schemaService = $injector.get('schemaService'); + const $q = $injector.get('$q'); + const $routeParams = $injector.get('$routeParams'); + const schemaService = $injector.get('schemaService'); const service = {}; @@ -88,14 +98,254 @@ angular.module('import').factory('connectionCSVService', } /** + * Given a CSV header row, create and return a promise that will resolve to + * a function that can take a CSV data row and return a connection object. + * If an error occurs while parsing a particular row, the resolved function + * will throw a ParseError describing the failure. * + * The provided CSV must contain columns for name and protocol. Optionally, + * the parentIdentifier of the target parent connection group, or a connection + * name path e.g. "ROOT/parent/child" may be included. Additionallty, + * connection parameters or attributes can be included. + * + * The names of connection attributes and parameters are not guaranteed to + * be mutually exclusive, so the CSV import format supports a distinguishing + * suffix. A column may be explicitly declared to be a parameter using a + * " (parameter)" suffix, or an attribute using an " (attribute)" suffix. + * No suffix is required if the name is unique across connections and + * attributes. + * + * If a parameter or attribute name conflicts with the standard + * "name", "protocol", "group", or "parentIdentifier" fields, the suffix is + * required. + * + * This returned object will be very similar to the Connection type, with + * the exception that a human-readable "group" field may be present. + * + * If a failure occurs while attempting to create the transformer function, + * the promise will be rejected with a ParseError describing the failure. * * @returns {Promise.>} * A promise that will resolve to a function that translates a CSV data - * row (array of strings) to a connection object. + * row (array of strings) to a connection object. */ service.getCSVTransformer = function getCSVTransformer(headerRow) { + // A promise that will be resolved with the transformer or rejected if + // an error occurs + const deferred = $q.defer(); + + getFieldLookups().then(({attributes, protocolParameters}) => { + + // All configuration required to generate a function that can + // transform a row of CSV into a connection object. + // NOTE: This is a single object instead of a collection of variables + // to ensure that no stale references are used - e.g. when one getter + // invokes another getter + const transformConfig = { + + // Callbacks for required fields + nameGetter: undefined, + protocolGetter: undefined, + + // Callbacks for a parent group ID or group path + groupGetter: _.noop, + parentIdentifierGetter: _.noop, + + // Callbacks that will generate either connection attributes or + // parameters. These callbacks will return a {type, name, value} + // object containing the type ("parameter" or "attribute"), + // the name of the attribute or parameter, and the corresponding + // value. + parameterOrAttributeGetters: [] + + }; + + // A set of all headers that have been seen so far. If any of these + // are duplicated, the CSV is invalid. + const headerSet = {}; + + // Iterate through the headers one by one + headerRow.forEach((rawHeader, index) => { + + // Trim to normalize all headers + const header = rawHeader.trim(); + + // Check if the header is duplicated + if (headerSet[header]) { + deferred.reject(new ParseError({ + message: 'Duplicate CSV Header: ' + header, + translatableMessage: new TranslatableMessage({ + key: 'CONNECTION_IMPORT.ERROR_DUPLICATE_CSV_HEADER', + variables: { HEADER: header } + }) + })); + return; + } + + // Mark that this particular header has already been seen + headerSet[header] = true; + + // A callback that returns the field at the current index + const fetchFieldAtIndex = row => row[index]; + + // Set up the name callback + if (header == 'name') + transformConfig.nameGetter = fetchFieldAtIndex; + + // Set up the protocol callback + else if (header == 'protocol') + transformConfig.protocolGetter = fetchFieldAtIndex; + + // Set up the group callback + else if (header == 'group') + transformConfig.groupGetter = fetchFieldAtIndex; + + // Set up the group parent ID callback + else if (header == 'parentIdentifier') + transformConfig.parentIdentifierGetter = fetchFieldAtIndex; + + // At this point, any other header might refer to a connection + // parameter or to an attribute + + // A field may be explicitly specified as a parameter + else if (header.endsWith(PARAMETER_SUFFIX)) { + + // Push as an explicit parameter getter + const parameterName = header.replace(PARAMETER_SUFFIX); + transformConfig.parameterOrAttributeGetters.push( + row => ({ + type: 'parameter', + name: parameterName, + value: fetchFieldAtIndex(row) + }) + ); + } + + // A field may be explicitly specified as a parameter + else if (header.endsWith(ATTRIBUTE_SUFFIX)) { + + // Push as an explicit attribute getter + const attributeName = header.replace(ATTRIBUTE_SUFFIX); + transformConfig.parameterOrAttributeGetters.push( + row => ({ + type: 'attribute', + name: parameterName, + value: fetchFieldAtIndex(row) + }) + ); + } + + // The field is ambiguous, either an attribute or parameter, + // so the getter will have to determine this for every row + else + transformConfig.parameterOrAttributeGetters.push(row => { + + // The name is just the value of the current header + const name = header; + + // The value is at the index that matches the position + // of the header + const value = fetchFieldAtIndex(row); + + // The protocol may determine whether a field is + // a parameter or an attribute (or both) + const protocol = transformConfig.protocolGetter(row); + + // Determine if the field refers to an attribute or a + // parameter (or both, which is an error) + const isAttribute = !!attributes[name]; + const isParameter = !!_.get( + protocolParameters, [protocol, name]); + + // If there is both an attribute and a protocol-specific + // parameter with the provided name, it's impossible to + // figure out which this should be + if (isAttribute && isParameter) + throw new ParseError({ + message: 'Ambiguous CSV Header: ' + header, + key: 'CONNECTION_IMPORT.ERROR_AMBIGUOUS_CSV_HEADER', + variables: { HEADER: header } + }); + + // It's neither an attribute or a parameter + else if (!isAttribute && !isParameter) + throw new ParseError({ + message: 'Invalid CSV Header: ' + header, + key: 'CONNECTION_IMPORT.ERROR_INVALID_CSV_HEADER', + variables: { HEADER: header } + }); + + // Choose the appropriate type + const type = isAttribute ? 'attributes' : 'parameters'; + + return { type, name, value }; + }); + }); + + // Fail if the name wasn't provided + if (!transformConfig.nameGetter) + return deferred.reject(new ParseError({ + message: 'The connection name must be provided', + key: 'CONNECTION_IMPORT.ERROR_REQUIRED_NAME' + })); + + // Fail if the protocol wasn't provided + if (!transformConfig.protocolGetter) + return deferred.reject(new ParseError({ + message: 'The connection protocol must be provided', + key: 'CONNECTION_IMPORT.ERROR_REQUIRED_PROTOCOL' + })); + + // The function to transform a CSV row into a connection object + deferred.resolve(function transformCSVRow(row) { + + const { + nameGetter, protocolGetter, + parentIdentifierGetter, groupGetter, + parameterOrAttributeGetters + } = transformConfig; + + // Set name and protocol + const name = nameGetter(row); + const protocol = protocolGetter(row); + + // Set the parent group ID and/or group path + const group = groupGetter && groupGetter(row); + const parentIdentifier = ( + parentIdentifierGetter && parentIdentifierGetter(row)); + + return { + + // Simple fields that are not protocol-specific + ...{ + name, + protocol, + parentIdentifier, + group + }, + + // Fields that might potentially be either attributes or + // parameters, depending on the protocol + ...parameterOrAttributeGetters.reduce((values, getter) => { + + // Determine the type, name, and value + const { type, name, value } = getter(row); + + // Set the value and continue on to the next attribute + // or parameter + values[type][name] = value; + return values; + + }, {parameters: {}, attributes: {}}) + + } + + }); + + }); + + return deferred.promise; }; return service; diff --git a/guacamole/src/main/frontend/src/app/import/services/connectionParseService.js b/guacamole/src/main/frontend/src/app/import/services/connectionParseService.js index 1051b800b..82b7e0c6b 100644 --- a/guacamole/src/main/frontend/src/app/import/services/connectionParseService.js +++ b/guacamole/src/main/frontend/src/app/import/services/connectionParseService.js @@ -38,15 +38,17 @@ angular.module('import').factory('connectionParseService', const $q = $injector.get('$q'); const $routeParams = $injector.get('$routeParams'); const schemaService = $injector.get('schemaService'); + const connectionCSVService = $injector.get('connectionCSVService'); const connectionGroupService = $injector.get('connectionGroupService'); const service = {}; /** * Perform basic checks, common to all file types - namely that the parsed - * data is an array, and contains at least one connection entry. + * data is an array, and contains at least one connection entry. Returns an + * error if any of these basic checks fails. * - * @throws {ParseError} + * returns {ParseError} * An error describing the parsing failure, if one of the basic checks * fails. */ @@ -54,80 +56,20 @@ angular.module('import').factory('connectionParseService', // Make sure that the file data parses to an array (connection list) if (!(parsedData instanceof Array)) - throw new ParseError({ + return new ParseError({ message: 'Import data must be a list of connections', - translatableMessage: new TranslatableMessage({ - key: 'SETTINGS_CONNECTION_IMPORT.ERROR_ARRAY_REQUIRED' - }) + key: 'CONNECTION_IMPORT.ERROR_ARRAY_REQUIRED' }); // Make sure that the connection list is not empty - contains at least // one connection if (!parsedData.length) - throw new ParseError({ - message: 'The provided CSV file is empty', - translatableMessage: new TranslatableMessage({ - key: 'SETTINGS_CONNECTION_IMPORT.ERROR_EMPTY_FILE' - }) + return new ParseError({ + message: 'The provided file is empty', + key: 'CONNECTION_IMPORT.ERROR_EMPTY_FILE' }); } - /** - * Returns a promise that resolves to an object detailing the connection - * attributes for the current data source, as well as the connection - * paremeters for every protocol, for the current data source. - * - * The object that the promise will contain an "attributes" key that maps to - * a set of attribute names, and a "protocolParameters" key that maps to an - * object mapping protocol names to sets of parameter names for that protocol. - * - * The intended use case for this object is to determine if there is a - * connection parameter or attribute with a given name, by e.g. checking the - * path `.protocolParameters[protocolName]` to see if a protocol exists, - * checking the path `.protocolParameters[protocolName][fieldName]` to see - * if a parameter exists for a given protocol, or checking the path - * `.attributes[fieldName]` to check if a connection attribute exists. - * - * @returns {Promise.} - */ - function getFieldLookups() { - - // The current data source - the one that the connections will be - // imported into - const dataSource = $routeParams.dataSource; - - // Fetch connection attributes and protocols for the current data source - return $q.all({ - attributes : schemaService.getConnectionAttributes(dataSource), - protocols : schemaService.getProtocols(dataSource) - }) - .then(function connectionStructureRetrieved({attributes, protocols}) { - - return { - - // Translate the forms and fields into a flat map of attribute - // name to `true` boolean value - attributes: attributes.reduce( - (attributeMap, form) => { - form.fields.forEach( - field => attributeMap[field.name] = true); - return attributeMap - }, {}), - - // Translate the protocol definitions into a map of protocol - // name to map of field name to `true` boolean value - protocolParameters: _.mapValues( - protocols, protocol => protocol.connectionForms.reduce( - (protocolFieldMap, form) => { - form.fields.forEach( - field => protocolFieldMap[field.name] = true); - return protocolFieldMap; - }, {})) - }; - }); - - } - /** * Returns a promise that resolves to an object mapping potential groups @@ -181,68 +123,9 @@ angular.module('import').factory('connectionParseService', return deferredGroupLookups.promise; } - -/* -// Example Connection JSON -{ - "attributes": { - "failover-only": "true", - "guacd-encryption": "none", - "guacd-hostname": "potato", - "guacd-port": "1234", - "ksm-user-config-enabled": "true", - "max-connections": "1", - "max-connections-per-user": "1", - "weight": "1" - }, - "name": "Bloatato", - "parameters": { - "audio-servername": "heyoooooooo", - "clipboard-encoding": "", - "color-depth": "", - "create-recording-path": "", - "cursor": "remote", - "dest-host": "pooootato", - "dest-port": "4444", - "disable-copy": "", - "disable-paste": "true", - "enable-audio": "true", - "enable-sftp": "true", - "force-lossless": "true", - "hostname": "potato", - "password": "taste", - "port": "4321", - "read-only": "", - "recording-exclude-mouse": "", - "recording-exclude-output": "", - "recording-include-keys": "", - "recording-name": "heyoooooo", - "recording-path": "/path/to/goo", - "sftp-disable-download": "", - "sftp-disable-upload": "", - "sftp-hostname": "what what good sir", - "sftp-port": "", - "sftp-private-key": "lol i'll never tell", - "sftp-server-alive-interval": "", - "swap-red-blue": "true", - "username": "test", - "wol-send-packet": "", - "wol-udp-port": "", - "wol-wait-time": "" - }, - - // or a numeric identifier - we will probably want to offer a way to allow - // them to specify a path like "ROOT/parent/child" or just "/parent/child" or - // something like that - // TODO: Call the - "parentIdentifier": "ROOT", - "protocol": "vnc" - -} -*/ /** - * Convert a provided JSON representation of a connection list into a JSON + * Convert a provided CSV representation of a connection list into a JSON * string to be submitted to the PATCH REST endpoint. The returned JSON * string will contain a PATCH operation to create each connection in the * provided list. @@ -259,36 +142,44 @@ angular.module('import').factory('connectionParseService', */ service.parseCSV = function parseCSV(csvData) { - const deferredConnections = $q.defer(); + // Convert to an array of arrays, one per CSV row (including the header) + // NOTE: skip_empty_lines is required, or a trailing newline will error + let parsedData; + try { + parsedData = parseCSVData(csvData, {skip_empty_lines: true}); + } - return $q.all({ - fieldLookups : getFieldLookups(), - groupLookups : getGroupLookups() - }) - .then(function lookupsReady({fieldLookups, groupLookups}) { + // If the CSV parser throws an error, reject with that error. No + // translation key will be available here. + catch(error) { + console.error(error); + const deferred = $q.defer(); + deferred.reject(error); + return deferred.promise; + } + + // Slice off the header row to get the data rows + const connectionData = parsedData.slice(1); + + // Check that the provided CSV is not empty (the parser always + // returns an array) + const checkError = performBasicChecks(connectionData); + if (checkError) { + const deferred = $q.defer(); + deferred.reject(checkError); + return deferred.promise; + } - const {attributes, protocolParameters} = fieldLookups; - - console.log({attributes, protocolParameters}, groupLookups); - - // Convert to an array of arrays, one per CSV row (including the header) - const parsedData = parseCSVData(csvData); - - // Slice off the header row to get the data rows - const connectionData = parsedData.slice(1); - - // Check that the provided CSV is not empty (the parser always - // returns an array) - performBasicChecks(connectionData); - - // The header row - an array of string header values - const header = parsedData[0]; - - // TODO: Connectionify this - deferredConnections.resolve(connectionData); - }); + // The header row - an array of string header values + const header = parsedData[0]; - return deferredConnections.promise; + return connectionCSVService.getCSVTransformer(header).then( + + // If the transformer was successfully generated, apply it to the + // data rows + // TODO: Also apply the group -> parentIdentifier transform + csvTransformer => connectionData.map(csvTransformer) + ); }; @@ -303,7 +194,7 @@ angular.module('import').factory('connectionParseService', * * @return {Promise.} * A promise resolving to an array of Connection objects, one for each - * connection in the provided CSV. + * connection in the provided YAML. */ service.parseYAML = function parseYAML(yamlData) { @@ -311,7 +202,9 @@ angular.module('import').factory('connectionParseService', const parsedData = parseYAMLData(yamlData); // Check that the data is the correct format, and not empty - performBasicChecks(parsedData); + const checkError = performBasicChecks(connectionData); + if (checkError) + return $q.defer().reject(checkError); // Convert to an array of Connection objects and return const deferredConnections = $q.defer(); @@ -332,7 +225,7 @@ angular.module('import').factory('connectionParseService', * * @return {Promise.} * A promise resolving to an array of Connection objects, one for each - * connection in the provided CSV. + * connection in the provided JSON. */ service.parseJSON = function parseJSON(jsonData) { @@ -340,7 +233,9 @@ angular.module('import').factory('connectionParseService', const parsedData = JSON.parse(yamlData); // Check that the data is the correct format, and not empty - performBasicChecks(parsedData); + const checkError = performBasicChecks(connectionData); + if (checkError) + return $q.defer().reject(checkError); // Convert to an array of Connection objects and return const deferredConnections = $q.defer(); diff --git a/guacamole/src/main/frontend/src/app/import/styles/import.css b/guacamole/src/main/frontend/src/app/import/styles/import.css new file mode 100644 index 000000000..f5551bb42 --- /dev/null +++ b/guacamole/src/main/frontend/src/app/import/styles/import.css @@ -0,0 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +.import .parseError { + color: red; +} \ No newline at end of file diff --git a/guacamole/src/main/frontend/src/app/import/templates/connectionImport.html b/guacamole/src/main/frontend/src/app/import/templates/connectionImport.html index c9c54e10e..39b12fece 100644 --- a/guacamole/src/main/frontend/src/app/import/templates/connectionImport.html +++ b/guacamole/src/main/frontend/src/app/import/templates/connectionImport.html @@ -1,11 +1,23 @@
-

{{'SETTINGS_CONNECTION_IMPORT.HEADER' | translate}}

+

{{'CONNECTION_IMPORT.HEADER' | translate}}

+ + +

+ + +

+ {{error.message}} +

+
diff --git a/guacamole/src/main/frontend/src/app/import/types/ParseError.js b/guacamole/src/main/frontend/src/app/import/types/ParseError.js index 21cd545d7..1e4ce478e 100644 --- a/guacamole/src/main/frontend/src/app/import/types/ParseError.js +++ b/guacamole/src/main/frontend/src/app/import/types/ParseError.js @@ -44,13 +44,22 @@ angular.module('import').factory('ParseError', [function defineParseError() { this.message = template.message; /** - * A message which can be translated using the translation service, - * consisting of a translation key and optional set of substitution - * variables. + * The key associated with the translation string that used when + * displaying this message. * - * @type TranslatableMessage + * @type String */ - this.translatableMessage = template.translatableMessage; + this.key = template.key; + + /** + * The object which should be passed through to the translation service + * for the sake of variable substitution. Each property of the provided + * object will be substituted for the variable of the same name within + * the translation string. + * + * @type Object + */ + this.variables = template.variables; }; diff --git a/guacamole/src/main/frontend/src/app/index/indexModule.js b/guacamole/src/main/frontend/src/app/index/indexModule.js index e0281f439..2bcc94527 100644 --- a/guacamole/src/main/frontend/src/app/index/indexModule.js +++ b/guacamole/src/main/frontend/src/app/index/indexModule.js @@ -35,6 +35,7 @@ angular.module('index', [ 'client', 'clipboard', 'home', + 'import', 'login', 'manage', 'navigation', diff --git a/guacamole/src/main/frontend/src/app/settings/templates/settingsConnections.html b/guacamole/src/main/frontend/src/app/settings/templates/settingsConnections.html index 76ebeebec..0718307a1 100644 --- a/guacamole/src/main/frontend/src/app/settings/templates/settingsConnections.html +++ b/guacamole/src/main/frontend/src/app/settings/templates/settingsConnections.html @@ -11,7 +11,7 @@ {{'SETTINGS_CONNECTIONS.ACTION_IMPORT_CONNECTIONS' | translate}} + href="#/import/{{dataSource | escape}}/connection/">{{'SETTINGS_CONNECTIONS.ACTION_IMPORT_CONNECTIONS' | translate}}