Skip to content
Skip to chapter

Chapter 17

Workspace services

Maintain Calendar events by identity

Use CalendarApp in Google Apps Script to create events on a selected calendar, handle time zones with Utilities.formatDate, and update by event ID.

By the end

Maintain timed and all-day practice events with stored identities, explicit time-zone rules, and a conservative recovery path.

Chapter navigation

Create two planning events in a secondary Google Calendar you own, then update their titles, descriptions, or dates without creating duplicate events. One event is a one-hour timed event; the other is a two-day all-day event. The script stores each created event's iCalUID in User Properties so subsequent runs update the existing events in place.

This chapter builds on Understand the runtime and permissions and the per-item checkpoint pattern from Read and write Sheets in batches.

Select a new calendar, not the default

Every Google account has a primary calendar and can own additional secondary calendars. Whereas CalendarApp.getDefaultCalendar() targets the executing user's primary calendar, this project selects a secondary calendar by ID with CalendarApp.getCalendarById(calendarId) and verifies that it is owned by your account, non-primary, named Book Calendar Practice, and configured in 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, and 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 instead identifies a calendar day rather than a specific hour:

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, while calendar.createAllDayEvent(title, startDate, endDate, options) creates an all-day event where the time-of-day component of each Date is ignored 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 shifts how timed events are displayed on screen without changing their underlying timestamp.

Keeping both the script and calendar in UTC (Etc/UTC, Etc/GMT, UTC, or GMT) avoids day-boundary shifts when comparing all-day events: getAllDayStartDate() and getAllDayEndDate() return midnight in the script's time zone, whereas 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 validates the format with a regular expression, constructs a Date, checks that getTime() is finite, and formats the Date back to UTC with Utilities.formatDate to reject overflow dates such as 2030-06-31 that Date would otherwise normalize into July. It 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 while a date-time string without an offset (YYYY-MM-DDTHH:mm:ss) is parsed in the local time zone. Requiring the trailing Z on timed strings avoids local-time ambiguity.

Regional time zones also shift across 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 though 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 outputs

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.

Run inspectCalendarLedger to read the stored User Properties ledger; both records 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), and creation 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).

Note that CalendarEvent.getId() in CalendarApp returns the iCalUID, whereas the Calendar REST API's events.get method takes an opaque REST event.id and searches by iCalUID through events.list({ iCalUID }) (see the REST event resource). Recurring event instances also share a single iCalUID while having distinct REST event.id values, so this project rejects recurring events.

Save each action's progress

The project stores its ledger object (calendarId plus up to three item records) as a JSON string under calendar-workflows.v1 in PropertiesService.getUserProperties() (see the Properties guide), enforcing an 8,000-byte ceiling measured in memory with Utilities.newBlob(text).getBytes():

StateWhat the script has recordedNext step
CREATE_UNCERTAINIntent and [BookCalendar-...] marker saved before calling createEvent / createAllDayEventBlocks 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 completedCompares current event fields against the desired fixture

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

State-changing entrypoints 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 record with a known iCalUID, the planner verifies 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, and matches the expected timed or all-day kind. It then compares the title, full description, start/end interval, and reminders, calling setters (setTitle, setDescription, setTime, setAllDayDates, removeAllReminders) only on fields that differ.

Before calling those setters, applyCalendarPlan writes UPDATING, and writes READY once all setters complete. If an update is interrupted after changing the title but before updating the time, the next previewCalendarPlan and applyCalendarPlan runs update only the remaining differing fields on that same iCalUID (or log CHECKPOINT_ONLY if all fields already match and only the ledger state needs to advance to READY).

Exercise: predict an unchanged run and a changed run

With both baseline 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 fixture 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 moved to 13:30 to 14:30 UTC with the same iCalUID and 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'.

Reconcile an uncertain create without dispatching another

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 and filters in memory for the item's [BookCalendar-...] description marker (avoiding the search option on getEvents, which filters after slicing max events).

  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, which re-verifies the single candidate's ownership, kind, marker, guest list, and recurrence and advances the ledger entry 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 handles single-calendar, non-recurring timed and all-day events. Enable the advanced Calendar service (Calendar) when you need REST event.id lookups, incremental sync tokens, recurring-event instance expansion, regional timeZone properties on start/end timestamps (see Calendars and events), events.update sendUpdates controls, or conditional updates using If-Match ETag headers.

Handle limits and ordinary failures

Each run inspects at most three tracked event IDs and performs at most four setter calls per updated event, while findCalendarRecoveryCandidates fetches at most 10 events in a single 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 or out-of-window fixtureFix the ISO date string and ensure start/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 refuses to overwrite 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.

Retire only the practice resources

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), move the Calendar plan practice script project to Trash, and optionally 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, build Google Forms and process form submissions.

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"
  ]
}