The design starts with a pure plan
Everything the plugin decides happens in one function. Calendar settings go in, a sync plan comes out: the events to process, and the events skipped with a reason. That function never reads or writes the vault. The dry-run preview and the real apply step both consume the exact same plan, so what you approve is what gets written.
feeds ─ fetch ─ parse ─ filters ─▶ SyncPlan
│
┌──────────────┴──────────────┐
preview modal apply step
(reads the plan) (reads the plan, writes notes)A preview that runs different code than apply is only a guess. Here they share the plan.
Feeds are plain ICS over HTTPS
Each calendar is read from its own private ICS address, through Obsidian's request API. There is no OAuth flow, no client secret, and no relay server, so there is nothing to register and nothing to host. The fetcher returns raw text or throws a specific, catchable error, and it checks that the response is actually calendar data before anything downstream sees it. A dead URL or an HTML login page fails loudly instead of parsing into an empty schedule.
Normalize first, then decide
Raw ICS is parsed with ical.js into one normalized event shape: UID, recurrence id, summary, organizer, attendees, start and end with their time zone, last modified, description, and status. Recurring events are expanded inside a configurable window of days back and days ahead, so the plan only ever contains dated occurrences.
A recurring series arrives as a master event plus any number of exceptions, one for each instance that was moved or edited. The parser pairs them using the UID and the recurrence id, so a single rescheduled Tuesday replaces its generated slot instead of showing up twice.
Filters are mechanical
Two filters run before a plan is built. The first is an exact title match for your own calendar blocks, the placeholders you put on a calendar that are not meetings. The second is a plain ignore list that you maintain by hand. Both are string comparisons with no scoring, no fuzzy matching, and no model in the loop. Every skipped event keeps the reason it was skipped, which is what the preview shows.
Identity is the ICS UID
A Meeting note is found by the event's UID, plus the recurrence id when the event is one modified instance of a series. Titles and times are never used to look a note up, because they are exactly what changes. Retitle a meeting or move it to Thursday and the same note is updated in place.
---
title: Quarterly planning sync
scheduled_start: 2026-10-08T15:00:00Z
scheduled_end: 2026-10-08T15:45:00Z
organizer: "[[Sam Rivera (example.com)]]"
series: "[[Quarterly planning]]"
ics_uid: [email protected]
ics_recurrence_id: 20261006T150000Z
---The recurrence id is the original start time of that one instance, in ICS's own format. It stays fixed even when the instance is moved, which is why this example starts on the 8th but keeps a recurrence id from the 6th. The UID names the series, and the recurrence id names which occurrence of it.
Matching goes through the metadata cache
Each attendee email is resolved against existing Person notes using Obsidian's metadata cache, checking the note's email and any entry in email_aliases. That handles the person who uses two addresses without any hand-rolled file parsing. When nothing matches, Graft creates a minimal stub: a name split into first and last, one company link, the email, and everything else blank. The next run finds the stub, so an unmatched attendee is created once and never duplicated.
Recurring events resolve to a Series note through the stored ICS UID. If a recurring master has no Series note yet, one is created immediately, on first sight. The recurrence rule already proves the meeting repeats, so there is no reason to wait for a few occurrences to accumulate. Every occurrence in that same run links to the new Series. Company links are validated against Company notes that exist, not trusted from the email domain.
Templates without a dependency
New notes are rendered by a small placeholder engine that fills values like {{title}}, {{organizer}}, and {{scheduled_start}}. It produces the same shape a Templater template would, without requiring Templater to be installed. A placeholder the engine does not recognize is left visibly unresolved instead of silently becoming an empty string, so a typo in a custom template shows up right away.
The plugin owns facts, you own prose
Updates go through Obsidian's front matter API and are limited to a fixed list of calendar-owned fields: title, channel, organizer, invitees, series, start and end, the invite-updated stamp, companies, and the two ICS identifiers. Fields like participants, actual start and end, transcripts, and project links are never touched, and neither is any body section other than the Invite Agenda.
The body has two agenda sections on purpose. The Series Agenda is copied once, from the Series note's standing agenda, when the note is created, and after that it belongs to you. The Invite Agenda mirrors the event description and is refreshed on every run. Notes you wrote by hand survive a resync unchanged.
Cancellations and disappearances
An occurrence is marked cancelled only when the feed says so explicitly with a cancelled status. An event that simply vanishes from a feed is different: it could be a cancellation, a permission change, or a broken feed. Graft surfaces it as a warning in the preview and changes nothing, so a flaky fetch can never quietly rewrite your vault.
Safety
A flaky fetch can never quietly rewrite your vault.