diff --git a/Duplicati/Server/webroot/ngax/scripts/controllers/CommandlineController.js b/Duplicati/Server/webroot/ngax/scripts/controllers/CommandlineController.js index 2bc18fd0c..38a38886e 100644 --- a/Duplicati/Server/webroot/ngax/scripts/controllers/CommandlineController.js +++ b/Duplicati/Server/webroot/ngax/scripts/controllers/CommandlineController.js @@ -54,11 +54,18 @@ backupApp.controller('CommandlineController', function($scope, $routeParams, $lo combined.unshift($scope.Command); - for(n in opts) { - if (opts[n] == null) + for (n in opts) { + var value = opts[n]; + if (n == 'include' || n == 'exclude') { + // Handle filters that appear multiple times + for (var i = 0; i < value.length; ++i) { + combined.push('--' + n + '=' + value[i]); + } + } else if (value == null) { combined.push(n); - else - combined.push(n + '=' + opts[n]); + } else { + combined.push(n + '=' + value); + } } options = { diff --git a/Duplicati/Server/webroot/ngax/scripts/directives/parseFilterType.js b/Duplicati/Server/webroot/ngax/scripts/directives/parseFilterType.js index 0fc22b2fc..90aee7227 100644 --- a/Duplicati/Server/webroot/ngax/scripts/directives/parseFilterType.js +++ b/Duplicati/Server/webroot/ngax/scripts/directives/parseFilterType.js @@ -3,11 +3,13 @@ backupApp.directive('parseFilterType', function(AppUtils, SystemInfo) { restrict: 'A', require: ['ngModel'], link: function(scope, element, attr, ctrl) { - var body = null; - ctrl[0].$parsers.push(function(txt) { var dirsep = SystemInfo.state.DirectorySeparator || '/'; - return AppUtils.buildFilter(txt, body, dirsep); + // Store type in scope for parseFilterContent directive + // This works because ng-repeat creates a child scope for each iteration + // It is still glitchy in some edge cases, but should be more consistent + scope.filterType = txt; + return AppUtils.buildFilter(txt, scope.filterBody, dirsep); }); ctrl[0].$formatters.push(function(src) { @@ -18,8 +20,7 @@ backupApp.directive('parseFilterType', function(AppUtils, SystemInfo) { return null; } - body = parts[1]; - + scope.filterType = parts[0]; return parts[0]; }); } @@ -31,23 +32,23 @@ backupApp.directive('parseFilterContent', function(AppUtils, SystemInfo) { restrict: 'A', require: ['ngModel'], link: function(scope, element, attr, ctrl) { - - var type = null; - ctrl[0].$parsers.push(function(txt) { var dirsep = SystemInfo.state.DirectorySeparator || '/'; - return AppUtils.buildFilter(type, txt, dirsep); + // Store type in scope for parseFilterType directive + scope.filterBody = txt; + return AppUtils.buildFilter(scope.filterType, txt, dirsep); }); ctrl[0].$formatters.push(function(src) { var dirsep = SystemInfo.state.DirectorySeparator || '/'; + // Remove double trailing slashes var parts = AppUtils.splitFilterIntoTypeAndBody(src, dirsep); if (parts == null) { type = null; return null; } - type = parts[0]; + scope.filterBody = parts[1]; return parts[1]; }); } diff --git a/Duplicati/Server/webroot/ngax/scripts/directives/restoreFilePicker.js b/Duplicati/Server/webroot/ngax/scripts/directives/restoreFilePicker.js index 3e71e8648..c5a6b026a 100644 --- a/Duplicati/Server/webroot/ngax/scripts/directives/restoreFilePicker.js +++ b/Duplicati/Server/webroot/ngax/scripts/directives/restoreFilePicker.js @@ -21,6 +21,7 @@ backupApp.directive('restoreFilePicker', function() { $scope.systeminfo = SystemInfo.watch($scope); $scope.treedata = []; + $scope.AppUtils = AppUtils; function compareablePath(path) { return scope.systeminfo.CaseSensitiveFilesystem ? path : path.toLowerCase(); diff --git a/Duplicati/Server/webroot/ngax/scripts/directives/sourceFolderPicker.js b/Duplicati/Server/webroot/ngax/scripts/directives/sourceFolderPicker.js index 27b77a6bd..afa88466c 100644 --- a/Duplicati/Server/webroot/ngax/scripts/directives/sourceFolderPicker.js +++ b/Duplicati/Server/webroot/ngax/scripts/directives/sourceFolderPicker.js @@ -178,7 +178,9 @@ backupApp.directive('sourceFolderPicker', function() { for(var i = 0; i < (scope.ngFilters || []).length; i++) { var f = AppUtils.splitFilterIntoTypeAndBody(scope.ngFilters[i], dirsep); if (f != null) { - if (f[0] == '-path') + if (f[1].indexOf('?') != -1 || f[1].indexOf('*') != -1) + anySpecials = true; + else if (f[0] == '-path') excludemap[compareablePath(f[1])] = true; else if (f[0] == '-folder') excludemap[compareablePath(f[1] + dirsep)] = true; diff --git a/Duplicati/Server/webroot/ngax/scripts/services/AppUtils.js b/Duplicati/Server/webroot/ngax/scripts/services/AppUtils.js index 0817690d2..353ed5525 100644 --- a/Duplicati/Server/webroot/ngax/scripts/services/AppUtils.js +++ b/Duplicati/Server/webroot/ngax/scripts/services/AppUtils.js @@ -138,11 +138,13 @@ backupApp.service('AppUtils', function($rootScope, $timeout, $cookies, DialogSer key: '-folder', prefix: '-', suffix: '!', + stripSep: true, rx: '\\-(.*)\\!' }, { name: gettextCatalog.getString('Exclude file'), key: '-path', prefix: '-', + stripSep: true, exclude: ['*', '?', '{'], rx: '\\-([^\\[\\{\\*\\?]+)' }, { @@ -257,12 +259,14 @@ backupApp.service('AppUtils', function($rootScope, $timeout, $cookies, DialogSer return new Date(dt); }; - this.parseOptionStrings = function(val, dict, validateCallback) { + this.parseOptionStrings = function (val, dict, validateCallback) { + // Parse options and return a dict with dict['--key']=value options + // Include and exclude filters are returned as arrays in dict['include'], dict['exclude'] if present dict = dict || {}; var lines = null; - if (val != null && typeof(val) == typeof([])) + if (val != null && Array.isArray(val)) lines = val; else lines = this.replace_all(val || '', '\r', '\n').split('\n'); @@ -287,8 +291,18 @@ backupApp.service('AppUtils', function($rootScope, $timeout, $cookies, DialogSer if (validateCallback) if (!validateCallback(dict, key, value)) return null; - - dict['--' + key] = value; + // Special handing for --include and --exclude, which are the only options that can appear multiple times (compare FilterCollector.DoExtractOptions) + // In all other cases, the last value overrides the earlier values + var lower = key.toLowerCase(); + if (lower == 'include' || lower == 'exclude') { + if (!Array.isArray(dict[lower])) { + dict[lower] = [value]; + } else { + dict[lower].push(value); + } + } else { + dict['--' + key] = value; + } } } @@ -300,6 +314,10 @@ backupApp.service('AppUtils', function($rootScope, $timeout, $cookies, DialogSer if (d['--' + k] !== undefined) { DialogService.dialog(gettextCatalog.getString('Error'), gettextCatalog.getString('Duplicate option {{opt}}', { opt: k })); return false; + } else if (k == 'include' || k == 'exclude') { + // Cannot specify filters in extra options + DialogService.dialog(gettextCatalog.getString('Error'), gettextCatalog.getString('Cannot specify filter include or excludes in extra options')); + return false; } return true; @@ -422,6 +440,13 @@ backupApp.service('AppUtils', function($rootScope, $timeout, $cookies, DialogSer return str.replace( new RegExp( "(" + this.preg_quote(pattern) + ")" , 'g' ), replacement ); }; + this.globToRegexp = function (str) { + // Escape special chars, except ? and * + str = (str + '').replace(/([\\\.\+\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, "\\$1"); + // Replace ? and * with .? and .* + return str.replace(/(\?|\*)/g, ".$1"); + }; + this.format = function() { if (arguments == null || arguments.length < 1) return null; @@ -568,13 +593,25 @@ backupApp.service('AppUtils', function($rootScope, $timeout, $cookies, DialogSer return [type, body]; } - for(var i in this.filterClasses) { + var m = []; + for (var i in this.filterClasses) { var n = matches(src, this.filterClasses[i]); if (n != null) - return n; + m.push(n); } - - return null; + if (m.length == 0) { + return null; + } + // Select match with shortest body, meaning longest prefix and suffix + var shortestIdx = 0; + var shortestLen = src.length; + for (i = 0; i < m.length; ++i) { + if (m[i][1].length < shortestLen) { + shortestIdx = i; + shortestLen = m[i][1].length; + } + } + return m[shortestIdx]; }; this.buildFilter = function(type, body, dirsep) { @@ -590,7 +627,10 @@ backupApp.service('AppUtils', function($rootScope, $timeout, $cookies, DialogSer if (pre.length >= 2 && pre[1] == '[') { //Regexp encode body .... } - + if (f.stripSep == true && body[body.length - 1] == dirsep) { + // Remove trailing dirsep + body = body.slice(0, -1); + } return pre + body + suf; }; @@ -609,7 +649,7 @@ backupApp.service('AppUtils', function($rootScope, $timeout, $cookies, DialogSer if (rx) filter = filter.substr(1, filter.length - 2); else - filter = this.replace_all(this.replace_all(this.preg_quote(filter), '*', '.*'), '?', '.'); + filter = this.globToRegexp(filter); try { res.push([flag == '+', new RegExp(filter, caseSensitive ? 'g' : 'gi')]); @@ -621,9 +661,11 @@ backupApp.service('AppUtils', function($rootScope, $timeout, $cookies, DialogSer }; this.evalFilter = function(path, filters, include) { - for(var i = 0; i < filters.length; i++) { + for (var i = 0; i < filters.length; i++) { var m = path.match(filters[i][1]); - if (m && m.length == 1 && m[0].length == path.length) + // Regex such as .* might match empty string at the end which is unwanted + // Check that the first match covers the full string + if (m && m.length >= 1 && m[0].length == path.length) return filters[i][0]; } diff --git a/Duplicati/Server/webroot/ngax/templates/restorefilepicker.html b/Duplicati/Server/webroot/ngax/templates/restorefilepicker.html index d23d0466e..2141bfe06 100644 --- a/Duplicati/Server/webroot/ngax/templates/restorefilepicker.html +++ b/Duplicati/Server/webroot/ngax/templates/restorefilepicker.html @@ -5,7 +5,7 @@ - +