Skip to content
Skip to chapter

Chapter 17

Workspace services

Create and update Calendar events

Create a timed and an all-day event on a practice calendar with CalendarApp, store each iCalUID, and update the same events on later runs.

By the end

Create and update timed and all-day practice events by their stored `iCalUID`, keep time zones in UTC, and recover when you cannot tell whether a create succeeded.

Chapter navigation

Create two planning events in a secondary Google Calendar you own, then change their titles, descriptions, or dates on later runs without creating duplicates. One event is a one-hour timed event, and the other is a two-day all-day event. The script saves each created event's iCalUID in User Properties to find it on later runs. You need the manifest and scope knowledge from Understand the runtime and permissions and the per-row completion markers from Read and write Sheets in batches.

Create a secondary practice calendar

Every Google account has a primary calendar and can own secondary calendars. CalendarApp.getDefaultCalendar() returns the running user's primary calendar. This project instead selects a secondary calendar by ID with CalendarApp.getCalendarById(calendarId). It checks that your account owns the calendar, that it is not your primary calendar, that it is named Book Calendar Practice, and that its time zone is UTC (see the Calendar reference).

  1. In Google Calendar on the web, click Add other calendars next to Other calendars and choose Create new calendar (see create a new calendar). Name it Book Calendar Practice.

  2. Open that calendar's Settings and sharing, set its Time zone to UTC, and keep it private.

  3. Under Event notifications and All-day event notifications, remove all default notifications. Under Other notifications, set each category to None (see Calendar notifications).

  4. Under Integrate calendar, copy the Calendar ID string.

Distinguish an instant from a calendar date

A JavaScript Date stores a millisecond timestamp relative to the Unix epoch. Two ISO strings with different UTC offsets can represent the same point in time: 2030-06-17T13:00:00Z and 2030-06-17T09:00:00-04:00 both represent 13:00 UTC.

An all-day date such as 2030-06-18 names a calendar day, not an instant:

Stable source keyKindStartEnd, exclusiveIntended result
plan-reviewtimed2030-06-17T13:00:00Z2030-06-17T14:00:00ZOne hour on June 17 at 13:00 UTC
quiet-daysall-day2030-06-182030-06-20June 18 and June 19 (June 20 is exclusive)

calendar.createEvent(title, start, end, options) creates a timed event. calendar.createAllDayEvent(title, startDate, endDate, options) creates an all-day event. It ignores the time of day in each Date, and endDate is exclusive.

Keep the script, calendar, and display zones separate

Three time-zone settings affect Calendar workflows (see Session.getScriptTimeZone() and Calendar time zones):

  • The script time zone (Session.getScriptTimeZone()), set to Etc/UTC in appsscript.json.

  • The calendar time zone (calendar.getTimeZone()), set to UTC in the calendar settings above.

  • The viewer's display time zone in the Google Calendar web UI, which changes how timed events appear on screen without changing their stored timestamp.

Keeping both the script and the calendar in UTC (Etc/UTC, Etc/GMT, UTC, or GMT) avoids a day shift when the script compares all-day dates. getAllDayStartDate() and getAllDayEndDate() return midnight in the script's time zone. By contrast, getStartTime() and getEndTime() use the calendar's time zone for all-day boundaries.

Parse a narrow date format

parseCalendarDate_ accepts YYYY-MM-DDTHH:mm:ssZ for timed events and YYYY-MM-DD for all-day events. It checks the format with a regular expression, builds a Date, and checks that getTime() is finite. It formats the Date back to an ISO string and compares it with the input. This comparison rejects dates such as 2030-06-31, which Date silently converts to July 1.

readCalendarItems_ also checks that every event falls within 2030-06-15 through 2030-06-22 UTC (2030-06-23T00:00:00Z exclusive).

In standard ECMAScript Date.parse, a date-only string (YYYY-MM-DD) is parsed as UTC. A date-time string without an offset (YYYY-MM-DDTHH:mm:ss) is parsed in the local time zone instead. Requiring the trailing Z on timed strings avoids local-time ambiguity.

Regional time zones also change their UTC offset at daylight-saving transitions. During the fall-back transition in America/New_York on November 5, 2017, 2017-11-05T01:30:00-04:00 (05:30:00.000Z) and 2017-11-05T01:30:00-05:00 (06:30:00.000Z) were 60 minutes apart. Even so, both formatted as 01:30 local time. Run illustrateCalendarInstants after installing the project to see Utilities.formatDate render both timestamps in Etc/UTC and America/New_York.

Install the complete project

  1. At script.google.com, create a New project named Calendar plan practice.

  2. Replace Code.gs with the complete project file below. In Project Settings, enable Show "appsscript.json" manifest file in editor, replace appsscript.json with the project manifest, and save both files.

  3. In Code.gs, set CALENDAR_WORKFLOW.calendarId to your Book Calendar Practice calendar ID, and set setupConfirmation to 'PRIVATE UTC CALENDAR WITHOUT NOTIFICATIONS'.

  4. Keep allowEventWrites: false and the three recovery fields set to ''.

The manifest requests https://www.googleapis.com/auth/calendar, which authorizes reading and writing calendars accessible to the executing user (see the authorization guide).

Code.gs
const CALENDAR_WORKFLOW = {
  calendarId: '',
  setupConfirmation: '', // PRIVATE UTC CALENDAR WITHOUT NOTIFICATIONS
  allowEventWrites: false,
  recoveryKey: '',
  recoveryICalUID: '',
  recoveryConfirmation: '',
};

// Supplied fictional data. Keep each key and kind stable after its first attempt.
const CALENDAR_ITEMS = [
  {
    key: 'plan-review',
    kind: 'timed',
    title: 'Exhibit plan review',
    start: '2030-06-17T13:00:00Z',
    end: '2030-06-17T14:00:00Z',
    description: 'Fictional rehearsal. Review the exhibit layout.',
  },
  {
    key: 'quiet-days',
    kind: 'all-day',
    title: 'Quiet preparation days',
    start: '2030-06-18',
    end: '2030-06-20', // Exclusive: June 18 and June 19 are included.
    description: 'Fictional rehearsal. Prepare the display notes.',
  },
];

const CALENDAR_LEDGER_KEY = 'calendar-workflows.v1';
const PRACTICE_START = '2030-06-15T00:00:00Z';
const PRACTICE_END = '2030-06-23T00:00:00Z';
const MAX_CALENDAR_ITEMS = 3;
const RECOVERY_PAGE_SIZE = 10;

