Skip to content

Commit 34c7599

Browse files
cli/discover: remove local collections if the remote collection is deleted
This works when the destination backend is 'filesystem' and the source is CalDAV-calendar-home-set. pimutils#868
1 parent fa549fb commit 34c7599

File tree

9 files changed

+107
-10
lines changed

9 files changed

+107
-10
lines changed

CHANGELOG.rst

+5-2
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@ Package maintainers and users who have to manually update their installation
99
may want to subscribe to `GitHub's tag feed
1010
<https://github.com/pimutils/vdirsyncer/tags.atom>`_.
1111

12-
Version 0.19.0
12+
Unreleasd 0.19
1313
==============
14-
14+
- Add ``implicit`` option to storage section. It creates/deletes implicitly
15+
collections in the destinations, when new collections are created/deleted
16+
in the source. The deletion is implemented only for the "filesystem" storage.
17+
See :ref:`storage_config`.
1518
- Add "description" and "order" as metadata. These fetch the CalDAV:
1619
calendar-description, CardDAV:addressbook-description and apple-ns:calendar-order
1720
properties.

docs/config.rst

+8
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,8 @@ Local
415415
#encoding = "utf-8"
416416
#post_hook = null
417417
#fileignoreext = ".tmp"
418+
#implicit = "create"
419+
#implicit = ["create", "delete"]
418420

419421
Can be used with `khal <http://lostpackets.de/khal/>`_. See :doc:`vdir` for
420422
a more formal description of the format.
@@ -437,6 +439,12 @@ Local
437439
new/updated file.
438440
:param fileeignoreext: The file extention to ignore. It is only useful
439441
if fileext is set to the empty string. The default is ``.tmp``.
442+
:param implicit: When a new collection is created on the source,
443+
create it in the destination without asking questions, when
444+
the value is "create". When the value is "delete" and a collection
445+
is removed on the source, remove it in the destination. The value
446+
can be a string or an array of strings. The deletion is implemented
447+
only for the "filesystem" storage.
440448

441449
.. storage:: singlefile
442450

tests/system/cli/test_config.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,9 @@ def test_read_config(read_config):
6161
"yesno": False,
6262
"number": 42,
6363
"instance_name": "bob_a",
64+
"implicit": [],
6465
},
65-
"bob_b": {"type": "carddav", "instance_name": "bob_b"},
66+
"bob_b": {'type': "carddav", "instance_name": "bob_b", "implicit": []},
6667
}
6768

6869

tests/system/utils/test_main.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def test_get_storage_init_args():
2121
from vdirsyncer.storage.memory import MemoryStorage
2222

2323
all, required = utils.get_storage_init_args(MemoryStorage)
24-
assert all == {"fileext", "collection", "read_only", "instance_name"}
24+
assert all == {"fileext", "collection", "read_only", "instance_name", "implicit"}
2525
assert not required
2626

2727

vdirsyncer/cli/config.py

+7
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ def _parse_section(self, section_type, name, options):
116116
raise ValueError("More than one general section.")
117117
self._general = options
118118
elif section_type == "storage":
119+
if "implicit" not in options:
120+
options["implicit"] = []
121+
elif isinstance(options["implicit"], str):
122+
options["implicit"] = [options['implicit']]
123+
elif not isinstance(options["implicit"], list):
124+
raise ValueError(
125+
"`implicit` parameter must be a list, string or absent.")
119126
self._storages[name] = options
120127
elif section_type == "pair":
121128
self._pairs[name] = options

vdirsyncer/cli/discover.py

+25
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
import aiohttp
88
import aiostream
99

10+
from . import cli_logger
1011
from .. import exceptions
1112
from .utils import handle_collection_not_found
13+
from .utils import handle_collection_was_removed
1214
from .utils import handle_storage_init_error
1315
from .utils import load_status
1416
from .utils import save_status
@@ -105,6 +107,29 @@ async def collections_for_pair(
105107
_handle_collection_not_found=handle_collection_not_found,
106108
)
107109
)
110+
if "from b" in (pair.collections or []):
111+
only_in_a = set((await a_discovered.get_self()).keys()) - set(
112+
(await b_discovered.get_self()).keys())
113+
if only_in_a and "delete" in pair.config_a["implicit"]:
114+
for a in only_in_a:
115+
try:
116+
handle_collection_was_removed(pair.config_a, a)
117+
save_status(status_path, pair.name, a, data_type="metadata")
118+
save_status(status_path, pair.name, a, data_type="items")
119+
except NotImplementedError as e:
120+
cli_logger.error(e)
121+
122+
if "from a" in (pair.collections or []):
123+
only_in_b = set((await b_discovered.get_self()).keys()) - set(
124+
(await a_discovered.get_self()).keys())
125+
if only_in_b and "delete" in pair.config_b["implicit"]:
126+
for b in only_in_b:
127+
try:
128+
handle_collection_was_removed(pair.config_b, b)
129+
save_status(status_path, pair.name, b, data_type="metadata")
130+
save_status(status_path, pair.name, b, data_type="items")
131+
except NotImplementedError as e:
132+
cli_logger.error(e)
108133

