apps script export (a helpful tool that saves time)

96 views
Skip to first unread message

Steve Horvath (SMAARTE Group)

unread,
Sep 10, 2025, 2:12:59 AMSep 10
to Google Apps Script Community
Exported scripts files (stored as Google Docs) can be fed right back into Gemini and make easier work of re-working things over time.  I hope you this useful for your own work.  I've seen many questions posted here that seem like they have answers Gemini can readily provide.

P.S.  I'm enjoying Gemini Ultra Deep Think when it works.  Seemingly random context window issues are annoying.
P.P.S. This script was created using Gemini.

EXAMPLE OUTPUT
EXAMPLE OUTPUT.png

APPS SCRIPT JSON
{
  "timeZone": "America/New_York",
  "dependencies": {
    "enabledAdvancedServices": [
      {
        "userSymbol": "Drive",
        "version": "v3",
        "serviceId": "drive"
      }
    ]
  },
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
  ]
}

MAIN GS FILE
/**ScriptExporter.gs
 * @fileoverview A standalone Google Apps Script utility to automatically find all
 * Apps Script projects in a user's My Drive and specified Shared Drives,
 * and export each one to its own Google Doc in a designated folder.
 * This version is optimized for execution directly from the script editor,
 * using logs instead of UI pop-ups for feedback.
 *
 * @version 3.1
 */

// ===============================================================
//                       CONFIGURATION
// ===============================================================

// TODO: Replace with the ID of the Google Drive folder where docs will be saved.
// How to get ID: Open the folder in Drive, the ID is the last part of the URL.
// Example: const DESTINATION_FOLDER_ID = '1a2b3c4d5e6f7g8h9i0j_kLmNoPqRsT';
const DESTINATION_FOLDER_ID = "";

// TODO: Add the IDs of any Shared Drives you want to search.
// How to get ID: Right-click the Shared Drive in the Drive UI, select "Get link".
// The ID is the part after "/folders/". Leave the array empty [] if you have none.
// Example: const SHARED_DRIVE_IDS = ['0A1B2C3D4E5F6G_H7I8J', '0K9L8M7N6P5Q4R_S3T2U'];
const SHARED_DRIVE_IDS = [""];


// ===============================================================
//                  MAIN SCRIPT LOGIC
// ===============================================================

/**
 * Main function to run the entire export process.
 */
function runFullExport() {
  Logger.log('--- SCRIPT EXECUTION STARTED ---');
  if (DESTINATION_FOLDER_ID === "YOUR_FOLDER_ID_HERE") {
    Logger.log('CRITICAL ERROR: Please set the DESTINATION_FOLDER_ID constant at the top of the script.');
    return;
  }

  try {
    Logger.log(`Attempting to access destination folder using Advanced Drive API. ID: "${DESTINATION_FOLDER_ID}"`);
    const folderMetadata = Drive.Files.get(DESTINATION_FOLDER_ID, { supportsAllDrives: true });
    Logger.log(`Successfully accessed destination folder metadata: "${folderMetadata.title}"`);
   
    const destinationFolder = DriveApp.getFolderById(DESTINATION_FOLDER_ID);
   
    const allProjects = getProjectList();
    processAllProjects(allProjects, destinationFolder);
  } catch (e) {
    Logger.log(`CRITICAL ERROR: Could not access the destination folder with ID "${DESTINATION_FOLDER_ID}". Please check that the ID is correct and you have access to it. Full error: ${e.toString()}`);
    Logger.log(`Stack Trace: ${e.stack}`);
  }
  Logger.log('--- SCRIPT EXECUTION FINISHED ---');
}

/**
 * Compiles a list of all Apps Script projects.
 */
function getProjectList() {
  Logger.log('Searching for Apps Script projects...');
  const MimeType = "application/vnd.google-apps.script";
  let projects = [];

  try {
    Logger.log('Searching in "My Drive"...');
    const myDriveFiles = DriveApp.searchFiles(`mimeType='${MimeType}' and trashed=false`);
    while (myDriveFiles.hasNext()) {
      projects.push(myDriveFiles.next());
    }
    Logger.log(`Found ${projects.length} project(s) in "My Drive".`);

    SHARED_DRIVE_IDS.forEach((driveId, index) => {
      try {
        const driveName = DriveApp.getFolderById(driveId).getName();
        Logger.log(`Searching in Shared Drive ${index + 1}: "${driveName}" (ID: ${driveId})`);
        const sharedDriveFiles = Drive.Files.list({
          q: `mimeType='${MimeType}' and trashed=false`,
          driveId: driveId,
          corpora: 'drive',
          includeItemsFromAllDrives: true,
          supportsAllDrives: true,
        });
        if (sharedDriveFiles.items) {
          sharedDriveFiles.items.forEach(item => {
            projects.push(DriveApp.getFileById(item.id));
          });
        }
        Logger.log(`Found a total of ${projects.length} project(s) after searching "${driveName}".`);
      } catch (e) {
         Logger.log(`WARNING: Could not search Shared Drive with ID "${driveId}". Error: ${e.toString()}`);
      }
    });

  } catch (e) {
    Logger.log(`ERROR during project search. Message: ${e.toString()}`);
  }

  Logger.log(`Total unique Apps Script projects found: ${projects.length}`);
  return projects;
}

/**
 * Iterates through projects and triggers the export process.
 */
function processAllProjects(projects, destinationFolder) {
  if (!projects || projects.length === 0) {
    Logger.log('No Apps Script projects found to process.');
    return;
  }
  Logger.log(`Starting to process ${projects.length} project(s).`);

  projects.forEach((project, index) => {
    Logger.log(`\n--- Processing project ${index + 1} of ${projects.length}: "${project.getName()}" (ID: ${project.getId()}) ---`);
    try {
      exportProjectToDoc(project.getId(), project.getName(), destinationFolder);
    } catch (e) {
      Logger.log(`ERROR processing project "${project.getName()}". Skipping. Error: ${e.toString()}`);
    }
  });
}

/**
 * Fetches project content using UrlFetchApp and the REST API, then writes it to a Google Doc.
 */
