Skip to content

Instantly share code, notes, and snippets.

@gerwitz
Last active August 14, 2025 14:44
Show Gist options
  • Save gerwitz/84bb33a39c9e35b3ea827c40a0c1fc23 to your computer and use it in GitHub Desktop.
Save gerwitz/84bb33a39c9e35b3ea827c40a0c1fc23 to your computer and use it in GitHub Desktop.
/**
* 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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/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