Last active
June 7, 2026 15:33
-
-
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'; | |
| // === TITLE ABBREVIATION MAP === | |
| const TITLE_ABBREVIATIONS = { | |
| // 'Original Text': 'Replacement', | |
| "Investment & Financial Management": "IFM", | |
| ": Introduction": "", | |
| "Introduction to ": "", | |
| "Technology and Innovation": "Technology & Innovation", | |
| "Logistics and Supply Chain": "Logistics & Supply Chain", | |
| }; | |
| // === TITLE PROCESSING === | |
| function processEventTitle(rawSummary) { | |
| if (!rawSummary) return { title: '(No title)', extraDescription: '' }; | |
| let title = rawSummary.replace(/\\/g, ''); | |
| let extraDescription = ''; | |
| const hasUE = title.includes('UE,'); | |
| const hasSE = title.includes('SE,'); | |
| // Move everything after the first '(' to the description, unless title contains "Prüfung" | |
| if (!title.includes('Prüfung')) { | |
| const bracketIndex = title.indexOf('('); | |
| if (bracketIndex !== -1) { | |
| extraDescription = title.substring(bracketIndex); | |
| title = title.substring(0, bracketIndex).trimEnd(); | |
| } | |
| } | |
| if (title.endsWith(': Exercise')) { | |
| title = title.substring(0, title.length - ': Exercise'.length); | |
| } | |
| for (const [key, value] of Object.entries(TITLE_ABBREVIATIONS)) { | |
| title = title.split(key).join(value); | |
| } | |
| if (hasUE) title += ' [Exercise]'; | |
| if (hasSE) title += ' [Seminar]'; | |
| return { title: title.trim(), extraDescription: extraDescription }; | |
| } | |
| // === 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); | |
| 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); | |
| const processed = processEventTitle(icsEvent.summary); | |
| Logger.log(`[UPDATED] UID: ${icsEvent.uid} | Title: ${processed.title || '(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); | |
| const processed = processEventTitle(icsEvent.summary); | |
| Logger.log(`[CREATED] UID: ${icsEvent.uid} | Title: ${processed.title || '(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(200); | |
| } | |
| 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, { muteHttpExceptions: true }); | |
| const statusCode = response.getResponseCode(); | |
| if (statusCode !== 200) { | |
| Logger.log(`Aborting sync: ICS server returned HTTP ${statusCode}.`); | |
| return null; | |
| } | |
| const text = response.getContentText(); | |
| if (!text || !text.includes('BEGIN:VCALENDAR')) { | |
| Logger.log('Aborting sync: Response does not contain a valid ICS calendar.'); | |
| return null; | |
| } | |
| Logger.log('ICS data fetched successfully.'); | |
| return text; | |
| } catch (error) { | |
| Logger.log(`Aborting sync: 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) { | |
| const { extraDescription } = processEventTitle(icsEvent.summary); | |
| 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'; | |
| } | |
| if (extraDescription) { | |
| desc += extraDescription + '\n\n'; | |
| } | |
| desc += icsEvent.description || ''; | |
| return desc; | |
| } | |
| // === EVENT CREATION/UPDATE LOGIC === | |
| function createEvent(calendar, icsEvent) { | |
| let event; | |
| const { title } = processEventTitle(icsEvent.summary); | |
| const description = buildDescription(icsEvent); | |
| if (icsEvent.isAllDay) { | |
| const endDate = icsEvent.end || new Date(icsEvent.start.getTime() + 24 * 60 * 60 * 1000); | |
| event = calendar.createAllDayEvent( | |
| 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( | |
| 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 { title } = processEventTitle(icsEvent.summary); | |
| const description = buildDescription(icsEvent); | |
| event.setTitle(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 } = processEventTitle(icsEvent.summary); | |
| 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 !== event.getTitle() || | |
| 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