function exportProjectToDoc(scriptId, projectName, destinationFolder) {
  Logger.log(`Fetching content for project ID: ${scriptId} using UrlFetchApp.`);
 
  try {
    const url = `https://script.googleapis.com/v1/projects/${scriptId}/content`;
    const options = {
      method: 'get',
      headers: {
        'Authorization': 'Bearer ' + ScriptApp.getOAuthToken()
      },
      muteHttpExceptions: true // Prevents script from stopping on HTTP errors
    };

    const response = UrlFetchApp.fetch(url, options);
    const responseCode = response.getResponseCode();
    const responseBody = response.getContentText();

    if (responseCode !== 200) {
      Logger.log(`ERROR: Failed to fetch project content. Response Code: ${responseCode}. Response Body: ${responseBody}`);
      return;
    }

    const content = JSON.parse(responseBody);
    if (!content.files || content.files.length === 0) {
      Logger.log(`No files found in project "${projectName}". Skipping doc creation.`);
      return;
    }
   
    Logger.log(`Creating Google Doc for project: "${projectName}"`);
    const docName = `Apps Script Export - ${projectName}`;
    const doc = DocumentApp.create(docName);
    const body = doc.getBody();
   
    const headerStyle = {};
    headerStyle[DocumentApp.Attribute.BOLD] = true;
    headerStyle[DocumentApp.Attribute.FONT_SIZE] = 14;
    headerStyle[DocumentApp.Attribute.FOREGROUND_COLOR] = '#666666';

    const codeStyle = {};
    codeStyle[DocumentApp.Attribute.FONT_FAMILY] = DocumentApp.FontFamily.CONSOLAS;
    codeStyle[DocumentApp.Attribute.FONT_SIZE] = 10;
    codeStyle[DocumentApp.Attribute.BACKGROUND_COLOR] = '#f3f3f3';
   
    content.files.forEach(file => {
      Logger.log(`Appending file: "${file.name}.${file.type === 'SERVER_JS' ? 'gs' : 'json'}"`);
      body.appendParagraph(`// FILE: ${file.name}.${file.type === 'SERVER_JS' ? 'gs' : 'json'}`)
        .setAttributes(headerStyle);
     
      const codeBlock = body.appendParagraph(file.source);
      codeBlock.setAttributes(codeStyle);
      body.appendParagraph('');
    });
   
    doc.saveAndClose();
    const docFile = DriveApp.getFileById(doc.getId());
    docFile.moveTo(destinationFolder);
   
    Logger.log(`Successfully created and moved document: ${docFile.getName()} (ID: ${docFile.getId()})`);

  } catch (e) {
    Logger.log(`CRITICAL ERROR exporting project "${projectName}" (ID: ${scriptId}). Error: ${e.toString()}`);
    Logger.log(`Stack Trace: ${e.stack}`);
  }
}

Steve Horvath

unread,
Sep 20, 2025, 1:34:05 AM (4 days ago) Sep 20
to google-apps-sc...@googlegroups.com
This updated version of the apps script exporter is more useful.  It uses the properties service to enhance performance and avoid exporting scripts that haven't changed.  It also sends an email summary.  It's a great way to present data to Gemini for refinement.


JSON
{
  "timeZone": "America/Los_Angeles",
  "dependencies": {
    "enabledAdvancedServices": [
      {
        "userSymbol": "Drive",
        "version": "v3",
        "serviceId": "drive"
      }
    ]
  },
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
  ]
}

GS FILE
/**ScriptExporter.gs
 * @fileoverview A standalone Google Apps Script utility to export specified and
 * standalone Apps Script projects to formatted Google Docs.
 */

// ===============================================================
//                       CONFIGURATION
// ===============================================================

// The ID of the Google Drive folder where exported documents will be saved.
const DESTINATION_FOLDER_ID = "";
// The email address to receive a summary report upon completion.
const ADMIN_EMAIL_ADDRESS = ""; // <-- IMPORTANT: Set your email address here

// --- CHOOSE HASH STORAGE METHOD ---
// 'PROPERTIES_SERVICE': Faster, more efficient. Uses a central key-value store. (Recommended)
// 'DRIVE_PROPERTIES': Slower. Scans every file in the destination folder for custom properties.
const HASH_STORAGE_METHOD = 'PROPERTIES_SERVICE';

// --- EXECUTION CONTROL ---
// The script will attempt to pause and resume before hitting the 6-minute limit.
const MAX_EXECUTION_TIME_SECONDS = 300; // = 5 minutes

// A list of Shared Drive IDs to search. Leave empty [] if you don't need to search any.
const SHARED_DRIVE_IDS = [];

// Add projects here you want to export every time.
const PRIORITY_PROJECTS = [
  { id: "", name: "" },
  { id: "", name: ""},
  { id: "", name: ""}
];


// ===============================================================
//               MAIN SCRIPT & BATCH PROCESSING
// ===============================================================

/**
 * Main function that starts or continues the export process.
 * Can be run manually to start, and is called by triggers to resume.
 */
function runFullExport() {
  const scriptProperties = PropertiesService.getScriptProperties();
  const executionStartTime = Date.now();
  let jobState;

  try {
    const storedState = scriptProperties.getProperty('jobState');
    jobState = storedState ? JSON.parse(storedState) : null;

    // If no state exists, this is a new job.
    if (!jobState) {
      Logger.log('--- NEW EXPORT JOB STARTED (V7.8) ---');
      cleanupOldTriggers(); // Clean up in case of a previous failed run
      const destinationFolder = DriveApp.getFolderById(DESTINATION_FOLDER_ID);
      Logger.log(`Successfully accessed destination folder: "${destinationFolder.getName()}"`);

      const allProjects = getStandaloneProjectList();
      const existingHashes = getExistingHashes();

      jobState = {
        allProjects: allProjects,
        processedProjectIds: [],
        existingHashes: existingHashes,
        successCount: 0,
        failCount: 0,
        skippedCount: 0,
        jobStartTime: executionStartTime,
        exportedFileDetails: [] // To store details for the email report
      };
    } else {
      Logger.log('--- EXPORT JOB RESUMED ---');
    }

    // Process a batch of projects
    const projectsToProcess = jobState.allProjects.filter(p => !jobState.processedProjectIds.includes(p.id));

    Logger.log(`\nFound ${projectsToProcess.length} project(s) remaining to process.`);

    for (const project of projectsToProcess) {
      // Check if we are running out of time before processing the next item
      const elapsedTime = (Date.now() - executionStartTime) / 1000;
      if (elapsedTime >= MAX_EXECUTION_TIME_SECONDS) {
        Logger.log(`Approaching execution limit. Pausing job.`);
        scriptProperties.setProperty('jobState', JSON.stringify(jobState));
        createNextTrigger();
        return; // Exit to allow the next trigger to take over
      }

      Logger.log(`\n--- [${jobState.processedProjectIds.length + 1}/${jobState.allProjects.length}] Processing: "${project.name}" (ID: ${project.id}) ---`);
     
      const result = exportProjectToDoc(project, jobState.existingHashes);

      // Update state based on the result
      if (result.status === 'SUCCESS') {
        jobState.successCount++;
        jobState.existingHashes[project.id] = result.hash;
        jobState.exportedFileDetails.push(result.details); // Store details for the report
      } else if (result.status === 'SKIPPED') {
        jobState.skippedCount++;
      } else {
        jobState.failCount++;
      }
      jobState.processedProjectIds.push(project.id);
      Utilities.sleep(1000);
    }
   
    // If the loop completes, all projects have been processed.
    Logger.log('\n--- All projects processed. Finalizing job. ---');
    sendCompletionEmail(jobState);
    cleanup(jobState);

  } catch (e) {
    Logger.log(`CRITICAL ERROR in main execution: ${e.toString()}\nStack: ${e.stack}`);
    cleanup(jobState); // Attempt to clean up even if there's an error, passing current state
  }
}

