Skip to content

Commit 1915946

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 646e0fb commit 1915946

File tree

9 files changed

+106
-7
lines changed

9 files changed

+106
-7
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ Version 0.19.0
4444
==============
4545

4646
- Add "shell" password fetch strategy to pass command string to a shell.
47+
- Add ``implicit`` option to storage section. It creates/deletes implicitly
48+
collections in the destinations, when new collections are created/deleted
49+
in the source. The deletion is implemented only for the "filesystem" storage.
50+
See :ref:`storage_config`.
4751
- Add "description" and "order" as metadata. These fetch the CalDAV:
4852
calendar-description, ``CardDAV:addressbook-description`` and
4953
``apple-ns:calendar-order`` properties respectively.

docs/config.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,8 @@ Local
379379
#encoding = "utf-8"
380380
#post_hook = null
381381
#fileignoreext = ".tmp"
382+
#implicit = "create"
383+
#implicit = ["create", "delete"]
382384

383385
Can be used with `khal <http://lostpackets.de/khal/>`_. See :doc:`vdir` for
384386
a more formal description of the format.
@@ -401,6 +403,12 @@ Local
401403
new/updated file.
402404
:param fileeignoreext: The file extention to ignore. It is only useful
403405
if fileext is set to the empty string. The default is ``.tmp``.
406+
:param implicit: When a new collection is created on the source,
407+
create it in the destination without asking questions, when
408+
the value is "create". When the value is "delete" and a collection
409+
is removed on the source, remove it in the destination. The value
410+
can be a string or an array of strings. The deletion is implemented
411+
only for the "filesystem" storage.
404412

405413
.. storage:: singlefile
406414

tests/system/cli/test_config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,9 @@ def test_read_config(read_config):
6262
"yesno": False,
6363
"number": 42,
6464
"instance_name": "bob_a",
65+
"implicit": [],
6566
},
66-
"bob_b": {"type": "carddav", "instance_name": "bob_b"},
67+
"bob_b": {'type': "carddav", "instance_name": "bob_b", "implicit": []},
6768
}
6869

6970

tests/system/utils/test_main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def test_get_storage_init_args():
2222
from vdirsyncer.storage.memory import MemoryStorage
2323

2424
all, required = utils.get_storage_init_args(MemoryStorage)
25-
assert all == {"fileext", "collection", "read_only", "instance_name", "no_delete"}
25+
assert all == {"fileext", "collection", "read_only", "instance_name", "implicit", "no_delete"}
2626
assert not required
2727

2828

vdirsyncer/cli/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,13 @@ def _parse_section(
119119
raise ValueError("More than one general section.")
120120
self._general = options
121121
elif section_type == "storage":
122+
if "implicit" not in options:
123+
options["implicit"] = []
124+
elif isinstance(options["implicit"], str):
125+
options["implicit"] = [options['implicit']]
126+
elif not isinstance(options["implicit"], list):
127+
raise ValueError(
128+
"`implicit` parameter must be a list, string or absent.")
122129
self._storages[name] = options
123130
elif section_type == "pair":
124131
self._pairs[name] = options

vdirsyncer/cli/discover.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
import aiohttp
1010
import aiostream
1111

12+
from . import cli_logger
1213
from .. import exceptions
1314
from .utils import handle_collection_not_found
15+
from .utils import handle_collection_was_removed
1416
from .utils import handle_storage_init_error
1517
from .utils import load_status
1618
from .utils import save_status
@@ -106,6 +108,29 @@ async def collections_for_pair(
106108
_handle_collection_not_found=handle_collection_not_found,
107109
)
108110
)
111+
if "from b" in (pair.collections or []):
112+
only_in_a = set((await a_discovered.get_self()).keys()) - set(
113+
(await b_discovered.get_self()).keys())
114+
if only_in_a and "delete" in pair.config_a["implicit"]:
115+
for a in only_in_a:
116+
try:
117+
handle_collection_was_removed(pair.config_a, a)
118+
save_status(status_path, pair.name, a, data_type="metadata")
119+
save_status(status_path, pair.name, a, data_type="items")
120+
except NotImplementedError as e:
121+
cli_logger.error(e)
122+
123+
if "from a" in (pair.collections or []):
124+
only_in_b = set((await b_discovered.get_self()).keys()) - set(
125+
(await a_discovered.get_self()).keys())
126+
if only_in_b and "delete" in pair.config_b["implicit"]:
127+
for b in only_in_b:
128+
try:
129+
handle_collection_was_removed(pair.config_b, b)
130+
save_status(status_path, pair.name, b, data_type="metadata")
131+
save_status(status_path, pair.name, b, data_type="items")
132+
except NotImplementedError as e:
133+
cli_logger.error(e)
109134

110135
await _sanity_check_collections(rv, connector=connector)
111136

vdirsyncer/cli/utils.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,12 @@ def save_status(
245245
status_name = get_status_name(pair, collection)
246246
path = expand_path(os.path.join(base_path, status_name)) + "." + data_type
247247
prepare_status_path(path)
248+
if data is None:
249+
try:
250+
os.remove(path)
251+
except OSError: # the file has not existed
252+
pass
253+
return
248254

249255
with atomic_write(path, mode="w", overwrite=True) as f:
250256
json.dump(data, f)
@@ -342,6 +348,19 @@ def assert_permissions(path: str, wanted: int) -> None:
342348
os.chmod(path, wanted)
343349

344350

351+
def handle_collection_was_removed(config, collection):
352+
if "delete" in config["implicit"]:
353+
storage_type = config["type"]
354+
cls, config = storage_class_from_config(config)
355+
config["collection"] = collection
356+
try:
357+
args = cls.delete_collection(**config)
358+
args["type"] = storage_type
359+
return args
360+
except NotImplementedError as e:
361+
cli_logger.error(e)
362+
363+
345364
async def handle_collection_not_found(config, collection, e=None):
346365
storage_name = config.get("instance_name", None)
347366

@@ -351,7 +370,8 @@ async def handle_collection_not_found(config, collection, e=None):
351370
)
352371
)
353372

354-
if click.confirm("Should vdirsyncer attempt to create it?"):
373+
if "create" in config["implicit"] or click.confirm(
374+
"Should vdirsyncer attempt to create it?"):
355375
storage_type = config["type"]
356376
cls, config = storage_class_from_config(config)
357377
config["collection"] = collection

vdirsyncer/storage/base.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ class Storage(metaclass=StorageMeta):
5252
5353
:param read_only: Whether the synchronization algorithm should avoid writes
5454
to this storage. Some storages accept no value other than ``True``.
55+
:param implicit: Whether the synchronization shall create/delete collections
56+
in the destination, when these were created/removed from the source. Must
57+
be a possibly empty list of strings.
5558
"""
5659

5760
fileext = ".txt"
@@ -79,9 +82,16 @@ class Storage(metaclass=StorageMeta):
7982
# The attribute values to show in the representation of the storage.
8083
_repr_attributes: list[str] = []
8184

82-
def __init__(self, instance_name=None, read_only=None, no_delete=None, collection=None):
85+
def __init__(self, instance_name=None, read_only=None, collection=None,
86+
implicit=None, no_delete=None):
8387
if read_only is None:
8488
read_only = self.read_only
89+
if implicit is None:
90+
self.implicit = []
91+
elif isinstance(implicit, str):
92+
self.implicit = [implicit]
93+
else:
94+
self.implicit = implicit
8595
if self.read_only and not read_only:
8696
raise exceptions.UserError("This storage can only be read-only.")
8797
self.read_only = bool(read_only)
@@ -129,6 +139,18 @@ async def create_collection(cls, collection, **kwargs):
129139
"""
130140
raise NotImplementedError
131141

142+
@classmethod
143+
def delete_collection(cls, collection, **kwargs):
144+
'''
145+
Delete the specified collection and return the new arguments.
146+
147+
``collection=None`` means the arguments are already pointing to a
148+
possible collection location.
149+
150+
The returned args should contain the collection name, for UI purposes.
151+
'''
152+
raise NotImplementedError()
153+
132154
def __repr__(self):
133155
try:
134156
if self.instance_name:

vdirsyncer/storage/filesystem.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import errno
44
import logging
55
import os
6+
import shutil
67
import subprocess
78

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

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

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

0 commit comments

Comments
 (0)