diff --git a/CliClient/tests/pathUtils.js b/CliClient/tests/pathUtils.js index 9114bf1fe..bd38729ef 100644 --- a/CliClient/tests/pathUtils.js +++ b/CliClient/tests/pathUtils.js @@ -1,6 +1,6 @@ require('app-module-path').addPath(__dirname); -const { friendlySafeFilename } = require('lib/path-utils.js'); +const { extractExecutablePath, quotePath, unquotePath, friendlySafeFilename } = require('lib/path-utils.js'); const { fileContentEqual, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js'); process.on('unhandledRejection', (reason, p) => { @@ -36,4 +36,41 @@ describe('pathUtils', function() { done(); }); + it('should quote and unquote paths', async (done) => { + const testCases = [ + ['', ''], + ['/my/path', '/my/path'], + ['/my/path with spaces', '"/my/path with spaces"'], + ['/my/weird"path', '"/my/weird\\"path"'], + ['c:\\Windows\\test.dll', 'c:\\Windows\\test.dll'], + ['c:\\Windows\\test test.dll', '"c:\\Windows\\test test.dll"'], + ]; + + for (let i = 0; i < testCases.length; i++) { + const t = testCases[i]; + expect(quotePath(t[0])).toBe(t[1]); + expect(unquotePath(quotePath(t[0]))).toBe(t[0]); + } + + done(); + }); + + it('should extract executable path from command', async (done) => { + const testCases = [ + ['', ''], + ['/my/cmd -some -args', '/my/cmd'], + ['"/my/cmd" -some -args', '"/my/cmd"'], + ['"/my/cmd"', '"/my/cmd"'], + ['"/my/cmd and space" -some -flags', '"/my/cmd and space"'], + ['"" -some -flags', '""'], + ]; + + for (let i = 0; i < testCases.length; i++) { + const t = testCases[i]; + expect(extractExecutablePath(t[0])).toBe(t[1]); + } + + done(); + }); + }); \ No newline at end of file diff --git a/ElectronClient/app/gui/ConfigScreen.jsx b/ElectronClient/app/gui/ConfigScreen.jsx index 8cbe53c7a..be2a6d93b 100644 --- a/ElectronClient/app/gui/ConfigScreen.jsx +++ b/ElectronClient/app/gui/ConfigScreen.jsx @@ -7,6 +7,7 @@ const { Header } = require('./Header.min.js'); const { themeStyle } = require('../theme.js'); const pathUtils = require('lib/path-utils.js'); const { _ } = require('lib/locale.js'); +const { commandArgumentsToString } = require('lib/string-utils'); const SyncTargetRegistry = require('lib/SyncTargetRegistry'); const shared = require('lib/components/shared/config-shared.js'); @@ -95,6 +96,15 @@ class ConfigScreenComponent extends React.Component { color: theme.color, }); + const subLabel = Object.assign({}, labelStyle, { + opacity: 0.7, + marginBottom: Math.round(rowStyle.marginBottom * 0.7), + }); + + const invisibleLabel = Object.assign({}, labelStyle, { + opacity: 0, + }); + const controlStyle = { display: 'inline-block', color: theme.color, @@ -109,6 +119,7 @@ class ConfigScreenComponent extends React.Component { }); const updateSettingValue = (key, value) => { + // console.info(key + ' = ' + value); return shared.updateSettingValue(this, key, value); } @@ -158,23 +169,93 @@ class ConfigScreenComponent extends React.Component { ); } else if (md.type === Setting.TYPE_STRING) { - const onTextChange = (event) => { - updateSettingValue(key, event.target.value); - } - const inputStyle = Object.assign({}, controlStyle, { width: '50%', minWidth: '20em', border: '1px solid' }); const inputType = md.secure === true ? 'password' : 'text'; - return ( -
-
- {onTextChange(event)}} /> - { descriptionComp } -
- ); + if (md.subType === 'file_path_and_args') { + inputStyle.marginBottom = subLabel.marginBottom; + + const splitCmd = cmdString => { + const path = pathUtils.extractExecutablePath(cmdString); + const args = cmdString.substr(path.length + 1); + return [pathUtils.unquotePath(path), args]; + } + + const joinCmd = cmdArray => { + if (!cmdArray[0] && !cmdArray[1]) return ''; + let cmdString = pathUtils.quotePath(cmdArray[0]); + if (!cmdString) cmdString = '""'; + if (cmdArray[1]) cmdString += ' ' + cmdArray[1]; + return cmdString; + } + + const onPathChange = event => { + const cmd = splitCmd(this.state.settings[key]); + cmd[0] = event.target.value; + updateSettingValue(key, joinCmd(cmd)); + } + + const onArgsChange = event => { + const cmd = splitCmd(this.state.settings[key]); + cmd[1] = event.target.value; + updateSettingValue(key, joinCmd(cmd)); + } + + const browseButtonClick = () => { + const paths = bridge().showOpenDialog(); + if (!paths || !paths.length) return; + const cmd = splitCmd(this.state.settings[key]); + cmd[0] = paths[0] + updateSettingValue(key, joinCmd(cmd)); + } + + const cmd = splitCmd(this.state.settings[key]); + + return ( +
+
+
+
+
+
+
Path:
+
Arguments:
+
+
+
+ {onPathChange(event)}} value={cmd[0]} /> + +
+ {onArgsChange(event)}} value={cmd[1]}/> +
+
+ +
+
+
+
+
+ { descriptionComp } +
+
+
+ ); + } else { + const onTextChange = (event) => { + updateSettingValue(key, event.target.value); + } + + return ( +
+
+ {onTextChange(event)}} /> + { descriptionComp } +
+ ); + } } else if (md.type === Setting.TYPE_INT) { const onNumChange = (event) => { updateSettingValue(key, event.target.value); diff --git a/ReactNativeClient/lib/models/Setting.js b/ReactNativeClient/lib/models/Setting.js index bde6651c5..bf353dd3f 100644 --- a/ReactNativeClient/lib/models/Setting.js +++ b/ReactNativeClient/lib/models/Setting.js @@ -122,7 +122,7 @@ class Setting extends BaseModel { 'sidebarVisibility': { value: true, type: Setting.TYPE_BOOL, public: false, appTypes: ['desktop'] }, 'tagHeaderIsExpanded': { value: true, type: Setting.TYPE_BOOL, public: false, appTypes: ['desktop'] }, 'folderHeaderIsExpanded': { value: true, type: Setting.TYPE_BOOL, public: false, appTypes: ['desktop'] }, - 'editor': { value: '', type: Setting.TYPE_STRING, public: true, appTypes: ['cli', 'desktop'], label: () => _('Text editor command'), description: () => _('The editor command (may include arguments) that will be used to open a note. If none is provided it will try to auto-detect the default editor.') }, + 'editor': { value: '', type: Setting.TYPE_STRING, subType: 'file_path_and_args', public: true, appTypes: ['cli', 'desktop'], label: () => _('Text editor command'), description: () => _('The editor command (may include arguments) that will be used to open a note. If none is provided it will try to auto-detect the default editor.') }, 'showAdvancedOptions': { value: false, type: Setting.TYPE_BOOL, public: true, appTypes: ['mobile' ], label: () => _('Show advanced options') }, 'sync.target': { value: SyncTargetRegistry.nameToId('dropbox'), type: Setting.TYPE_INT, isEnum: true, public: true, section:'sync', label: () => _('Synchronisation target'), description: (appType) => { return appType !== 'cli' ? null : _('The target to synchonise to. Each sync target may have additional parameters which are named as `sync.NUM.NAME` (all documented below).') }, options: () => { return SyncTargetRegistry.idAndLabelPlainObject(); diff --git a/ReactNativeClient/lib/path-utils.js b/ReactNativeClient/lib/path-utils.js index 7bc0646f7..bbb4903b3 100644 --- a/ReactNativeClient/lib/path-utils.js +++ b/ReactNativeClient/lib/path-utils.js @@ -114,4 +114,43 @@ function ltrimSlashes(path) { return path.replace(/^\/+/, ''); } -module.exports = { basename, dirname, filename, isHidden, fileExtension, safeFilename, friendlySafeFilename, safeFileExtension, toSystemSlashes, rtrimSlashes, ltrimSlashes }; \ No newline at end of file +function quotePath(path) { + if (!path) return ''; + if (path.indexOf('"') < 0 && path.indexOf(' ') < 0) return path; + path = path.replace(/"/, '\\"'); + return '"' + path + '"'; +} + +function unquotePath(path) { + if (!path.length) return ''; + if (path.length && path[0] === '"') { + path = path.substr(1, path.length - 2); + } + path = path.replace(/\\"/, '"'); + return path; +} + +function extractExecutablePath(cmd) { + if (!cmd.length) return ''; + + const quoteType = ['"', "'"].indexOf(cmd[0]) >= 0 ? cmd[0] : ''; + + let output = ''; + for (let i = 0; i < cmd.length; i++) { + const c = cmd[i]; + if (quoteType) { + if (i > 0 && c === quoteType) { + output += c; + break; + } + } else { + if (c === ' ') break; + } + + output += c; + } + + return output; +} + +module.exports = { extractExecutablePath, basename, dirname, filename, isHidden, fileExtension, safeFilename, friendlySafeFilename, safeFileExtension, toSystemSlashes, rtrimSlashes, ltrimSlashes, quotePath, unquotePath }; \ No newline at end of file diff --git a/ReactNativeClient/lib/string-utils.js b/ReactNativeClient/lib/string-utils.js index 099e7f4ea..1488626ab 100644 --- a/ReactNativeClient/lib/string-utils.js +++ b/ReactNativeClient/lib/string-utils.js @@ -123,6 +123,19 @@ function wrap(text, indent, width) { }); } +function commandArgumentsToString(args) { + let output = []; + for (let i = 0; i < args.length; i++) { + let arg = args[i]; + const quote = arg.indexOf('"') >= 0 ? "'" : '"'; + if (arg.indexOf(' ') >= 0) { + arg = quote + arg + quote; + } + output.push(arg); + } + return output.join(' ');; +} + function splitCommandString(command, options = null) { options = options || {}; if (!('handleEscape' in options)) { @@ -252,6 +265,6 @@ function scriptType(s) { } module.exports = Object.assign( - { removeDiacritics, escapeFilename, wrap, splitCommandString, padLeft, toTitleCase, urlDecode, escapeHtml, surroundKeywords, scriptType }, + { removeDiacritics, escapeFilename, wrap, splitCommandString, padLeft, toTitleCase, urlDecode, escapeHtml, surroundKeywords, scriptType, commandArgumentsToString }, stringUtilsCommon, );