Skip to content

Instantly share code, notes, and snippets.

@koenidv
Last active February 18, 2026 13:24
Show Gist options
  • Select an option

  • Save koenidv/35da9f28a19fdcb249412f9cfe72ce68 to your computer and use it in GitHub Desktop.

Select an option

Save koenidv/35da9f28a19fdcb249412f9cfe72ce68 to your computer and use it in GitHub Desktop.
Workspace Script: Invite to ICS events
// === CONFIGURATION ===
const ICS_URL = 'YOUR ICS URL';
const CALENDAR_ID = 'HIDDEN CALENDAR ID @group.calendar.google.com';
const INVITE_CALENDAR_ID = 'VISIBLE CALENDAR DI @group.calendar.google.com';
const MAX_CHANGES_PER_RUN = 500;
const PROGRESS_KEY = 'ics_sync_progress';
// === MAIN SYNC FUNCTION ===
function syncICSToGoogleCalendar() {
const scriptProperties = PropertiesService.getScriptProperties();
const calendar = CalendarApp.getCalendarById(CALENDAR_ID);
if (!calendar) {
Logger.log('Calendar not found!');
return;
}
Logger.log('--- Sync started ---');
const icsData = fetchICSData(ICS_URL);
if (!icsData) return;
const allIcsEvents = parseICS(icsData);
// === FIX: Only sync future events (starting from now) ===
const now = new Date();
const icsEvents = allIcsEvents.filter(e => {
const eventEnd = e.end || new Date(e.start.getTime() + (e.isAllDay ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000));
return eventEnd >= now;
});
Logger.log(`Filtered to ${icsEvents.length} future events (skipped ${allIcsEvents.length - icsEvents.length} past events).`);
let progress = JSON.parse(scriptProperties.getProperty(PROGRESS_KEY) || '{}');
if (!progress || !progress.mode) progress = { mode: 'upsert', index: 0, eventUids: [] };
const existingEvents = getExistingEvents(calendar);
const icsEventMap = new Map();
icsEvents.forEach(e => icsEventMap.set(e.uid, e));
let changes = 0;
// === UPSERT (CREATE/UPDATE) EVENTS ===
if (progress.mode === 'upsert') {
Logger.log('Upsert phase started (creating/updating events)');
for (let i = progress.index; i < icsEvents.length; i++) {
const icsEvent = icsEvents[i];
progress.eventUids.push(icsEvent.uid);
const existingEvent = existingEvents.get(icsEvent.uid);
if (existingEvent) {
if (hasEventChanged(existingEvent, icsEvent)) {
updateEvent(existingEvent, icsEvent);
Logger.log(`[UPDATED] UID: ${icsEvent.uid} | Title: ${icsEvent.summary || '(No title)'} | Location: ${icsEvent.location ? icsEvent.location.replace(/\\/g, '') : ''}`);
changes++;
} else {
Logger.log(`[NO CHANGE] UID: ${icsEvent.uid} | Title: ${icsEvent.summary || '(No title)'}`)
}
} else {
createEvent(calendar, icsEvent);
Logger.log(`[CREATED] UID: ${icsEvent.uid} | Title: ${icsEvent.summary || '(No title)'} | Location: ${icsEvent.location ? icsEvent.location.replace(/\\/g, '') : ''}`);
changes++;
}
if (changes >= MAX_CHANGES_PER_RUN) {
progress.index = i + 1;
scriptProperties.setProperty(PROGRESS_KEY, JSON.stringify(progress));
Logger.log(`Upserted ${changes} events this run. Progress saved at index ${progress.index}.`);
Logger.log('--- Sync paused due to quota limit ---');
return;
}
Utilities.sleep(50);
}
progress.mode = 'delete';
progress.index = 0;
scriptProperties.setProperty(PROGRESS_KEY, JSON.stringify(progress));
Logger.log('Upsert phase complete, moving to deletion phase.');
Logger.log('--- Sync paused, will continue with deletions next run ---');
return;
}
// === DELETE FUTURE EVENTS NOT IN ICS ===
if (progress.mode === 'delete') {
Logger.log('Delete phase started (removing future events not in ICS)');
const eventUidsToKeep = new Set(progress.eventUids);
const allUids = Array.from(existingEvents.keys());
for (let i = progress.index; i < allUids.length; i++) {
const uid = allUids[i];
if (!eventUidsToKeep.has(uid)) {
const event = existingEvents.get(uid);
// Only delete future events, leave past events alone
if (event.getEndTime() >= now) {
Logger.log(`[REMOVED] UID: ${uid} | Title: ${event.getTitle()} | Location: ${event.getLocation()}`);
event.deleteEvent();
changes++;
}
}
if (changes >= MAX_CHANGES_PER_RUN) {
progress.index = i + 1;
scriptProperties.setProperty(PROGRESS_KEY, JSON.stringify(progress));
Logger.log(`Deleted ${changes} events this run. Progress saved at index ${progress.index}.`);
Logger.log('--- Sync paused due to quota limit ---');
return;
}
Utilities.sleep(400);
}
scriptProperties.deleteProperty(PROGRESS_KEY);
Logger.log('Sync completed successfully!');
Logger.log('--- Sync finished ---');
}
}
// === CLEANUP DUPLICATE PAST EVENTS ===
function cleanupDuplicatePastEvents() {
const calendar = CalendarApp.getCalendarById(CALENDAR_ID);
if (!calendar) {
Logger.log('Calendar not found!');
return;
}
const now = new Date();
// Look back up to 2 years for duplicates
const pastDate = new Date(now.getTime() - (730 * 24 * 60 * 60 * 1000));
const events = calendar.getEvents(pastDate, now);
Logger.log(`Found ${events.length} past events to scan for duplicates.`);
// Group events by UID
const uidGroups = new Map();
events.forEach(event => {
const uid = event.getTag('ics_uid');
if (uid) {
if (!uidGroups.has(uid)) {
uidGroups.set(uid, []);
}
uidGroups.get(uid).push(event);
}
});
let deletedCount = 0;
let duplicateGroups = 0;
uidGroups.forEach((eventList, uid) => {
if (eventList.length <= 1) return;
duplicateGroups++;
// Find event with an RSVP (any response other than INVITED / no response)
let rsvpEvent = null;
for (const event of eventList) {
const guests = event.getGuestList();
const hasResponse = guests.some(
guest => guest.getGuestStatus() !== CalendarApp.GuestStatus.INVITED
);
if (hasResponse) {
rsvpEvent = event;
break;
}
}
// Keep the RSVP'd event, or the first one if none were RSVP'd
const keepEvent = rsvpEvent || eventList[0];
const keepReason = rsvpEvent ? 'has RSVP' : 'first instance (no RSVPs found)';
Logger.log(`[DUPLICATES] UID: ${uid} | Title: ${keepEvent.getTitle()} | ${eventList.length} copies | Keeping: ${keepReason}`);
for (const event of eventList) {
if (event.getId() !== keepEvent.getId()) {
event.deleteEvent();
deletedCount++;
Utilities.sleep(50);
}
}
});
Logger.log(`Cleanup complete. Found ${duplicateGroups} duplicate groups, deleted ${deletedCount} events.`);
}
// === ICS FETCHING AND PARSING ===
function fetchICSData(url) {
try {
const response = UrlFetchApp.fetch(url);
Logger.log('ICS data fetched successfully.');
return response.getContentText();
} catch (error) {
Logger.log(`Error fetching ICS data: ${error}`);
return null;
}
}
function parseICS(icsData) {
const events = [];
const lines = icsData.split(/\r?\n/);
let currentEvent = null;
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
while (i + 1 < lines.length && (lines[i + 1].startsWith(' ') || lines[i + 1].startsWith('\t'))) {
i++;
line += lines[i].substring(1);
}
if (line === 'BEGIN:VEVENT') {
currentEvent = {};
} else if (line === 'END:VEVENT' && currentEvent) {
if (currentEvent.uid && currentEvent.start) {
events.push(currentEvent);
}
currentEvent = null;
} else if (currentEvent) {
const colonIndex = line.indexOf(':');
const semicolonIndex = line.indexOf(';');
let key, value, params = {};
if (semicolonIndex !== -1 && (colonIndex === -1 || semicolonIndex < colonIndex)) {
key = line.substring(0, semicolonIndex);
const paramString = line.substring(semicolonIndex + 1, colonIndex);
value = line.substring(colonIndex + 1);
paramString.split(';').forEach(param => {
const [pKey, pValue] = param.split('=');
if (pKey && pValue) params[pKey] = pValue;
});
} else if (colonIndex !== -1) {
key = line.substring(0, colonIndex);
value = line.substring(colonIndex + 1);
} else {
continue;
}
switch (key) {
case 'UID':
currentEvent.uid = value;
break;
case 'SUMMARY':
currentEvent.summary = value;
break;
case 'DESCRIPTION':
currentEvent.description = value.replace(/\\;/g, '\n').replace(/\\n/g, '\n').replace(/\\,/g, ',');
break;
case 'LOCATION':
currentEvent.location = value ? value.replace(/\\/g, '') : '';
break;
case 'DTSTART':
currentEvent.start = parseICSDate(value, params);
currentEvent.isAllDay = !value.includes('T');
break;
case 'DTEND':
currentEvent.end = parseICSDate(value, params);
break;
case 'LAST-MODIFIED':
case 'DTSTAMP':
if (!currentEvent.lastModified) {
currentEvent.lastModified = parseICSDate(value, params);
}
break;
}
}
}
Logger.log(`Parsed ${events.length} events from ICS feed.`);
return events;
}
function parseICSDate(dateString, params) {
dateString = dateString.replace(/TZID=[^:]+:/, '');
if (dateString.length === 8) {
const year = parseInt(dateString.substring(0, 4));
const month = parseInt(dateString.substring(4, 6)) - 1;
const day = parseInt(dateString.substring(6, 8));
return new Date(year, month, day);
} else {
const year = parseInt(dateString.substring(0, 4));
const month = parseInt(dateString.substring(4, 6)) - 1;
const day = parseInt(dateString.substring(6, 8));
const hour = parseInt(dateString.substring(9, 11));
const minute = parseInt(dateString.substring(11, 13));
const second = parseInt(dateString.substring(13, 15));
if (dateString.endsWith('Z')) {
return new Date(Date.UTC(year, month, day, hour, minute, second));
} else {
return new Date(year, month, day, hour, minute, second);
}
}
}
// === EXISTING EVENTS MAP (UID -> Event) ===
function getExistingEvents(calendar) {
const eventMap = new Map();
const now = new Date();
const futureDate = new Date(now.getTime() + (365 * 24 * 60 * 60 * 1000));
// Small buffer into the past to catch currently-running events
const pastDate = new Date(now.getTime() - (24 * 60 * 60 * 1000));
const events = calendar.getEvents(pastDate, futureDate);
events.forEach(event => {
const uid = event.getTag('ics_uid');
if (uid) eventMap.set(uid, event);
});
Logger.log(`Loaded ${eventMap.size} existing events from calendar.`);
return eventMap;
}
// === DESCRIPTION BUILDER WITH NAVIGATUM LINK ===
function buildDescription(icsEvent) {
let desc = '';
let roomCode = null;
if (icsEvent.location) {
const match = icsEvent.location.match(/\(([^\]]+)\)\s*$/);
if (match) {
roomCode = match[1].trim();
}
}
if (roomCode) {
desc += 'NavigaTUM: https://nav.tum.de/en/room/' + encodeURIComponent(roomCode) + '\n\n';
}
desc += icsEvent.description || '';
return desc;
}
// === EVENT CREATION/UPDATE LOGIC ===
function createEvent(calendar, icsEvent) {
let event;
const description = buildDescription(icsEvent);
if (icsEvent.isAllDay) {
const endDate = icsEvent.end || new Date(icsEvent.start.getTime() + 24 * 60 * 60 * 1000);
event = calendar.createAllDayEvent(
icsEvent.summary || '(No title)',
icsEvent.start,
endDate,
{
description: description,
location: icsEvent.location ? icsEvent.location.replace(/\\/g, '') : '',
guests: INVITE_CALENDAR_ID,
sendInvites: true
}
);
} else {
const endDate = icsEvent.end || new Date(icsEvent.start.getTime() + 60 * 60 * 1000);
event = calendar.createEvent(
icsEvent.summary || '(No title)',
icsEvent.start,
endDate,
{
description: description,
location: icsEvent.location ? icsEvent.location.replace(/\\/g, '') : '',
guests: INVITE_CALENDAR_ID,
sendInvites: true
}
);
}
event.setTag('ics_uid', icsEvent.uid);
return event;
}
function updateEvent(event, icsEvent) {
const description = buildDescription(icsEvent);
event.setTitle(icsEvent.summary || '(No title)');
event.setDescription(description);
event.setLocation(icsEvent.location ? icsEvent.location.replace(/\\/g, '') : '');
const endDate = icsEvent.end || new Date(icsEvent.start.getTime() + 60 * 60 * 1000);
if (icsEvent.isAllDay) {
event.setAllDayDate(icsEvent.start);
} else {
event.setTime(icsEvent.start, endDate);
}
const guests = event.getGuestList().map(g => g.getEmail());
if (!guests.includes(INVITE_CALENDAR_ID)) {
event.addGuest(INVITE_CALENDAR_ID);
}
}
function hasEventChanged(event, icsEvent) {
const title = event.getTitle();
const description = event.getDescription();
const location = event.getLocation();
const startTime = event.getStartTime().getTime();
const icsEndDate = icsEvent.end || new Date(icsEvent.start.getTime() + 60 * 60 * 1000);
const endTime = event.getEndTime().getTime();
const newDescription = buildDescription(icsEvent);
return title !== (icsEvent.summary || '(No title)') ||
description !== newDescription ||
location !== (icsEvent.location ? icsEvent.location.replace(/\\/g, '') : '') ||
startTime !== icsEvent.start.getTime() ||
endTime !== icsEndDate.getTime();
}
// === OPTIONAL: TRIGGER SETUP ===
function setupTrigger() {
const triggers = ScriptApp.getProjectTriggers();
triggers.forEach(trigger => {
if (trigger.getHandlerFunction() === 'syncICSToGoogleCalendar') {
ScriptApp.deleteTrigger(trigger);
}
});
ScriptApp.newTrigger('syncICSToGoogleCalendar')
.timeBased()
.everyHours(1)
.create();
Logger.log('Trigger set up to run every hour');
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment