Audience
Advanced users or IT staff who manage multiple Google Calendars (personal, work, shared) and need all events mirrored into a single “primary” calendar for automation or third-party tools. I suggest using an email address that you use for nothing else so that you can use its calendar freely without worrying about how many various events are on it. Mine has EVERYTHING on it so that ChatGPT can tell me what's coming up.
Why This Matters
Google Calendar lets you view multiple calendars side by side, but most integrations (including ChatGPT, reminders, and automations) only see your primary calendar. That means if events stay in “Other calendars,” they won’t show up in connected tools.
The solution: a one-way mirroring script that copies events from any number of source calendars into your primary calendar.
How It Works
A Google Apps Script uses the Calendar Advanced Service and Calendar API to read from one or more source calendars.
Each source event is mirrored into your primary calendar.
Mirrors are tracked using private extended properties (mirrorSourceId), so updates are patched instead of duplicated.
Orphaned mirrors (when a source event is deleted) are cleaned up automatically.
Script runs on a time-driven trigger (hourly is recommended).
Setup Guide
1. Permissions & Prerequisites
Make sure you have “Make changes to events” access to each source calendar.
In Apps Script:
Enable Calendar API under Services → + Add a service.
In Project Settings → Google Cloud Console, enable Google Calendar API there too.
2. Add Calendar IDs
In Google Calendar, go to Settings & sharing for each source calendar.
Scroll to Integrate calendar → Calendar ID.
Copy the ID (e.g., abc123@group.calendar.google.com or username@gmail.com).
Paste into the SOURCE_CALENDAR_IDS array in the script.
3. Install the Script
Go to script.new.
Paste in the mirroring script (this is included at the end of this article).
Update the configuration block (CONFIG) with your source IDs.
4. Run and Authorize
Run syncAll() once manually.
Approve Google’s authorization prompts.
Confirm mirrored events appear in your primary calendar (look for your chosen prefix, e.g. • ).
5. Set a Trigger
In Apps Script → Triggers → Add Trigger.
Choose syncAll.
Select Time-driven → Hour timer → Every hour.
(Optional) Add a daily trigger that looks a year ahead to catch long-term events.
Maintenance
Duplicates: Prevented by private properties. If needed, use cleanupDeleteAllMirrorsFromThisScript() to reset mirrors.
Lag: Mirrors appear within an hour (default). You can shorten to 15 minutes if quotas allow.
Read-only calendars: Subscribed ICS calendars work, but are one-way only (no push-back edits).
Logs: Use Executions in Apps Script to troubleshoot errors.
Key Benefits
One calendar becomes your single source of truth.
External tools (like ChatGPT, smart reminders, or mobile apps) can reliably query all events.
Still keep separate calendars for sharing/visual clarity while unifying them behind the scenes.
Pro tip: Pair this with your automation stack (e.g., n8n or Zapier) for event-based triggers if you need near-instant updates. Apps Script’s time-driven model is usually “good enough” for hourly or daily sync.
/**
* Google Calendar → Primary mirror
* One-way sync: copies/updates/deletes mirrored events in your PRIMARY calendar.
* Requires: Advanced Google service "Calendar API" enabled in Apps Script AND Google Cloud console.
*/
const CONFIG = {
SOURCE_CALENDAR_IDS: [
// Add each source calendar ID (email-style ID or the calendar's ID string)
"YourPersonalCalendar@gmail.com",
// You can add more, e.g. "family@example.com"
],
// Sync window: how far to look back/forward from "now"
LOOKBACK_DAYS: 1,
LOOKAHEAD_DAYS: 240,
// Optional prefix so you can visually spot mirrored events in the primary
TITLE_PREFIX: "• ",
// If true, mirrored events will include the source calendar's name in the title
INCLUDE_SOURCE_NAME_IN_TITLE: true,
// Primary calendar ID (usually "primary")
PRIMARY_CAL_ID: "primary"
};
// Private extended properties used to find/update mirrors
const PROP = {
SOURCE_ID: "mirrorSourceId", // "<sourceCalId>:<eventId>"
SOURCE_CAL: "mirrorSourceCalendarId" // "<sourceCalId>"
};
function syncAll() {
const now = new Date();
const timeMin = iso(addDays(now, -CONFIG.LOOKBACK_DAYS));
const timeMax = iso(addDays(now, CONFIG.LOOKAHEAD_DAYS));
CONFIG.SOURCE_CALENDAR_IDS.forEach(sourceCalId => {
try {
syncOneCalendar(sourceCalId, timeMin, timeMax);
} catch (e) {
console.error(`Sync error for ${sourceCalId}: ${e && e.stack ? e.stack : e}`);
}
});
}
/**
* Sync a single source calendar into the primary.
*/
function syncOneCalendar(sourceCalId, timeMin, timeMax) {
// 1) Pull all source events in window
const sourceEvents = listAllEvents(sourceCalId, { timeMin, timeMax });
// 2) Build a set of source keys for cleanup
const presentSourceKeys = new Set(
sourceEvents
.filter(e => e.status !== "cancelled")
.map(e => keyFor(sourceCalId, e.id))
);
// 3) Upsert mirrors for each source event
sourceEvents.forEach(src => {
if (src.status === "cancelled") return; // skip cancelled
upsertMirror(sourceCalId, src);
});
// 4) Delete mirrors whose sources disappeared
deleteOrphanMirrors(sourceCalId, presentSourceKeys, timeMin, timeMax);
}
/**
* Create or update the mirror of one source event.
*/
function upsertMirror(sourceCalId, srcEvent) {
const primaryId = CONFIG.PRIMARY_CAL_ID;
const sourceKey = keyFor(sourceCalId, srcEvent.id);
// Find existing mirror in primary via privateExtendedProperty filter
const existing = listAllEvents(primaryId, {
privateExtendedProperty: `${PROP.SOURCE_ID}=${sourceKey}`
});
const srcTitle = buildTitle(srcEvent.summary || "(No title)", sourceCalId);
const body = buildMirrorBody(srcEvent, sourceCalId);
if (existing.length > 0) {
// Update if changed
const mirror = existing[0];
if (eventsDiffer(srcEvent, mirror)) {
const patch = Object.assign({}, body);
Calendar.Events.patch(
patch,
primaryId,
mirror.id
);
}
} else {
// Create mirror
const createBody = Object.assign({}, body, {
extendedProperties: {
private: {
[PROP.SOURCE_ID]: sourceKey,
[PROP.SOURCE_CAL]: sourceCalId
}
}
});
Calendar.Events.insert(createBody, primaryId);
}
}
/**
* Delete mirrored events in primary that no longer exist in the source window.
*/
function deleteOrphanMirrors(sourceCalId, presentSourceKeys, timeMin, timeMax) {
const primaryId = CONFIG.PRIMARY_CAL_ID;
// Query all mirrors for this source calendar in the window
const mirrors = listAllEvents(primaryId, {
privateExtendedProperty: `${PROP.SOURCE_CAL}=${sourceCalId}`,
timeMin,
timeMax,
showDeleted: false
});
mirrors.forEach(m => {
const mKey = getPrivateProp(m, PROP.SOURCE_ID);
if (!mKey || !presentSourceKeys.has(mKey)) {
// Mirror without a live source → delete
Calendar.Events.remove(primaryId, m.id);
Calendar.Events.remove(primaryId, e.id);
}
});
}
/**
* Build event body for insert/patch to mirror source fields.
* Preserves all-day vs timed events, description, location, reminders.
*/
function buildMirrorBody(src, sourceCalId) {
const title = buildTitle(src.summary || "(No title)", sourceCalId);
const body = {
summary: title,
description: src.description || "",
location: src.location || "",
// Copy start/end preserving all-day (date) vs timed (dateTime with timeZone)
start: cloneDateLike(src.start),
end: cloneDateLike(src.end),
// Try to carry reminders if present
reminders: src.reminders ? JSON.parse(JSON.stringify(src.reminders)) : { useDefault: true },
// Try to carry visibility if present
visibility: src.visibility || "default",
// Hangout/Meet links are generally read-only; don't copy attendees to avoid accidental invites
attendees: [], // keep primary clean; we are mirroring for visibility, not invitations
};
// Optional: keep color for quick scanning
if (src.colorId) body.colorId = src.colorId;
return body;
}
/**
* Decide if events differ enough to patch.
*/
function eventsDiffer(a, b) {
const fields = [
"summary", "location", "visibility"
];
for (const f of fields) {
if ((a[f] || "") !== (b[f] || "")) return true;
}
if (!dateLikeEqual(a.start, b.start)) return true;
if (!dateLikeEqual(a.end, b.end)) return true;
const aDesc = a.description || "";
const bDesc = b.description || "";
if (aDesc !== bDesc) return true;
// Reminders (simple check)
const aRem = JSON.stringify(a.reminders || {});
const bRem = JSON.stringify(b.reminders || {});
if (aRem !== bRem) return true;
// If source changed after mirror updated, consider it different
const aUpd = a.updated || "";
const bUpd = b.updated || "";
if (aUpd && bUpd && aUpd !== bUpd) return true;
return false;
}
/**
* Helpers
*/
function keyFor(sourceCalId, eventId) {
return `${sourceCalId}:${eventId}`;
}
function buildTitle(baseTitle, sourceCalId) {
const prefix = CONFIG.TITLE_PREFIX || "";
if (!CONFIG.INCLUDE_SOURCE_NAME_IN_TITLE) return `${prefix}${baseTitle}`;
const name = sourceCalId;
return `${prefix}${baseTitle} — [${name}]`;
}
function getPrivateProp(ev, key) {
return ev.extendedProperties &&
ev.extendedProperties.private &&
ev.extendedProperties.private[key];
}
function cloneDateLike(dt) {
if (!dt) return null;
// All-day events use { date: "YYYY-MM-DD" }
if (dt.date) return { date: dt.date };
// Timed events use { dateTime: "...", timeZone: "..." }
return {
dateTime: dt.dateTime,
timeZone: dt.timeZone || Session.getScriptTimeZone()
};
}
function dateLikeEqual(a, b) {
if (!a && !b) return true;
if (!a || !b) return false;
if (a.date || b.date) {
return a.date === b.date;
}
return (a.dateTime === b.dateTime) &&
((a.timeZone || "") === (b.timeZone || ""));
}
function addDays(d, n) {
const x = new Date(d.getTime());
x.setDate(x.getDate() + n);
return x;
}
function iso(d) {
return d.toISOString();
}
/**
* Paginated list helper. Accepts the same options as Calendar.Events.list.
*/
function listAllEvents(calId, opts) {
const options = Object.assign({
maxResults: 2500,
singleEvents: true,
orderBy: "startTime",
showDeleted: false
}, opts || {});
let items = [];
let pageToken;
do {
const resp = Calendar.Events.list(calId, Object.assign({}, options, { pageToken }));
if (resp.items && resp.items.length) items = items.concat(resp.items);
pageToken = resp.nextPageToken;
} while (pageToken);
return items;
}
/**
* Allows you to see which events will be deleted if you choose to delete all mirrors. This might be useful at the start when you need to resync things.
*/
function countMirrorsInPrimary(timeWindowDays = 365) {
const primaryId = "primary";
const now = new Date();
const timeMin = new Date(now.getTime() - timeWindowDays*24*3600*1000).toISOString();
const timeMax = new Date(now.getTime() + timeWindowDays*24*3600*1000).toISOString();
const events = listAllEvents(primaryId, {
timeMin, timeMax, singleEvents: true, showDeleted: false
});
const mirrors = events.filter(e =>
e.extendedProperties &&
e.extendedProperties.private &&
e.extendedProperties.private.mirrorSourceId
);
Logger.log(`Total events scanned: ${events.length}`);
Logger.log(`Mirrors found: ${mirrors.length}`);
}
/**
* Deletes all events that have been added by this script.
*/
function cleanupDeleteAllMirrorsFromThisScript(timeWindowDays = 365) {
const primaryId = "primary";
const now = new Date();
const timeMin = new Date(now.getTime() - timeWindowDays*24*3600*1000).toISOString();
const timeMax = new Date(now.getTime() + timeWindowDays*24*3600*1000).toISOString();
const events = listAllEvents(primaryId, {
timeMin, timeMax, singleEvents: true, showDeleted: false
});
const mirrors = events.filter(e =>
e.extendedProperties &&
e.extendedProperties.private &&
e.extendedProperties.private.mirrorSourceId
);
mirrors.forEach(e => Calendar.Events.remove(primaryId, e.id));
Logger.log(`Deleted ${mirrors.length} mirrored events.`);
}
Was this article helpful?
That’s Great!
Thank you for your feedback
Sorry! We couldn't be helpful
Thank you for your feedback
Feedback sent
We appreciate your effort and will try to fix the article