/**
 * Cleans up script properties and triggers after a job is complete or has failed.
 * @param {Object} finalState The completed job state object.
 */
function cleanup(finalState) {
  Logger.log('Cleaning up job state and triggers...');
  const scriptProperties = PropertiesService.getScriptProperties();
  let stateToPersist = finalState;

  // If no state was passed (e.g., from an error handler before state was initialized),
  // try to read the last known state from properties as a fallback.
  if (!stateToPersist) {
    Logger.log('Cleanup called without state, reading from PropertiesService as a fallback.');
    const storedState = scriptProperties.getProperty('jobState');
    stateToPersist = storedState ? JSON.parse(storedState) : {};
  }
 
  // Persist the final hashes to the permanent 'exportHashes' property
  if (stateToPersist && stateToPersist.existingHashes) {
    const hashCount = Object.keys(stateToPersist.existingHashes).length;
    if (hashCount > 0) {
      Logger.log(`Persisting ${hashCount} hashes to PropertiesService...`);
      scriptProperties.setProperty('exportHashes', JSON.stringify(stateToPersist.existingHashes));
    } else {
       Logger.log('Job state contained no hashes to persist.');
    }
  } else {
    Logger.log('No final hashes found in job state to persist.');
  }

  // Delete the temporary job state property
  scriptProperties.deleteProperty('jobState');
  cleanupOldTriggers();
  Logger.log('Cleanup complete.');
}


/**
 * Creates a trigger to run this script again in approximately 1 minute.
 */
function createNextTrigger() {
  cleanupOldTriggers(); // Ensure no duplicates
  ScriptApp.newTrigger('runFullExport')
    .timeBased()
    .after(60 * 1000) // 60 seconds
    .create();
  Logger.log('Next execution trigger created.');
}

/**
 * Deletes all existing triggers for the current project.
 */
function cleanupOldTriggers() {
    try {
        ScriptApp.getProjectTriggers().forEach(trigger => {
            ScriptApp.deleteTrigger(trigger);
        });
    } catch (e) {
        Logger.log(`Could not clean up triggers. This may be due to permissions. Error: ${e}`);
    }
}

/**
 * Sends a summary email to the administrator.
 * @param {Object} finalState The completed job state object.
 */
function sendCompletionEmail(finalState) {
  if (!ADMIN_EMAIL_ADDRESS || ADMIN_EMAIL_ADDRESS === "your_...@example.com" || !finalState) {
    Logger.log('Admin email not set or final state missing. Skipping summary email.');
    return;
  }

  const jobDuration = ((Date.now() - finalState.jobStartTime) / 1000 / 60).toFixed(2);
  const subject = `Apps Script Export Summary: ${finalState.successCount} File(s) Updated`;
 
  let tableRows = '';
  if (finalState.exportedFileDetails && finalState.exportedFileDetails.length > 0) {
    finalState.exportedFileDetails.forEach((detail, index) => {
      const gsCountDisplay = detail.gsCount > 0 ? detail.gsCount : '-';
      const htmlCountDisplay = detail.htmlCount > 0 ? detail.htmlCount : '-';
      const locDisplay = detail.linesOfCode > 0 ? detail.linesOfCode : '-';
      const rowStyle = `style="border: 1px solid #ccc; padding: 10px; vertical-align: top; ${index % 2 === 0 ? 'background-color: #fdfdfd;' : ''}"`;
     
      tableRows += `
        <tr>
          <td ${rowStyle}><a href="${detail.url}" style="color: #0066cc; text-decoration: none;">${detail.name}</a></td>
          <td ${rowStyle} style="text-align: center; border-left: 1px solid #ccc; border-right: 1px solid #ccc;">${gsCountDisplay}</td>
          <td ${rowStyle} style="text-align: center; border-left: 1px solid #ccc; border-right: 1px solid #ccc;">${htmlCountDisplay}</td>
          <td ${rowStyle} style="text-align: center; border-left: 1px solid #ccc; border-right: 1px solid #ccc;">${locDisplay}</td>
        </tr>
      `;
    });
  } else {
    tableRows = '<tr><td colspan="4" style="border: 1px solid #ccc; padding: 10px;">No new files were exported in this run.</td></tr>';
  }

  const htmlBody = `
    <div style="font-family: Arial, sans-serif; font-size: 14px; color: #333; border: 1px solid #ddd; padding: 20px; max-width: 950px; margin: 20px auto; border-radius: 8px;">
      <h2 style="border-bottom: 2px solid #eee; padding-bottom: 10px;">Apps Script Export Summary</h2>
      <ul style="list-style-type: none; padding: 0;">
        <li style="margin-bottom: 8px;"><strong>Successful Exports:</strong> ${finalState.successCount}</li>
        <li style="margin-bottom: 8px;"><strong>Skipped (No Changes):</strong> ${finalState.skippedCount}</li>
        <li style="margin-bottom: 8px;"><strong>Failed Exports:</strong> ${finalState.failCount}</li>
        <li style="margin-bottom: 8px;"><strong>Total Projects Processed:</strong> ${finalState.allProjects.length}</li>
        <li style="margin-bottom: 8px;"><strong>Total Duration:</strong> ${jobDuration} minutes</li>
      </ul>
      <h3 style="margin-top: 25px;">Updated Files</h3>
      <table style="border-collapse: collapse; width: 100%; margin-top: 20px; border: 2px solid #555;">
        <thead>
          <tr>
            <th style="width: 510px; border: 1px solid #ccc; padding: 10px; background-color: #f7f7f7; border-bottom: 2px solid #555; text-align: left;">Script Name (Link to Google Doc)</th>
            <th style="width: 165px; border: 1px solid #ccc; padding: 10px; background-color: #f7f7f7; border-bottom: 2px solid #555; text-align: center;"># GS Files</th>
            <th style="width: 165px; border: 1px solid #ccc; padding: 10px; background-color: #f7f7f7; border-bottom: 2px solid #555; text-align: center;"># HTML Files</th>
            <th style="width: 165px; border: 1px solid #ccc; padding: 10px; background-color: #f7f7f7; border-bottom: 2px solid #555; text-align: center;">Lines of Code</th>
          </tr>
        </thead>
        <tbody>
          ${tableRows}
        </tbody>
      </table>
      <p style="font-size:12px; color: #888; margin-top: 20px;">This is an automated message.</p>
    </div>
  `;
 
  try {
    GmailApp.sendEmail(ADMIN_EMAIL_ADDRESS, subject, "Please enable HTML to view this report.", { htmlBody: htmlBody });
    Logger.log(`Summary email sent to ${ADMIN_EMAIL_ADDRESS}.`);
  } catch (e) {
    Logger.log(`Failed to send summary email. Error: ${e}`);
  }
}