109134
await _sanity_check_collections(rv, connector=connector)
110135

vdirsyncer/cli/utils.py

+21-2
Original file line numberDiff line numberDiff line change
@@ -232,10 +232,15 @@ def manage_sync_status(base_path, pair_name, collection_name):
232232

233233
def save_status(base_path, pair, collection=None, data_type=None, data=None):
234234
assert data_type is not None
235-
assert data is not None
236235
status_name = get_status_name(pair, collection)
237236
path = expand_path(os.path.join(base_path, status_name)) + "." + data_type
238237
prepare_status_path(path)
238+
if data is None:
239+
try:
240+
os.remove(path)
241+
except OSError: # the file has not existed
242+
pass
243+
return
239244

240245
with atomic_write(path, mode="w", overwrite=True) as f:
241246
json.dump(data, f)
@@ -335,6 +340,19 @@ def assert_permissions(path, wanted):
335340
os.chmod(path, wanted)
336341

337342

343+
def handle_collection_was_removed(config, collection):
344+
if "delete" in config["implicit"]:
345+
storage_type = config["type"]
346+
cls, config = storage_class_from_config(config)
347+
config["collection"] = collection
348+
try:
349+
args = cls.delete_collection(**config)
350+
args["type"] = storage_type
351+
return args
352+
except NotImplementedError as e:
353+
cli_logger.error(e)
354+
355+
338356
async def handle_collection_not_found(config, collection, e=None):
339357
storage_name = config.get("instance_name", None)
340358

@@ -344,7 +362,8 @@ async def handle_collection_not_found(config, collection, e=None):
344362
)
345363
)
346364

347-
if click.confirm("Should vdirsyncer attempt to create it?"):
365+
if "create" in config["implicit"] or click.confirm(
366+
"Should vdirsyncer attempt to create it?"):
348367
storage_type = config["type"]
349368
cls, config = storage_class_from_config(config)
350369
config["collection"] = collection

vdirsyncer/storage/base.py

+23-1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ class Storage(metaclass=StorageMeta):
4141
4242
:param read_only: Whether the synchronization algorithm should avoid writes
4343
to this storage. Some storages accept no value other than ``True``.
44+
:param implicit: Whether the synchronization shall create/delete collections
45+
in the destination, when these were created/removed from the source. Must
46+
be a possibly empty list of strings.
4447
"""
4548

4649
fileext = ".txt"
@@ -64,9 +67,16 @@ class Storage(metaclass=StorageMeta):
6467
# The attribute values to show in the representation of the storage.
6568
_repr_attributes = ()
6669

67-
def __init__(self, instance_name=None, read_only=None, collection=None):
70+
def __init__(self, instance_name=None, read_only=None, collection=None,
71+
implicit=None):
6872
if read_only is None:
6973
read_only = self.read_only
74+
if implicit is None:
75+
self.implicit = []
76+
elif isinstance(implicit, str):
77+
self.implicit = [implicit]
78+
else:
79+
self.implicit = implicit
7080
if self.read_only and not read_only:
7181
raise exceptions.UserError("This storage can only be read-only.")
7282
self.read_only = bool(read_only)
@@ -108,6 +118,18 @@ async def create_collection(cls, collection, **kwargs):
108118
"""
109119
raise NotImplementedError()
110120

121+
@classmethod
122+
def delete_collection(cls, collection, **kwargs):
123+
'''
124+
Delete the specified collection and return the new arguments.
125+
126+
``collection=None`` means the arguments are already pointing to a
127+
possible collection location.
128+
129+
The returned args should contain the collection name, for UI purposes.
130+
'''
131+
raise NotImplementedError()
132+
111133
def __repr__(self):
112134
try:
113135
if self.instance_name:

vdirsyncer/storage/filesystem.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import errno
22
import logging
33
import os
4+
import shutil
45
import subprocess
56

67
from atomicwrites import atomic_write
@@ -62,9 +63,7 @@ async def discover(cls, path, **kwargs):
6263
def _validate_collection(cls, path):
6364
if not os.path.isdir(path) or os.path.islink(path):
6465
return False
65-
if os.path.basename(path).startswith("."):
66-
return False
67-
return True
66+
return not os.path.basename(path).startswith(".")
6867

6968
@classmethod
7069
async def create_collection(cls, collection, **kwargs):
@@ -80,6 +79,19 @@ async def create_collection(cls, collection, **kwargs):
8079
kwargs["collection"] = collection
8180
return kwargs
8281

82+
@classmethod
83+
def delete_collection(cls, collection, **kwargs):
84+
kwargs = dict(kwargs)
85+
path = kwargs['path']
86+
87+
if collection is not None:
88+
path = os.path.join(path, collection)
89+
shutil.rmtree(path, ignore_errors=True)
90+
91+
kwargs["path"] = path
92+
kwargs["collection"] = collection
93+
return kwargs
94+
8395
def _get_filepath(self, href):
8496
return os.path.join(self.path, href)
8597

0 commit comments

Comments
 (0)