function previewCalendarPlan() {
  const context = readCalendarContext_();
  const plan = buildCalendarPlan_(context);
  for (const entry of plan) {
    console.log(JSON.stringify({
      key: entry.item.key,
      kind: entry.item.kind,
      title: entry.item.title,
      start: entry.item.start.toISOString(),
      endExclusive: entry.item.end.toISOString(),
      state: entry.record ? entry.record.state : 'NEW',
      iCalUID: entry.record ? entry.record.iCalUID : '',
      action: entry.action,
      changes: entry.changes,
    }));
  }
  console.log(JSON.stringify({
    plannedItems: plan.length,
    trackedItems: context.ledger.records.length,
    blocked: plan.some(/** @param {*} entry */ (entry) => entry.action === 'BLOCKED'),
    eventWrites: 0,
  }));
}

function applyCalendarPlan() {
  if (CALENDAR_WORKFLOW.allowEventWrites !== true) {
    throw new Error('Event writes are disabled. Preview and review before enabling them.');
  }
  withCalendarUserLock_(() => {
    const context = readCalendarContext_();
    const plan = buildCalendarPlan_(context);
    if (plan.some(/** @param {*} entry */ (entry) => entry.action === 'BLOCKED')) {
      throw new Error('An uncertain create blocks all event writes. Inspect and reconcile it.');
    }
    for (const entry of plan) {
      if (entry.action === 'CREATE') {
        createCalendarItem_(context, entry.item);
      } else if (entry.action === 'UPDATE') {
        updateCalendarItem_(context.ledger, entry);
      } else if (entry.action === 'CHECKPOINT_ONLY') {
        entry.record.state = 'READY';
        saveCalendarLedger_(context.ledger);
      }
      console.log(JSON.stringify({ key: entry.item.key, action: entry.action }));
    }
  });
}

function inspectCalendarLedger() {
  console.log(JSON.stringify(readCalendarLedger_()));
}

function findCalendarRecoveryCandidates() {
  const context = readCalendarContext_();
  const record = requireUncertainRecord_(context.ledger);
  const scan = scanCalendarRecovery_(context.calendar, record);
  console.log(JSON.stringify({
    key: record.key,
    marker: record.marker,
    windowStart: PRACTICE_START,
    windowEndExclusive: PRACTICE_END,
    pageMayBeTruncated: scan.truncated,
    matchingCandidates: scan.matches.map(/** @param {CalendarApp.CalendarEvent} event */ (event) => ({
      iCalUID: event.getId(),
      title: event.getTitle(),
      allDay: event.isAllDayEvent(),
      start: event.getStartTime().toISOString(),
      endExclusive: event.getEndTime().toISOString(),
    })),
  }));
}

function adoptCalendarRecovery() {
  withCalendarUserLock_(() => {
    const context = readCalendarContext_();
    const record = requireUncertainRecord_(context.ledger);
    const id = CALENDAR_WORKFLOW.recoveryICalUID;
    const confirmation = 'ADOPT ' + id + ' FOR ' + record.key;
    if (!isCalendarText_(id, 512) || CALENDAR_WORKFLOW.recoveryConfirmation !== confirmation) {
      throw new Error('Supply the inspected CalendarApp iCalUID and exact ADOPT confirmation.');
    }
    const scan = scanCalendarRecovery_(context.calendar, record);
    if (scan.truncated || scan.matches.length !== 1 || scan.matches[0].getId() !== id) {
      throw new Error('Adoption requires one matching candidate in a non-full recovery page. Stop and inspect.');
    }
    const item = context.items.find(/** @param {*} candidate */ (candidate) => candidate.key === record.key);
    requireSafeCalendarEvent_(scan.matches[0], record, item);
    if (context.ledger.records.some(/** @param {*} candidate */ (candidate) => candidate.iCalUID === id)) {
      throw new Error('This event identity is already recorded. Preserve the ledger.');
    }
    record.iCalUID = id;
    record.state = 'ID_KNOWN';
    saveCalendarLedger_(context.ledger);
    console.log(JSON.stringify({ key: record.key, state: record.state, iCalUID: id }));
  });
}

function illustrateCalendarInstants() {
  // This function only calculates and logs. It does not read or write Calendar.
  const first = new Date('2017-11-05T01:30:00-04:00');
  const second = new Date('2017-11-05T01:30:00-05:00');
  console.log(JSON.stringify({
    firstUtc: first.toISOString(),
    secondUtc: second.toISOString(),
    minutesApart: (second.getTime() - first.getTime()) / 60000,
    firstInNewYork: Utilities.formatDate(first, 'America/New_York', 'yyyy-MM-dd HH:mm Z'),
    secondInNewYork: Utilities.formatDate(second, 'America/New_York', 'yyyy-MM-dd HH:mm Z'),
  }));
}

function readCalendarContext_() {
  const config = CALENDAR_WORKFLOW;
  if (!isCalendarText_(config.calendarId, 256) || /\s|\//.test(config.calendarId) ||
      config.calendarId.toLowerCase() === 'primary') {
    throw new Error('Set the exact ID of the new secondary practice calendar, not a URL or primary.');
  }
  if (config.setupConfirmation !== 'PRIVATE UTC CALENDAR WITHOUT NOTIFICATIONS') {
    throw new Error('Complete the private UTC calendar and notification setup before confirming it.');
  }
  const items = readCalendarItems_();
  const ledger = readCalendarLedger_();
  if (ledger.calendarId && ledger.calendarId !== config.calendarId) {
    throw new Error('This ledger belongs to a different calendar. Do not retarget or reset it.');
  }
  for (const record of ledger.records) {
    const item = items.find((candidate) => candidate.key === record.key);
    if (!item || item.kind !== record.kind) {
      throw new Error('Keep every tracked source key and kind unchanged: ' + record.key);
    }
  }
  const newCount = items.filter(/** @param {*} item */ (item) => !ledger.records.some(/** @param {*} record */ (record) => record.key === item.key)).length;
  if (ledger.records.length + newCount > MAX_CALENDAR_ITEMS) {
    throw new Error('The three-item lifetime cap is full. Do not erase an old identity.');
  }
  if (!isUtcCalendarZone_(Session.getScriptTimeZone())) {
    throw new Error('The script time zone must be UTC. Restore the supplied manifest.');
  }
  const calendar = CalendarApp.getCalendarById(config.calendarId);
  if (!calendar || calendar.isMyPrimaryCalendar() || !calendar.isOwnedByMe() ||
      calendar.getName() !== 'Book Calendar Practice' || !isUtcCalendarZone_(calendar.getTimeZone())) {
    throw new Error('Target must be your owned secondary Book Calendar Practice calendar in UTC.');
  }
  ledger.calendarId = config.calendarId;
  return { calendar: calendar, items: items, ledger: ledger };
}