// ===============================================================
//                  HASH MANAGEMENT FUNCTIONS
// ===============================================================

/**
 * Controller function to get hashes based on the selected storage method.
 * @return {Object.<string, string>} A map of project IDs to content hashes.
 */
function getExistingHashes() {
  if (HASH_STORAGE_METHOD === 'PROPERTIES_SERVICE') {
    return getHashesFromPropertiesService();
  } else {
    return getHashesFromDriveProperties(DESTINATION_FOLDER_ID);
  }
}

/**
 * Retrieves the hash map from the script's PropertiesService.
 * @return {Object.<string, string>} The map of project IDs to content hashes.
 */
function getHashesFromPropertiesService() {
  Logger.log('Retrieving hashes from PropertiesService...');
  try {
    const scriptProperties = PropertiesService.getScriptProperties();
    const hashesJson = scriptProperties.getProperty('exportHashes');
    if (hashesJson) {
      const hashes = JSON.parse(hashesJson);
      Logger.log(`Found ${Object.keys(hashes).length} stored hashes.`);
      return hashes;
    }
  } catch (e) {
    Logger.log(`Could not parse hashes from PropertiesService. Starting fresh. Error: ${e}`);
  }
  Logger.log('No hashes found in PropertiesService.');
  return {};
}

/**
 * Scans the destination folder and builds a map of existing exports by reading file properties.
 * @param {string} folderId The ID of the folder to scan.
 * @return {Object.<string, string>} A map of project IDs to content hashes.
 */
function getHashesFromDriveProperties(folderId) {
    Logger.log('Scanning Drive folder for existing exports...');
    const exportsMap = {};
    let pageToken = null;
    do {
        const results = Drive.Files.list({
            q: `'${folderId}' in parents and trashed = false`,
            fields: 'nextPageToken, files(id, properties, createdTime)',
            spaces: 'drive',
            pageToken: pageToken,
            pageSize: 1000
        });
        if (results.files) {
            results.files.forEach(file => {
                if (file.properties && file.properties.appsScriptId && file.properties.contentHash) {
                    const scriptId = file.properties.appsScriptId;
                    if (!exportsMap[scriptId] || new Date(file.createdTime) > new Date(exportsMap[scriptId].createdTime)) {
                       exportsMap[scriptId] = { hash: file.properties.contentHash, createdTime: file.createdTime };
                    }
                }
            });
        }
        pageToken = results.nextPageToken;
    } while (pageToken);

    const finalMap = {};
    for (const id in exportsMap) {
        finalMap[id] = exportsMap[id].hash;
    }
    Logger.log(`Found ${Object.keys(finalMap).length} previously exported projects via Drive properties.`);
    return finalMap;
}

// ===============================================================
//                  CORE PROCESSING FUNCTIONS
// ===============================================================

/**
 * Compiles a list of standalone Apps Script projects from the priority list and Drive search.
 */
function getStandaloneProjectList() {
  Logger.log('Searching for Apps Script projects...');
  const projectsInfo = [];
  const processedIds = new Set();
  PRIORITY_PROJECTS.forEach(p => {
    if (p.id && p.name && !processedIds.has(p.id)) {
      projectsInfo.push({ id: p.id, name: p.name });
      processedIds.add(p.id);
    }
  });
  const query = `mimeType='application/vnd.google-apps.script' and trashed=false`;
  try {
    Logger.log(`Searching My Drive...`);
    const myDriveFiles = DriveApp.searchFiles(query);
    while (myDriveFiles.hasNext()) {
      const file = myDriveFiles.next();
      if (!processedIds.has(file.getId())) {
        projectsInfo.push({ id: file.getId(), name: file.getName() });
        processedIds.add(file.getId());
      }
    }
  } catch(e) {
      Logger.log(`Error searching My Drive: ${e.toString()}`);
  }
  if (SHARED_DRIVE_IDS && SHARED_DRIVE_IDS.length > 0 && SHARED_DRIVE_IDS[0] !== '') {
    SHARED_DRIVE_IDS.forEach(driveId => {
      try {
        Logger.log(`Searching Shared Drive ID: ${driveId}`);
        let pageToken = null;
        do {
          const results = Drive.Files.list({ q: query, driveId: driveId, corpora: 'drive', includeItemsFromAllDrives: true, supportsAllDrives: true, pageToken: pageToken, pageSize: 1000, fields: 'nextPageToken, files(id, name)' });
          if (results.files) {
            results.files.forEach(file => {
              if(!processedIds.has(file.id)){
                  projectsInfo.push({ id: file.id, name: file.name });
                  processedIds.add(file.id);
              }
            });
          }
          pageToken = results.nextPageToken;
        } while(pageToken);
      } catch (e) {
        Logger.log(`Could not search Shared Drive ${driveId}. Ensure Drive API is enabled. Error: ${e.toString()}`);
      }
    });
  } else {
      Logger.log('No Shared Drive IDs provided, skipping search.');
  }
  Logger.log(`Total unique standalone projects found: ${projectsInfo.length}`);
  return projectsInfo;
}


/**
 * Fetches, compares, and exports a single project to a Google Doc.
 * @param {Object} project The project object with id and name.
 * @param {Object} existingHashes A map of existing hashes.
 * @return {Object} An object with the operation status and details of the export.
 */
