From 2bfdfa8b9acc837e022eff5dc3545eaa13e39ea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B8=D0=BB=D1=8F=D0=BD=20=D0=9F=D0=B0=D0=BB=D0=B0?= =?UTF-8?q?=D1=83=D0=B7=D0=BE=D0=B2?= Date: Sat, 2 May 2020 12:54:52 +0000 Subject: [PATCH] cli/discover: remove/add local collections if the remote collection is deleted/created This works when the destination backend is 'filesystem'. -- add a new parameter to storage section: implicit = ["create", "delete"] Changes cli/utils.py:save_status(): when data is None, remove the underlaying file. --- CHANGELOG.rst | 7 +++++++ docs/config.rst | 8 ++++++++ vdirsyncer/cli/config.py | 6 ++++++ vdirsyncer/cli/discover.py | 23 ++++++++++++++++++++++- vdirsyncer/cli/utils.py | 22 ++++++++++++++++++++-- vdirsyncer/storage/base.py | 18 +++++++++++++++++- vdirsyncer/storage/filesystem.py | 18 +++++++++++++++--- 7 files changed, 95 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 24419caeb..f37a193f7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,13 @@ Package maintainers and users who have to manually update their installation may want to subscribe to `GitHub's tag feed `_. +Unreleased 0.1 +============== +- Add ``implicit`` option to storage section. It creates/deletes implicitly + collections in the destinations, when new collections are created/deleted + in the source. The deletion is implemented only for the "filesystem" storage. + See :ref:`storage_config`. + Version 0.16.8 ============== diff --git a/docs/config.rst b/docs/config.rst index 425f740e8..fe5345789 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -408,6 +408,8 @@ Local fileext = "..." #encoding = "utf-8" #post_hook = null + #implicit = "create" + #implicit = ["create", "delete"] Can be used with `khal `_. See :doc:`vdir` for a more formal description of the format. @@ -426,6 +428,12 @@ Local :param post_hook: A command to call for each item creation and modification. The command will be called with the path of the new/updated file. + :param implicit: When a new collection is created on the source, and the + value is "create", create the collection in the destination without + asking questions. When the value is "delete" and a collection + is removed on the source, remove it in the destination. The value + can be a string or an array of strings. The deletion is implemented + only for the "filesystem" storage. .. storage:: singlefile diff --git a/vdirsyncer/cli/config.py b/vdirsyncer/cli/config.py index 339bde993..d2c50cd23 100644 --- a/vdirsyncer/cli/config.py +++ b/vdirsyncer/cli/config.py @@ -111,6 +111,12 @@ def _parse_section(self, section_type, name, options): raise ValueError('More than one general section.') self._general = options elif section_type == 'storage': + if 'implicit' not in options: + options['implicit'] = [] + elif isinstance(options['implicit'], str): + options['implicit'] = [options['implicit']] + elif not isinstance(options['implicit'], list): + raise ValueError('`implicit` parameter must be a list, string or absent.') self._storages[name] = options elif section_type == 'pair': self._pairs[name] = options diff --git a/vdirsyncer/cli/discover.py b/vdirsyncer/cli/discover.py index fdc8f9db7..9bbe199c6 100644 --- a/vdirsyncer/cli/discover.py +++ b/vdirsyncer/cli/discover.py @@ -5,7 +5,7 @@ from .. import exceptions from ..utils import cached_property -from .utils import handle_collection_not_found +from .utils import handle_collection_not_found, handle_collection_was_removed from .utils import handle_storage_init_error from .utils import load_status from .utils import save_status @@ -80,6 +80,27 @@ def collections_for_pair(status_path, pair, from_cache=True, get_b_discovered=b_discovered.get_self, _handle_collection_not_found=handle_collection_not_found )) + if "from b" in pair.collections: + only_in_a = set(a_discovered.get_self().keys()) - set(b_discovered.get_self().keys()) + if only_in_a and 'delete' in pair.config_a['implicit']: + for a in only_in_a: + try: + handle_collection_was_removed(pair.config_a, a) + save_status(status_path, pair.name, a, data_type='metadata') + save_status(status_path, pair.name, a, data_type='items') + except NotImplementedError as e: + cli_logger.error(e) + + if "from a" in pair.collections: + only_in_b = set(b_discovered.get_self().keys()) - set(a_discovered.get_self().keys()) + if only_in_b and 'delete' in pair.config_b['implicit']: + for b in only_in_b: + try: + handle_collection_was_removed(pair.config_b, b) + save_status(status_path, pair.name, b, data_type='metadata') + save_status(status_path, pair.name, b, data_type='items') + except NotImplementedError as e: + cli_logger.error(e) _sanity_check_collections(rv) diff --git a/vdirsyncer/cli/utils.py b/vdirsyncer/cli/utils.py index d8b420d5d..007341a8e 100644 --- a/vdirsyncer/cli/utils.py +++ b/vdirsyncer/cli/utils.py @@ -231,10 +231,15 @@ def manage_sync_status(base_path, pair_name, collection_name): def save_status(base_path, pair, collection=None, data_type=None, data=None): assert data_type is not None - assert data is not None status_name = get_status_name(pair, collection) path = expand_path(os.path.join(base_path, status_name)) + '.' + data_type prepare_status_path(path) + if data is None: + try: + os.remove(path) + except OSError: # the file has not existed + pass + return with atomic_write(path, mode='w', overwrite=True) as f: json.dump(data, f) @@ -397,6 +402,19 @@ def assert_permissions(path, wanted): os.chmod(path, wanted) +def handle_collection_was_removed(config, collection): + if 'delete' in config['implicit']: + storage_type = config['type'] + cls, config = storage_class_from_config(config) + config['collection'] = collection + try: + args = cls.delete_collection(**config) + args['type'] = storage_type + return args + except NotImplementedError as e: + cli_logger.error(e) + + def handle_collection_not_found(config, collection, e=None): storage_name = config.get('instance_name', None) @@ -404,7 +422,7 @@ def handle_collection_not_found(config, collection, e=None): .format(f'{e}\n' if e else '', json.dumps(collection), storage_name)) - if click.confirm('Should vdirsyncer attempt to create it?'): + if 'create' in config['implicit'] or click.confirm('Should vdirsyncer attempt to create it?'): storage_type = config['type'] cls, config = storage_class_from_config(config) config['collection'] = collection diff --git a/vdirsyncer/storage/base.py b/vdirsyncer/storage/base.py index b78f828b7..58594c428 100644 --- a/vdirsyncer/storage/base.py +++ b/vdirsyncer/storage/base.py @@ -40,6 +40,9 @@ class Storage(metaclass=StorageMeta): :param read_only: Whether the synchronization algorithm should avoid writes to this storage. Some storages accept no value other than ``True``. + :param implicit: Whether the synchronization shall create/delete collections + in the destination, when these were created/removed from the source. Must + be a possibly empty list of strings. ''' fileext = '.txt' @@ -63,9 +66,10 @@ class Storage(metaclass=StorageMeta): # The attribute values to show in the representation of the storage. _repr_attributes = () - def __init__(self, instance_name=None, read_only=None, collection=None): + def __init__(self, instance_name=None, read_only=None, collection=None, implicit=[]): if read_only is None: read_only = self.read_only + self.implicit = implicit # unused from within the Storage classes if self.read_only and not read_only: raise exceptions.UserError('This storage can only be read-only.') self.read_only = bool(read_only) @@ -105,6 +109,18 @@ def create_collection(cls, collection, **kwargs): ''' raise NotImplementedError() + @classmethod + def delete_collection(cls, collection, **kwargs): + ''' + Delete the specified collection and return the new arguments. + + ``collection=None`` means the arguments are already pointing to a + possible collection location. + + The returned args should contain the collection name, for UI purposes. + ''' + raise NotImplementedError() + def __repr__(self): try: if self.instance_name: diff --git a/vdirsyncer/storage/filesystem.py b/vdirsyncer/storage/filesystem.py index b3981197e..185ea256f 100644 --- a/vdirsyncer/storage/filesystem.py +++ b/vdirsyncer/storage/filesystem.py @@ -1,6 +1,7 @@ import errno import logging import os +import shutil import subprocess from atomicwrites import atomic_write @@ -55,9 +56,7 @@ def discover(cls, path, **kwargs): def _validate_collection(cls, path): if not os.path.isdir(path): return False - if os.path.basename(path).startswith('.'): - return False - return True + return not os.path.basename(path).startswith('.') @classmethod def create_collection(cls, collection, **kwargs): @@ -73,6 +72,19 @@ def create_collection(cls, collection, **kwargs): kwargs['collection'] = collection return kwargs + @classmethod + def delete_collection(cls, collection, **kwargs): + kwargs = dict(kwargs) + path = kwargs['path'] + + if collection is not None: + path = os.path.join(path, collection) + shutil.rmtree(path, ignore_errors=True) + + kwargs['path'] = path + kwargs['collection'] = collection + return kwargs + def _get_filepath(self, href): return os.path.join(self.path, href)