/** @param {*} context */
function buildCalendarPlan_(context) {
  return context.items.map(/** @param {*} item */ (item) => {
    const record = context.ledger.records.find(/** @param {*} candidate */ (candidate) => candidate.key === item.key);
    if (!record) return { item: item, record: null, event: null, action: 'CREATE', changes: [] };
    if (record.state === 'CREATE_UNCERTAIN') {
      return { item: item, record: record, event: null, action: 'BLOCKED', changes: [] };
    }
    const event = context.calendar.getEventById(record.iCalUID);
    requireSafeCalendarEvent_(event, record, item);
    const changes = [];
    if (event.getTitle() !== item.title) changes.push('title');
    if (event.getDescription() !== calendarDescription_(item, record)) changes.push('description');
    const start = item.kind === 'all-day' ? event.getAllDayStartDate() : event.getStartTime();
    const end = item.kind === 'all-day' ? event.getAllDayEndDate() : event.getEndTime();
    if (start.getTime() !== item.start.getTime() || end.getTime() !== item.end.getTime()) changes.push('time');
    if (event.getEmailReminders().length || event.getPopupReminders().length) changes.push('reminders');
    const action = changes.length ? 'UPDATE' : record.state === 'READY' ? 'NO_CHANGE' : 'CHECKPOINT_ONLY';
    return { item: item, record: record, event: event, action: action, changes: changes };
  });
}

/** @param {CalendarApp.CalendarEvent|null} event @param {*} record @param {*} item */
function requireSafeCalendarEvent_(event, record, item) {
  if (!event) throw new Error('Known event is missing or inaccessible: ' + record.key + '. Do not recreate it.');
  if (!event.isOwnedByMe() || event.isRecurringEvent() || event.getGuestList().length ||
      !event.getDescription().split('\n').includes(record.marker)) {
    throw new Error('Event ownership, guests, recurrence or marker changed: ' + record.key + '. Stop and inspect.');
  }
  if (event.isAllDayEvent() !== (item.kind === 'all-day')) {
    throw new Error('Do not change a tracked event between timed and all-day: ' + record.key);
  }
}

/** @param {*} context @param {*} item */
function createCalendarItem_(context, item) {
  const record = {
    key: item.key,
    kind: item.kind,
    marker: '[BookCalendar-' + Utilities.getUuid() + ']',
    iCalUID: '',
    state: 'CREATE_UNCERTAIN',
  };
  if (context.ledger.records.some(/** @param {*} candidate */ (candidate) => candidate.marker === record.marker)) {
    throw new Error('Marker collision. No event was attempted for this item.');
  }
  context.ledger.records.push(record);
  saveCalendarLedger_(context.ledger); // Intent before dispatch, for this item.
  const options = { description: calendarDescription_(item, record), sendInvites: false };
  const event = item.kind === 'all-day'
    ? context.calendar.createAllDayEvent(item.title, item.start, item.end, options)
    : context.calendar.createEvent(item.title, item.start, item.end, options);
  const id = event.getId();
  if (!isCalendarText_(id, 512)) throw new Error('Creation returned no usable identity. Inspect the saved marker.');
  record.iCalUID = id;
  record.state = 'ID_KNOWN';
  saveCalendarLedger_(context.ledger); // Save identity before any further event mutation.
  event.removeAllReminders();
  record.state = 'READY';
  saveCalendarLedger_(context.ledger); // Finish this item before starting the next.
}

/** @param {*} ledger @param {*} entry */
function updateCalendarItem_(ledger, entry) {
  const item = entry.item;
  const record = entry.record;
  const event = entry.event;
  record.state = 'UPDATING';
  saveCalendarLedger_(ledger);
  if (entry.changes.includes('title')) event.setTitle(item.title);
  if (entry.changes.includes('description')) event.setDescription(calendarDescription_(item, record));
  if (entry.changes.includes('time')) {
    if (item.kind === 'all-day') event.setAllDayDates(item.start, item.end);
    else event.setTime(item.start, item.end);
  }
  if (entry.changes.includes('reminders')) event.removeAllReminders();
  record.state = 'READY';
  saveCalendarLedger_(ledger);
}

/** @param {*} ledger */
function requireUncertainRecord_(ledger) {
  const record = ledger.records.find(/** @param {*} candidate */ (candidate) => candidate.key === CALENDAR_WORKFLOW.recoveryKey);
  if (!record || record.state !== 'CREATE_UNCERTAIN') {
    throw new Error('Set recoveryKey to a saved CREATE_UNCERTAIN item from inspectCalendarLedger.');
  }
  return record;
}

/** @param {CalendarApp.Calendar} calendar @param {*} record */
function scanCalendarRecovery_(calendar, record) {
  // No search option: CalendarApp applies search after the start/max slice.
  const events = calendar.getEvents(new Date(PRACTICE_START), new Date(PRACTICE_END), {
    start: 0,
    max: RECOVERY_PAGE_SIZE,
  });
  return {
    truncated: events.length >= RECOVERY_PAGE_SIZE,
    matches: events.filter(/** @param {CalendarApp.CalendarEvent} event */ (event) => event.getDescription().split('\n').includes(record.marker)),
  };
}

/** @param {*} item @param {*} record */
function calendarDescription_(item, record) {
  return item.description + '\n\n' + record.marker;
}

