Zum Inhalt springen

Adapters

Dieser Inhalt ist noch nicht in deiner Sprache verfügbar.

An adapter turns one provider’s API into Aperio’s cal-core vocabulary. Each lives in crates/adapter-<provider> as a normal Rust library and implements the cal-core feature traits it can support; a matching crates/adapter-<provider>-plugin exposes it over the plugin ABI.

A provider implements only the traits it can. Capabilities are declared in the plugin’s plugin.json and surfaced to the UI so it can hide affordances a backend can’t fulfil.

TraitProvides
Adapterbase: authenticate, capabilities
CalendarFeaturecalendars + events
TasksFeaturetask lists + tasks (+ optional sections, assignees, membership)
ContactsFeatureaddress books + contacts

A “tasks-only” provider like Todoist or Vikunja declares only ["tasks"]; a full provider like Google or Microsoft Graph declares calendars, tasks and contacts.

How the host reads data: full vs. delta vs. range

Section titled “How the host reads data: full vs. delta vs. range”

Adapters differ in how they enumerate changes, which the host must respect when caching:

  • Delta / sync-token providers return changes since a token (Google syncToken, Graph delta, CalDAV sync-collection, EWS SyncFolderItems). A 410 Gone/invalid token means “do a full resync”.
  • Folder-complete snapshots return the entire set (EWS events after the folder-sync rework, CalDAV/iCloud events via PROPFIND-enumeration + sync-collection, iCal feeds). These set ChangeSet.complete = true so the host stores an unbounded cache window and serves every later view range straight from the snapshot.
  • Range-scoped reads return only what overlaps a time window (Google/Graph event reads, plus the legacy CalDAV ctag fallback for servers without sync-collection). The host keeps a bounded cache window for these and re-fetches when the view moves outside it.

The host’s token is authoritative for a delta. get_*_delta(…, since_token) must compute its changes relative to since_token — the cursor the host’s cache is actually at — not any cursor the adapter persists for itself. Other host paths read the same provider on their own schedule (the reminder scanner calls get_events directly), so a stateful adapter that drains from its own advancing cursor would skip changes that path already consumed but the host never cached, stranding an edited event at its old time until a full resync. CalDAV is the model — sync-collection drains straight from the passed token; EWS learned this the hard way and now seeds its SyncFolderItems drain from since_token too.

Reads never block the first paint. get_events/get_tasks/ get_contacts serve whatever snapshot exists right now and run the refresh in the background (stale-while-revalidate). When it lands the host emits cache-updated and the view re-reads. A slow cold sync — e.g. a first iCloud full fetch — therefore fills in progressively instead of freezing startup for 20 s+.

Recurring events. Most adapters return the recurring master with its RRULE; the frontend expands occurrences for the visible range via rrule.js. Adapters must therefore pass a master through even when its first occurrence falls outside the requested window (it may still recur into it). Microsoft Graph is the exception — it uses /calendarView, which expands occurrences server-side.

A changed occurrence has an id of its own. CalDAV, Google and EWS keep an occurrence somebody changed apart from its series, and their adapters return it as an override with the id {series}::rid::{slot}. The slot is the instant the rule gives the occurrence, and it stays put when the occurrence is moved (cal_core::split_override_id reads it). update_event with such an id writes that one occurrence, and delete_event with it removes that one occurrence, so the series skips the slot from then on. That is also how a changed occurrence moved to another calendar leaves its source. An adapter that cannot find the occurrence in its slot fails the call. It never writes to or deletes the series instead, because a provider keeps no copy of what that overwrites. add_event_exdate on a slot that holds an override removes the override too, however far it was moved.

