Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Add options remove_details and required_attendee #1106

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions vdirsyncer/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions vdirsyncer/cli/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 17 additions & 3 deletions vdirsyncer/sync/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []

Expand All @@ -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),
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
136 changes: 104 additions & 32 deletions vdirsyncer/vobject.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand All @@ -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.
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand Down