function readCalendarItems_() {
  if (!Array.isArray(CALENDAR_ITEMS) || CALENDAR_ITEMS.length < 1 || CALENDAR_ITEMS.length > MAX_CALENDAR_ITEMS) {
    throw new Error('Supply one through three fictional items, not an unbounded source.');
  }
  const keys = new Set();
  const windowStart = new Date(PRACTICE_START).getTime();
  const windowEnd = new Date(PRACTICE_END).getTime();
  return CALENDAR_ITEMS.map(/** @param {*} item */ (item) => {
    if (!item || !isCalendarKey_(item.key) || keys.has(item.key) ||
        !['timed', 'all-day'].includes(item.kind) || !isCalendarText_(item.title, 120) ||
        /[\r\n<>]/.test(item.title) || !isCalendarText_(item.description, 500) ||
        /[<>]|BookCalendar-/.test(item.description)) {
      throw new Error('Use unique short keys, timed/all-day kinds, and short plain-text titles and descriptions.');
    }
    keys.add(item.key);
    const start = parseCalendarDate_(item.start, item.kind);
    const end = parseCalendarDate_(item.end, item.kind);
    if (end.getTime() <= start.getTime() || start.getTime() < windowStart || end.getTime() > windowEnd) {
      throw new Error('Use a positive interval entirely within June 15 through June 22, 2030.');
    }
    return { key: item.key, kind: item.kind, title: item.title, description: item.description, start: start, end: end };
  });
}

/** @param {string} text @param {'timed'|'all-day'} kind */
function parseCalendarDate_(text, kind) {
  const pattern = kind === 'all-day' ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
  if (typeof text !== 'string' || !pattern.test(text)) {
    throw new Error('Use YYYY-MM-DD for all-day dates and YYYY-MM-DDTHH:mm:ssZ for timed instants.');
  }
  const date = new Date(kind === 'all-day' ? text + 'T00:00:00Z' : text);
  if (!Number.isFinite(date.getTime())) throw new Error('Invalid calendar date: ' + text);
  const roundTrip = kind === 'all-day' ? date.toISOString().slice(0, 10) : date.toISOString().replace('.000Z', 'Z');
  if (roundTrip !== text) throw new Error('Date normalization changed the supplied value: ' + text);
  return date;
}

function readCalendarLedger_() {
  const raw = PropertiesService.getUserProperties().getProperty(CALENDAR_LEDGER_KEY);
  if (raw === null) return { calendarId: '', records: [] };
  let ledger;
  try { ledger = JSON.parse(raw); }
  catch (error) { throw new Error('Ledger JSON is invalid. Preserve it; do not reset it.'); }
  if (!ledger || !isCalendarText_(ledger.calendarId, 256) ||
      !Array.isArray(ledger.records) || ledger.records.length > MAX_CALENDAR_ITEMS) {
    throw new Error('Ledger shape or size is invalid. Preserve it; do not reset it.');
  }
  const keys = new Set();
  const markers = new Set();
  const ids = new Set();
  for (const record of ledger.records) {
    if (!record || !isCalendarKey_(record.key) || keys.has(record.key) ||
        !['timed', 'all-day'].includes(record.kind) || typeof record.marker !== 'string' ||
        !/^\[BookCalendar-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\]$/i.test(record.marker) ||
        markers.has(record.marker) || typeof record.iCalUID !== 'string' ||
        !['CREATE_UNCERTAIN', 'ID_KNOWN', 'UPDATING', 'READY'].includes(record.state) ||
        (record.state === 'CREATE_UNCERTAIN' ? record.iCalUID !== '' : !isCalendarText_(record.iCalUID, 512)) ||
        (record.iCalUID && ids.has(record.iCalUID))) {
      throw new Error('Ledger record is invalid. Preserve the identities and inspect the saved data.');
    }
    keys.add(record.key);
    markers.add(record.marker);
    if (record.iCalUID) ids.add(record.iCalUID);
  }
  return ledger;
}

/** @param {*} ledger */
function saveCalendarLedger_(ledger) {
  const text = JSON.stringify(ledger);
  if (Utilities.newBlob(text).getBytes().length > 8000) {
    throw new Error('Ledger exceeds this example\'s storage allowance. Do not delete recovery records.');
  }
  PropertiesService.getUserProperties().setProperty(CALENDAR_LEDGER_KEY, text);
}

/** @param {function(): void} operation */
function withCalendarUserLock_(operation) {
  const lock = LockService.getUserLock();
  if (!lock.tryLock(1000)) throw new Error('Another run holds this user lock. Wait and inspect its outcome.');
  try { operation(); }
  finally { lock.releaseLock(); }
}

/** @param {unknown} value @returns {value is string} */
function isCalendarKey_(value) {
  return typeof value === 'string' && /^[a-z][a-z0-9-]{0,39}$/.test(value);
}

/** @param {unknown} value @param {number} maxLength @returns {value is string} */
function isCalendarText_(value, maxLength) {
  return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
}

/** @param {string} zone */
function isUtcCalendarZone_(zone) {
  return ['Etc/UTC', 'Etc/GMT', 'UTC', 'GMT'].includes(zone);
}

Download Code.gs
appsscript.json
{
  "timeZone": "Etc/UTC",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/calendar"
  ]
}

Download appsscript.json

Preview, create, and inspect the two events

Select previewCalendarPlan and click Run, authorizing the project when prompted. On a fresh setup, Execution log prints one CREATE plan line for plan-review and one for quiet-days (both in state NEW), followed by:

Source
{"plannedItems":2,"trackedItems":0,"blocked":false,"eventWrites":0}

previewCalendarPlan reads Book Calendar Practice and User Properties without creating events or updating properties.

Next, set allowEventWrites to true, save Code.gs, and run applyCalendarPlan once, then reset allowEventWrites to false and save. In Google Calendar, navigate to June 17 through June 19, 2030, and inspect Book Calendar Practice:

  • Exhibit plan review runs from 13:00 to 14:00 UTC on June 17, 2030.

  • Quiet preparation days is an all-day event spanning June 18 and June 19, 2030 (excluding June 20).

  • Both event descriptions end with a unique [BookCalendar-...] marker line, and neither event has guests or reminders.

The script keeps a record of its events in User Properties and calls this record the ledger. Run inspectCalendarLedger to read it. Both items now have non-empty iCalUID values and state READY.

Store the event identity and its calendar context