Attendee scheduling is server-side, never client SMTP. When the user opts to notify, the adapter asks the provider to email attendees: EWS flips SendMeetingInvitations* to SendToAllAndSaveCopy, Google appends ?sendUpdates=all, and Graph sends automatically once attendees are in the body. On CalDAV the server schedules by itself (RFC 6638, detected at discovery via schedule-outbox-URL): a new event gets ORGANIZER+ATTENDEE only when the server schedules and the user notifies. Every update reads the resource and carries ORGANIZER, ATTENDEE, SEQUENCE and STATUS back verbatim, changing rows only for a changed invitee list (scheduling::plan_block), because on such a server a PUT without ORGANIZER cancels the meeting. Each calendar carries a supports_scheduling flag — static for EWS/Google/Graph, runtime-detected for CalDAV — that gates the UI toggle. The transient send_invitations (on NewEvent/Event) and send_cancellations (on delete_event) ride the call, never the stored data. Note: on Graph, attendees are in the body only when notifying; on a scheduling CalDAV server the organizer’s copy is the invitation, so every saved change reaches the attendees (see always_notifies_attendees below).

The organizer is never an attendee. Providers list the organizer among the attendees, and an appointment made in Outlook lists nobody else. The rule lives in cal_core::attendee:

  • On read, each adapter hands its rows to people_from_read, which drops the organizer’s row from attendees and attendee_responses. The row is found by the provider’s own flag (EWS ResponseType “Organizer”, Graph response “organizer”, Google attendees[].organizer), or, where there is none (CalDAV), by the normalised address equal to the organizer. An unknown organizer drops nothing.
  • Only the organizer notifies. The adapter says whether the connected account organizes the event (EWS MyResponseType, Graph isOrganizer, Google organizer.self, CalDAV ORGANIZER equal to any of the account’s calendar-user addresses). The provider’s answer counts first, even without an organizer address. Without an answer, an event with an organizer is organized_elsewhere and one without is the account’s own (cal_core::attendee::organized_elsewhere). For an event organized elsewhere the editors offer no “notify attendees”, and a meeting provider attached to it invites nobody (host_core::meetings::meeting_guests).
  • On write, both hosts run host_core::event_write before any store sees the event: guard_update drops the organizer, clears send_invitations unless the account organizes the event and someone else is invited (or was, until this edit removed them), and sets keep_attendees when the edit left the invitees as the cache last read them. EWS, Google and Graph then leave the provider’s attendee list alone, so a title or time change never rewrites it. An empty list alone never clears the provider’s; clear_attendees, set when the edit removed every invitee the cache had read, does. guard_create does the same for a create; a create derived from an existing event carries that event’s organizer and organized_elsewhere (never sent) so it applies there too.

And some take only an attendee’s own changes. On a scheduling CalDAV server (iCloud), a meeting somebody else organizes accepts the attendee’s reply and the attendee’s own alarms and refuses everything else (RFC 6638 §3.2.2.1). Those calendars carry invitations_reply_only, and the editors show such a meeting read-only apart from those, with a delete that tells the organizer (decisions 77a, 83b; invitationLocked, declineSentence in @aperio/shared). Microsoft Graph leaves the flag false: it mails the guests by itself, but it takes an invitee’s edits.

Some providers always notify. On an RFC 6638 CalDAV server (iCloud) and on Microsoft Graph, a saved change to a meeting the account organizes and its deletion reach the attendees whatever the request says. Those calendars carry always_notifies_attendees (and a display-only notifier_name), and the shared rules (attendeeNotice, cancellationNotice in @aperio/shared) show a sentence that says who informs the attendees instead of a notify checkbox or a “remove without notifying” choice the provider would not keep (decisions 76a, 80a, 82b).

Free/busy lookup runs through get_free_busy(emails, range) and the host query_free_busy command (the dialog’s “Check availability” button). Each provider answers in its own dialect: EWS GetUserAvailability SOAP (RequestedView=Detailed, one MailboxData per address, results in request order), CalDAV/iCloud an RFC 6638 iTIP VFREEBUSY POSTed to the principal’s schedule-outbox-URL (busy periods parsed out of the schedule-response), Google POST /freeBusy, Graph POST /me/calendar/getSchedule. All degrade gracefully: a mailbox the server can’t resolve (or a provider that can’t answer) yields an empty slot list — “availability unknown” — rather than failing the call. Local/iCal calendars return empty.

