0

Update Following Instances

by
Published today

Change an occurrence of a recurring event and every one after it, by splitting the series in two.

Script gcal Verified

The script

Submitted by hugo989 Typescript (fetch-only)
Verified 5 hours ago
1
//native
2

3
/**
4
 * Update Following Instances
5
 * Change one occurrence of a recurring event and all later ones ("this and following events"). Like Google Calendar, this splits the series: the original is cut off just before `instance_id` (from List Event Instances) and a new recurring event, with your changes, starts at that occurrence. Only the fields you set change; `start`/`end` (`YYYY-MM-DD` or RFC 3339) move the first new occurrence, `recurrence` (e.g. `["RRULE:FREQ=WEEKLY;BYDAY=TU"]`) replaces the rule. A COUNT limit is carried over as the occurrences that remain. Returns the trimmed original and the new series.
6
 */
7
export async function main(
8
  auth: RT.Gcal,
9
  instance_id: string,
10
  calendar_id: string = "primary",
11
  summary: string | undefined,
12
  description: string | undefined,
13
  location: string | undefined,
14
  start: string | undefined,
15
  end: string | undefined,
16
  time_zone: string | undefined,
17
  attendees: string[] | undefined,
18
  recurrence: string[] | undefined,
19
  send_updates: "all" | "externalOnly" | "none" = "all"
20
) {
21
  const base = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendar_id || "primary")}/events`
22
  const call = async (path: string, method = "GET", body?: object) => {
23
    const response = await fetch(`${base}${path}`, {
24
      method,
25
      headers: {
26
        Authorization: `Bearer ${auth.token}`,
27
        ...(body ? { "Content-Type": "application/json" } : {}),
28
        Accept: "application/json",
29
      },
30
      body: body ? JSON.stringify(body) : undefined,
31
    })
32
    if (!response.ok) {
33
      throw new Error(`${response.status} ${await response.text()}`)
34
    }
35
    return await response.json()
36
  }
37

38
  const instance = await call(`/${instance_id}`)
39
  if (!instance.recurringEventId) {
40
    throw new Error(`${instance_id} is not an instance of a recurring event`)
41
  }
42
  const series = await call(`/${instance.recurringEventId}`)
43
  const rules: string[] = series.recurrence ?? []
44

45
  // The original series ends just before this occurrence (UNTIL is inclusive).
46
  const original = instance.originalStartTime
47
  let until: string
48
  if (original.date) {
49
    const d = new Date(`${original.date}T00:00:00Z`)
50
    d.setUTCDate(d.getUTCDate() - 1)
51
    until = d.toISOString().slice(0, 10).replace(/-/g, "")
52
  } else {
53
    const d = new Date(new Date(original.dateTime).getTime() - 1000)
54
    until = d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "")
55
  }
56

57
  // COUNT counts from the series start, so the new series gets what remains.
58
  let remaining: number | undefined
59
  const count = rules.join(";").match(/;COUNT=(\d+)/)
60
  if (count) {
61
    const before = await call(
62
      `/${series.id}/instances?showDeleted=true&maxResults=2500&timeMax=${encodeURIComponent(original.dateTime ?? `${original.date}T00:00:00Z`)}`
63
    )
64
    remaining = Number(count[1]) - before.items.length
65
  }
66

67
  const trimmed = await call(
68
    `/${series.id}?sendUpdates=${send_updates || "all"}`,
69
    "PATCH",
70
    {
71
      recurrence: rules.map((r) =>
72
        r.startsWith("RRULE:")
73
          ? `${r.replace(/;(UNTIL|COUNT)=[^;]+/g, "")};UNTIL=${until}`
74
          : r
75
      ),
76
    }
77
  )
78

79
  const when = (v: string | undefined, current: any) =>
80
    !v
81
      ? { ...current, timeZone: time_zone || current.timeZone }
82
      : /^\d{4}-\d{2}-\d{2}$/.test(v)
83
        ? { date: v }
84
        : { dateTime: v, timeZone: time_zone || current.timeZone }
85
  const created = await call(`?sendUpdates=${send_updates || "all"}`, "POST", {
86
    summary: summary || series.summary,
87
    description: description || series.description,
88
    location: location || series.location,
89
    colorId: series.colorId,
90
    transparency: series.transparency,
91
    visibility: series.visibility,
92
    reminders: series.reminders,
93
    guestsCanModify: series.guestsCanModify,
94
    guestsCanInviteOthers: series.guestsCanInviteOthers,
95
    guestsCanSeeOtherGuests: series.guestsCanSeeOtherGuests,
96
    attendees: attendees?.length
97
      ? attendees.map((email) => ({ email }))
98
      : series.attendees?.map((a: any) => ({
99
          email: a.email,
100
          optional: a.optional,
101
        })),
102
    start: when(start, instance.start),
103
    end: when(end, instance.end),
104
    recurrence:
105
      (recurrence?.length ? recurrence : undefined) ??
106
      rules.map((r) =>
107
        r.startsWith("RRULE:") && remaining != null
108
          ? r.replace(/;COUNT=\d+/, `;COUNT=${remaining}`)
109
          : r
110
      ),
111
  })
112

113
  return { original: trimmed, new_series: created }
114
}
115