function exportProjectToDoc(project, existingHashes) {
  try {
    const url = `https://script.googleapis.com/v1/projects/${project.id}/content`;
    const options = { headers: { 'Authorization': 'Bearer ' + ScriptApp.getOAuthToken() }, muteHttpExceptions: true };
    const response = UrlFetchApp.fetch(url, options);

    if (response.getResponseCode() !== 200) {
      Logger.log(`FAILED: Could not fetch content. Code: ${response.getResponseCode()}`);
      return { status: 'FAILED' };
    }
    const content = JSON.parse(response.getContentText());

    if (!content.files || content.files.length === 0) {
      Logger.log(`SKIPPED: Project is empty.`);
      return { status: 'SKIPPED' };
    }

    const newHash = computeContentHash(content.files);
    const oldHash = existingHashes[project.id];

    if (newHash === oldHash) {
        Logger.log(`SKIPPED: No changes detected.`);
        return { status: 'SKIPPED' };
    }
    Logger.log(`Content has changed. New Hash: ${newHash}`);

    const timeZone = Session.getScriptTimeZone();
    const formattedDate = Utilities.formatDate(new Date(), timeZone, "MM/dd/yy HH:mm");
    const docName = `[SCRIPT EXPORT] - ${project.name} - ${formattedDate}`;
    const doc = DocumentApp.create(docName);
    const body = doc.getBody();
    const codeStyle = { [DocumentApp.Attribute.FONT_FAMILY]: DocumentApp.FontFamily.CONSOLAS, [DocumentApp.Attribute.FONT_SIZE]: 9, [DocumentApp.Attribute.BACKGROUND_COLOR]: '#f7f7f7' };

    content.files.forEach((file, index) => {
      if (index > 0) body.appendPageBreak();
      let extension = (file.type === 'HTML') ? 'html' : (file.type === 'JSON') ? 'json' : 'gs';
      body.appendParagraph(`File: ${file.name}.${extension}`).setHeading(DocumentApp.ParagraphHeading.HEADING1);
      const codeParagraph = body.appendParagraph(file.source || "// (Empty File)");
      codeParagraph.setAttributes(codeStyle);
    });

    doc.saveAndClose();
    const newFile = DriveApp.getFileById(doc.getId());
    const destinationFolder = DriveApp.getFolderById(DESTINATION_FOLDER_ID);
    newFile.moveTo(destinationFolder); // Use moveTo for modern, efficient file handling
   
    Drive.Files.update({ properties: { 'appsScriptId': project.id, 'contentHash': newHash }}, newFile.getId());
    Logger.log(`SUCCESS: Document created and properties set.`);
   
    // Gather details for the report
    const gsCount = content.files.filter(f => f.type === 'SERVER_JS').length;
    const htmlCount = content.files.filter(f => f.type === 'HTML').length;
    let linesOfCode = 0;
    content.files.forEach(file => {
      if (file.source) {
        linesOfCode += file.source.split('\n').length;
      }
    });

    return {
      status: 'SUCCESS',
      hash: newHash,
      details: {
        name: doc.getName(),
        url: newFile.getUrl(),
        gsCount: gsCount,
        htmlCount: htmlCount,
        linesOfCode: linesOfCode
      }
    };

  } catch (e) {
    Logger.log(`CRITICAL ERROR during export. Error: ${e.toString()}\nStack: ${e.stack}`);
    return { status: 'FAILED' };
  }
}

// ===============================================================
//                       UTILITY FUNCTIONS
// ===============================================================

/**
 * Computes a SHA-256 hash for the combined content of all script files.
 * @param {Array<Object>} files An array of file objects from the Apps Script API response.
 * @return {string} A hex string representation of the SHA-256 hash.
 */
function computeContentHash(files) {
  // Sort files by name to ensure consistent hash calculation
  const sortedFiles = files.sort((a, b) => a.name.localeCompare(b.name));
 
  // Concatenate all file sources into a single string
  const combinedSource = sortedFiles.map(file => file.source).join('');
 
  // Compute the hash
  const digest = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, combinedSource, Utilities.Charset.UTF_8);
 
  // Format the hash as a hex string
  return digest.map(byte => {
    const hex = (byte & 0xFF).toString(16);
    return hex.length === 1 ? '0' + hex : hex;
  }).join('');
}



Regards,
Steve Horvath


--
You received this message because you are subscribed to the Google Groups "Google Apps Script Community" group.
To unsubscribe from this group and stop receiving emails from it, send an email to google-apps-script-c...@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/google-apps-script-community/b91ad3b1-bcd1-4788-bfcf-47cfd2ca2131n%40googlegroups.com.

Andrea Alberto

unread,
Sep 22, 2025, 8:39:34 AM (2 days ago) Sep 22
to google-apps-sc...@googlegroups.com
Hi,

Nice approach.

Just to share another possible approach: I use gemini-cli and clasp to export locally GAS projects.

Andrea

Tim Dobson

unread,
Sep 23, 2025, 11:02:19 AM (yesterday) Sep 23
to google-apps-sc...@googlegroups.com
On Mon, 22 Sept 2025 at 13:39, Andrea Alberto <andrea....@gmail.com> wrote:
> Just to share another possible approach: I use gemini-cli and clasp to export locally GAS projects.

SMAARTE Group

unread,
Sep 23, 2025, 6:48:49 PM (17 hours ago) Sep 23
to google-apps-sc...@googlegroups.com
In this era of AI code assistance, it is essential that folks who are not "coders" and/or those who do not want to install a client on their machine can avail themselves of easy-to-use solutions.  While there are certainly advantages to establishing a robust coding ecosystem with a local client, clasp, etc. if coding is a significant part of one's daily tasks, for those of us where coding is peripheral, the opportunity to plug and play apps script code into a Gemini web interface (or others) is priceless.

Below is a further enhanced set of code that auto-archives prior versions of scripts (without the need to deploy and version).  This Script Exporter can be set on a daily timer.  It makes life easy without having to implement any "coder" solutions.

Now if Google would see fit to expose bound apps scripts to the API...


/**Script Exporter Declarations.gs
 */

// The ID of the Google Drive folders where exported documents will be saved.
const SCRIPT_EXPORT_FOLDER_ID = "";
const SCRIPT_EXPORT_ARCHIVE_FOLDER_ID = "";

// A list of Shared Drive IDs to search. Leave empty [] if you don't need to search any.
const SHARED_DRIVE_IDS = [];

// The email address to receive a summary report upon completion.
const ADMIN_EMAIL_ADDRESS = "yourem...@email.com"; // <-- IMPORTANT: Set your email address here

// --- CHOOSE HASH STORAGE METHOD ---
// 'PROPERTIES_SERVICE': Faster, more efficient. Uses a central key-value store. (Recommended)
// 'DRIVE_PROPERTIES': Slower. Scans every file in the destination folder for custom properties.
const HASH_STORAGE_METHOD = 'PROPERTIES_SERVICE';

// --- EXECUTION CONTROL ---
// The script will attempt to pause and resume before hitting the 6-minute limit.
const MAX_EXECUTION_TIME_SECONDS = 300; // = 5 minutes

// Add projects here you want to export every time.
const PRIORITY_PROJECTS = [
  { id: "", name: "" },
  { id: "", name: ""},
  { id: "", name: ""}
];
/**ScriptExporter.gs
 * @fileoverview A standalone Google Apps Script utility to export specified and
 * found standalone Apps Script projects to formatted Google Docs.
 *
 * @version 8.0 - Integrated ErrorHandler, added intelligent archiving of old exports.
 */

// NOTE: All configuration constants have been moved to the 'Declarations.gs' file.

// ===============================================================
//               MAIN SCRIPT & BATCH PROCESSING
// ===============================================================
/**
 * Main function that starts or continues the export process.
 * Can be run manually to start, and is called by triggers to resume.
 */