RSVP rides three pieces: read-side population of Event.organizer + attendee_responses (per provider — CalDAV ATTENDEE;PARTSTAT, EWS ResponseType, Google/Graph responseStatus), current_user_email() for the “am I a non-organizer attendee?” gate (CalDAV calendar-user-address, Graph /me, Google primary-calendar id, EWS login), and respond_to_event(event_id, status, send_response): EWS AcceptItem/DeclineItem/TentativelyAcceptItem, Graph /accept|/decline|/tentativelyAccept, Google self-responseStatus patch + sendUpdates, CalDAV PARTSTAT PUT (RFC 6638 servers auto-emit the iTIP REPLY; Schedule-Reply: F suppresses it). NeedsAction isn’t respondable. The shim maps a null current_user_email slot to Ok(None) so read-only adapters hide RSVP rather than erroring.

Contact.emails, Contact.phone_numbers and Contact.urls are lists of ContactValue { value, label } — the label being free text, because two of the five contact providers store whatever word the user typed. Each provider records the same idea in its own way, and the adapter translates at its edge:

ProviderWhere the label livesFree labels?
CardDAVTYPE parameter; Apple’s grouped property + X-ABLabel for custom onesyes
Google Peopletype on each emailAddresses/phoneNumbers/urls entryyes
Exchange (EWS)the entry Key (MobilePhone, HomePhone, …)no — four voice slots + fax, three email slots
Microsoft Graphthe collection the value sits in (mobilePhone, homePhones, businessPhones)no — and mobilePhone holds exactly one
Local storestored verbatim as JSONyes

Three rules follow from that asymmetry:

  • The value always travels, the word may not. On a fixed-slot provider, a label with no slot of its own (or one already taken) falls back to the next free slot rather than dropping the value. What can’t be written at all is logged, never discarded silently. The fallback deliberately skips Exchange’s fax keys: a voice number filed under HomeFax would be dialled as a fax by every other client.
  • A masked write replaces, so read before you write. Google’s updatePersonFields clears any listed field the body omits. Aperio models one dated entry (the anniversary) but Google lets a contact carry several, so update_person re-reads events and passes the others back through — without that, renaming a contact deleted every custom date on it.
  • A bare string is still a legal channel. Everything stored before labels existed is a plain "max@example.com" on the wire and in the cache, so ContactValue deserialises both shapes and the frontends normalise via toContactValues from @aperio/shared.

Alongside the channels, a contact carries anniversary, job_title and department. Every provider has all three except Microsoft Graph, which has no anniversary property on contact in v1.0 — only birthday. It stays null on Outlook accounts rather than being faked onto another field.

It also carries the honorific name_prefix (“Prof. Dr.”) and name_suffix (“jun.”): vCard N components 4/5 on CardDAV, honorificPrefix / honorificSuffix on Google People, title / generation on Graph. EWS surfaces both only inside the read-only CompleteName wrapper, so the EWS adapter neither reads nor writes them — they stay null there rather than being written somewhere they could not round-trip.

AdapterCrateCapabilitiesProtocol
Local storeadapter-localcalendars, tasks, contactshost SQLite (source of truth)
CalDAV / iCloudadapter-caldavcalendars, tasks, contactsCalDAV / CardDAV
Googleadapter-googlecalendars, tasks, contactsGoogle REST APIs (OAuth2)
Microsoft Graphadapter-microsoft-graphcalendars, tasks, contactsMicrosoft Graph (OAuth2)
Exchange (EWS)adapter-ewscalendars, tasks, contactsExchange Web Services (SOAP)
Vikunjaadapter-vikunjatasksVikunja REST API
Todoistadapter-todoisttasksTodoist REST v2 (+ Sync API)

There is also an iCal/ICS subscription adapter (adapter-ical, read-only calendar feeds).

Each page below covers the protocol, the authentication flow, provider quirks worth knowing, and how to test the adapter.