Files
odysseus/src/caldav_writeback.py

299 lines
11 KiB
Python
Raw Normal View History

feat: CalDAV write-back — push local event create/update/delete to the remote (#800) (#1282) * feat: CalDAV write-back — push local event create/update/delete to the remote (#800) CalDAV sync was pull-only (src/caldav_sync.py), so events created, edited, or deleted in Odysseus on a CalDAV-backed calendar only changed local SQLite and never reached the server — they silently vanished on the next pull and never appeared on the user's phone (iCloud, etc.). This adds the missing write half: - src/caldav_writeback.py builds the VEVENT, re-discovers the remote calendar by the same URL-hash the local id was derived from (the remote URL isn't stored), and PUTs/DELETEs the event by UID via the caldav lib. The pure pieces (build_event_ical, find_remote_calendar, push_event) take inputs by argument so they unit-test against a fake client with no network. - create/update/delete event handlers (routes/calendar_routes.py) call it best-effort for caldav-sourced calendars only: the local DB stays the source of truth, a remote failure is logged, never fatal, and local calendars are untouched. Tests: tests/test_caldav_writeback.py (9, pure logic incl. iCal serialization, hash discovery, create/update/delete orchestration) and tests/test_caldav_writeback_route.py (3, route-level: a caldav calendar pushes, a local one does not, delete pushes a delete). 12 passed. Note: write-back re-discovers the remote calendar per write (the URL isn't persisted locally); a follow-up could cache it. Live-iCloud verification needs a real account — flagging for a maintainer pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drive #800 route regression without TestClient (fixes local hang) Same fix as the document route test: the CalDAV write-back route regression used Starlette TestClient (middleware app + threadpool) which hung in the maintainer's environment. Rework it to call the async create/delete calendar handlers directly — extracted from the router — with a minimal fake request, temp-SQLite-patched SessionLocal, and writeback_event stubbed to record calls. Same coverage (a caldav calendar pushes, a local one does not, delete pushes a delete), completes in ~0.3s with no TestClient. Verified the maintainer's exact batch: pytest tests/test_caldav_writeback.py tests/test_caldav_writeback_route.py -> 12 passed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:44:02 +08:00
"""CalDAV write-back: push local create/update/delete out to the remote (#800).
``src/caldav_sync.py`` is a one-way pull (remote → local). So events created,
edited, or deleted in Odysseus on a CalDAV-backed calendar only changed the local
SQLite copy and never reached the server (iCloud/Nextcloud/Radicale/Fastmail) —
they'd silently disappear on the next pull and never show on the user's phone.
This adds the missing write half. The remote calendar URL isn't stored locally
(the local calendar id is a one-way hash of it), so we re-discover the remote
calendar by matching that same hash, then PUT/DELETE the VEVENT by its UID via
the `caldav` lib. Writes are best-effort: the local DB stays the source of truth,
and a remote failure is reported, never fatal to the local operation.
The pure pieces (``build_event_ical``, ``find_remote_calendar``, ``push_event``)
take their inputs by argument so they unit-test against a fake client with no
network.
"""
import asyncio
import logging
from datetime import timezone
logger = logging.getLogger(__name__)
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
def _stable_cal_id(remote_url: str, owner: str = "", account_id: str = "") -> str:
# Reuse the sync module's hashing so owner+account_id scoping stays consistent.
feat: CalDAV write-back — push local event create/update/delete to the remote (#800) (#1282) * feat: CalDAV write-back — push local event create/update/delete to the remote (#800) CalDAV sync was pull-only (src/caldav_sync.py), so events created, edited, or deleted in Odysseus on a CalDAV-backed calendar only changed local SQLite and never reached the server — they silently vanished on the next pull and never appeared on the user's phone (iCloud, etc.). This adds the missing write half: - src/caldav_writeback.py builds the VEVENT, re-discovers the remote calendar by the same URL-hash the local id was derived from (the remote URL isn't stored), and PUTs/DELETEs the event by UID via the caldav lib. The pure pieces (build_event_ical, find_remote_calendar, push_event) take inputs by argument so they unit-test against a fake client with no network. - create/update/delete event handlers (routes/calendar_routes.py) call it best-effort for caldav-sourced calendars only: the local DB stays the source of truth, a remote failure is logged, never fatal, and local calendars are untouched. Tests: tests/test_caldav_writeback.py (9, pure logic incl. iCal serialization, hash discovery, create/update/delete orchestration) and tests/test_caldav_writeback_route.py (3, route-level: a caldav calendar pushes, a local one does not, delete pushes a delete). 12 passed. Note: write-back re-discovers the remote calendar per write (the URL isn't persisted locally); a follow-up could cache it. Live-iCloud verification needs a real account — flagging for a maintainer pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drive #800 route regression without TestClient (fixes local hang) Same fix as the document route test: the CalDAV write-back route regression used Starlette TestClient (middleware app + threadpool) which hung in the maintainer's environment. Rework it to call the async create/delete calendar handlers directly — extracted from the router — with a minimal fake request, temp-SQLite-patched SessionLocal, and writeback_event stubbed to record calls. Same coverage (a caldav calendar pushes, a local one does not, delete pushes a delete), completes in ~0.3s with no TestClient. Verified the maintainer's exact batch: pytest tests/test_caldav_writeback.py tests/test_caldav_writeback_route.py -> 12 passed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:44:02 +08:00
from src.caldav_sync import _stable_cal_id as _sync_id
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
return _sync_id(remote_url, owner=owner, account_id=account_id)
feat: CalDAV write-back — push local event create/update/delete to the remote (#800) (#1282) * feat: CalDAV write-back — push local event create/update/delete to the remote (#800) CalDAV sync was pull-only (src/caldav_sync.py), so events created, edited, or deleted in Odysseus on a CalDAV-backed calendar only changed local SQLite and never reached the server — they silently vanished on the next pull and never appeared on the user's phone (iCloud, etc.). This adds the missing write half: - src/caldav_writeback.py builds the VEVENT, re-discovers the remote calendar by the same URL-hash the local id was derived from (the remote URL isn't stored), and PUTs/DELETEs the event by UID via the caldav lib. The pure pieces (build_event_ical, find_remote_calendar, push_event) take inputs by argument so they unit-test against a fake client with no network. - create/update/delete event handlers (routes/calendar_routes.py) call it best-effort for caldav-sourced calendars only: the local DB stays the source of truth, a remote failure is logged, never fatal, and local calendars are untouched. Tests: tests/test_caldav_writeback.py (9, pure logic incl. iCal serialization, hash discovery, create/update/delete orchestration) and tests/test_caldav_writeback_route.py (3, route-level: a caldav calendar pushes, a local one does not, delete pushes a delete). 12 passed. Note: write-back re-discovers the remote calendar per write (the URL isn't persisted locally); a follow-up could cache it. Live-iCloud verification needs a real account — flagging for a maintainer pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drive #800 route regression without TestClient (fixes local hang) Same fix as the document route test: the CalDAV write-back route regression used Starlette TestClient (middleware app + threadpool) which hung in the maintainer's environment. Rework it to call the async create/delete calendar handlers directly — extracted from the router — with a minimal fake request, temp-SQLite-patched SessionLocal, and writeback_event stubbed to record calls. Same coverage (a caldav calendar pushes, a local one does not, delete pushes a delete), completes in ~0.3s with no TestClient. Verified the maintainer's exact batch: pytest tests/test_caldav_writeback.py tests/test_caldav_writeback_route.py -> 12 passed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:44:02 +08:00
def build_event_ical(ev: dict) -> str:
"""Serialize a local event dict to a VCALENDAR/VEVENT iCalendar string.
``ev`` keys: uid, summary, description, location, dtstart (datetime),
dtend (datetime), all_day (bool), is_utc (bool), rrule (str).
Mirrors how the pull path interprets is_utc/all_day so a round-trip is stable.
"""
from icalendar import Calendar, Event as iEvent
from icalendar.prop import vRecur
cal = Calendar()
cal.add("prodid", "-//Odysseus//CalDAV write-back//EN")
cal.add("version", "2.0")
ve = iEvent()
ve.add("uid", ev["uid"])
ve.add("summary", ev.get("summary") or "")
if ev.get("description"):
ve.add("description", ev["description"])
if ev.get("location"):
ve.add("location", ev["location"])
dtstart = ev["dtstart"]
dtend = ev["dtend"]
if ev.get("all_day"):
ve.add("dtstart", dtstart.date())
ve.add("dtend", dtend.date())
elif ev.get("is_utc"):
# Stored as naive-UTC instants — re-attach UTC so the server gets a Z time.
ve.add("dtstart", dtstart.replace(tzinfo=timezone.utc))
ve.add("dtend", dtend.replace(tzinfo=timezone.utc))
else:
# Legacy naive-local ("floating") time — emit without a TZ.
ve.add("dtstart", dtstart)
ve.add("dtend", dtend)
if ev.get("rrule"):
try:
ve.add("rrule", vRecur.from_ical(ev["rrule"]))
except Exception:
logger.debug("CalDAV write-back: skipping unparseable rrule %r", ev.get("rrule"))
cal.add_component(ve)
return cal.to_ical().decode("utf-8")
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
def find_remote_calendar(calendars, local_cal_id: str, owner: str = "", account_id: str = ""):
"""Find the remote calendar whose URL hashes to ``local_cal_id``, or None.
``owner`` and ``account_id`` must match what was used when the local calendar
id was originally computed in ``_sync_blocking`` so the hash round-trips."""
feat: CalDAV write-back — push local event create/update/delete to the remote (#800) (#1282) * feat: CalDAV write-back — push local event create/update/delete to the remote (#800) CalDAV sync was pull-only (src/caldav_sync.py), so events created, edited, or deleted in Odysseus on a CalDAV-backed calendar only changed local SQLite and never reached the server — they silently vanished on the next pull and never appeared on the user's phone (iCloud, etc.). This adds the missing write half: - src/caldav_writeback.py builds the VEVENT, re-discovers the remote calendar by the same URL-hash the local id was derived from (the remote URL isn't stored), and PUTs/DELETEs the event by UID via the caldav lib. The pure pieces (build_event_ical, find_remote_calendar, push_event) take inputs by argument so they unit-test against a fake client with no network. - create/update/delete event handlers (routes/calendar_routes.py) call it best-effort for caldav-sourced calendars only: the local DB stays the source of truth, a remote failure is logged, never fatal, and local calendars are untouched. Tests: tests/test_caldav_writeback.py (9, pure logic incl. iCal serialization, hash discovery, create/update/delete orchestration) and tests/test_caldav_writeback_route.py (3, route-level: a caldav calendar pushes, a local one does not, delete pushes a delete). 12 passed. Note: write-back re-discovers the remote calendar per write (the URL isn't persisted locally); a follow-up could cache it. Live-iCloud verification needs a real account — flagging for a maintainer pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drive #800 route regression without TestClient (fixes local hang) Same fix as the document route test: the CalDAV write-back route regression used Starlette TestClient (middleware app + threadpool) which hung in the maintainer's environment. Rework it to call the async create/delete calendar handlers directly — extracted from the router — with a minimal fake request, temp-SQLite-patched SessionLocal, and writeback_event stubbed to record calls. Same coverage (a caldav calendar pushes, a local one does not, delete pushes a delete), completes in ~0.3s with no TestClient. Verified the maintainer's exact batch: pytest tests/test_caldav_writeback.py tests/test_caldav_writeback_route.py -> 12 passed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:44:02 +08:00
for cal in calendars:
try:
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
if _stable_cal_id(str(cal.url), owner=owner, account_id=account_id) == local_cal_id:
feat: CalDAV write-back — push local event create/update/delete to the remote (#800) (#1282) * feat: CalDAV write-back — push local event create/update/delete to the remote (#800) CalDAV sync was pull-only (src/caldav_sync.py), so events created, edited, or deleted in Odysseus on a CalDAV-backed calendar only changed local SQLite and never reached the server — they silently vanished on the next pull and never appeared on the user's phone (iCloud, etc.). This adds the missing write half: - src/caldav_writeback.py builds the VEVENT, re-discovers the remote calendar by the same URL-hash the local id was derived from (the remote URL isn't stored), and PUTs/DELETEs the event by UID via the caldav lib. The pure pieces (build_event_ical, find_remote_calendar, push_event) take inputs by argument so they unit-test against a fake client with no network. - create/update/delete event handlers (routes/calendar_routes.py) call it best-effort for caldav-sourced calendars only: the local DB stays the source of truth, a remote failure is logged, never fatal, and local calendars are untouched. Tests: tests/test_caldav_writeback.py (9, pure logic incl. iCal serialization, hash discovery, create/update/delete orchestration) and tests/test_caldav_writeback_route.py (3, route-level: a caldav calendar pushes, a local one does not, delete pushes a delete). 12 passed. Note: write-back re-discovers the remote calendar per write (the URL isn't persisted locally); a follow-up could cache it. Live-iCloud verification needs a real account — flagging for a maintainer pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drive #800 route regression without TestClient (fixes local hang) Same fix as the document route test: the CalDAV write-back route regression used Starlette TestClient (middleware app + threadpool) which hung in the maintainer's environment. Rework it to call the async create/delete calendar handlers directly — extracted from the router — with a minimal fake request, temp-SQLite-patched SessionLocal, and writeback_event stubbed to record calls. Same coverage (a caldav calendar pushes, a local one does not, delete pushes a delete), completes in ~0.3s with no TestClient. Verified the maintainer's exact batch: pytest tests/test_caldav_writeback.py tests/test_caldav_writeback_route.py -> 12 passed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:44:02 +08:00
return cal
except Exception:
continue
return None
def _resource_href(obj) -> str:
try:
return str(getattr(obj, "url", "") or "")
except Exception:
return ""
def _resource_etag(obj) -> str:
try:
etag = getattr(obj, "etag", None)
if callable(etag):
etag = etag()
return str(etag or "")
except Exception:
return ""
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
owner: str = "", account_id: str = "") -> dict:
feat: CalDAV write-back — push local event create/update/delete to the remote (#800) (#1282) * feat: CalDAV write-back — push local event create/update/delete to the remote (#800) CalDAV sync was pull-only (src/caldav_sync.py), so events created, edited, or deleted in Odysseus on a CalDAV-backed calendar only changed local SQLite and never reached the server — they silently vanished on the next pull and never appeared on the user's phone (iCloud, etc.). This adds the missing write half: - src/caldav_writeback.py builds the VEVENT, re-discovers the remote calendar by the same URL-hash the local id was derived from (the remote URL isn't stored), and PUTs/DELETEs the event by UID via the caldav lib. The pure pieces (build_event_ical, find_remote_calendar, push_event) take inputs by argument so they unit-test against a fake client with no network. - create/update/delete event handlers (routes/calendar_routes.py) call it best-effort for caldav-sourced calendars only: the local DB stays the source of truth, a remote failure is logged, never fatal, and local calendars are untouched. Tests: tests/test_caldav_writeback.py (9, pure logic incl. iCal serialization, hash discovery, create/update/delete orchestration) and tests/test_caldav_writeback_route.py (3, route-level: a caldav calendar pushes, a local one does not, delete pushes a delete). 12 passed. Note: write-back re-discovers the remote calendar per write (the URL isn't persisted locally); a follow-up could cache it. Live-iCloud verification needs a real account — flagging for a maintainer pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drive #800 route regression without TestClient (fixes local hang) Same fix as the document route test: the CalDAV write-back route regression used Starlette TestClient (middleware app + threadpool) which hung in the maintainer's environment. Rework it to call the async create/delete calendar handlers directly — extracted from the router — with a minimal fake request, temp-SQLite-patched SessionLocal, and writeback_event stubbed to record calls. Same coverage (a caldav calendar pushes, a local one does not, delete pushes a delete), completes in ~0.3s with no TestClient. Verified the maintainer's exact batch: pytest tests/test_caldav_writeback.py tests/test_caldav_writeback_route.py -> 12 passed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:44:02 +08:00
"""Create/update (or delete) ``ev`` on the matching remote calendar.
Returns ``{"ok": bool, ...}``. ``calendars`` is the discovered caldav
calendar list (injected so this is unit-testable with fakes).
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
``owner`` and ``account_id`` are forwarded to ``find_remote_calendar``
so the URL hash round-trips correctly (#2765).
feat: CalDAV write-back — push local event create/update/delete to the remote (#800) (#1282) * feat: CalDAV write-back — push local event create/update/delete to the remote (#800) CalDAV sync was pull-only (src/caldav_sync.py), so events created, edited, or deleted in Odysseus on a CalDAV-backed calendar only changed local SQLite and never reached the server — they silently vanished on the next pull and never appeared on the user's phone (iCloud, etc.). This adds the missing write half: - src/caldav_writeback.py builds the VEVENT, re-discovers the remote calendar by the same URL-hash the local id was derived from (the remote URL isn't stored), and PUTs/DELETEs the event by UID via the caldav lib. The pure pieces (build_event_ical, find_remote_calendar, push_event) take inputs by argument so they unit-test against a fake client with no network. - create/update/delete event handlers (routes/calendar_routes.py) call it best-effort for caldav-sourced calendars only: the local DB stays the source of truth, a remote failure is logged, never fatal, and local calendars are untouched. Tests: tests/test_caldav_writeback.py (9, pure logic incl. iCal serialization, hash discovery, create/update/delete orchestration) and tests/test_caldav_writeback_route.py (3, route-level: a caldav calendar pushes, a local one does not, delete pushes a delete). 12 passed. Note: write-back re-discovers the remote calendar per write (the URL isn't persisted locally); a follow-up could cache it. Live-iCloud verification needs a real account — flagging for a maintainer pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drive #800 route regression without TestClient (fixes local hang) Same fix as the document route test: the CalDAV write-back route regression used Starlette TestClient (middleware app + threadpool) which hung in the maintainer's environment. Rework it to call the async create/delete calendar handlers directly — extracted from the router — with a minimal fake request, temp-SQLite-patched SessionLocal, and writeback_event stubbed to record calls. Same coverage (a caldav calendar pushes, a local one does not, delete pushes a delete), completes in ~0.3s with no TestClient. Verified the maintainer's exact batch: pytest tests/test_caldav_writeback.py tests/test_caldav_writeback_route.py -> 12 passed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:44:02 +08:00
"""
uid = (ev or {}).get("uid") if isinstance(ev, dict) else None
if not uid:
return {"ok": False, "error": "event uid is required"}
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
remote = find_remote_calendar(calendars, local_cal_id, owner=owner, account_id=account_id)
feat: CalDAV write-back — push local event create/update/delete to the remote (#800) (#1282) * feat: CalDAV write-back — push local event create/update/delete to the remote (#800) CalDAV sync was pull-only (src/caldav_sync.py), so events created, edited, or deleted in Odysseus on a CalDAV-backed calendar only changed local SQLite and never reached the server — they silently vanished on the next pull and never appeared on the user's phone (iCloud, etc.). This adds the missing write half: - src/caldav_writeback.py builds the VEVENT, re-discovers the remote calendar by the same URL-hash the local id was derived from (the remote URL isn't stored), and PUTs/DELETEs the event by UID via the caldav lib. The pure pieces (build_event_ical, find_remote_calendar, push_event) take inputs by argument so they unit-test against a fake client with no network. - create/update/delete event handlers (routes/calendar_routes.py) call it best-effort for caldav-sourced calendars only: the local DB stays the source of truth, a remote failure is logged, never fatal, and local calendars are untouched. Tests: tests/test_caldav_writeback.py (9, pure logic incl. iCal serialization, hash discovery, create/update/delete orchestration) and tests/test_caldav_writeback_route.py (3, route-level: a caldav calendar pushes, a local one does not, delete pushes a delete). 12 passed. Note: write-back re-discovers the remote calendar per write (the URL isn't persisted locally); a follow-up could cache it. Live-iCloud verification needs a real account — flagging for a maintainer pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drive #800 route regression without TestClient (fixes local hang) Same fix as the document route test: the CalDAV write-back route regression used Starlette TestClient (middleware app + threadpool) which hung in the maintainer's environment. Rework it to call the async create/delete calendar handlers directly — extracted from the router — with a minimal fake request, temp-SQLite-patched SessionLocal, and writeback_event stubbed to record calls. Same coverage (a caldav calendar pushes, a local one does not, delete pushes a delete), completes in ~0.3s with no TestClient. Verified the maintainer's exact batch: pytest tests/test_caldav_writeback.py tests/test_caldav_writeback_route.py -> 12 passed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:44:02 +08:00
if remote is None:
return {"ok": False, "error": "remote calendar not found"}
remote_url = str(getattr(remote, "url", "") or "")
feat: CalDAV write-back — push local event create/update/delete to the remote (#800) (#1282) * feat: CalDAV write-back — push local event create/update/delete to the remote (#800) CalDAV sync was pull-only (src/caldav_sync.py), so events created, edited, or deleted in Odysseus on a CalDAV-backed calendar only changed local SQLite and never reached the server — they silently vanished on the next pull and never appeared on the user's phone (iCloud, etc.). This adds the missing write half: - src/caldav_writeback.py builds the VEVENT, re-discovers the remote calendar by the same URL-hash the local id was derived from (the remote URL isn't stored), and PUTs/DELETEs the event by UID via the caldav lib. The pure pieces (build_event_ical, find_remote_calendar, push_event) take inputs by argument so they unit-test against a fake client with no network. - create/update/delete event handlers (routes/calendar_routes.py) call it best-effort for caldav-sourced calendars only: the local DB stays the source of truth, a remote failure is logged, never fatal, and local calendars are untouched. Tests: tests/test_caldav_writeback.py (9, pure logic incl. iCal serialization, hash discovery, create/update/delete orchestration) and tests/test_caldav_writeback_route.py (3, route-level: a caldav calendar pushes, a local one does not, delete pushes a delete). 12 passed. Note: write-back re-discovers the remote calendar per write (the URL isn't persisted locally); a follow-up could cache it. Live-iCloud verification needs a real account — flagging for a maintainer pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drive #800 route regression without TestClient (fixes local hang) Same fix as the document route test: the CalDAV write-back route regression used Starlette TestClient (middleware app + threadpool) which hung in the maintainer's environment. Rework it to call the async create/delete calendar handlers directly — extracted from the router — with a minimal fake request, temp-SQLite-patched SessionLocal, and writeback_event stubbed to record calls. Same coverage (a caldav calendar pushes, a local one does not, delete pushes a delete), completes in ~0.3s with no TestClient. Verified the maintainer's exact batch: pytest tests/test_caldav_writeback.py tests/test_caldav_writeback_route.py -> 12 passed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:44:02 +08:00
try:
existing = remote.event_by_uid(uid)
except Exception:
existing = None
if delete:
if existing is None:
return {"ok": True, "note": "already absent on remote", "calendar_url": remote_url}
feat: CalDAV write-back — push local event create/update/delete to the remote (#800) (#1282) * feat: CalDAV write-back — push local event create/update/delete to the remote (#800) CalDAV sync was pull-only (src/caldav_sync.py), so events created, edited, or deleted in Odysseus on a CalDAV-backed calendar only changed local SQLite and never reached the server — they silently vanished on the next pull and never appeared on the user's phone (iCloud, etc.). This adds the missing write half: - src/caldav_writeback.py builds the VEVENT, re-discovers the remote calendar by the same URL-hash the local id was derived from (the remote URL isn't stored), and PUTs/DELETEs the event by UID via the caldav lib. The pure pieces (build_event_ical, find_remote_calendar, push_event) take inputs by argument so they unit-test against a fake client with no network. - create/update/delete event handlers (routes/calendar_routes.py) call it best-effort for caldav-sourced calendars only: the local DB stays the source of truth, a remote failure is logged, never fatal, and local calendars are untouched. Tests: tests/test_caldav_writeback.py (9, pure logic incl. iCal serialization, hash discovery, create/update/delete orchestration) and tests/test_caldav_writeback_route.py (3, route-level: a caldav calendar pushes, a local one does not, delete pushes a delete). 12 passed. Note: write-back re-discovers the remote calendar per write (the URL isn't persisted locally); a follow-up could cache it. Live-iCloud verification needs a real account — flagging for a maintainer pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drive #800 route regression without TestClient (fixes local hang) Same fix as the document route test: the CalDAV write-back route regression used Starlette TestClient (middleware app + threadpool) which hung in the maintainer's environment. Rework it to call the async create/delete calendar handlers directly — extracted from the router — with a minimal fake request, temp-SQLite-patched SessionLocal, and writeback_event stubbed to record calls. Same coverage (a caldav calendar pushes, a local one does not, delete pushes a delete), completes in ~0.3s with no TestClient. Verified the maintainer's exact batch: pytest tests/test_caldav_writeback.py tests/test_caldav_writeback_route.py -> 12 passed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:44:02 +08:00
existing.delete()
return {
"ok": True,
"calendar_url": remote_url,
"remote_href": _resource_href(existing),
"remote_etag": _resource_etag(existing),
}
feat: CalDAV write-back — push local event create/update/delete to the remote (#800) (#1282) * feat: CalDAV write-back — push local event create/update/delete to the remote (#800) CalDAV sync was pull-only (src/caldav_sync.py), so events created, edited, or deleted in Odysseus on a CalDAV-backed calendar only changed local SQLite and never reached the server — they silently vanished on the next pull and never appeared on the user's phone (iCloud, etc.). This adds the missing write half: - src/caldav_writeback.py builds the VEVENT, re-discovers the remote calendar by the same URL-hash the local id was derived from (the remote URL isn't stored), and PUTs/DELETEs the event by UID via the caldav lib. The pure pieces (build_event_ical, find_remote_calendar, push_event) take inputs by argument so they unit-test against a fake client with no network. - create/update/delete event handlers (routes/calendar_routes.py) call it best-effort for caldav-sourced calendars only: the local DB stays the source of truth, a remote failure is logged, never fatal, and local calendars are untouched. Tests: tests/test_caldav_writeback.py (9, pure logic incl. iCal serialization, hash discovery, create/update/delete orchestration) and tests/test_caldav_writeback_route.py (3, route-level: a caldav calendar pushes, a local one does not, delete pushes a delete). 12 passed. Note: write-back re-discovers the remote calendar per write (the URL isn't persisted locally); a follow-up could cache it. Live-iCloud verification needs a real account — flagging for a maintainer pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drive #800 route regression without TestClient (fixes local hang) Same fix as the document route test: the CalDAV write-back route regression used Starlette TestClient (middleware app + threadpool) which hung in the maintainer's environment. Rework it to call the async create/delete calendar handlers directly — extracted from the router — with a minimal fake request, temp-SQLite-patched SessionLocal, and writeback_event stubbed to record calls. Same coverage (a caldav calendar pushes, a local one does not, delete pushes a delete), completes in ~0.3s with no TestClient. Verified the maintainer's exact batch: pytest tests/test_caldav_writeback.py tests/test_caldav_writeback_route.py -> 12 passed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:44:02 +08:00
ical = build_event_ical(ev)
if existing is not None:
existing.data = ical
existing.save()
return {
"ok": True,
"updated": True,
"calendar_url": remote_url,
"remote_href": _resource_href(existing),
"remote_etag": _resource_etag(existing),
}
created = remote.save_event(ical)
return {
"ok": True,
"created": True,
"calendar_url": remote_url,
"remote_href": _resource_href(created),
"remote_etag": _resource_etag(created),
}
feat: CalDAV write-back — push local event create/update/delete to the remote (#800) (#1282) * feat: CalDAV write-back — push local event create/update/delete to the remote (#800) CalDAV sync was pull-only (src/caldav_sync.py), so events created, edited, or deleted in Odysseus on a CalDAV-backed calendar only changed local SQLite and never reached the server — they silently vanished on the next pull and never appeared on the user's phone (iCloud, etc.). This adds the missing write half: - src/caldav_writeback.py builds the VEVENT, re-discovers the remote calendar by the same URL-hash the local id was derived from (the remote URL isn't stored), and PUTs/DELETEs the event by UID via the caldav lib. The pure pieces (build_event_ical, find_remote_calendar, push_event) take inputs by argument so they unit-test against a fake client with no network. - create/update/delete event handlers (routes/calendar_routes.py) call it best-effort for caldav-sourced calendars only: the local DB stays the source of truth, a remote failure is logged, never fatal, and local calendars are untouched. Tests: tests/test_caldav_writeback.py (9, pure logic incl. iCal serialization, hash discovery, create/update/delete orchestration) and tests/test_caldav_writeback_route.py (3, route-level: a caldav calendar pushes, a local one does not, delete pushes a delete). 12 passed. Note: write-back re-discovers the remote calendar per write (the URL isn't persisted locally); a follow-up could cache it. Live-iCloud verification needs a real account — flagging for a maintainer pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drive #800 route regression without TestClient (fixes local hang) Same fix as the document route test: the CalDAV write-back route regression used Starlette TestClient (middleware app + threadpool) which hung in the maintainer's environment. Rework it to call the async create/delete calendar handlers directly — extracted from the router — with a minimal fake request, temp-SQLite-patched SessionLocal, and writeback_event stubbed to record calls. Same coverage (a caldav calendar pushes, a local one does not, delete pushes a delete), completes in ~0.3s with no TestClient. Verified the maintainer's exact batch: pytest tests/test_caldav_writeback.py tests/test_caldav_writeback_route.py -> 12 passed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:44:02 +08:00
def _discover_calendars(client):
"""Discover the principal's calendars, falling back to the URL itself —
same strategy as the pull path."""
from caldav.lib.error import AuthorizationError, NotFoundError
try:
return client.principal().calendars()
except (AuthorizationError, NotFoundError):
raise
except Exception:
try:
return [client.calendar(url=str(client.url))]
except Exception:
return []
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
def _writeback_blocking(local_cal_id, ev, delete, url, username, password,
owner="", account_id="") -> dict:
from src.caldav_sync import _build_dav_client
# Redirects disabled here too: the write-back path opens its own DAVClient,
# so it needs the same SSRF-via-redirect protection as the pull path.
client = _build_dav_client(url, username, password)
feat: CalDAV write-back — push local event create/update/delete to the remote (#800) (#1282) * feat: CalDAV write-back — push local event create/update/delete to the remote (#800) CalDAV sync was pull-only (src/caldav_sync.py), so events created, edited, or deleted in Odysseus on a CalDAV-backed calendar only changed local SQLite and never reached the server — they silently vanished on the next pull and never appeared on the user's phone (iCloud, etc.). This adds the missing write half: - src/caldav_writeback.py builds the VEVENT, re-discovers the remote calendar by the same URL-hash the local id was derived from (the remote URL isn't stored), and PUTs/DELETEs the event by UID via the caldav lib. The pure pieces (build_event_ical, find_remote_calendar, push_event) take inputs by argument so they unit-test against a fake client with no network. - create/update/delete event handlers (routes/calendar_routes.py) call it best-effort for caldav-sourced calendars only: the local DB stays the source of truth, a remote failure is logged, never fatal, and local calendars are untouched. Tests: tests/test_caldav_writeback.py (9, pure logic incl. iCal serialization, hash discovery, create/update/delete orchestration) and tests/test_caldav_writeback_route.py (3, route-level: a caldav calendar pushes, a local one does not, delete pushes a delete). 12 passed. Note: write-back re-discovers the remote calendar per write (the URL isn't persisted locally); a follow-up could cache it. Live-iCloud verification needs a real account — flagging for a maintainer pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drive #800 route regression without TestClient (fixes local hang) Same fix as the document route test: the CalDAV write-back route regression used Starlette TestClient (middleware app + threadpool) which hung in the maintainer's environment. Rework it to call the async create/delete calendar handlers directly — extracted from the router — with a minimal fake request, temp-SQLite-patched SessionLocal, and writeback_event stubbed to record calls. Same coverage (a caldav calendar pushes, a local one does not, delete pushes a delete), completes in ~0.3s with no TestClient. Verified the maintainer's exact batch: pytest tests/test_caldav_writeback.py tests/test_caldav_writeback_route.py -> 12 passed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:44:02 +08:00
calendars = _discover_calendars(client)
if not calendars:
return {"ok": False, "error": "no remote calendars discovered"}
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
return push_event(calendars, local_cal_id, ev, delete=delete,
owner=owner, account_id=account_id)
feat: CalDAV write-back — push local event create/update/delete to the remote (#800) (#1282) * feat: CalDAV write-back — push local event create/update/delete to the remote (#800) CalDAV sync was pull-only (src/caldav_sync.py), so events created, edited, or deleted in Odysseus on a CalDAV-backed calendar only changed local SQLite and never reached the server — they silently vanished on the next pull and never appeared on the user's phone (iCloud, etc.). This adds the missing write half: - src/caldav_writeback.py builds the VEVENT, re-discovers the remote calendar by the same URL-hash the local id was derived from (the remote URL isn't stored), and PUTs/DELETEs the event by UID via the caldav lib. The pure pieces (build_event_ical, find_remote_calendar, push_event) take inputs by argument so they unit-test against a fake client with no network. - create/update/delete event handlers (routes/calendar_routes.py) call it best-effort for caldav-sourced calendars only: the local DB stays the source of truth, a remote failure is logged, never fatal, and local calendars are untouched. Tests: tests/test_caldav_writeback.py (9, pure logic incl. iCal serialization, hash discovery, create/update/delete orchestration) and tests/test_caldav_writeback_route.py (3, route-level: a caldav calendar pushes, a local one does not, delete pushes a delete). 12 passed. Note: write-back re-discovers the remote calendar per write (the URL isn't persisted locally); a follow-up could cache it. Live-iCloud verification needs a real account — flagging for a maintainer pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drive #800 route regression without TestClient (fixes local hang) Same fix as the document route test: the CalDAV write-back route regression used Starlette TestClient (middleware app + threadpool) which hung in the maintainer's environment. Rework it to call the async create/delete calendar handlers directly — extracted from the router — with a minimal fake request, temp-SQLite-patched SessionLocal, and writeback_event stubbed to record calls. Same coverage (a caldav calendar pushes, a local one does not, delete pushes a delete), completes in ~0.3s with no TestClient. Verified the maintainer's exact batch: pytest tests/test_caldav_writeback.py tests/test_caldav_writeback_route.py -> 12 passed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:44:02 +08:00
def _persist_writeback_result(owner: str, calendar_id: str, uid: str, result: dict, *, delete: bool) -> None:
from core.database import CalendarCal, CalendarDeletedEvent, CalendarEvent, SessionLocal
if not uid or not isinstance(result, dict):
return
db = SessionLocal()
try:
calendar = db.query(CalendarCal).filter(
CalendarCal.id == calendar_id,
CalendarCal.owner == owner,
).first()
if calendar and result.get("calendar_url"):
calendar.caldav_base_url = result.get("calendar_url")
if delete:
tombstone = db.query(CalendarDeletedEvent).filter(
CalendarDeletedEvent.uid == uid,
CalendarDeletedEvent.owner == owner,
).first()
if result.get("ok"):
if tombstone:
db.delete(tombstone)
elif tombstone:
tombstone.last_error = str(result.get("error") or result)[:500]
db.commit()
return
event = (
db.query(CalendarEvent)
.join(CalendarCal)
.filter(CalendarEvent.uid == uid, CalendarCal.owner == owner)
.first()
)
if event and result.get("ok"):
if result.get("remote_href"):
event.remote_href = result.get("remote_href")
if result.get("remote_etag"):
event.remote_etag = result.get("remote_etag")
event.caldav_sync_pending = None
db.commit()
except Exception:
db.rollback()
logger.exception("CalDAV write-back metadata persistence failed")
finally:
db.close()
feat: CalDAV write-back — push local event create/update/delete to the remote (#800) (#1282) * feat: CalDAV write-back — push local event create/update/delete to the remote (#800) CalDAV sync was pull-only (src/caldav_sync.py), so events created, edited, or deleted in Odysseus on a CalDAV-backed calendar only changed local SQLite and never reached the server — they silently vanished on the next pull and never appeared on the user's phone (iCloud, etc.). This adds the missing write half: - src/caldav_writeback.py builds the VEVENT, re-discovers the remote calendar by the same URL-hash the local id was derived from (the remote URL isn't stored), and PUTs/DELETEs the event by UID via the caldav lib. The pure pieces (build_event_ical, find_remote_calendar, push_event) take inputs by argument so they unit-test against a fake client with no network. - create/update/delete event handlers (routes/calendar_routes.py) call it best-effort for caldav-sourced calendars only: the local DB stays the source of truth, a remote failure is logged, never fatal, and local calendars are untouched. Tests: tests/test_caldav_writeback.py (9, pure logic incl. iCal serialization, hash discovery, create/update/delete orchestration) and tests/test_caldav_writeback_route.py (3, route-level: a caldav calendar pushes, a local one does not, delete pushes a delete). 12 passed. Note: write-back re-discovers the remote calendar per write (the URL isn't persisted locally); a follow-up could cache it. Live-iCloud verification needs a real account — flagging for a maintainer pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drive #800 route regression without TestClient (fixes local hang) Same fix as the document route test: the CalDAV write-back route regression used Starlette TestClient (middleware app + threadpool) which hung in the maintainer's environment. Rework it to call the async create/delete calendar handlers directly — extracted from the router — with a minimal fake request, temp-SQLite-patched SessionLocal, and writeback_event stubbed to record calls. Same coverage (a caldav calendar pushes, a local one does not, delete pushes a delete), completes in ~0.3s with no TestClient. Verified the maintainer's exact batch: pytest tests/test_caldav_writeback.py tests/test_caldav_writeback_route.py -> 12 passed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:44:02 +08:00
async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
ev: dict, *, delete: bool = False) -> dict:
"""Best-effort push of a local change to the remote CalDAV server.
No-ops (``{"skipped": ...}``) when the calendar isn't CalDAV-backed or no
credentials are configured. Never raises — a remote failure is logged and
returned, the local DB remaining the source of truth.
"""
if calendar_source != "caldav":
return {"skipped": "not a caldav calendar"}
try:
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
from src.caldav_sync import _load_caldav_accounts
from src.secret_storage import decrypt
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
from core.database import CalendarCal, SessionLocal
accounts = _load_caldav_accounts(owner)
if not accounts:
feat: CalDAV write-back — push local event create/update/delete to the remote (#800) (#1282) * feat: CalDAV write-back — push local event create/update/delete to the remote (#800) CalDAV sync was pull-only (src/caldav_sync.py), so events created, edited, or deleted in Odysseus on a CalDAV-backed calendar only changed local SQLite and never reached the server — they silently vanished on the next pull and never appeared on the user's phone (iCloud, etc.). This adds the missing write half: - src/caldav_writeback.py builds the VEVENT, re-discovers the remote calendar by the same URL-hash the local id was derived from (the remote URL isn't stored), and PUTs/DELETEs the event by UID via the caldav lib. The pure pieces (build_event_ical, find_remote_calendar, push_event) take inputs by argument so they unit-test against a fake client with no network. - create/update/delete event handlers (routes/calendar_routes.py) call it best-effort for caldav-sourced calendars only: the local DB stays the source of truth, a remote failure is logged, never fatal, and local calendars are untouched. Tests: tests/test_caldav_writeback.py (9, pure logic incl. iCal serialization, hash discovery, create/update/delete orchestration) and tests/test_caldav_writeback_route.py (3, route-level: a caldav calendar pushes, a local one does not, delete pushes a delete). 12 passed. Note: write-back re-discovers the remote calendar per write (the URL isn't persisted locally); a follow-up could cache it. Live-iCloud verification needs a real account — flagging for a maintainer pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drive #800 route regression without TestClient (fixes local hang) Same fix as the document route test: the CalDAV write-back route regression used Starlette TestClient (middleware app + threadpool) which hung in the maintainer's environment. Rework it to call the async create/delete calendar handlers directly — extracted from the router — with a minimal fake request, temp-SQLite-patched SessionLocal, and writeback_event stubbed to record calls. Same coverage (a caldav calendar pushes, a local one does not, delete pushes a delete), completes in ~0.3s with no TestClient. Verified the maintainer's exact batch: pytest tests/test_caldav_writeback.py tests/test_caldav_writeback_route.py -> 12 passed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:44:02 +08:00
return {"skipped": "caldav not configured"}
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
# Find which account owns this calendar.
acc = None
if len(accounts) > 1:
db = SessionLocal()
try:
cal_row = db.query(CalendarCal).filter(CalendarCal.id == calendar_id).first()
cal_account_id = cal_row.account_id if cal_row else None
finally:
db.close()
if cal_account_id:
acc = next((a for a in accounts if a.get("id") == cal_account_id), None)
# Fall back to first account (covers single-account and legacy rows with
# no account_id stamped).
if acc is None:
acc = accounts[0]
url = (acc.get("url") or "").strip()
user = (acc.get("username") or "").strip()
pw = decrypt(acc.get("password") or "")
if not (url and user and pw):
return {"skipped": "caldav account credentials incomplete"}
from src.caldav_sync import validate_caldav_url
try:
url = validate_caldav_url(url)
except ValueError as e:
logger.warning("CalDAV write-back URL rejected: %s", e)
return {"ok": False, "error": str(e)[:200]}
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
acc_id = acc.get("id") or ""
result = await asyncio.to_thread(
_writeback_blocking, calendar_id, ev, delete, url, user, pw, owner, acc_id
)
_persist_writeback_result(owner, calendar_id, (ev or {}).get("uid", ""), result, delete=delete)
feat: CalDAV write-back — push local event create/update/delete to the remote (#800) (#1282) * feat: CalDAV write-back — push local event create/update/delete to the remote (#800) CalDAV sync was pull-only (src/caldav_sync.py), so events created, edited, or deleted in Odysseus on a CalDAV-backed calendar only changed local SQLite and never reached the server — they silently vanished on the next pull and never appeared on the user's phone (iCloud, etc.). This adds the missing write half: - src/caldav_writeback.py builds the VEVENT, re-discovers the remote calendar by the same URL-hash the local id was derived from (the remote URL isn't stored), and PUTs/DELETEs the event by UID via the caldav lib. The pure pieces (build_event_ical, find_remote_calendar, push_event) take inputs by argument so they unit-test against a fake client with no network. - create/update/delete event handlers (routes/calendar_routes.py) call it best-effort for caldav-sourced calendars only: the local DB stays the source of truth, a remote failure is logged, never fatal, and local calendars are untouched. Tests: tests/test_caldav_writeback.py (9, pure logic incl. iCal serialization, hash discovery, create/update/delete orchestration) and tests/test_caldav_writeback_route.py (3, route-level: a caldav calendar pushes, a local one does not, delete pushes a delete). 12 passed. Note: write-back re-discovers the remote calendar per write (the URL isn't persisted locally); a follow-up could cache it. Live-iCloud verification needs a real account — flagging for a maintainer pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: drive #800 route regression without TestClient (fixes local hang) Same fix as the document route test: the CalDAV write-back route regression used Starlette TestClient (middleware app + threadpool) which hung in the maintainer's environment. Rework it to call the async create/delete calendar handlers directly — extracted from the router — with a minimal fake request, temp-SQLite-patched SessionLocal, and writeback_event stubbed to record calls. Same coverage (a caldav calendar pushes, a local one does not, delete pushes a delete), completes in ~0.3s with no TestClient. Verified the maintainer's exact batch: pytest tests/test_caldav_writeback.py tests/test_caldav_writeback_route.py -> 12 passed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:44:02 +08:00
if not result.get("ok"):
logger.warning("CalDAV write-back did not apply: %s", result.get("error") or result)
return result
except Exception as e:
logger.exception("CalDAV write-back raised")
result = {"ok": False, "error": str(e)[:200]}
_persist_writeback_result(owner, calendar_id, (ev or {}).get("uid", ""), result, delete=delete)
return result