Last active
February 18, 2026 13:24
-
-
Save koenidv/35da9f28a19fdcb249412f9cfe72ce68 to your computer and use it in GitHub Desktop.
Workspace Script: Invite to ICS events
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // === 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