Skip to content

Instantly share code, notes, and snippets.

@chasecmiller
Created October 6, 2025 19:03
Show Gist options
  • Save chasecmiller/074b71df27b842d40df26a08264906d0 to your computer and use it in GitHub Desktop.
Save chasecmiller/074b71df27b842d40df26a08264906d0 to your computer and use it in GitHub Desktop.
Bring Torc.dev events into my calendar and drive.
/**
* Torc.dev Events brings together developers, makers, and innovators to share tools, experiments, and ideas
* at the intersection of technology and creativity.
*
* This brings events from the torc.dev/community site into a Google folder and a calendar.
*
* You really care about the cron function at the bottom, and that's it. You can disable the calendar functionality.
*/
/**
* Main function to fetch all upcoming events from guild.host
* @param {string} guildSlug - The guild slug (e.g., 'torc-dev')
* @param {number} pageSize - Number of events per page (max 100)
* @return {Array} Array of all events
*/
function getAllUpcomingEvents(guildSlug = 'torc-dev', pageSize = 100) {
const allEvents = [];
let hasMore = true;
let afterCursor = null;
let pageCount = 0;
while (hasMore) {
pageCount++;
Logger.log(`Fetching page ${pageCount}...`);
const pageData = fetchEventsPage(guildSlug, pageSize, afterCursor);
if (!pageData || !pageData.events) {
Logger.log('No events data received');
break;
}
// Extract events from this page
const edges = pageData.events.edges || [];
Logger.log(`Found ${edges.length} events on page ${pageCount}`);
edges.forEach(edge => {
const event = parseEvent(edge.node);
allEvents.push(event);
});
// Check if there are more pages
const pageInfo = pageData.events.pageInfo;
hasMore = pageInfo && pageInfo.hasNextPage;
if (hasMore) {
afterCursor = pageInfo.endCursor;
Logger.log(`More pages available. Next cursor: ${afterCursor}`);
} else {
Logger.log('No more pages. Fetching complete.');
}
}
Logger.log(`Total events fetched: ${allEvents.length}`);
return allEvents;
}
/**
* Fetch a single page of events
*/
function fetchEventsPage(guildSlug, pageSize, afterCursor) {
let url = `https://guild.host/api/next/${guildSlug}/events/upcoming?first=${pageSize}`;
if (afterCursor) {
url += `&after=${encodeURIComponent(afterCursor)}`;
}
try {
const response = UrlFetchApp.fetch(url, {
method: 'get',
muteHttpExceptions: true
});
const statusCode = response.getResponseCode();
if (statusCode !== 200) {
Logger.log(`Error: HTTP ${statusCode}`);
return null;
}
const data = JSON.parse(response.getContentText());
return data;
} catch (error) {
Logger.log(`Error fetching events: ${error.message}`);
return null;
}
}
/**
* Parse event data into a clean object
*/
function parseEvent(node) {
return {
id: node.id,
slug: node.slug,
name: node.name,
description: node.description || '',
startAt: node.startAt, // ISO 8601 UTC format: "2025-10-31T17:30:00+00:00"
endAt: node.endAt, // ISO 8601 UTC format
timeZone: node.timeZone, // IANA timezone: "America/New_York"
fullUrl: node.fullUrl,
shortUrl: node.shortUrl,
hasVenue: node.hasVenue,
hasExternalUrl: node.hasExternalUrl,
venue: node.venue ? {
id: node.venue.id,
location: node.venue.address?.location?.geojson?.coordinates || null
} : null,
owner: node.owner?.name || '',
socialCardUrl: node.uploadedSocialCard?.url || node.generatedSocialCardURL,
createdAt: node.createdAt,
updatedAt: node.updatedAt
};
}
/**
* Convert ISO 8601 UTC time to ICS format in the event's local timezone
* Times from guild.host are in UTC (+00:00), but we need to represent them
* in the event's local timezone for proper ICS formatting.
*
* @param {string} isoString - ISO 8601 UTC timestamp
* @param {string} timeZone - IANA timezone (e.g., "America/New_York")
* @return {string} ICS formatted datetime (YYYYMMDDTHHMMSS)
*/
function toICSDateTime(isoString, timeZone) {
// Parse the UTC time
const date = new Date(isoString);
// Format for ICS: YYYYMMDDTHHMMSS
// We'll use the UTC time and let the TZID handle localization
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
const hour = String(date.getUTCHours()).padStart(2, '0');
const minute = String(date.getUTCMinutes()).padStart(2, '0');
const second = String(date.getUTCSeconds()).padStart(2, '0');
return `${year}${month}${day}T${hour}${minute}${second}Z`;
}
/**
* Generate ICS file content from an event
*
* TIMEZONE HANDLING:
* Guild.host returns times in UTC (+00:00) with a separate timeZone field.
* We use UTC times with the Z suffix, which allows the user's calendar app
* to properly convert to their local timezone.
*/
function generateICS(event) {
const now = new Date();
const dtstamp = toICSDateTime(now.toISOString(), 'UTC');
const dtstart = toICSDateTime(event.startAt, event.timeZone);
const dtend = toICSDateTime(event.endAt, event.timeZone);
// Escape special characters in text fields
const escapeICS = (text) => {
return text.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\n/g, '\\n');
};
// Generate a UID
const uid = `${event.slug}@guild.host`;
// Build location string
let location = '';
if (event.hasVenue && event.venue?.location) {
const [lng, lat] = event.venue.location;
location = `geo:${lat},${lng}`;
} else if (event.hasExternalUrl) {
location = 'Online Event';
}
const ics = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Guild.host//Events//EN',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTAMP:${dtstamp}`,
`DTSTART:${dtstart}`,
`DTEND:${dtend}`,
`SUMMARY:${escapeICS(event.name)}`,
`DESCRIPTION:${escapeICS(event.description)}\\n\\nEvent URL: ${event.fullUrl}`,
location ? `LOCATION:${escapeICS(location)}` : null,
`URL:${event.fullUrl}`,
`ORGANIZER;CN=${escapeICS(event.owner)}:[email protected]`,
'STATUS:CONFIRMED',
'TRANSP:OPAQUE',
'END:VEVENT',
'END:VCALENDAR'
].filter(line => line !== null).join('\r\n');
return ics;
}
/**
* Get the property store for tracking event files
*/
function getEventFileMapping() {
const props = PropertiesService.getUserProperties();
const mapping = props.getProperty('GUILD_EVENT_FILES');
return mapping ? JSON.parse(mapping) : {};
}
/**
* Save the event file mapping
*/
function saveEventFileMapping(mapping) {
const props = PropertiesService.getUserProperties();
props.setProperty('GUILD_EVENT_FILES', JSON.stringify(mapping));
}
/**
* Generate filename with datetime prefix
*/
function generateFileName(event) {
// Format datetime for filename: YYYYMMDD-HHMM
const startDate = new Date(event.startAt);
const datePrefix = [
startDate.getUTCFullYear(),
String(startDate.getUTCMonth() + 1).padStart(2, '0'),
String(startDate.getUTCDate()).padStart(2, '0')
].join('');
const timePrefix = [
String(startDate.getUTCHours()).padStart(2, '0'),
String(startDate.getUTCMinutes()).padStart(2, '0')
].join('');
// Clean event name for filename
const cleanName = event.name
.replace(/[^a-z0-9]/gi, '_')
.replace(/_+/g, '_')
.substring(0, 50); // Limit length
return `${datePrefix}-${timePrefix}_${cleanName}_${event.slug}.ics`;
}
/**
* Save ICS files to Google Drive with intelligent duplicate handling
* Uses event slug as stable identifier to update existing files
*
* @param {string} guildSlug - The guild slug
* @param {string} folderId - Google Drive folder ID (or folder name)
*/
function saveICSFilesToDrive(guildSlug = 'torc-dev', folderId = null) {
const events = getAllUpcomingEvents(guildSlug);
// Get or create the folder
let folder;
if (folderId) {
try {
folder = DriveApp.getFolderById(folderId);
} catch (e) {
// Try by name
const folders = DriveApp.getFoldersByName(folderId);
if (folders.hasNext()) {
folder = folders.next();
} else {
Logger.log(`Creating new folder: ${folderId}`);
folder = DriveApp.createFolder(folderId);
}
}
} else {
// Use root folder
folder = DriveApp.getRootFolder();
}
Logger.log(`Saving ${events.length} ICS files to folder: ${folder.getName()}`);
// Load existing file mapping
const fileMapping = getEventFileMapping();
const savedFiles = [];
const updatedEvents = [];
const newEvents = [];
events.forEach(event => {
const icsContent = generateICS(event);
const fileName = generateFileName(event);
const eventKey = `${guildSlug}:${event.slug}`;
let file;
let isUpdate = false;
// Check if we already have a file for this event
if (fileMapping[eventKey]) {
try {
// Try to get the existing file
file = DriveApp.getFileById(fileMapping[eventKey]);
// Update the file content
file.setContent(icsContent);
// Update filename if it changed (due to name/time change)
if (file.getName() !== fileName) {
file.setName(fileName);
Logger.log(`Updated: ${fileName} (renamed)`);
} else {
Logger.log(`Updated: ${fileName}`);
}
isUpdate = true;
updatedEvents.push(event.slug);
} catch (e) {
// File no longer exists, remove from mapping
Logger.log(`File for ${event.slug} no longer exists, creating new one`);
delete fileMapping[eventKey];
file = null;
}
}
// Create new file if we don't have one
if (!file) {
file = folder.createFile(fileName, icsContent, 'text/calendar');
fileMapping[eventKey] = file.getId();
Logger.log(`Created: ${fileName}`);
newEvents.push(event.slug);
}
savedFiles.push({
name: fileName,
url: file.getUrl(),
event: event.name,
action: isUpdate ? 'updated' : 'created'
});
});
// Save the updated mapping
saveEventFileMapping(fileMapping);
Logger.log(`\n=== SUMMARY ===`);
Logger.log(`New events: ${newEvents.length}`);
Logger.log(`Updated events: ${updatedEvents.length}`);
Logger.log(`Total files: ${savedFiles.length}`);
return savedFiles;
}
/**
* Clean up orphaned files (events that no longer exist)
* @param {string} guildSlug - The guild slug
* @param {boolean} dryRun - If true, only report what would be deleted
*/
function cleanupOrphanedFiles(guildSlug = 'torc-dev', dryRun = true) {
const events = getAllUpcomingEvents(guildSlug);
const currentSlugs = new Set(events.map(e => e.slug));
const fileMapping = getEventFileMapping();
const orphanedFiles = [];
Object.keys(fileMapping).forEach(eventKey => {
// Check if this key is for the current guild
if (!eventKey.startsWith(`${guildSlug}:`)) {
return;
}
const slug = eventKey.split(':')[1];
// If event no longer exists in the API
if (!currentSlugs.has(slug)) {
const fileId = fileMapping[eventKey];
try {
const file = DriveApp.getFileById(fileId);
orphanedFiles.push({
eventKey: eventKey,
fileName: file.getName(),
fileId: fileId
});
if (!dryRun) {
file.setTrashed(true);
delete fileMapping[eventKey];
Logger.log(`Deleted: ${file.getName()}`);
} else {
Logger.log(`Would delete: ${file.getName()}`);
}
} catch (e) {
// File already gone, just remove from mapping
delete fileMapping[eventKey];
Logger.log(`Removed mapping for non-existent file: ${eventKey}`);
}
}
});
if (!dryRun) {
saveEventFileMapping(fileMapping);
}
Logger.log(`\n=== CLEANUP SUMMARY ===`);
Logger.log(`Orphaned files found: ${orphanedFiles.length}`);
Logger.log(`Dry run: ${dryRun}`);
return orphanedFiles;
}
/**
* Reset the file mapping (useful for debugging)
*/
function resetFileMapping() {
const props = PropertiesService.getUserProperties();
props.deleteProperty('GUILD_EVENT_FILES');
Logger.log('File mapping reset');
}
/**
* Debug: Show current file mapping
*/
function debugShowMapping() {
const mapping = getEventFileMapping();
Logger.log('=== CURRENT FILE MAPPING ===');
Logger.log(JSON.stringify(mapping, null, 2));
Logger.log(`\nTotal tracked files: ${Object.keys(mapping).length}`);
}
/**
* DEBUG: Dump all events to log
*/
function debugDumpEvents(guildSlug = 'torc-dev') {
Logger.log('=== FETCHING EVENTS ===\n');
const events = getAllUpcomingEvents(guildSlug);
Logger.log(`\n=== FOUND ${events.length} EVENTS ===\n`);
events.forEach((event, index) => {
Logger.log(`\n--- Event ${index + 1} ---`);
Logger.log(`Name: ${event.name}`);
Logger.log(`Slug: ${event.slug}`);
Logger.log(`Start (UTC): ${event.startAt}`);
Logger.log(`End (UTC): ${event.endAt}`);
Logger.log(`TimeZone: ${event.timeZone}`);
Logger.log(`URL: ${event.fullUrl}`);
Logger.log(`Has Venue: ${event.hasVenue}`);
Logger.log(`Description: ${event.description.substring(0, 100)}...`);
// Show ICS format times
Logger.log(`ICS Start: ${toICSDateTime(event.startAt, event.timeZone)}`);
Logger.log(`ICS End: ${toICSDateTime(event.endAt, event.timeZone)}`);
});
Logger.log('\n=== TIMEZONE INFO ===');
Logger.log('Guild.host times are in UTC (+00:00 offset)');
Logger.log('The timeZone field indicates the event\'s intended timezone');
Logger.log('ICS files use UTC times with Z suffix for proper localization');
}
function saveToMyFolder() {
saveICSFilesToDrive('torc-dev', '1Ba6XYVGsyRehR-4xPK5yHdf9xFqKjcTe');
}
/**
* Sync events to Google Calendar
* Uses extended properties to track guild.host events and avoid duplicates
*
* @param {string} guildSlug - The guild slug
* @param {string} calendarId - Calendar ID (use 'primary' for main calendar, or specific calendar ID)
*/
function syncEventsToCalendar(guildSlug = 'torc-dev', calendarId = 'primary') {
const events = getAllUpcomingEvents(guildSlug);
const calendar = calendarId === 'primary'
? CalendarApp.getDefaultCalendar()
: CalendarApp.getCalendarById(calendarId);
if (!calendar) {
Logger.log('Error: Calendar not found');
return;
}
Logger.log(`Syncing ${events.length} events to calendar: ${calendar.getName()}`);
const stats = {
created: 0,
updated: 0,
skipped: 0,
errors: 0
};
events.forEach(event => {
try {
const result = syncEventToCalendar(event, calendar, guildSlug);
stats[result]++;
} catch (error) {
Logger.log(`Error syncing ${event.name}: ${error.message}`);
stats.errors++;
}
});
Logger.log('\n=== SYNC SUMMARY ===');
Logger.log(`Created: ${stats.created}`);
Logger.log(`Updated: ${stats.updated}`);
Logger.log(`Skipped: ${stats.skipped}`);
Logger.log(`Errors: ${stats.errors}`);
return stats;
}
/**
* Sync a single event to Google Calendar
* Returns: 'created', 'updated', or 'skipped'
*/
function syncEventToCalendar(event, calendar, guildSlug) {
const eventKey = `guildhost_${guildSlug}_${event.slug}`;
// Parse dates
const startDate = new Date(event.startAt);
const endDate = new Date(event.endAt);
// Search for existing event by our custom property
const existingEvent = findCalendarEventByProperty(calendar, eventKey, startDate, endDate);
// Prepare event details
const eventDetails = {
title: event.name,
description: formatEventDescription(event),
location: getEventLocation(event)
};
if (existingEvent) {
// Check if event needs updating
if (needsUpdate(existingEvent, event, eventDetails)) {
updateCalendarEvent(existingEvent, event, eventDetails);
Logger.log(`Updated: ${event.name}`);
return 'updated';
} else {
Logger.log(`Skipped (no changes): ${event.name}`);
return 'skipped';
}
} else {
// Create new event
const calEvent = calendar.createEvent(
eventDetails.title,
startDate,
endDate,
{
description: eventDetails.description,
location: eventDetails.location
}
);
// Tag it with our identifier
calEvent.setTag(eventKey, event.id);
calEvent.setTag('guildhost_slug', event.slug);
calEvent.setTag('guildhost_url', event.fullUrl);
calEvent.setTag('guildhost_updated', event.updatedAt);
Logger.log(`Created: ${event.name}`);
return 'created';
}
}
/**
* Find an existing calendar event by our custom property
*/
function findCalendarEventByProperty(calendar, eventKey, startDate, endDate) {
// Search in a range around the event time
const searchStart = new Date(startDate.getTime() - 7 * 24 * 60 * 60 * 1000); // 7 days before
const searchEnd = new Date(endDate.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days after
const events = calendar.getEvents(searchStart, searchEnd);
for (let i = 0; i < events.length; i++) {
const event = events[i];
const tag = event.getTag(eventKey);
if (tag) {
return event;
}
}
return null;
}
/**
* Format event description with guild.host info
*/
function formatEventDescription(event) {
let description = event.description || '';
// Add source info
description += `\n\n---\n`;
description += `πŸ“… Guild.host Event\n`;
description += `πŸ”— ${event.fullUrl}\n`;
if (event.owner) {
description += `πŸ‘€ Organized by: ${event.owner}\n`;
}
description += `⏰ Timezone: ${event.timeZone}`;
return description;
}
/**
* Get event location string
*/
function getEventLocation(event) {
if (event.hasVenue && event.venue?.location) {
const [lng, lat] = event.venue.location;
return `${lat},${lng}`;
} else if (event.hasExternalUrl) {
return 'Online Event';
}
return '';
}
/**
* Check if calendar event needs updating
*/
function needsUpdate(calendarEvent, guildEvent, eventDetails) {
// Check if title changed
if (calendarEvent.getTitle() !== eventDetails.title) {
return true;
}
// Check if description changed (approximately)
const currentDesc = calendarEvent.getDescription() || '';
if (!currentDesc.includes(guildEvent.description.substring(0, 50))) {
return true;
}
// Check if times changed
const calStart = calendarEvent.getStartTime().getTime();
const guildStart = new Date(guildEvent.startAt).getTime();
if (Math.abs(calStart - guildStart) > 60000) { // More than 1 minute difference
return true;
}
// Check if updatedAt changed
const lastUpdate = calendarEvent.getTag('guildhost_updated');
if (lastUpdate !== guildEvent.updatedAt) {
return true;
}
return false;
}
/**
* Update an existing calendar event
*/
function updateCalendarEvent(calendarEvent, guildEvent, eventDetails) {
calendarEvent.setTitle(eventDetails.title);
calendarEvent.setDescription(eventDetails.description);
calendarEvent.setLocation(eventDetails.location);
calendarEvent.setTime(new Date(guildEvent.startAt), new Date(guildEvent.endAt));
// Update our tracking tags
calendarEvent.setTag('guildhost_updated', guildEvent.updatedAt);
}
/**
* Remove guild.host events from calendar that no longer exist
* @param {string} guildSlug - The guild slug
* @param {string} calendarId - Calendar ID
* @param {boolean} dryRun - If true, only report what would be deleted
*/
function cleanupCalendarEvents(guildSlug = 'torc-dev', calendarId = 'primary', dryRun = true) {
const events = getAllUpcomingEvents(guildSlug);
const currentSlugs = new Set(events.map(e => e.slug));
const calendar = calendarId === 'primary'
? CalendarApp.getDefaultCalendar()
: CalendarApp.getCalendarById(calendarId);
// Search far into the future
const now = new Date();
const future = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000); // 1 year
const calendarEvents = calendar.getEvents(now, future);
const orphaned = [];
calendarEvents.forEach(calEvent => {
const slug = calEvent.getTag('guildhost_slug');
// Is this a guild.host event?
if (slug) {
// Does it still exist?
if (!currentSlugs.has(slug)) {
orphaned.push({
title: calEvent.getTitle(),
slug: slug,
startTime: calEvent.getStartTime()
});
if (!dryRun) {
calEvent.deleteEvent();
Logger.log(`Deleted: ${calEvent.getTitle()}`);
} else {
Logger.log(`Would delete: ${calEvent.getTitle()}`);
}
}
}
});
Logger.log(`\n=== CLEANUP SUMMARY ===`);
Logger.log(`Orphaned events found: ${orphaned.length}`);
Logger.log(`Dry run: ${dryRun}`);
return orphaned;
}
/**
* List all guild.host events currently in calendar
*/
function listGuildHostEvents(calendarId = 'primary') {
const calendar = calendarId === 'primary'
? CalendarApp.getDefaultCalendar()
: CalendarApp.getCalendarById(calendarId);
const now = new Date();
const future = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
const calendarEvents = calendar.getEvents(now, future);
const guildEvents = [];
calendarEvents.forEach(calEvent => {
const slug = calEvent.getTag('guildhost_slug');
if (slug) {
guildEvents.push({
title: calEvent.getTitle(),
slug: slug,
startTime: calEvent.getStartTime(),
url: calEvent.getTag('guildhost_url')
});
}
});
Logger.log(`\n=== GUILD.HOST EVENTS IN CALENDAR ===`);
Logger.log(`Total: ${guildEvents.length}\n`);
guildEvents.forEach(event => {
Logger.log(`${event.startTime.toISOString()} - ${event.title}`);
Logger.log(` Slug: ${event.slug}`);
Logger.log(` URL: ${event.url}\n`);
});
return guildEvents;
}
/**
* Full sync: Files + Calendar
*/
function fullSync(guildSlug = 'torc-dev', driveFolderId = 'Guild Events', calendarId = 'primary') {
Logger.log('=== STARTING FULL SYNC ===\n');
Logger.log('Step 1: Syncing to Drive...');
const driveResults = saveICSFilesToDrive(guildSlug, driveFolderId);
Logger.log('\nStep 2: Syncing to Calendar...');
const calendarResults = syncEventsToCalendar(guildSlug, calendarId);
Logger.log('\n=== FULL SYNC COMPLETE ===');
Logger.log(`Drive: ${driveResults.length} files`);
Logger.log(`Calendar: ${calendarResults.created + calendarResults.updated} events`);
}
/**
* THIS IS WHAT YOU CARE ABOUT. IGNORE EVERYTHING ABOVE.
*/
function cron() {
cleanupOrphanedFiles('torc-dev', false);
saveToMyFolder();
syncEventsToCalendar('torc-dev', 'primary');
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment