Refactor CardDAV report planning branches

This commit is contained in:
2026-07-21 12:53:51 +02:00
parent 70ee3c0148
commit 3ec4b3c4ad

View File

@@ -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(