diff --git a/src/govoplan_addresses/backend/service.py b/src/govoplan_addresses/backend/service.py index 0c967da..42e2f8b 100644 --- a/src/govoplan_addresses/backend/service.py +++ b/src/govoplan_addresses/backend/service.py @@ -19,6 +19,7 @@ from govoplan_addresses.backend.carddav import ( AddressCardDAVAddressBook, AddressCardDAVClient, AddressCardDAVError, + AddressCardDAVObject, AddressCardDAVPreconditionFailed, AddressCardDAVReportResult, AddressCardDAVSyncUnsupported, @@ -59,7 +60,7 @@ from govoplan_addresses.backend.schemas import ( ContactPostalAddressPayload, ContactUpdateRequest, ) -from govoplan_addresses.backend.vcard import ParsedVCardIssue, contacts_to_vcard, parse_vcards_with_issues +from govoplan_addresses.backend.vcard import ParsedVCard, ParsedVCardIssue, contacts_to_vcard, parse_vcards_with_issues class AddressBookError(ValueError): @@ -772,152 +773,358 @@ def _plan_carddav_report( for item in report.objects: href = item.href seen_hrefs.add(href) - local = local_by_href.get(href) - if item.deleted: - if local is not None and local.deleted_at is None: - if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source): - _add_sync_plan_item( - plan, - AddressSyncPlanItem( - action="conflict", - href=href, - contact_id=local.id, - display_name=local.display_name, - etag=item.etag, - message="Remote object was deleted while the local contact changed since the last successful sync.", - ), - ) - elif reads_remote: - _add_sync_plan_item(plan, AddressSyncPlanItem(action="delete", href=href, contact_id=local.id, display_name=local.display_name, etag=item.etag)) - else: - _add_sync_plan_item(plan, AddressSyncPlanItem(action="unchanged", href=href, etag=item.etag, message="Remote delete already reflected locally.")) - continue + _plan_carddav_report_object( + sync_source=sync_source, + client=client, + item=item, + local=local_by_href.get(href), + reads_remote=reads_remote, + plan=plan, + ) - raw_vcard = item.address_data - if not raw_vcard: - try: - raw_vcard = client.fetch_object(href) - except AddressCardDAVError as exc: - _add_sync_plan_item(plan, AddressSyncPlanItem(action="error", href=href, etag=item.etag, message=str(exc))) - continue - parsed, error_message = _parse_single_remote_vcard(raw_vcard) - if parsed is None: - _add_sync_plan_item(plan, AddressSyncPlanItem(action="error", href=href, etag=item.etag, message=error_message or "Remote vCard could not be parsed.")) - continue + if plan.stats.full_sync: + _plan_carddav_full_sync_absences( + sync_source=sync_source, + local_by_href=local_by_href, + seen_hrefs=seen_hrefs, + reads_remote=reads_remote, + plan=plan, + ) - if local is None: - if reads_remote: - _add_sync_plan_item( - plan, - AddressSyncPlanItem( - action="create", - href=href, - remote_uid=parsed.source_ref, - display_name=parsed.payload.display_name, - etag=item.etag, - raw_vcard=raw_vcard, - parsed_payload=parsed.payload, - source_revision=item.etag or parsed.source_revision, - ), - ) - else: - _add_sync_plan_item(plan, AddressSyncPlanItem(action="unchanged", href=href, etag=item.etag, message="Remote object ignored by export-only sync source.")) - continue + _plan_carddav_outbound_local_changes(session, sync_source=sync_source, plan=plan) - remote_revision = item.etag or parsed.source_revision - if local.deleted_at is not None and _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source): + +def _plan_carddav_report_object( + *, + sync_source: AddressSyncSource, + client: AddressCardDAVClient, + item: AddressCardDAVObject, + local: Contact | None, + reads_remote: bool, + plan: AddressSyncPlan, +) -> None: + if item.deleted: + _plan_carddav_remote_deletion( + sync_source=sync_source, + item=item, + local=local, + reads_remote=reads_remote, + plan=plan, + ) + return + loaded = _load_carddav_report_vcard(client=client, item=item, plan=plan) + if loaded is None: + return + raw_vcard, parsed = loaded + _plan_carddav_live_object( + sync_source=sync_source, + item=item, + local=local, + reads_remote=reads_remote, + raw_vcard=raw_vcard, + parsed=parsed, + plan=plan, + ) + + +def _plan_carddav_remote_deletion( + *, + sync_source: AddressSyncSource, + item: AddressCardDAVObject, + local: Contact | None, + reads_remote: bool, + plan: AddressSyncPlan, +) -> None: + if local is None or local.deleted_at is not None: + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="unchanged", + href=item.href, + etag=item.etag, + message="Remote delete already reflected locally.", + ), + ) + return + if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source): + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="conflict", + href=item.href, + contact_id=local.id, + display_name=local.display_name, + etag=item.etag, + message="Remote object was deleted while the local contact changed since the last successful sync.", + ), + ) + elif reads_remote: + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="delete", + href=item.href, + contact_id=local.id, + display_name=local.display_name, + etag=item.etag, + ), + ) + + +def _load_carddav_report_vcard( + *, + client: AddressCardDAVClient, + item: AddressCardDAVObject, + plan: AddressSyncPlan, +) -> tuple[str, ParsedVCard] | None: + raw_vcard = item.address_data + if not raw_vcard: + try: + raw_vcard = client.fetch_object(item.href) + except AddressCardDAVError as exc: _add_sync_plan_item( plan, - AddressSyncPlanItem( - action="remote_delete", - href=href, - remote_uid=parsed.source_ref, - contact_id=local.id, - display_name=local.display_name, - etag=item.etag or local.source_revision, - message="Local delete will be pushed to CardDAV.", - ), + AddressSyncPlanItem(action="error", href=item.href, etag=item.etag, message=str(exc)), ) - continue + return None + parsed, error_message = _parse_single_remote_vcard(raw_vcard) + if parsed is None: + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="error", + href=item.href, + etag=item.etag, + message=error_message or "Remote vCard could not be parsed.", + ), + ) + return None + return raw_vcard, parsed - if local.deleted_at is None and local.source_revision == remote_revision: - if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source): - _add_sync_plan_item( - plan, - AddressSyncPlanItem( - action="remote_update", - href=href, - remote_uid=parsed.source_ref, - contact_id=local.id, - display_name=local.display_name, - etag=item.etag or local.source_revision, - raw_vcard=_carddav_contact_vcard(local, href=href), - source_revision=item.etag or local.source_revision, - message="Local update will be pushed to CardDAV.", - ), - ) - continue - _add_sync_plan_item( - plan, - AddressSyncPlanItem(action="unchanged", href=href, remote_uid=parsed.source_ref, contact_id=local.id, display_name=local.display_name, etag=item.etag), - ) - continue - if _local_contact_changed_after_last_sync(local, sync_source): +def _plan_carddav_live_object( + *, + sync_source: AddressSyncSource, + item: AddressCardDAVObject, + local: Contact | None, + reads_remote: bool, + raw_vcard: str, + parsed: ParsedVCard, + plan: AddressSyncPlan, +) -> None: + if local is None: + _plan_new_carddav_remote_object( + item=item, + reads_remote=reads_remote, + raw_vcard=raw_vcard, + parsed=parsed, + plan=plan, + ) + return + remote_revision = item.etag or parsed.source_revision + if _carddav_local_delete_needs_push(local, sync_source): + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="remote_delete", + href=item.href, + remote_uid=parsed.source_ref, + contact_id=local.id, + display_name=local.display_name, + etag=item.etag or local.source_revision, + message="Local delete will be pushed to CardDAV.", + ), + ) + return + if local.deleted_at is None and local.source_revision == remote_revision: + _plan_matching_carddav_revision( + sync_source=sync_source, + item=item, + local=local, + parsed=parsed, + plan=plan, + ) + return + if _local_contact_changed_after_last_sync(local, sync_source): + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="conflict", + href=item.href, + remote_uid=parsed.source_ref, + contact_id=local.id, + display_name=local.display_name, + etag=item.etag, + raw_vcard=raw_vcard, + parsed_payload=parsed.payload, + source_revision=item.etag or parsed.source_revision, + message="Local contact changed since the last successful sync.", + ), + ) + return + _plan_carddav_remote_update( + item=item, + local=local, + reads_remote=reads_remote, + raw_vcard=raw_vcard, + parsed=parsed, + remote_revision=remote_revision, + plan=plan, + ) + + +def _plan_new_carddav_remote_object( + *, + item: AddressCardDAVObject, + reads_remote: bool, + raw_vcard: str, + parsed: ParsedVCard, + plan: AddressSyncPlan, +) -> None: + if reads_remote: + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="create", + href=item.href, + remote_uid=parsed.source_ref, + display_name=parsed.payload.display_name, + etag=item.etag, + raw_vcard=raw_vcard, + parsed_payload=parsed.payload, + source_revision=item.etag or parsed.source_revision, + ), + ) + else: + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="unchanged", + href=item.href, + etag=item.etag, + message="Remote object ignored by export-only sync source.", + ), + ) + + +def _carddav_local_delete_needs_push(local: Contact, sync_source: AddressSyncSource) -> bool: + return bool( + local.deleted_at is not None + and _sync_source_writes_remote(sync_source) + and _local_contact_changed_after_last_sync(local, sync_source) + ) + + +def _plan_matching_carddav_revision( + *, + sync_source: AddressSyncSource, + item: AddressCardDAVObject, + local: Contact, + parsed: ParsedVCard, + plan: AddressSyncPlan, +) -> None: + if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source): + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="remote_update", + href=item.href, + remote_uid=parsed.source_ref, + contact_id=local.id, + display_name=local.display_name, + etag=item.etag or local.source_revision, + raw_vcard=_carddav_contact_vcard(local, href=item.href), + source_revision=item.etag or local.source_revision, + message="Local update will be pushed to CardDAV.", + ), + ) + return + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="unchanged", + href=item.href, + remote_uid=parsed.source_ref, + contact_id=local.id, + display_name=local.display_name, + etag=item.etag, + ), + ) + + +def _plan_carddav_remote_update( + *, + item: AddressCardDAVObject, + local: Contact, + reads_remote: bool, + raw_vcard: str, + parsed: ParsedVCard, + remote_revision: str | None, + plan: AddressSyncPlan, +) -> None: + if reads_remote: + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="update", + href=item.href, + remote_uid=parsed.source_ref, + contact_id=local.id, + display_name=parsed.payload.display_name or local.display_name, + etag=item.etag, + raw_vcard=raw_vcard, + parsed_payload=parsed.payload, + source_revision=remote_revision, + ), + ) + else: + _add_sync_plan_item( + plan, + AddressSyncPlanItem( + action="unchanged", + href=item.href, + contact_id=local.id, + display_name=local.display_name, + etag=item.etag, + message="Remote update ignored by export-only sync source.", + ), + ) + + +def _plan_carddav_full_sync_absences( + *, + sync_source: AddressSyncSource, + local_by_href: dict[str, Contact], + seen_hrefs: set[str], + reads_remote: bool, + plan: AddressSyncPlan, +) -> None: + for href, contact in local_by_href.items(): + if href in seen_hrefs or contact.deleted_at is not None: + continue + if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(contact, sync_source): _add_sync_plan_item( plan, AddressSyncPlanItem( action="conflict", href=href, - remote_uid=parsed.source_ref, - contact_id=local.id, - display_name=local.display_name, - etag=item.etag, - raw_vcard=raw_vcard, - parsed_payload=parsed.payload, - source_revision=item.etag or parsed.source_revision, - message="Local contact changed since the last successful sync.", + contact_id=contact.id, + display_name=contact.display_name, + etag=contact.source_revision, + message="Remote object is absent from full sync, but the local contact changed since the last successful sync.", ), ) - continue - - if reads_remote: + elif reads_remote: _add_sync_plan_item( plan, AddressSyncPlanItem( - action="update", + action="delete", href=href, - remote_uid=parsed.source_ref, - contact_id=local.id, - display_name=parsed.payload.display_name or local.display_name, - etag=item.etag, - raw_vcard=raw_vcard, - parsed_payload=parsed.payload, - source_revision=remote_revision, + contact_id=contact.id, + display_name=contact.display_name, + message="Remote object is absent from full sync.", ), ) - else: - _add_sync_plan_item(plan, AddressSyncPlanItem(action="unchanged", href=href, contact_id=local.id, display_name=local.display_name, etag=item.etag, message="Remote update ignored by export-only sync source.")) - - if plan.stats.full_sync: - for href, contact in local_by_href.items(): - if href not in seen_hrefs and contact.deleted_at is None: - if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(contact, sync_source): - _add_sync_plan_item( - plan, - AddressSyncPlanItem( - action="conflict", - href=href, - contact_id=contact.id, - display_name=contact.display_name, - etag=contact.source_revision, - message="Remote object is absent from full sync, but the local contact changed since the last successful sync.", - ), - ) - elif reads_remote: - _add_sync_plan_item(plan, AddressSyncPlanItem(action="delete", href=href, contact_id=contact.id, display_name=contact.display_name, message="Remote object is absent from full sync.")) - - _plan_carddav_outbound_local_changes(session, sync_source=sync_source, plan=plan) def _plan_carddav_outbound_local_changes(