function runFullExport() {
  const scriptProperties = PropertiesService.getScriptProperties();
  const executionStartTime = Date.now();
  let jobState;
  try {
    const storedState = scriptProperties.getProperty('jobState');
    jobState = storedState ? JSON.parse(storedState) : null;
    // If no state exists, this is a new job.
    if (!jobState) {
      Logger.log('--- NEW EXPORT JOB STARTED (V8.0) ---');
      cleanupOldTriggers(); // Clean up in case of a previous failed run
      const destinationFolder = DriveApp.getFolderById(SCRIPT_EXPORT_FOLDER_ID);
    // Integrated centralized error handling
    const context = jobState ? `Job was in progress. Processed ${jobState.processedProjectIds.length} of ${jobState.allProjects.length} projects.` : 'Job failed during initialization.';
    handleError_(e, 'runFullExport', 'ScriptExporter.gs', 'CRITICAL', context);
    cleanup(jobState); // Attempt to clean up even if there's an error
  }
}

    return getHashesFromDriveProperties(SCRIPT_EXPORT_FOLDER_ID);
      Logger.log(`FAILED: Could not fetch content for "${project.name}". Code: ${response.getResponseCode()}. Message: ${response.getContentText()}`);
      return { status: 'FAILED' };
    }
    const content = JSON.parse(response.getContentText());
    if (!content.files || content.files.length === 0) {
      Logger.log(`SKIPPED: Project "${project.name}" is empty.`);
      return { status: 'SKIPPED' };
    }
    const newHash = computeContentHash(content.files);
    const oldHash = existingHashes[project.id];
    if (newHash === oldHash) {
      Logger.log(`SKIPPED: No changes detected for "${project.name}".`);
      return { status: 'SKIPPED' };
    }
    Logger.log(`Content has changed for "${project.name}". New Hash: ${newHash}`);

    // --- NEW: Archive the old version before creating the new one ---
    findAndArchiveOldExport_(project.id);

    const timeZone = Session.getScriptTimeZone();
    const formattedDate = Utilities.formatDate(new Date(), timeZone, "MM/dd/yy HH:mm");
    const docName = `[SCRIPT EXPORT] - ${project.name} - ${formattedDate}`;
    const doc = DocumentApp.create(docName);
    const body = doc.getBody();
    const codeStyle = { [DocumentApp.Attribute.FONT_FAMILY]: DocumentApp.FontFamily.CONSOLAS, [DocumentApp.Attribute.FONT_SIZE]: 9, [DocumentApp.Attribute.BACKGROUND_COLOR]: '#f7f7f7' };
    content.files.forEach((file, index) => {
      if (index > 0) body.appendPageBreak();
      let extension = (file.type === 'HTML') ? 'html' : (file.type === 'JSON') ? 'json' : 'gs';
      body.appendParagraph(`File: ${file.name}.${extension}`).setHeading(DocumentApp.ParagraphHeading.HEADING1);
      const codeParagraph = body.appendParagraph(file.source || "// (Empty File)");
      codeParagraph.setAttributes(codeStyle);
    });
    doc.saveAndClose();
    const newFile = DriveApp.getFileById(doc.getId());
    const destinationFolder = DriveApp.getFolderById(SCRIPT_EXPORT_FOLDER_ID);
    newFile.moveTo(destinationFolder);
    Drive.Files.update({ properties: { 'appsScriptId': project.id, 'contentHash': newHash } }, newFile.getId());
    Logger.log(`SUCCESS: Document created for "${project.name}" and properties set.`);
    // Gather details for the report
    const gsCount = content.files.filter(f => f.type === 'SERVER_JS').length;
    const htmlCount = content.files.filter(f => f.type === 'HTML').length;
    let linesOfCode = 0;
    content.files.forEach(file => {
      if (file.source) {
        linesOfCode += file.source.split('\n').length;
      }
    });
    return {
      status: 'SUCCESS',
      hash: newHash,
      details: {
        name: doc.getName(),
        url: newFile.getUrl(),
        gsCount: gsCount,
        htmlCount: htmlCount,
        linesOfCode: linesOfCode
      }
    };
  } catch (e) {
    // Integrated centralized error handling
    handleError_(e, 'exportProjectToDoc', 'ScriptExporter.gs', 'ERROR', `Failed to export project: "${project.name}" (ID: ${project.id})`);
    return { status: 'FAILED' };
  }
}

// ===============================================================
//                       UTILITY FUNCTIONS
// ===============================================================
/**
 * Computes a SHA-256 hash for the combined content of all script files.
 * @param {Array<Object>} files An array of file objects from the Apps Script API response.
 * @return {string} A hex string representation of the SHA-256 hash.
 */
function computeContentHash(files) {
  // Sort files by name to ensure consistent hash calculation
  const sortedFiles = files.sort((a, b) => a.name.localeCompare(b.name));
  // Concatenate all file sources into a single string
  const combinedSource = sortedFiles.map(file => (file.source || "")).join('');
  // Compute the hash
  const digest = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, combinedSource, Utilities.Charset.UTF_8);
  // Format the hash as a hex string
  return digest.map(byte => {
    const hex = (byte & 0xFF).toString(16);
    return hex.length === 1 ? '0' + hex : hex;
  }).join('');
}

/**
 * Finds a previous export for a given script ID, renames it, and moves it to the archive folder.
 * This intelligently identifies the old file using a unique, persistent script property.
 * @param {string} projectId The Apps Script project ID.
 */
function findAndArchiveOldExport_(projectId) {
  const FILENAME_PREFIX = '[SCRIPT EXPORT]';
  const ARCHIVE_PREFIX = '[zzz OLD SCRIPT EXPORT]';

  try {
    const archiveFolder = DriveApp.getFolderById(SCRIPT_EXPORT_ARCHIVE_FOLDER_ID);
    // Search for files that have the specific project ID in their custom properties.
    const query = `'${SCRIPT_EXPORT_FOLDER_ID}' in parents and properties has { key='appsScriptId' and value='${projectId}' } and trashed=false`;
    const files = Drive.Files.list({ q: query, spaces: 'drive', fields: 'files(id, name)' });

    if (files.files && files.files.length > 0) {
      const oldFileId = files.files[0].id; // Should only be one, take the first.
      const oldFile = DriveApp.getFileById(oldFileId);
      const oldName = oldFile.getName();
      let newName = oldName.startsWith(FILENAME_PREFIX)
        ? oldName.replace(FILENAME_PREFIX, ARCHIVE_PREFIX)
        : `${ARCHIVE_PREFIX} - ${oldName}`;

      Logger.log(`Archiving previous version: "${oldName}" -> "${newName}"`);
      oldFile.setName(newName);
      oldFile.moveTo(archiveFolder);
    } else {
      Logger.log(`No previous export found to archive for project ID: ${projectId}.`);
    }
  } catch (e) {
    // A failure to archive is not critical; log a warning and continue.
    handleError_(e, 'findAndArchiveOldExport_', 'ScriptExporter.gs', 'WARNING', `Failed to archive old export for project ID: ${projectId}. The new export will still proceed.`);
  }
}


/**ErrorHandler.gs
 * description Centralized error handling, reporting, and operational summaries for the entire script project.
 */

