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/discover.py b/vdirsyncer/cli/discover.py index fdc8f9db7..359feb204 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,21 @@ 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 )) + 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.get('implicit'): + for b in only_in_b: + try: + handle_collection_was_removed(pair.config_b, b) + try: + save_status(status_path, pair.name, b, data_type='metadata', data=None) + except OSError: # the .metadata file has not existed + pass + try: + save_status(status_path, pair.name, b, data_type='items', data=None) + except OSError: # the .items file has not existed + pass + 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..eca7c6396 100644 --- a/vdirsyncer/cli/utils.py +++ b/vdirsyncer/cli/utils.py @@ -231,10 +231,12 @@ 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: + os.remove(path) + return with atomic_write(path, mode='w', overwrite=True) as f: json.dump(data, f) @@ -397,6 +399,19 @@ def assert_permissions(path, wanted): os.chmod(path, wanted) +def handle_collection_was_removed(config, collection): + if 'delete' in config.get('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 +419,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.get('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..19f1af47e 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. Can + be a string, an array of strings, or skipped. ''' fileext = '.txt' @@ -63,9 +66,15 @@ 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=None): if read_only is None: read_only = self.read_only + if implicit is None: + self.implicit = [] + elif isinstance(implicit, str): + self.implicit = [implicit] + else: + self.implicit = implicit 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 +114,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)