From 21d04b895a7b3ee0803e36e4f0b3f5e8e6cf1379 Mon Sep 17 00:00:00 2001 From: Simon Frei Date: Thu, 13 Jan 2022 23:38:21 +0100 Subject: [PATCH] lib, gui: Default ignores for new folders (fixes #7428) (#7530) --- cmd/syncthing/cli/client.go | 29 +- cmd/syncthing/cli/operations.go | 36 ++ .../syncthing/core/syncthingController.js | 315 ++++++++++++------ .../syncthing/core/uniqueFolderDirective.js | 2 +- .../syncthing/core/validDeviceidDirective.js | 2 +- .../syncthing/device/editDeviceModalView.html | 12 +- .../syncthing/folder/editFolderModalView.html | 97 +++--- lib/api/api.go | 1 + lib/api/confighandler.go | 22 ++ lib/config/config.go | 6 + lib/config/config.pb.go | 308 ++++++++++++++--- lib/config/config_test.go | 3 + lib/config/mocks/mocked_wrapper.go | 65 ++++ lib/config/wrapper.go | 8 + lib/model/model.go | 15 +- lib/model/model_test.go | 6 +- proto/lib/config/config.proto | 9 +- 17 files changed, 718 insertions(+), 218 deletions(-) diff --git a/cmd/syncthing/cli/client.go b/cmd/syncthing/cli/client.go index 539192b3e..75923b421 100644 --- a/cmd/syncthing/cli/client.go +++ b/cmd/syncthing/cli/client.go @@ -10,8 +10,10 @@ import ( "bytes" "context" "crypto/tls" + "encoding/json" "errors" "fmt" + "io" "net" "net/http" "strings" @@ -25,6 +27,7 @@ import ( type APIClient interface { Get(url string) (*http.Response, error) Post(url, body string) (*http.Response, error) + PutJSON(url string, o interface{}) (*http.Response, error) } type apiClient struct { @@ -118,20 +121,36 @@ func (c *apiClient) Do(req *http.Request) (*http.Response, error) { return resp, checkResponse(resp) } -func (c *apiClient) Get(url string) (*http.Response, error) { - request, err := http.NewRequest("GET", c.Endpoint()+"rest/"+url, nil) +func (c *apiClient) Request(url, method string, r io.Reader) (*http.Response, error) { + request, err := http.NewRequest(method, c.Endpoint()+"rest/"+url, r) if err != nil { return nil, err } return c.Do(request) } -func (c *apiClient) Post(url, body string) (*http.Response, error) { - request, err := http.NewRequest("POST", c.Endpoint()+"rest/"+url, bytes.NewBufferString(body)) +func (c *apiClient) RequestString(url, method, data string) (*http.Response, error) { + return c.Request(url, method, bytes.NewBufferString(data)) +} + +func (c *apiClient) RequestJSON(url, method string, o interface{}) (*http.Response, error) { + data, err := json.Marshal(o) if err != nil { return nil, err } - return c.Do(request) + return c.Request(url, method, bytes.NewBuffer(data)) +} + +func (c *apiClient) Get(url string) (*http.Response, error) { + return c.RequestString(url, "GET", "") +} + +func (c *apiClient) Post(url, body string) (*http.Response, error) { + return c.RequestString(url, "POST", body) +} + +func (c *apiClient) PutJSON(url string, o interface{}) (*http.Response, error) { + return c.RequestJSON(url, "PUT", o) } var errNotFound = errors.New("invalid endpoint or API call") diff --git a/cmd/syncthing/cli/operations.go b/cmd/syncthing/cli/operations.go index 347a99005..bef49cc92 100644 --- a/cmd/syncthing/cli/operations.go +++ b/cmd/syncthing/cli/operations.go @@ -7,8 +7,12 @@ package cli import ( + "bufio" "fmt" + "path/filepath" + "github.com/syncthing/syncthing/lib/config" + "github.com/syncthing/syncthing/lib/fs" "github.com/urfave/cli" ) @@ -38,6 +42,12 @@ var operationCommand = cli.Command{ ArgsUsage: "[folder id]", Action: expects(1, foldersOverride), }, + { + Name: "default-ignores", + Usage: "Set the default ignores (config) from a file", + ArgsUsage: "path", + Action: expects(1, setDefaultIgnores), + }, }, } @@ -74,3 +84,29 @@ func foldersOverride(c *cli.Context) error { } return fmt.Errorf("Folder " + rid + " not found") } + +func setDefaultIgnores(c *cli.Context) error { + client, err := getClientFactory(c).getClient() + if err != nil { + return err + } + dir, file := filepath.Split(c.Args()[0]) + filesystem := fs.NewFilesystem(fs.FilesystemTypeBasic, dir) + + fd, err := filesystem.Open(file) + if err != nil { + return err + } + scanner := bufio.NewScanner(fd) + var lines []string + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + fd.Close() + if err := scanner.Err(); err != nil { + return err + } + + _, err = client.PutJSON("config/defaults/ignores", config.Ignores{Lines: lines}) + return err +} diff --git a/gui/default/syncthing/core/syncthingController.js b/gui/default/syncthing/core/syncthingController.js index 692007351..1e6e20ef0 100755 --- a/gui/default/syncthing/core/syncthingController.js +++ b/gui/default/syncthing/core/syncthingController.js @@ -58,6 +58,9 @@ angular.module('syncthing.core') text: '', error: null, disabled: false, + originalLines: [], + defaultLines: [], + saved: false, }; resetRemoteNeed(); @@ -409,8 +412,14 @@ angular.module('syncthing.core') console.log("FolderScanProgress", data); }); + // May be called through .error with the presented arguments, or through + // .catch with the http response object containing the same arguments. $scope.emitHTTPError = function (data, status, headers, config) { - $scope.$emit('HTTPError', { data: data, status: status, headers: headers, config: config }); + var out = data; + if (data && !data.data) { + out = { data: data, status: status, headers: headers, config: config }; + } + $scope.$emit('HTTPError', out); }; var debouncedFuncs = {}; @@ -741,7 +750,7 @@ angular.module('syncthing.core') } function shouldSetDefaultFolderPath() { - return $scope.config.defaults.folder.path && !$scope.editingExisting && $scope.folderEditor.folderPath.$pristine && !$scope.editingDefaults; + return $scope.config.defaults.folder.path && $scope.folderEditor.folderPath.$pristine && $scope.currentFolder._editing == "add"; } function resetRemoteNeed() { @@ -750,7 +759,6 @@ angular.module('syncthing.core') $scope.remoteNeedDevice = undefined; } - function setDefaultTheme() { if (!document.getElementById("fallback-theme-css")) { @@ -767,13 +775,9 @@ angular.module('syncthing.core') } } - function saveIgnores(ignores, cb) { - $http.post(urlbase + '/db/ignores?folder=' + encodeURIComponent($scope.currentFolder.id), { + function saveIgnores(ignores) { + return $http.post(urlbase + '/db/ignores?folder=' + encodeURIComponent($scope.currentFolder.id), { ignore: ignores - }).success(function () { - if (cb) { - cb(); - } }); }; @@ -1268,8 +1272,9 @@ angular.module('syncthing.core') if (cfg) { cfg.paused = pause; $scope.config.folders = folderList($scope.folders); - $scope.saveConfig(); + return $scope.saveConfig(); } + return $q.when(); }; $scope.showListenerStatus = function () { @@ -1421,18 +1426,14 @@ angular.module('syncthing.core') }); }; - $scope.saveConfig = function (callback) { + $scope.saveConfig = function () { var cfg = JSON.stringify($scope.config); var opts = { headers: { 'Content-Type': 'application/json' } }; - $http.put(urlbase + '/config', cfg, opts).finally(refreshConfig).then(function() { - if (callback) { - callback(); - } - }, $scope.emitHTTPError); + return $http.put(urlbase + '/config', cfg, opts).finally(refreshConfig).catch($scope.emitHTTPError); }; $scope.urVersions = function () { @@ -1512,7 +1513,7 @@ angular.module('syncthing.core') // here as well... $scope.devices = deviceMap($scope.config.devices); - $scope.saveConfig(function () { + $scope.saveConfig.then(function () { if (themeChanged) { document.location.reload(true); } @@ -1578,11 +1579,11 @@ angular.module('syncthing.core') } $scope.editDeviceModalTitle = function() { - if ($scope.editingDefaults) { + if ($scope.editingDeviceDefaults()) { return $translate.instant("Edit Device Defaults"); } var title = ''; - if ($scope.editingExisting) { + if ($scope.editingDeviceExisting()) { title += $translate.instant("Edit Device"); } else { title += $translate.instant("Add Device"); @@ -1595,16 +1596,23 @@ angular.module('syncthing.core') }; $scope.editDeviceModalIcon = function() { - if ($scope.editingDefaults || $scope.editingExisting) { + if ($scope.has(["existing", "defaults"], $scope.currentDevice._editing)) { return 'fas fa-pencil-alt'; } return 'fas fa-desktop'; }; + $scope.editingDeviceDefaults = function() { + return $scope.currentDevice._editing == 'defaults'; + } + + $scope.editingDeviceExisting = function() { + return $scope.currentDevice._editing == 'existing'; + } + $scope.editDeviceExisting = function (deviceCfg) { $scope.currentDevice = $.extend({}, deviceCfg); - $scope.editingExisting = true; - $scope.editingDefaults = false; + $scope.currentDevice._editing = "existing"; $scope.willBeReintroducedBy = undefined; if (deviceCfg.introducedBy) { var introducerDevice = $scope.devices[deviceCfg.introducedBy]; @@ -1633,7 +1641,7 @@ angular.module('syncthing.core') $scope.editDeviceDefaults = function () { $http.get(urlbase + '/config/defaults/device').then(function (p) { $scope.currentDevice = p.data; - $scope.editingDefaults = true; + $scope.currentDevice._editing = "defaults"; editDeviceModal(); }, $scope.emitHTTPError); }; @@ -1671,8 +1679,7 @@ angular.module('syncthing.core') $scope.currentDevice = p.data; $scope.currentDevice.name = name; $scope.currentDevice.deviceID = deviceID; - $scope.editingExisting = false; - $scope.editingDefaults = false; + $scope.currentDevice._editing = "add"; initShareEditing('device'); $scope.currentSharing.unrelated = $scope.folderList(); editDeviceModal(); @@ -1682,7 +1689,7 @@ angular.module('syncthing.core') $scope.deleteDevice = function () { $('#editDevice').modal('hide'); - if (!$scope.editingExisting) { + if ($scope.currentDevice._editing != "existing") { return; } @@ -1705,13 +1712,13 @@ angular.module('syncthing.core') return x.trim(); }); delete $scope.currentDevice._addressesStr; - if ($scope.editingDefaults) { + if ($scope.currentDevice._editing == "defaults") { $scope.config.defaults.device = $scope.currentDevice; } else { setDeviceConfig(); } delete $scope.currentSharing; - delete $scope.currentDevice; + $scope.currentDevice = {}; $scope.saveConfig(); }; @@ -1955,20 +1962,34 @@ angular.module('syncthing.core') $('#folder-ignores textarea').focus(); } }).one('hidden.bs.modal', function () { - window.location.hash = ""; - $scope.currentFolder = {}; + var p = $q.when(); + // If the modal was closed default patterns should still apply + if ($scope.currentFolder._editing == "add-ignores" && !$scope.ignores.saved && $scope.ignores.defaultLines) { + p = saveFolderAddIgnores($scope.currentFolder.id, true); + } + p.then(function () { + window.location.hash = ""; + $scope.currentFolder = {}; + $scope.ignores = {}; + }); }); }; $scope.editFolderModalTitle = function() { - if ($scope.editingDefaults) { + if ($scope.editingFolderDefaults()) { return $translate.instant("Edit Folder Defaults"); } var title = ''; - if ($scope.editingExisting) { - title += $translate.instant("Edit Folder"); - } else { - title += $translate.instant("Add Folder"); + switch ($scope.currentFolder._editing) { + case "existing": + title = $translate.instant("Edit Folder"); + break; + case "add": + title = $translate.instant("Add Folder"); + break; + case "add-ignores": + title = $translate.instant("Set Ignores on Added Folder"); + break; } if ($scope.currentFolder.id !== '') { title += ' (' + $scope.folderLabel($scope.currentFolder.id) + ')'; @@ -1977,12 +1998,20 @@ angular.module('syncthing.core') }; $scope.editFolderModalIcon = function() { - if ($scope.editingDefaults || $scope.editingExisting) { + if ($scope.has(["existing", "defaults"], $scope.currentFolder._editing)) { return 'fas fa-pencil-alt'; } return 'fas fa-folder'; }; + $scope.editingFolderDefaults = function() { + return $scope.currentFolder._editing == 'defaults'; + } + + $scope.editingFolderExisting = function() { + return $scope.currentFolder._editing == 'existing'; + } + function editFolder(initialTab) { if ($scope.currentFolder.path.length > 1 && $scope.currentFolder.path.slice(-1) === $scope.system.pathSeparator) { $scope.currentFolder.path = $scope.currentFolder.path.slice(0, -1); @@ -2033,39 +2062,60 @@ angular.module('syncthing.core') }; $scope.editFolderExisting = function(folderCfg, initialTab) { - $scope.editingExisting = true; - $scope.editingDefaults = false; $scope.currentFolder = angular.copy(folderCfg); - - $scope.ignores.text = 'Loading...'; - $scope.ignores.error = null; - $scope.ignores.disabled = true; - $http.get(urlbase + '/db/ignores?folder=' + encodeURIComponent($scope.currentFolder.id)) - .success(function (data) { - $scope.currentFolder.ignores = data.ignore || []; - $scope.ignores.text = $scope.currentFolder.ignores.join('\n'); - $scope.ignores.error = data.error; - $scope.ignores.disabled = false; - }) - .error(function (err) { - $scope.ignores.text = $translate.instant("Failed to load ignore patterns."); - $scope.emitHTTPError(err); - }); - + $scope.currentFolder._editing = "existing"; + editFolderLoadIgnores(); editFolder(initialTab); }; - $scope.editFolderDefaults = function() { - $http.get(urlbase + '/config/defaults/folder') - .success(function (data) { - $scope.currentFolder = data; - $scope.editingExisting = false; - $scope.editingDefaults = true; - editFolder(); - }) - .error($scope.emitHTTPError); + function editFolderLoadingIgnores() { + $scope.ignores.text = 'Loading...'; + $scope.ignores.error = null; + $scope.ignores.disabled = true; + } + + function editFolderGetIgnores() { + return $http.get(urlbase + '/db/ignores?folder=' + encodeURIComponent($scope.currentFolder.id)) + .then(function(r) { + return r.data; + }, function (response) { + $scope.ignores.text = $translate.instant("Failed to load ignore patterns."); + return $q.reject(response); + }); }; + function editFolderLoadIgnores() { + editFolderLoadingIgnores(); + return editFolderGetIgnores().then(editFolderInitIgnores, $scope.emitHTTPError); + } + + $scope.editFolderDefaults = function() { + $q.all([ + $http.get(urlbase + '/config/defaults/folder').then(function (response) { + $scope.currentFolder = response.data; + $scope.currentFolder._editing = "defaults"; + }), + getDefaultIgnores().then(editFolderInitIgnores), + ]).then(editFolder, $scope.emitHTTPError); + }; + + function getDefaultIgnores() { + return $http.get(urlbase + '/config/defaults/ignores').then(function (r) { + return r.data.lines; + }); + } + + function editFolderInitIgnores(data) { + $scope.ignores.originalLines = data.ignore || []; + setIgnoresText(data.ignore); + $scope.ignores.error = data.error; + $scope.ignores.disabled = false; + } + + function setIgnoresText(lines) { + $scope.ignores.text = lines ? lines.join('\n') : ""; + } + $scope.selectAllSharedDevices = function (state) { var devices = $scope.currentSharing.shared; for (var i = 0; i < devices.length; i++) { @@ -2093,9 +2143,6 @@ angular.module('syncthing.core') $scope.addFolderAndShare = function (folderID, pendingFolder, device) { addFolderInit(folderID).then(function() { - $scope.currentFolder.viewFlags = { - importFromOtherDevice: true - }; $scope.currentSharing.selected[device] = true; $scope.currentFolder.label = pendingFolder.offeredBy[device].label; for (var k in pendingFolder.offeredBy) { @@ -2110,19 +2157,16 @@ angular.module('syncthing.core') }; function addFolderInit(folderID) { - $scope.editingExisting = false; - $scope.editingDefaults = false; - return $http.get(urlbase + '/config/defaults/folder').then(function(p) { - $scope.currentFolder = p.data; + return $http.get(urlbase + '/config/defaults/folder').then(function (response) { + $scope.currentFolder = response.data; + $scope.currentFolder._editing = "add"; $scope.currentFolder.id = folderID; - initShareEditing('folder'); $scope.currentSharing.unrelated = $scope.currentSharing.unrelated.concat($scope.currentSharing.shared); $scope.currentSharing.shared = []; - - $scope.ignores.text = ''; - $scope.ignores.error = null; - $scope.ignores.disabled = false; + // Ignores don't need to be initialized here, as that happens in + // a second step if the user indicates in the creation modal + // that they want to set ignores }, $scope.emitHTTPError); } @@ -2142,7 +2186,14 @@ angular.module('syncthing.core') }; $scope.saveFolder = function () { - $('#editFolder').modal('hide'); + if ($scope.currentFolder._editing == "add-ignores") { + // On modal being hidden without clicking save, the defaults will be saved. + $scope.ignores.saved = true; + saveFolderAddIgnores($scope.currentFolder.id); + hideFolderModal(); + return; + } + var folderCfg = angular.copy($scope.currentFolder); $scope.currentSharing.selected[$scope.myID] = true; var newDevices = []; @@ -2191,44 +2242,88 @@ angular.module('syncthing.core') } delete folderCfg._guiVersioning; - if ($scope.editingDefaults) { + if ($scope.currentFolder._editing == "defaults") { + hideFolderModal(); + $scope.config.defaults.ignores.lines = ignoresArray(); $scope.config.defaults.folder = folderCfg; $scope.saveConfig(); - } else { - saveFolderExisting(folderCfg); + return; } + + // This is a new folder where ignores should apply before it first starts. + if ($scope.currentFolder._addIgnores) { + folderCfg.paused = true; + } + $scope.folders[folderCfg.id] = folderCfg; + $scope.config.folders = folderList($scope.folders); + + if ($scope.currentFolder._editing == "existing") { + hideFolderModal(); + saveFolderIgnoresExisting(); + $scope.saveConfig(); + return; + } + + // No ignores to be set on the new folder, save directly. + if (!$scope.currentFolder._addIgnores) { + hideFolderModal(); + $scope.saveConfig(); + return; + } + + // Add folder (paused), load existing ignores and if there are none, + // load default ignores, then let the user edit them. + $scope.saveConfig().then(function() { + editFolderLoadingIgnores(); + $scope.currentFolder._editing = "add-ignores"; + $('.nav-tabs a[href="#folder-ignores"]').tab('show'); + return editFolderGetIgnores(); + }).then(function(data) { + // Error getting ignores -> leave error message. + if (!data) { + return; + } + if ((data.ignore && data.ignore.length > 0) || data.error) { + editFolderInitIgnores(data); + } else { + getDefaultIgnores().then(function(lines) { + setIgnoresText(lines); + $scope.ignores.defaultLines = lines; + $scope.ignores.disabled = false; + }); + } + }, $scope.emitHTTPError); }; - function saveFolderExisting(folderCfg) { - var ignoresLoaded = !$scope.ignores.disabled; + function saveFolderIgnoresExisting() { + if ($scope.ignores.disabled) { + return; + } + var ignores = ignoresArray(); + + function arrayDiffers(a, b) { + return !a !== !b || a.length !== b.length || a.some(function(v, i) { return v !== b[i]; }); + } + if (arrayDiffers(ignores, $scope.ignores.originalLines)) { + return saveIgnores(ignores); + }; + } + + function saveFolderAddIgnores(folderID, useDefault) { + var ignores = useDefault ? $scope.ignores.defaultLines : ignoresArray(); + return saveIgnores(ignores).then(function () { + return $scope.setFolderPause(folderID, $scope.currentFolder.paused); + }); + }; + + function ignoresArray() { var ignores = $scope.ignores.text.split('\n'); // Split always returns a minimum 1-length array even for no patterns if (ignores.length === 1 && ignores[0] === "") { ignores = []; } - if (!$scope.editingExisting && ignores.length) { - folderCfg.paused = true; - }; - - $scope.folders[folderCfg.id] = folderCfg; - $scope.config.folders = folderList($scope.folders); - - function arrayEquals(a, b) { - return a.length === b.length && a.every(function(v, i) { return v === b[i] }); - } - - if (ignoresLoaded && $scope.editingExisting && !arrayEquals(ignores, folderCfg.ignores)) { - saveIgnores(ignores); - }; - - $scope.saveConfig(function () { - if (!$scope.editingExisting && ignores.length) { - saveIgnores(ignores, function () { - $scope.setFolderPause(folderCfg.id, false); - }); - } - }); - }; + return ignores; + } $scope.ignoreFolder = function (device, folderID, offeringDevice) { var ignoredFolder = { @@ -2282,8 +2377,8 @@ angular.module('syncthing.core') }; $scope.deleteFolder = function (id) { - $('#editFolder').modal('hide'); - if (!$scope.editingExisting) { + hideFolderModal(); + if ($scope.currentFolder._editing != "existing") { return; } @@ -2295,6 +2390,10 @@ angular.module('syncthing.core') $scope.saveConfig(); }; + function hideFolderModal() { + $('#editFolder').modal('hide'); + } + function resetRestoreVersions() { $scope.restoreVersions = { folder: null, @@ -2839,6 +2938,10 @@ angular.module('syncthing.core') return Object.keys(dict).length; }; + $scope.has = function (array, element) { + return array.indexOf(element) >= 0; + }; + $scope.dismissNotification = function (id) { var idx = $scope.config.options.unackedNotificationIDs.indexOf(id); if (idx > -1) { diff --git a/gui/default/syncthing/core/uniqueFolderDirective.js b/gui/default/syncthing/core/uniqueFolderDirective.js index 18ae56140..240bd3707 100644 --- a/gui/default/syncthing/core/uniqueFolderDirective.js +++ b/gui/default/syncthing/core/uniqueFolderDirective.js @@ -4,7 +4,7 @@ angular.module('syncthing.core') require: 'ngModel', link: function (scope, elm, attrs, ctrl) { ctrl.$parsers.unshift(function (viewValue) { - if (scope.editingExisting) { + if (scope.currentFolder._editing != "add") { // we shouldn't validate ctrl.$setValidity('uniqueFolder', true); } else if (scope.folders.hasOwnProperty(viewValue)) { diff --git a/gui/default/syncthing/core/validDeviceidDirective.js b/gui/default/syncthing/core/validDeviceidDirective.js index ca44ccc45..8c0456488 100644 --- a/gui/default/syncthing/core/validDeviceidDirective.js +++ b/gui/default/syncthing/core/validDeviceidDirective.js @@ -4,7 +4,7 @@ angular.module('syncthing.core') require: 'ngModel', link: function (scope, elm, attrs, ctrl) { ctrl.$parsers.unshift(function (viewValue) { - if (scope.editingExisting) { + if (scope.currentDevice._editing != "add") { // we shouldn't validate ctrl.$setValidity('validDeviceid', true); } else { diff --git a/gui/default/syncthing/device/editDeviceModalView.html b/gui/default/syncthing/device/editDeviceModalView.html index 0b772c437..6ae453821 100644 --- a/gui/default/syncthing/device/editDeviceModalView.html +++ b/gui/default/syncthing/device/editDeviceModalView.html @@ -3,14 +3,14 @@
-
+
-
+
@@ -40,7 +40,7 @@ A device with that ID is already added.

-
+
{{currentDevice.deviceID}}
-
+
@@ -172,7 +172,7 @@ -
+
diff --git a/gui/default/syncthing/folder/editFolderModalView.html b/gui/default/syncthing/folder/editFolderModalView.html index 5fd8bc8e3..2c0c35597 100644 --- a/gui/default/syncthing/folder/editFolderModalView.html +++ b/gui/default/syncthing/folder/editFolderModalView.html @@ -2,44 +2,44 @@