Because event titles can be edited and multiple events can share a title, the script never searches by title to check whether an event was already created. Instead, each source item has a stable key (plan-review, quiet-days). When the script creates an event, it stores the string returned by event.getId() (the event's iCalUID).

On subsequent runs, the script looks up each tracked event on the target calendar with calendar.getEventById(iCalUID). Calling CalendarApp.getEventById(iCalUID) at the service level would instead search the user's primary calendar.

CalendarEvent.getId() in CalendarApp returns the iCalUID. The Calendar REST API uses a different identifier. events.get takes a REST event.id, and finding an event by iCalUID needs events.list({ iCalUID }) (see the REST event resource). The instances of a recurring event share one iCalUID but have different REST event.id values, so this project rejects recurring events.

Save each action's progress

The project stores its ledger (calendarId plus up to three item records) as a JSON string under calendar-workflows.v1 in PropertiesService.getUserProperties() (see the Properties guide). It refuses to save a ledger larger than 8,000 bytes, measured with Utilities.newBlob(text).getBytes(). Each record carries one of four states:

StateWhat the script has recordedNext step
CREATE_UNCERTAINThe planned event and its [BookCalendar-...] marker are saved before createEvent or createAllDayEvent is calledBlocks applyCalendarPlan until reconciled
ID_KNOWNiCalUID saved after creation or adoption. Reminder removal or field updates may remainInspect and preview the known event
UPDATINGSaved before running field setters on an existing eventInspect and preview the known event
READYCreation or update and reminder removal completedLater runs compare the event's current fields with its entry in CALENDAR_ITEMS

During creation, the script writes CREATE_UNCERTAIN with a Utilities.getUuid() marker before calling Calendar. It writes ID_KNOWN with event.getId() immediately after creation returns, removes any event reminders, and writes READY before moving to the next item.

applyCalendarPlan and adoptCalendarRecovery, the two functions that change events or the ledger, acquire LockService.getUserLock() with tryLock(1000) and release it in finally.

Change a known event instead of replacing it

Run previewCalendarPlan again without changing Code.gs. It logs NO_CHANGE for both items.

For each item with a known iCalUID, buildCalendarPlan_ checks that the event:

  • Still belongs to your account.

  • Has no guests. getGuestList() excludes the owner by default.

  • Is non-recurring.

  • Retains its [BookCalendar-...] description marker.

  • Matches the expected timed or all-day kind.

It then compares the title, full description, start and end times, and reminders. It calls setters (setTitle, setDescription, setTime, setAllDayDates, removeAllReminders) only on fields that differ.

Before calling those setters, applyCalendarPlan writes UPDATING, and it writes READY once all setters complete. If an update is interrupted after changing the title, the next previewCalendarPlan and applyCalendarPlan runs update only the remaining differing fields on that same iCalUID. If all fields already match and only the ledger state needs to change to READY, the run logs CHECKPOINT_ONLY.

Exercise: predict an unchanged run and a changed run

With both original events in READY state:

  1. Run previewCalendarPlan and confirm both items report NO_CHANGE. Set allowEventWrites: true, run applyCalendarPlan once, and reset allowEventWrites: false. eventWrites is 0 and both iCalUID values remain unchanged.

  2. In the plan-review entry of CALENDAR_ITEMS in Code.gs, change title to 'Exhibit plan review, revised', start to '2030-06-17T13:30:00Z', end to '2030-06-17T14:30:00Z', and description to 'Fictional rehearsal. Review the revised exhibit layout.'

  3. Run previewCalendarPlan. It logs UPDATE for plan-review (title, description, and time) and NO_CHANGE for quiet-days. Set allowEventWrites: true, run applyCalendarPlan once, and reset allowEventWrites: false. Verify in Google Calendar that the timed event now runs from 13:30 to 14:30 UTC with the same iCalUID, and that no third event was created.

  4. In quiet-days, change end from '2030-06-20' to '2030-06-21'. Run previewCalendarPlan (which reports a time update), apply the change once, and confirm in Calendar that Quiet preparation days now spans June 18, June 19, and June 20 (with June 21 exclusive).

  5. Temporarily change quiet-days end to '2030-06-31' and run previewCalendarPlan with allowEventWrites: false. parseCalendarDate_ rejects the invalid calendar date before any Calendar call. Restore end to '2030-06-21'.

Recover when you are not sure an event was created

If an error occurs during creation and leaves an item in CREATE_UNCERTAIN, reset allowEventWrites to false and run inspectCalendarLedger:

  1. Copy the item's key into recoveryKey in Code.gs and run findCalendarRecoveryCandidates. It calls calendar.getEvents(start, end, { start: 0, max: 10 }) over the June 15 through June 23, 2030 window. It then keeps the events whose description contains the item's [BookCalendar-...] marker. It does not use the search option of getEvents, because that filter runs after the max slice and could miss the event.

  2. If findCalendarRecoveryCandidates finds the single matching event in Book Calendar Practice, copy its iCalUID into recoveryICalUID and set recoveryConfirmation to 'ADOPT <iCalUID> FOR <key>' (substituting the actual iCalUID and key).

  3. Run adoptCalendarRecovery. It re-verifies the single candidate's ownership, kind, marker, guest list, and recurrence, then changes the item's state in the ledger from CREATE_UNCERTAIN to ID_KNOWN without modifying the event. Clear all three recovery fields in Code.gs afterward.

  4. Run previewCalendarPlan to inspect any remaining field or reminder updates on the adopted event, then apply if needed.

Know when CalendarApp is no longer enough

CalendarApp is enough for non-recurring timed and all-day events on one calendar. Enable the advanced Calendar service (Calendar) when you need one of these:

  • Lookups by REST event.id.

  • Incremental sync tokens.

  • The individual instances of a recurring event.

  • A regional timeZone on start and end timestamps (see Calendars and events).

  • The sendUpdates control of events.update.

  • An update that is refused if the event changed since you read it (If-Match ETag headers).

Handle limits and ordinary failures

Each run looks up at most three tracked event IDs and makes at most four setter calls per updated event. findCalendarRecoveryCandidates fetches at most 10 events in one getEvents call. See the Apps Script quota table and the Calendar API quota guide for daily creation and rate limits.

SymptomResponse
Missing ID, confirmation, wrong name, primary calendar, or ownership failureVerify calendarId points to your secondary Book Calendar Practice calendar and setupConfirmation matches.
Script or calendar time zone rejectedConfirm both appsscript.json and Book Calendar Practice are set to UTC.
Invalid date, or an event date outside the allowed windowFix the ISO date string and make sure the start and end dates fall within June 15 through June 22, 2030 UTC.
Known ID returns no eventCheck whether the event was deleted or moved in Book Calendar Practice before deciding how to proceed.
Guest, recurrence, kind, or marker changedInspect the event in Google Calendar. The script stops without changing an event whose kind, guest list, or marker no longer matches.
Lock unavailableWait for the active execution to finish in Executions, then run inspectCalendarLedger.
CREATE_UNCERTAINFollow the findCalendarRecoveryCandidates and adoptCalendarRecovery steps above.

Delete the practice events and project

When finished, reset allowEventWrites to false and clear the recovery fields in Code.gs. In Google Calendar, delete the two practice events (or delete the secondary Book Calendar Practice calendar), and move the Calendar plan practice script project to Trash. You can also revoke its OAuth grant in your Google Account settings (see the authorization guide).

For related Calendar workflows, see Create Calendar events from Sheet rows, Email tomorrow's Calendar agenda, and the Calendar snippets.

Next, read Google Forms scripts and response triggers.

Project files

Complete source files for this chapter's examples.

Maintain a small fictional Calendar plan
Code.gs
const CALENDAR_WORKFLOW = {
  calendarId: '',
  setupConfirmation: '', // PRIVATE UTC CALENDAR WITHOUT NOTIFICATIONS
  allowEventWrites: false,
  recoveryKey: '',
  recoveryICalUID: '',
  recoveryConfirmation: '',
};

// Supplied fictional data. Keep each key and kind stable after its first attempt.
const CALENDAR_ITEMS = [
  {
    key: 'plan-review',
    kind: 'timed',
    title: 'Exhibit plan review',
    start: '2030-06-17T13:00:00Z',
    end: '2030-06-17T14:00:00Z',
    description: 'Fictional rehearsal. Review the exhibit layout.',
  },
  {
    key: 'quiet-days',
    kind: 'all-day',
    title: 'Quiet preparation days',
    start: '2030-06-18',
    end: '2030-06-20', // Exclusive: June 18 and June 19 are included.
    description: 'Fictional rehearsal. Prepare the display notes.',
  },
];

const CALENDAR_LEDGER_KEY = 'calendar-workflows.v1';
const PRACTICE_START = '2030-06-15T00:00:00Z';
const PRACTICE_END = '2030-06-23T00:00:00Z';
const MAX_CALENDAR_ITEMS = 3;
const RECOVERY_PAGE_SIZE = 10;

function previewCalendarPlan() {
  const context = readCalendarContext_();
  const plan = buildCalendarPlan_(context);
  for (const entry of plan) {
    console.log(JSON.stringify({
      key: entry.item.key,
      kind: entry.item.kind,
      title: entry.item.title,
      start: entry.item.start.toISOString(),
      endExclusive: entry.item.end.toISOString(),
      state: entry.record ? entry.record.state : 'NEW',
      iCalUID: entry.record ? entry.record.iCalUID : '',
      action: entry.action,
      changes: entry.changes,
    }));
  }
  console.log(JSON.stringify({
    plannedItems: plan.length,
    trackedItems: context.ledger.records.length,
    blocked: plan.some(/** @param {*} entry */ (entry) => entry.action === 'BLOCKED'),
    eventWrites: 0,
  }));
}

function applyCalendarPlan() {
  if (CALENDAR_WORKFLOW.allowEventWrites !== true) {
    throw new Error('Event writes are disabled. Preview and review before enabling them.');
  }
  withCalendarUserLock_(() => {
    const context = readCalendarContext_();
    const plan = buildCalendarPlan_(context);
    if (plan.some(/** @param {*} entry */ (entry) => entry.action === 'BLOCKED')) {
      throw new Error('An uncertain create blocks all event writes. Inspect and reconcile it.');
    }
    for (const entry of plan) {
      if (entry.action === 'CREATE') {
        createCalendarItem_(context, entry.item);
      } else if (entry.action === 'UPDATE') {
        updateCalendarItem_(context.ledger, entry);
      } else if (entry.action === 'CHECKPOINT_ONLY') {
        entry.record.state = 'READY';
        saveCalendarLedger_(context.ledger);
      }
      console.log(JSON.stringify({ key: entry.item.key, action: entry.action }));
    }
  });
}

function inspectCalendarLedger() {
  console.log(JSON.stringify(readCalendarLedger_()));
}

function findCalendarRecoveryCandidates() {
  const context = readCalendarContext_();
  const record = requireUncertainRecord_(context.ledger);
  const scan = scanCalendarRecovery_(context.calendar, record);
  console.log(JSON.stringify({
    key: record.key,
    marker: record.marker,
    windowStart: PRACTICE_START,
    windowEndExclusive: PRACTICE_END,
    pageMayBeTruncated: scan.truncated,
    matchingCandidates: scan.matches.map(/** @param {CalendarApp.CalendarEvent} event */ (event) => ({
      iCalUID: event.getId(),
      title: event.getTitle(),
      allDay: event.isAllDayEvent(),
      start: event.getStartTime().toISOString(),
      endExclusive: event.getEndTime().toISOString(),
    })),
  }));
}

function adoptCalendarRecovery() {
  withCalendarUserLock_(() => {
    const context = readCalendarContext_();
    const record = requireUncertainRecord_(context.ledger);
    const id = CALENDAR_WORKFLOW.recoveryICalUID;
    const confirmation = 'ADOPT ' + id + ' FOR ' + record.key;
    if (!isCalendarText_(id, 512) || CALENDAR_WORKFLOW.recoveryConfirmation !== confirmation) {
      throw new Error('Supply the inspected CalendarApp iCalUID and exact ADOPT confirmation.');
    }
    const scan = scanCalendarRecovery_(context.calendar, record);
    if (scan.truncated || scan.matches.length !== 1 || scan.matches[0].getId() !== id) {
      throw new Error('Adoption requires one matching candidate in a non-full recovery page. Stop and inspect.');
    }
    const item = context.items.find(/** @param {*} candidate */ (candidate) => candidate.key === record.key);
    requireSafeCalendarEvent_(scan.matches[0], record, item);
    if (context.ledger.records.some(/** @param {*} candidate */ (candidate) => candidate.iCalUID === id)) {
      throw new Error('This event identity is already recorded. Preserve the ledger.');
    }
    record.iCalUID = id;
    record.state = 'ID_KNOWN';
    saveCalendarLedger_(context.ledger);
    console.log(JSON.stringify({ key: record.key, state: record.state, iCalUID: id }));
  });
}

function illustrateCalendarInstants() {
  // This function only calculates and logs. It does not read or write Calendar.
  const first = new Date('2017-11-05T01:30:00-04:00');
  const second = new Date('2017-11-05T01:30:00-05:00');
  console.log(JSON.stringify({
    firstUtc: first.toISOString(),
    secondUtc: second.toISOString(),
    minutesApart: (second.getTime() - first.getTime()) / 60000,
    firstInNewYork: Utilities.formatDate(first, 'America/New_York', 'yyyy-MM-dd HH:mm Z'),
    secondInNewYork: Utilities.formatDate(second, 'America/New_York', 'yyyy-MM-dd HH:mm Z'),
  }));
}

function readCalendarContext_() {
  const config = CALENDAR_WORKFLOW;
  if (!isCalendarText_(config.calendarId, 256) || /\s|\//.test(config.calendarId) ||
      config.calendarId.toLowerCase() === 'primary') {
    throw new Error('Set the exact ID of the new secondary practice calendar, not a URL or primary.');
  }
  if (config.setupConfirmation !== 'PRIVATE UTC CALENDAR WITHOUT NOTIFICATIONS') {
    throw new Error('Complete the private UTC calendar and notification setup before confirming it.');
  }
  const items = readCalendarItems_();
  const ledger = readCalendarLedger_();
  if (ledger.calendarId && ledger.calendarId !== config.calendarId) {
    throw new Error('This ledger belongs to a different calendar. Do not retarget or reset it.');
  }
  for (const record of ledger.records) {
    const item = items.find((candidate) => candidate.key === record.key);
    if (!item || item.kind !== record.kind) {
      throw new Error('Keep every tracked source key and kind unchanged: ' + record.key);
    }
  }
  const newCount = items.filter(/** @param {*} item */ (item) => !ledger.records.some(/** @param {*} record */ (record) => record.key === item.key)).length;
  if (ledger.records.length + newCount > MAX_CALENDAR_ITEMS) {
    throw new Error('The three-item lifetime cap is full. Do not erase an old identity.');
  }
  if (!isUtcCalendarZone_(Session.getScriptTimeZone())) {
    throw new Error('The script time zone must be UTC. Restore the supplied manifest.');
  }
  const calendar = CalendarApp.getCalendarById(config.calendarId);
  if (!calendar || calendar.isMyPrimaryCalendar() || !calendar.isOwnedByMe() ||
      calendar.getName() !== 'Book Calendar Practice' || !isUtcCalendarZone_(calendar.getTimeZone())) {
    throw new Error('Target must be your owned secondary Book Calendar Practice calendar in UTC.');
  }
  ledger.calendarId = config.calendarId;
  return { calendar: calendar, items: items, ledger: ledger };
}

/** @param {*} context */
function buildCalendarPlan_(context) {
  return context.items.map(/** @param {*} item */ (item) => {
    const record = context.ledger.records.find(/** @param {*} candidate */ (candidate) => candidate.key === item.key);
    if (!record) return { item: item, record: null, event: null, action: 'CREATE', changes: [] };
    if (record.state === 'CREATE_UNCERTAIN') {
      return { item: item, record: record, event: null, action: 'BLOCKED', changes: [] };
    }
    const event = context.calendar.getEventById(record.iCalUID);
    requireSafeCalendarEvent_(event, record, item);
    const changes = [];
    if (event.getTitle() !== item.title) changes.push('title');
    if (event.getDescription() !== calendarDescription_(item, record)) changes.push('description');
    const start = item.kind === 'all-day' ? event.getAllDayStartDate() : event.getStartTime();
    const end = item.kind === 'all-day' ? event.getAllDayEndDate() : event.getEndTime();
    if (start.getTime() !== item.start.getTime() || end.getTime() !== item.end.getTime()) changes.push('time');
    if (event.getEmailReminders().length || event.getPopupReminders().length) changes.push('reminders');
    const action = changes.length ? 'UPDATE' : record.state === 'READY' ? 'NO_CHANGE' : 'CHECKPOINT_ONLY';
    return { item: item, record: record, event: event, action: action, changes: changes };
  });
}

/** @param {CalendarApp.CalendarEvent|null} event @param {*} record @param {*} item */
function requireSafeCalendarEvent_(event, record, item) {
  if (!event) throw new Error('Known event is missing or inaccessible: ' + record.key + '. Do not recreate it.');
  if (!event.isOwnedByMe() || event.isRecurringEvent() || event.getGuestList().length ||
      !event.getDescription().split('\n').includes(record.marker)) {
    throw new Error('Event ownership, guests, recurrence or marker changed: ' + record.key + '. Stop and inspect.');
  }
  if (event.isAllDayEvent() !== (item.kind === 'all-day')) {
    throw new Error('Do not change a tracked event between timed and all-day: ' + record.key);
  }
}

/** @param {*} context @param {*} item */
function createCalendarItem_(context, item) {
  const record = {
    key: item.key,
    kind: item.kind,
    marker: '[BookCalendar-' + Utilities.getUuid() + ']',
    iCalUID: '',
    state: 'CREATE_UNCERTAIN',
  };
  if (context.ledger.records.some(/** @param {*} candidate */ (candidate) => candidate.marker === record.marker)) {
    throw new Error('Marker collision. No event was attempted for this item.');
  }
  context.ledger.records.push(record);
  saveCalendarLedger_(context.ledger); // Intent before dispatch, for this item.
  const options = { description: calendarDescription_(item, record), sendInvites: false };
  const event = item.kind === 'all-day'
    ? context.calendar.createAllDayEvent(item.title, item.start, item.end, options)
    : context.calendar.createEvent(item.title, item.start, item.end, options);
  const id = event.getId();
  if (!isCalendarText_(id, 512)) throw new Error('Creation returned no usable identity. Inspect the saved marker.');
  record.iCalUID = id;
  record.state = 'ID_KNOWN';
  saveCalendarLedger_(context.ledger); // Save identity before any further event mutation.
  event.removeAllReminders();
  record.state = 'READY';
  saveCalendarLedger_(context.ledger); // Finish this item before starting the next.
}

/** @param {*} ledger @param {*} entry */
function updateCalendarItem_(ledger, entry) {
  const item = entry.item;
  const record = entry.record;
  const event = entry.event;
  record.state = 'UPDATING';
  saveCalendarLedger_(ledger);
  if (entry.changes.includes('title')) event.setTitle(item.title);
  if (entry.changes.includes('description')) event.setDescription(calendarDescription_(item, record));
  if (entry.changes.includes('time')) {
    if (item.kind === 'all-day') event.setAllDayDates(item.start, item.end);
    else event.setTime(item.start, item.end);
  }
  if (entry.changes.includes('reminders')) event.removeAllReminders();
  record.state = 'READY';
  saveCalendarLedger_(ledger);
}

/** @param {*} ledger */
function requireUncertainRecord_(ledger) {
  const record = ledger.records.find(/** @param {*} candidate */ (candidate) => candidate.key === CALENDAR_WORKFLOW.recoveryKey);
  if (!record || record.state !== 'CREATE_UNCERTAIN') {
    throw new Error('Set recoveryKey to a saved CREATE_UNCERTAIN item from inspectCalendarLedger.');
  }
  return record;
}

/** @param {CalendarApp.Calendar} calendar @param {*} record */
function scanCalendarRecovery_(calendar, record) {
  // No search option: CalendarApp applies search after the start/max slice.
  const events = calendar.getEvents(new Date(PRACTICE_START), new Date(PRACTICE_END), {
    start: 0,
    max: RECOVERY_PAGE_SIZE,
  });
  return {
    truncated: events.length >= RECOVERY_PAGE_SIZE,
    matches: events.filter(/** @param {CalendarApp.CalendarEvent} event */ (event) => event.getDescription().split('\n').includes(record.marker)),
  };
}

/** @param {*} item @param {*} record */
function calendarDescription_(item, record) {
  return item.description + '\n\n' + record.marker;
}

function readCalendarItems_() {
  if (!Array.isArray(CALENDAR_ITEMS) || CALENDAR_ITEMS.length < 1 || CALENDAR_ITEMS.length > MAX_CALENDAR_ITEMS) {
    throw new Error('Supply one through three fictional items, not an unbounded source.');
  }
  const keys = new Set();
  const windowStart = new Date(PRACTICE_START).getTime();
  const windowEnd = new Date(PRACTICE_END).getTime();
  return CALENDAR_ITEMS.map(/** @param {*} item */ (item) => {
    if (!item || !isCalendarKey_(item.key) || keys.has(item.key) ||
        !['timed', 'all-day'].includes(item.kind) || !isCalendarText_(item.title, 120) ||
        /[\r\n<>]/.test(item.title) || !isCalendarText_(item.description, 500) ||
        /[<>]|BookCalendar-/.test(item.description)) {
      throw new Error('Use unique short keys, timed/all-day kinds, and short plain-text titles and descriptions.');
    }
    keys.add(item.key);
    const start = parseCalendarDate_(item.start, item.kind);
    const end = parseCalendarDate_(item.end, item.kind);
    if (end.getTime() <= start.getTime() || start.getTime() < windowStart || end.getTime() > windowEnd) {
      throw new Error('Use a positive interval entirely within June 15 through June 22, 2030.');
    }
    return { key: item.key, kind: item.kind, title: item.title, description: item.description, start: start, end: end };
  });
}

/** @param {string} text @param {'timed'|'all-day'} kind */
function parseCalendarDate_(text, kind) {
  const pattern = kind === 'all-day' ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
  if (typeof text !== 'string' || !pattern.test(text)) {
    throw new Error('Use YYYY-MM-DD for all-day dates and YYYY-MM-DDTHH:mm:ssZ for timed instants.');
  }
  const date = new Date(kind === 'all-day' ? text + 'T00:00:00Z' : text);
  if (!Number.isFinite(date.getTime())) throw new Error('Invalid calendar date: ' + text);
  const roundTrip = kind === 'all-day' ? date.toISOString().slice(0, 10) : date.toISOString().replace('.000Z', 'Z');
  if (roundTrip !== text) throw new Error('Date normalization changed the supplied value: ' + text);
  return date;
}

function readCalendarLedger_() {
  const raw = PropertiesService.getUserProperties().getProperty(CALENDAR_LEDGER_KEY);
  if (raw === null) return { calendarId: '', records: [] };
  let ledger;
  try { ledger = JSON.parse(raw); }
  catch (error) { throw new Error('Ledger JSON is invalid. Preserve it; do not reset it.'); }
  if (!ledger || !isCalendarText_(ledger.calendarId, 256) ||
      !Array.isArray(ledger.records) || ledger.records.length > MAX_CALENDAR_ITEMS) {
    throw new Error('Ledger shape or size is invalid. Preserve it; do not reset it.');
  }
  const keys = new Set();
  const markers = new Set();
  const ids = new Set();
  for (const record of ledger.records) {
    if (!record || !isCalendarKey_(record.key) || keys.has(record.key) ||
        !['timed', 'all-day'].includes(record.kind) || typeof record.marker !== 'string' ||
        !/^\[BookCalendar-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\]$/i.test(record.marker) ||
        markers.has(record.marker) || typeof record.iCalUID !== 'string' ||
        !['CREATE_UNCERTAIN', 'ID_KNOWN', 'UPDATING', 'READY'].includes(record.state) ||
        (record.state === 'CREATE_UNCERTAIN' ? record.iCalUID !== '' : !isCalendarText_(record.iCalUID, 512)) ||
        (record.iCalUID && ids.has(record.iCalUID))) {
      throw new Error('Ledger record is invalid. Preserve the identities and inspect the saved data.');
    }
    keys.add(record.key);
    markers.add(record.marker);
    if (record.iCalUID) ids.add(record.iCalUID);
  }
  return ledger;
}

/** @param {*} ledger */
function saveCalendarLedger_(ledger) {
  const text = JSON.stringify(ledger);
  if (Utilities.newBlob(text).getBytes().length > 8000) {
    throw new Error('Ledger exceeds this example\'s storage allowance. Do not delete recovery records.');
  }
  PropertiesService.getUserProperties().setProperty(CALENDAR_LEDGER_KEY, text);
}

/** @param {function(): void} operation */
function withCalendarUserLock_(operation) {
  const lock = LockService.getUserLock();
  if (!lock.tryLock(1000)) throw new Error('Another run holds this user lock. Wait and inspect its outcome.');
  try { operation(); }
  finally { lock.releaseLock(); }
}

/** @param {unknown} value @returns {value is string} */
function isCalendarKey_(value) {
  return typeof value === 'string' && /^[a-z][a-z0-9-]{0,39}$/.test(value);
}

/** @param {unknown} value @param {number} maxLength @returns {value is string} */
function isCalendarText_(value, maxLength) {
  return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
}

/** @param {string} zone */
function isUtcCalendarZone_(zone) {
  return ['Etc/UTC', 'Etc/GMT', 'UTC', 'GMT'].includes(zone);
}

appsscript.json

Download file
appsscript.json
{
  "timeZone": "Etc/UTC",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/calendar"
  ]
}