const ERROR_HANDLER_CONFIG = {
  RATE_LIMIT_MINUTES: 15,
  RATE_LIMIT_COUNT: 5, // Max 5 non-critical emails in 15 minutes
  SEVERITY_COLORS: {
    'CRITICAL': '#D9534F',
    'ERROR': '#E67E22',
    'WARNING': '#F1C40F',
    'INFO': '#5BC0DE'
  }
};

/**
 * Logs an error and sends a detailed HTML email notification to the admin.
 */
function handleError_(error, functionName, fileName, severity = 'ERROR', context = '') {
  // Ensure required constants are defined
  if (typeof ADMIN_EMAIL_ADDRESS === 'undefined' || typeof ASSOCIATION_NAME === 'undefined') {
    console.error("ERROR HANDLER FAILED: Missing ADMIN_EMAIL_ADDRESS or ASSOCIATION_NAME in Declarations.gs.");
    console.error(`Original Error: ${error.toString()} in ${fileName}>${functionName}`);
    return;
  }

  severity = severity.toUpperCase();
  const scriptUrl = `https://script.google.com/d/${ScriptApp.getScriptId()}/edit`;
  const subject = `[${severity}] Script Error in ${functionName}`;
  // Use the script's configured timezone
  const timestamp = new Date().toLocaleString('en-US', { timeZone: Session.getScriptTimeZone() });

  // Log the full error to Stackdriver/Logger
  const logMessage = `${severity} ERROR in ${fileName} > ${functionName}. Context: ${context || 'N/A'}. Message: ${error.toString()}. Stack: ${error.stack || 'N/A'}`;
  console.error(logMessage);

  // Rate limiting for non-critical errors
  if (severity !== 'CRITICAL' && isRateLimited_()) {
    console.warn(`Rate limit exceeded. Suppressing error email for ${subject}.`);
    return;
  }

  const severityColor = ERROR_HANDLER_CONFIG.SEVERITY_COLORS[severity] || ERROR_HANDLER_CONFIG.SEVERITY_COLORS['ERROR'];

  // Build a detailed HTML body
  const htmlBody = `
    <div style="font-family: Arial, sans-serif; font-size: 14px; color: #333; max-width: 800px; margin: auto; border: 1px solid #ddd; padding: 20px; border-radius: 8px;">
      <h2 style="color: ${severityColor}; border-bottom: 2px solid #eee; padding-bottom: 10px;">Script Error Report (${severity})</h2>
      <p>An error occurred in the automation script.</p>
      <table style="border-collapse: collapse; width: 100%; border: 1px solid #ddd; margin-bottom: 20px;">
        <tr style="background-color: #f2f2f2;">
          <td style="padding: 12px; border: 1px solid #ddd; font-weight: bold; width: 120px;">Timestamp</td>
          <td style="padding: 12px; border: 1px solid #ddd;">${timestamp} (${Session.getScriptTimeZone()})</td>
        </tr>
        <tr>
          <td style="padding: 12px; border: 1px solid #ddd; font-weight: bold;">File</td>
          <td style="padding: 12px; border: 1px solid #ddd;">${fileName}</td>
        </tr>
        <tr>
          <td style="padding: 12px; border: 1px solid #ddd; font-weight: bold;">Function</td>
          <td style="padding: 12px; border: 1px solid #ddd;">${functionName}</td>
        </tr>
        ${context ? `<tr>
          <td style="padding: 12px; border: 1px solid #ddd; font-weight: bold;">Context</td>
          <td style="padding: 12px; border: 1px solid #ddd;">${context}</td>
        </tr>` : ''}
        <tr>
          <td style="padding: 12px; border: 1px solid #ddd; font-weight: bold;">Error Message</td>
          <td style="padding: 12px; border: 1px solid #ddd; color: ${severityColor};">${error.message}</td>
        </tr>
        <tr>
          <td style="padding: 12px; border: 1px solid #ddd; font-weight: bold;">Stack Trace</td>
          <td style="padding: 12px; border: 1px solid #ddd; white-space: pre-wrap; font-family: monospace; background-color: #f9f9f9;">${error.stack || 'N/A'}</td>
        </tr>
      </table>
      <div style="text-align: center; margin: 20px 0;">
        <a href="${scriptUrl}" style="background-color: #4285F4; color: white; padding: 12px 25px; text-decoration: none; border-radius: 5px; font-size: 16px; display: inline-block;">Open Script Editor</a>
      </div>
      <p style="font-size: 12px; color: #777; text-align: center;">This is an automated notification.</p>
    </div>
  `;

  // Send the email notification
  try {
    // Determine the 'from' address
    const fromAddress = (typeof EMAIL_FROM_ADDRESS !== 'undefined' && EMAIL_FROM_ADDRESS) ? EMAIL_FROM_ADDRESS : ADMIN_EMAIL_ADDRESS;

    GmailApp.sendEmail(ADMIN_EMAIL_ADDRESS, subject, "", {
      htmlBody: htmlBody,
      from: fromAddress,
      name: `${ASSOCIATION_NAME} Script Error Reporter`
    });
  } catch (e) {
    // Fallback logger if GmailApp fails
    console.error(`Failed to send error report email. Email Send Error: ${e.toString()}. Original Error: ${logMessage}`);
  }
}


/**
 * [ENHANCED] Sends an operational summary email (e.g., success report, non-critical issues).
 * Now includes file/function context and a link to the script.
 * @param {string} subject The subject of the email.
 * @param {string} summaryTitle The main title for the report (e.g., "Sync Success").
 * @param {string} introductoryText A brief explanation of the report.
 * @param {Object<string, any>} metrics An object containing key-value pairs of metrics.
 * @param {string} fileName The name of the .gs file containing the function.
 * @param {string} functionName The name of the primary function that executed.
 * @param {Array<string>} [details=[]] An optional array of detailed log messages or errors (implies issues if present).
 */
