Last active
August 14, 2025 14:44
-
-
Save gerwitz/84bb33a39c9e35b3ea827c40a0c1fc23 to your computer and use it in GitHub Desktop.
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
/** | |
* This script automates finding Google Doc attachments in Google Calendar events, | |
* converting them to richly formatted Markdown files using a self-contained, | |
* custom converter, and organizing them into monthly folders. | |
* | |
* This version handles both direct file attachments AND special "Notes by Gemini" | |
* attachments which may not have a standard mimeType. It uses PropertiesService | |
* to track processed files, avoiding file permission errors. | |
* | |
* --- SETUP REQUIREMENTS --- | |
* 1. ADVANCED CALENDAR SERVICE: You must enable the "Calendar API" in the | |
* "Services" section of the Apps Script editor. | |
* | |
* 2. NO OTHER SCRIPT FILES ARE NEEDED. | |
*/ | |
// === CONFIGURATION === | |
const CALENDAR_ID = "primary"; | |
const DESTINATION_FOLDER_NAME = "AI Meeting Notes"; | |
const ARCHIVE_FOLDER_NAME = "Archive"; // Only used by the optional manual function | |
// === END OF CONFIGURATION === | |
/** | |
* ================================================================= | |
* PRIMARY FUNCTION - TO BE RUN BY A TIME-DRIVEN TRIGGER | |
* ================================================================= | |
*/ | |
function findAndExportNotesFromCalendar() { | |
try { | |
if (typeof Calendar === 'undefined') { throw new Error("The Advanced Calendar Service is not enabled."); } | |
const now = new Date(); | |
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); | |
Logger.log(`Scanning for events from ${oneHourAgo.toLocaleString()} to ${now.toLocaleString()}`); | |
const rootDestinationFolder = getOrCreateFolder_(DESTINATION_FOLDER_NAME); | |
const options = { | |
timeMin: oneHourAgo.toISOString(), | |
timeMax: now.toISOString(), | |
showDeleted: false, | |
singleEvents: true, | |
orderBy: 'startTime', | |
timeZone: Session.getScriptTimeZone() | |
}; | |
const response = Calendar.Events.list(CALENDAR_ID, options); | |
if (!response.items || response.items.length === 0) { | |
Logger.log("No events found in the specified time window."); | |
return; | |
} | |
Logger.log(`Found ${response.items.length} event(s) to check.`); | |
for (const event of response.items) { | |
if (!event.attachments || event.attachments.length === 0) continue; | |
Logger.log(`Checking event: "${event.summary}"`); | |
for (const attachment of event.attachments) { | |
// --- FINAL LOGIC: Check for either a standard Doc or the special Gemini Note --- | |
const isProcessable = (attachment.mimeType === MimeType.GOOGLE_DOCS) || (attachment.title === "Notes by Gemini"); | |
if (isProcessable && attachment.fileId) { | |
const fileId = attachment.fileId; | |
try { | |
if (!isAlreadyProcessed_(fileId)) { | |
const file = DriveApp.getFileById(fileId); | |
const eventDate = getEventDateString_(event); | |
const eventTitle = event.summary || 'Untitled Event'; | |
const sanitizedTitle = eventTitle.replace(/[\/\\?%*:|"<>]/g, '-'); | |
const newFilename = `${eventDate} ${sanitizedTitle}.md`; | |
const monthFolderName = eventDate.substring(0, 7); | |
const monthlyDestinationFolder = getOrCreateSubfolder_(rootDestinationFolder, monthFolderName); | |
Logger.log(`Processing "${newFilename}" from attachment titled "${attachment.title}".`); | |
processFile_(file, newFilename, monthlyDestinationFolder); | |
} | |
} catch (e) { | |
Logger.log(`Could not process document ID "${fileId}". It may have been deleted or permissions changed. Error: ${e.toString()}`); | |
} | |
} | |
} | |
} | |
Logger.log("Calendar scan and export process complete!"); | |
} catch (e) { | |
Logger.log(`An unexpected error occurred during script execution: ${e.toString()}`); | |
} | |
} | |
/** | |
* ================================================================= | |
* CORE HELPER FUNCTIONS | |
* ================================================================= | |
*/ | |
function processFile_(file, newFilename, destinationFolder) { | |
const docName = file.getName(); | |
try { | |
const markdownBlob = exportDocAsMarkdownBlob_(file.getId(), newFilename); | |
if (markdownBlob) { | |
destinationFolder.createFile(markdownBlob); | |
Logger.log(`Successfully exported "${docName}" to Markdown as "${newFilename}".`); | |
markAsProcessed_(file.getId()); | |
Logger.log(`File ID ${file.getId()} marked as processed.`); | |
} else { | |
Logger.log(`Skipping file "${docName}" due to an export failure.`); | |
} | |
} catch (e) { | |
Logger.log(`Skipping file "${docName}" due to an unexpected error during processing: ${e.toString()}`); | |
} | |
} | |
function exportDocAsMarkdownBlob_(fileId, newFilename) { | |
const url = `https://www.googleapis.com/drive/v2/files/${fileId}/export?mimeType=text/html`; | |
const params = { | |
method: "GET", | |
headers: { "Authorization": "Bearer " + ScriptApp.getOAuthToken() }, | |
muteHttpExceptions: true | |
}; | |
try { | |
const response = UrlFetchApp.fetch(url, params); | |
if (response.getResponseCode() !== 200) { | |
Logger.log(`Error exporting file ID ${fileId} to HTML. API responded with code ${response.getResponseCode()}. Response: ${response.getContentText()}`); | |
return null; | |
} | |
const htmlContent = response.getContentText(); | |
const markdownContent = convertHtmlToMarkdown_(htmlContent); | |
return Utilities.newBlob(markdownContent, MimeType.PLAIN_TEXT, newFilename); | |
} catch (e) { | |
Logger.log(`A failure occurred during the export/conversion process for file ID ${fileId}. Error: ${e.toString()}`); | |
return null; | |
} | |
} | |
/** | |
* ================================================================= | |
* CUSTOM HTML -> MARKDOWN CONVERTER | |
* ================================================================= | |
*/ | |
function convertHtmlToMarkdown_(html) { | |
if (!html) return ''; | |
let text = html.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, ''); | |
text = text.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n'); | |
text = text.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n'); | |
text = text.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n'); | |
text = text.replace(/<h4[^>]*>(.*?)<\/h4>/gi, '#### $1\n\n'); | |
text = text.replace(/<h5[^>]*>(.*?)<\/h5>/gi, '##### $1\n\n'); | |
text = text.replace(/<h6[^>]*>(.*?)<\/h6>/gi, '###### $1\n\n'); | |
text = text.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n'); | |
text = text.replace(/<li[^>]*>(.*?)<\/li>/gi, '* $1\n'); | |
text = text.replace(/<\/(ul|ol)>/gi, '\n'); | |
text = text.replace(/<hr[^>]*>/gi, '\n---\n\n'); | |
text = text.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gi, '> $1\n\n'); | |
text = text.replace(/<a[^>]*href=["'](.*?)["'][^>]*>(.*?)<\/a>/gi, '[$2]($1)'); | |
text = text.replace(/<(strong|b)>(.*?)<\/(strong|b)>/gi, '**$2**'); | |
text = text.replace(/<(em|i)>(.*?)<\/(em|i)>/gi, '*$2*'); | |
text = text.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`'); | |
text = text.replace(/<br\s*\/?>/gi, ' \n'); | |
text = text.replace(/<\/?[^>]+>/g, ''); | |
text = text.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' '); | |
text = text.replace(/\n{3,}/g, '\n\n'); | |
return text.trim(); | |
} | |
/** | |
* ================================================================= | |
* UTILITY HELPER FUNCTIONS | |
* ================================================================= | |
*/ | |
function isAlreadyProcessed_(fileId) { | |
const properties = PropertiesService.getScriptProperties(); | |
return properties.getProperty(fileId) !== null; | |
} | |
function markAsProcessed_(fileId) { | |
const properties = PropertiesService.getScriptProperties(); | |
properties.setProperty(fileId, new Date().toUTCString()); | |
} | |
function getOrCreateSubfolder_(parentFolder, subfolderName) { | |
const subfolders = parentFolder.getFoldersByName(subfolderName); | |
if (subfolders.hasNext()) { return subfolders.next(); } | |
Logger.log(`Creating new monthly subfolder: "${subfolderName}"`); | |
return parentFolder.createFolder(subfolderName); | |
} | |
function getOrCreateFolder_(folderName) { | |
const folders = DriveApp.getFoldersByName(folderName); | |
if (folders.hasNext()) { return folders.next(); } | |
Logger.log(`Folder "${folderName}" not found. Creating it now.`); | |
return DriveApp.createFolder(folderName); | |
} | |
function getEventDateString_(event) { | |
const dateString = event.start.dateTime || event.start.date; | |
return dateString.substring(0, 10); | |
} | |
/** | |
* ================================================================= | |
* MAINTENANCE & MANUAL FUNCTIONS (Optional) | |
* ================================================================= | |
*/ | |
function clearProcessedFileCache() { | |
Logger.log("Clearing all processed file IDs from the script's property store."); | |
PropertiesService.getScriptProperties().deleteAllProperties(); | |
Logger.log("Cache cleared."); | |
} | |
function exportDocsInFolder() { | |
const SOURCE_FOLDER_NAME = "Meet Recordings"; | |
const archiveFolder = getOrCreateFolder_(ARCHIVE_FOLDER_NAME); | |
const destinationFolder = getOrCreateFolder_(DESTINATION_FOLDER_NAME); | |
function isArchived_(file) { | |
const parents = file.getParents(); | |
while (parents.hasNext()) { | |
if (parents.next().getId() === archiveFolder.getId()) return true; | |
} | |
return false; | |
} | |
const sourceFolder = getOrCreateFolder_(SOURCE_FOLDER_NAME); | |
const files = sourceFolder.getFilesByType(MimeType.GOOGLE_DOCS); | |
Logger.log(`Starting manual export from folder: "${SOURCE_FOLDER_NAME}"`); | |
while(files.hasNext()){ | |
const file = files.next(); | |
if (!isArchived_(file)) { | |
const newFilename = file.getName() + ".md"; | |
// Manual function uses a different processFile function | |
manualProcessFile_(file, newFilename, destinationFolder, archiveFolder); | |
} | |
} | |
Logger.log("Manual export process complete."); | |
} | |
function manualProcessFile_(file, newFilename, destinationFolder, archiveFolder) { | |
try { | |
const markdownBlob = exportDocAsMarkdownBlob_(file.getId(), newFilename); | |
if (markdownBlob) { | |
destinationFolder.createFile(markdownBlob); | |
file.moveTo(archiveFolder); // The manual function can still try to move files | |
} | |
} catch(e) { | |
Logger.log(`Could not process file "${file.getName()}" in manual mode. Error: ${e.toString()}`); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment