diff --git a/vdirsyncer/cli/config.py b/vdirsyncer/cli/config.py index 8aed8249..939e9d0e 100644 --- a/vdirsyncer/cli/config.py +++ b/vdirsyncer/cli/config.py @@ -237,6 +237,9 @@ def __init__(self, full_config: Config, name: str, options: dict[str, str]): options.pop("conflict_resolution", None) ) + self.required_attendee = options.pop("required_attendee", None) + self.remove_details = options.pop("remove_details", False) + try: self.collections = options.pop("collections") except KeyError: diff --git a/vdirsyncer/cli/tasks.py b/vdirsyncer/cli/tasks.py index 6e8f5ba3..277a9dfd 100644 --- a/vdirsyncer/cli/tasks.py +++ b/vdirsyncer/cli/tasks.py @@ -79,6 +79,8 @@ def error_callback(e): force_delete=force_delete, error_callback=error_callback, partial_sync=pair.partial_sync, + remove_details=pair.remove_details, + required_attendee=pair.required_attendee, ) if sync_failed: diff --git a/vdirsyncer/sync/__init__.py b/vdirsyncer/sync/__init__.py index 25b46a32..26d5a14f 100644 --- a/vdirsyncer/sync/__init__.py +++ b/vdirsyncer/sync/__init__.py @@ -42,7 +42,7 @@ def __init__(self, storage: Storage, status: SubStatus): self.status = status self._item_cache = {} # type: ignore[var-annotated] - async def prepare_new_status(self) -> bool: + async def prepare_new_status(self, remove_details: bool = False, required_attendee: str | None = None) -> bool: storage_nonempty = False prefetch = [] @@ -67,6 +67,12 @@ def _store_props(ident: str, props: ItemMetadata) -> None: # Prefetch items if prefetch: async for href, item, etag in self.storage.get_multi(prefetch): + if required_attendee: + item = item.only_with_attendee(required_attendee) + if item is None: + continue + if remove_details: + item = item.without_details() _store_props( item.ident, ItemMetadata(href=href, hash=item.hash, etag=etag), @@ -105,6 +111,8 @@ async def sync( force_delete=False, error_callback=None, partial_sync="revert", + remove_details: bool = False, + required_attendee: str | None = None, ) -> None: """Synchronizes two storages. @@ -146,8 +154,14 @@ async def sync( a_info = _StorageInfo(storage_a, SubStatus(status, "a")) b_info = _StorageInfo(storage_b, SubStatus(status, "b")) - a_nonempty = await a_info.prepare_new_status() - b_nonempty = await b_info.prepare_new_status() + a_nonempty = await a_info.prepare_new_status( + remove_details=remove_details, + required_attendee=required_attendee + ) + b_nonempty = await b_info.prepare_new_status( + remove_details=remove_details, + required_attendee=required_attendee + ) if status_nonempty and not force_delete: if a_nonempty and not b_nonempty: diff --git a/vdirsyncer/vobject.py b/vdirsyncer/vobject.py index 51482215..af692f3c 100644 --- a/vdirsyncer/vobject.py +++ b/vdirsyncer/vobject.py @@ -35,6 +35,13 @@ ) +def _includes_attendee(component, attendee_email): + for attendee_line in component.get_all("ATTENDEE"): + sections = attendee_line.split(";") + if f"CN={attendee_email}" in sections and "PARTSTAT=ACCEPTED" in sections: + return True + + class Item: """Immutable wrapper class for VCALENDAR (VEVENT, VTODO) and VCARD""" @@ -57,6 +64,53 @@ def with_uid(self, new_uid): return Item("\r\n".join(parsed.dump_lines())) + def only_with_attendee(self, email: str): + """Returns True if the given attendee has accepted an invite to this event""" + parsed = _Component.parse(self.raw) + + parsed.subcomponents = [ + subcomponent + for subcomponent in parsed.subcomponents + if subcomponent.name != "VEVENT" or _includes_attendee(subcomponent, email) + ] + + if not any( + True + for subcomponent in parsed.subcomponents + if subcomponent.name == "VEVENT" + ): + return None + + return Item("\r\n".join(parsed.dump_lines())) + + def without_details(self): + """Returns a minimal version of this item. + + Filters out data to reduce content size and hide private details: + * Description + * Location + * Organizer + * Attendees list + * Redundant timezone data (actual timezone of event is preserved) + """ + parsed = _Component.parse(self.raw) + stack = [parsed] + while stack: + component = stack.pop() + + component.subcomponents = [ + subcomp for subcomp + in component.subcomponents + if subcomp.name != "VTIMEZONE" + ] + for field in ["DESCRIPTION", "ORGANIZER", "ATTENDEE", "LOCATION"]: + if field in component: + del component[field] + + stack.extend(component.subcomponents) + + return Item("\r\n".join(parsed.dump_lines())) + @cached_property def raw(self): """Raw content of the item, as unicode string. @@ -94,7 +148,8 @@ def ident(self): # with a picture, which bloats the status file. # # 2. The status file would contain really sensitive information. - return self.uid or self.hash + my_ident = self.uid or self.hash + return my_ident @property def parsed(self): @@ -235,14 +290,25 @@ def _get_item_type(components, wrappers): raise ValueError("Not sure how to join components.") +def _extract_prop_value(line, key): + if line.startswith(key): + prefix_without_params = f"{key}:" + prefix_with_params = f"{key};" + if line.startswith(prefix_without_params): + return line[len(prefix_without_params) :] + elif line.startswith(prefix_with_params): + return line[len(prefix_with_params) :].split(":", 1)[-1] + + return None + class _Component: """ Raw outline of the components. Vdirsyncer's operations on iCalendar and VCard objects are limited to - retrieving the UID and splitting larger files into items. Consequently this - parser is very lazy, with the downside that manipulation of item properties - are extremely costly. + retrieving the UID, removing fields, and splitting larger files into items. + Consequently this parser is very lazy, with the downside that manipulation + of item properties are extremely costly. Other features: @@ -318,20 +384,27 @@ def dump_lines(self): def __delitem__(self, key): prefix = (f"{key}:", f"{key};") new_lines = [] - lineiter = iter(self.props) - while True: - for line in lineiter: - if line.startswith(prefix): - break + + in_target_prop = False + for line in iter(self.props): + if in_target_prop: + if line.startswith((" ", "\t")): + # Continuing with the prop contents, drop this line + pass + elif line.startswith(prefix): + # Another instance of the target prop, drop this line + pass else: + # No longer in the target prop, keep this line + in_target_prop = False new_lines.append(line) else: - break - - for line in lineiter: - if not line.startswith((" ", "\t")): + if line.startswith(prefix): + # Entering the target prop, drop this line + in_target_prop = True + else: + # Un-targetted prop, keep this line new_lines.append(line) - break self.props = new_lines @@ -353,26 +426,25 @@ def __contains__(self, obj): raise ValueError(obj) def __getitem__(self, key): - prefix_without_params = f"{key}:" - prefix_with_params = f"{key};" - iterlines = iter(self.props) - for line in iterlines: - if line.startswith(prefix_without_params): - rv = line[len(prefix_without_params) :] - break - elif line.startswith(prefix_with_params): - rv = line[len(prefix_with_params) :].split(":", 1)[-1] - break - else: + try: + return next(self.get_all(key)) + except StopIteration: raise KeyError - - for line in iterlines: - if line.startswith((" ", "\t")): - rv += line[1:] + + def get_all(self, key: str): + rv = None + for line in iter(self.props): + if rv is None: + rv = _extract_prop_value(line, key) else: - break - - return rv + if line.startswith((" ", "\t")): + rv += line[1:] + else: + yield rv + rv = _extract_prop_value(line, key) + + if rv is not None: + yield rv def get(self, key, default=None): try: