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.
Maintain timed and all-day practice events with stored identities, explicit time-zone rules, and a conservative recovery path.
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).
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, and 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 instead identifies a calendar day rather than a specific hour:
| 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, 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 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 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
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 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:
{"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.
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():
| State | What the script has recorded | Next step |
|---|---|---|
CREATE_UNCERTAIN | Intent and [BookCalendar-...] marker saved before calling createEvent / createAllDayEvent | 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 | Compares 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:
Run
previewCalendarPlanand confirm both items reportNO_CHANGE. SetallowEventWrites: true, runapplyCalendarPlanonce, and resetallowEventWrites: false;eventWritesis0and bothiCalUIDvalues remain unchanged.In the
plan-reviewfixture inCode.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 moved to13:30to14:30UTC with the sameiCalUIDand 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'.
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:
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 and filters in memory for the item's[BookCalendar-...]description marker (avoiding thesearchoption ongetEvents, which filters after slicingmaxevents).If
findCalendarRecoveryCandidatesfinds the single matching event inBook Calendar Practice, copy itsiCalUIDintorecoveryICalUIDand setrecoveryConfirmationto'ADOPT <iCalUID> FOR <key>'(substituting the actualiCalUIDandkey).Run
adoptCalendarRecovery, which re-verifies the single candidate's ownership, kind, marker, guest list, and recurrence and advances the ledger entry 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 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.
| 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 or out-of-window fixture | Fix the ISO date string and ensure start/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 refuses to overwrite 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. |
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.
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"
]
}