// 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) {
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() {