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.
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.
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).
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.Open that calendar's Settings and sharing, set its Time zone to UTC, and keep it private.
Under Event notifications and All-day event notifications, remove all default notifications. Under Other notifications, set each category to None (see Calendar notifications).
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 key | Kind | Start | End, exclusive | Intended result |
|---|---|---|---|---|
plan-review | timed | 2030-06-17T13:00:00Z | 2030-06-17T14:00:00Z | One hour on June 17 at 13:00 UTC |
quiet-days | all-day | 2030-06-18 | 2030-06-20 | June 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 toEtc/UTCinappsscript.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
At
script.google.com, create a New project namedCalendar plan practice.Replace
Code.gswith the complete project file below. In Project Settings, enable Show "appsscript.json" manifest file in editor, replaceappsscript.jsonwith the project manifest, and save both files.In
Code.gs, setCALENDAR_WORKFLOW.calendarIdto yourBook Calendar Practicecalendar ID, and setsetupConfirmationto'PRIVATE UTC CALENDAR WITHOUT NOTIFICATIONS'.Keep
allowEventWrites: falseand 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).
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);
}
{
"timeZone": "Etc/UTC",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/calendar"
]
}
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:
{"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 reviewruns from13:00to14:00UTC on June 17, 2030.Quiet preparation daysis 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:
| State | What the script has recorded | Next step |
|---|---|---|
CREATE_UNCERTAIN | The planned event and its [BookCalendar-...] marker are saved before createEvent or createAllDayEvent is called | Blocks applyCalendarPlan until reconciled |
ID_KNOWN | iCalUID saved after creation or adoption. Reminder removal or field updates may remain | Inspect and preview the known event |
UPDATING | Saved before running field setters on an existing event | Inspect and preview the known event |
READY | Creation or update and reminder removal completed | Later 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:
Run
previewCalendarPlanand confirm both items reportNO_CHANGE. SetallowEventWrites: true, runapplyCalendarPlanonce, and resetallowEventWrites: false.eventWritesis0and bothiCalUIDvalues remain unchanged.In the
plan-reviewentry ofCALENDAR_ITEMSinCode.gs, changetitleto'Exhibit plan review, revised',startto'2030-06-17T13:30:00Z',endto'2030-06-17T14:30:00Z', anddescriptionto'Fictional rehearsal. Review the revised exhibit layout.'Run
previewCalendarPlan. It logsUPDATEforplan-review(title,description, andtime) andNO_CHANGEforquiet-days. SetallowEventWrites: true, runapplyCalendarPlanonce, and resetallowEventWrites: false. Verify in Google Calendar that the timed event now runs from13:30to14:30UTC with the sameiCalUID, and that no third event was created.In
quiet-days, changeendfrom'2030-06-20'to'2030-06-21'. RunpreviewCalendarPlan(which reports atimeupdate), apply the change once, and confirm in Calendar thatQuiet preparation daysnow spans June 18, June 19, and June 20 (with June 21 exclusive).Temporarily change
quiet-daysendto'2030-06-31'and runpreviewCalendarPlanwithallowEventWrites: false.parseCalendarDate_rejects the invalid calendar date before any Calendar call. Restoreendto'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:
Copy the item's
keyintorecoveryKeyinCode.gsand runfindCalendarRecoveryCandidates. It callscalendar.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 thesearchoption ofgetEvents, because that filter runs after themaxslice and could miss the event.If
findCalendarRecoveryCandidatesfinds the single matching event inBook Calendar Practice, copy itsiCalUIDintorecoveryICalUIDand setrecoveryConfirmationto'ADOPT <iCalUID> FOR <key>'(substituting the actualiCalUIDandkey).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 fromCREATE_UNCERTAINtoID_KNOWNwithout modifying the event. Clear all three recovery fields inCode.gsafterward.Run
previewCalendarPlanto 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
timeZoneon start and end timestamps (see Calendars and events).The
sendUpdatescontrol ofevents.update.An update that is refused if the event changed since you read it (
If-MatchETag 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.
| Symptom | Response |
|---|---|
| Missing ID, confirmation, wrong name, primary calendar, or ownership failure | Verify calendarId points to your secondary Book Calendar Practice calendar and setupConfirmation matches. |
| Script or calendar time zone rejected | Confirm both appsscript.json and Book Calendar Practice are set to UTC. |
| Invalid date, or an event date outside the allowed window | Fix 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 event | Check whether the event was deleted or moved in Book Calendar Practice before deciding how to proceed. |
| Guest, recurrence, kind, or marker changed | Inspect the event in Google Calendar. The script stops without changing an event whose kind, guest list, or marker no longer matches. |
| Lock unavailable | Wait for the active execution to finish in Executions, then run inspectCalendarLedger. |
CREATE_UNCERTAIN | Follow 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
Download fileconst 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{
"timeZone": "Etc/UTC",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/calendar"
]
}