function sendOperationalSummary_(subject, summaryTitle, introductoryText, metrics, fileName, functionName, details = []) {
    if (typeof ADMIN_EMAIL_ADDRESS === 'undefined' || typeof ASSOCIATION_NAME === 'undefined') {
        console.error("SUMMARY FAILED: Missing ADMIN_EMAIL_ADDRESS or ASSOCIATION_NAME.");
        return;
    }

    const timestamp = new Date().toLocaleString('en-US', { timeZone: Session.getScriptTimeZone() });
    const scriptUrl = `https://script.google.com/d/${ScriptApp.getScriptId()}/edit`;
    const hasIssues = details.length > 0;

    // Determine color and styling based on success or issues
    const titleColor = hasIssues ? '#D9534F' : '#4CAF50'; // Red for issues, Green for success

    // Build Metrics Table
    let metricsTable = '<table style="border-collapse: collapse; width: 100%; border: 1px solid #ddd; margin-bottom: 20px; font-size: 14px;">';
    // Add Header
    metricsTable += '<thead><tr><th colspan="2" style="padding: 12px; background-color: #f2f2f2; text-align: left; border: 1px solid #ddd; font-size: 16px;">Operation Metrics</th></tr></thead><tbody>';

    for (const key in metrics) {
        metricsTable += `
            <tr>
                <td style="padding: 10px; border: 1px solid #ddd; font-weight: bold; width: 250px; background-color: #f9f9f9;">${key}</td>
                <td style="padding: 10px; border: 1px solid #ddd;">${metrics[key]}</td>
            </tr>`;
    }
    metricsTable += '</tbody></table>';

    // Build Details Section and Action Banner
    let detailsSection = '';
    let actionRequiredBanner = '';

    if (hasIssues) {
        // Add a prominent banner if there are issues
        actionRequiredBanner = `
            <div style="background-color: #F2DEDE; color: #A94442; padding: 15px; margin-bottom: 20px; border: 1px solid #EBCCD1; border-radius: 4px; text-align: center;">
                <strong>ATTENTION REQUIRED:</strong> Please review the detailed issues listed below.
            </div>`;

        detailsSection = `
            <h3 style="color: #A94442; border-bottom: 1px solid #eee; padding-bottom: 8px; margin-top: 25px; font-size: 16px;">Detailed Logs/Issues</h3>
            <div style="white-space: pre-wrap; font-family: monospace; background-color: #fefefe; padding: 15px; border: 1px solid #D9534F; border-radius: 4px; font-size: 12px; margin-bottom: 20px; line-height: 1.4;">${details.join('\n')}</div>`;
    }

    // Build Script Context Section (NEW)
    const contextSection = `
        <div style="background-color: #f0f0f0; padding: 15px; border-radius: 5px; margin-bottom: 25px; font-size: 13px;">
            <h4 style="margin-top: 0; font-size: 16px; margin-bottom: 10px;">Execution Context</h4>
            <p style="margin: 5px 0;"><strong>Timestamp:</strong> ${timestamp} (${Session.getScriptTimeZone()})</p>
            <p style="margin: 5px 0;"><strong>Source File:</strong> <code>${fileName}</code></p>
            <p style="margin: 5px 0;"><strong>Function:</strong> <code>${functionName}</code></p>
            <div style="margin-top: 15px;">
                <a href="${scriptUrl}" style="background-color: #4285F4; color: white; padding: 8px 15px; text-decoration: none; border-radius: 4px; font-size: 13px; display: inline-block;">Open Script Project</a>
            </div>
        </div>
    `;

    const htmlBody = `
    <div style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px; color: #333; max-width: 800px; margin: auto; border: 1px solid #e0e0e0; padding: 25px; border-radius: 8px; background-color: #ffffff;">
      <div style="border-bottom: 2px solid ${titleColor}; padding-bottom: 10px; margin-bottom: 20px;">
        <h2 style="color: ${titleColor}; margin: 0;">${summaryTitle}</h2>
      </div>

      ${actionRequiredBanner}

      <p style="line-height: 1.5;">${introductoryText}</p>

      ${contextSection}

      ${metricsTable}

      ${detailsSection}

      <p style="font-size: 12px; color: #777; text-align: center; margin-top: 30px; border-top: 1px solid #eee; padding-top: 15px;">This is an automated operational summary.</p>
    </div>
  `;

    try {
        const fromAddress = (typeof EMAIL_FROM_ADDRESS !== 'undefined' && EMAIL_FROM_ADDRESS) ? EMAIL_FROM_ADDRESS : ADMIN_EMAIL_ADDRESS;
        GmailApp.sendEmail(ADMIN_EMAIL_ADDRESS, subject, "", {
            htmlBody: htmlBody,
            from: fromAddress,
            name: `${ASSOCIATION_NAME} Automation Summary`
        });
        Logger.log(`Operational summary sent: ${subject}`);
    } catch (e) {
        console.error(`Failed to send operational summary email. Error: ${e.toString()}`);
    }
}

/**
 * Checks if the error reporting rate limit has been exceeded using CacheService.
 */
function isRateLimited_() {
  const cache = CacheService.getScriptCache();
  const key = 'errorHandlerRateLimit';
  const now = new Date().getTime();
  const limit = ERROR_HANDLER_CONFIG.RATE_LIMIT_COUNT;
  const windowMs = ERROR_HANDLER_CONFIG.RATE_LIMIT_MINUTES * 60 * 1000;

  const cachedData = cache.get(key);
  let timestamps = cachedData ? JSON.parse(cachedData) : [];

  // Filter out timestamps outside the window
  timestamps = timestamps.filter(ts => now - ts < windowMs);

  if (timestamps.length >= limit) {
    return true;
  }

  // Add current timestamp and update cache
  timestamps.push(now);
  // Set cache expiration slightly longer than the window duration
  cache.put(key, JSON.stringify(timestamps), (windowMs / 1000) + 60);

  return false;
}



Regards,
Steve Horvath
--
You received this message because you are subscribed to the Google Groups "Google Apps Script Community" group.
To unsubscribe from this group and stop receiving emails from it, send an email to google-apps-script-c...@googlegroups.com.

saikumar rk

unread,
3:13 AM (9 hours ago) 3:13 AM
to google-apps-sc...@googlegroups.com

Hi SMAARTE Group Team,

Thank you again for the thoughtful and non-coder-friendly Script Exporter solution. It's exactly the kind of plug-and-play utility that’s empowering for those of us who use Apps Script peripherally.

I’ve started implementing it and had a few follow-up questions I was hoping you could shed light on:

  1. Calling a Localhost API for Testing
    Is it possible to call a localhost API (e.g., http://localhost:3000/test) using UrlFetchApp.fetch() from Apps Script for testing purposes? Or does it require the endpoint to be publicly accessible?

  2. Scope Authorization for script.external_request
    I added "https://www.googleapis.com/auth/script.external_request" in my appsscript.json. When I run the script, I get the "Google hasn’t verified this app" warning. But it doesn’t show the actual OAuth scopes — and after clicking "Continue", it just opens Gmail (my email).
    Is this expected behavior during development with unverified scopes? Or could this indicate a misconfiguration?

  3. Test Users and OAuth Setup
    In the Google Cloud Console, I’ve added test users under OAuth consent screen and enabled all required scopes (Gmail, Calendar, etc.). Still, the behavior in point #2 persists. Is there a specific step I might be missing when trying to work with these sensitive scopes during testing?

Any insights you can share would be greatly appreciated. Thank you again for building and sharing such a smart solution — and for your time helping those of us still learning the ropes.

Warm regards,
Sai Kumar


Kim Nilsson

unread,
3:50 AM (8 hours ago) 3:50 AM
to Google Apps Script Community
Hello Sai,

I recommend looking into this project, which is trying to do exactly that, running the code "locally", as to not waste Google's APIs or your quota.


Bruce is a long-time member of this forum and extremely skilled, who needed this for himself, but was kind enough to publish it for us all to use.
Reply all
Reply to author
Forward
0 new messages