diff --git a/deploy/index.html b/deploy/index.html
index 1fac2f82..4c3f05d8 100644
--- a/deploy/index.html
+++ b/deploy/index.html
@@ -1861,101 +1861,16 @@
3Configure your scripts
btn.disabled = true
btn.innerHTML = ' Deploying…'
+ const deployedProjects = []
try {
- const deployedProjects = []
const total = selectedIds.length
for (let i = 0; i < total; i++) {
- const catalogId = selectedIds[i]
- const script = SCRIPT_CATALOG.find((s) => s.id === catalogId)
- if (!script) {
- showStatus(
- 'error',
- `❌ Unknown script selection: ${escapeHtml(catalogId)}`
- )
- return
- }
- const projectTitle = `Petry-Projects – ${script.name}`
- const progress = total > 1 ? ` (${i + 1}/${total})` : ''
-
- showStatus(
- 'info',
- ` Fetching source files for ${escapeHtml(script.name)}${progress}…`
- )
-
- // Fetch source files for the selected script.
- const files = await fetchScriptFiles(catalogId)
-
- // ── Idempotent project lookup / creation ────────────────────────
- let projectId = null
- const storedId = getStoredScriptId(catalogId, projectTitle)
- if (storedId) {
- showStatus(
- 'info',
- ` Verifying existing project ${escapeHtml(projectTitle)}${progress}…`
- )
- try {
- await apiFetch(`${APPS_SCRIPT_API}/projects/${storedId}`)
- projectId = storedId
- } catch {
- // Project no longer exists — fall through to creation.
- }
- }
-
- if (!projectId) {
- showStatus(
- 'info',
- ` Creating project ${escapeHtml(projectTitle)}${progress}…`
- )
- const project = await apiFetch(`${APPS_SCRIPT_API}/projects`, {
- method: 'POST',
- body: JSON.stringify({ title: projectTitle }),
- })
- projectId = validateScriptId(project.scriptId)
- storeScriptId(catalogId, projectTitle, projectId)
- }
-
- showStatus(
- 'info',
- ` Uploading files for ${escapeHtml(projectTitle)}${progress}…`
- )
-
- // All scripts use an hourly trigger. Schedule logic lives in config.gs.
- const setupSource = buildSetupSource(script.triggerFn)
-
- // Upload source files: manifest + setup.gs + code.gs + config.gs (template).
- await apiFetch(`${APPS_SCRIPT_API}/projects/${projectId}/content`, {
- method: 'PUT',
- body: JSON.stringify({
- files: [
- {
- name: 'appsscript',
- type: 'JSON',
- source: APPSSCRIPT_MANIFEST,
- },
- {
- name: 'setup',
- type: 'SERVER_JS',
- source: setupSource,
- },
- ...files.map((f) => ({
- name: f.name,
- type: 'SERVER_JS',
- source: f.source,
- })),
- ],
- }),
- })
-
- deployedProjects.push({
- scriptId: projectId,
- title: projectTitle,
- catalogId,
- })
- deployedScripts.set(catalogId, {
- scriptId: projectId,
- title: projectTitle,
- })
+ const deployed = await deployOneScript(selectedIds[i], i, total)
+ // A null result means the selection was invalid and an error was
+ // already shown — abort the whole deploy.
+ if (!deployed) return
+ deployedProjects.push(deployed)
}
// Refresh Step 2 to show deployment badges
@@ -1986,30 +1901,31 @@ 3Configure your scripts
// Show Step 3 config cards
renderConfigCards(deployedProjects)
} catch (err) {
- const isApiDisabled =
- err.message && err.message.includes('Apps Script API')
- const errorDetail = err.stack || err.message || String(err)
- const errorDetailBlock =
- `` +
- `Error detail
` +
- `${escapeHtml(errorDetail)}
` +
- `` +
- ` `
- const errorHtml = isApiDisabled
- ? '❌ Deployment failed: User has not enabled the Apps Script API.' +
- '' +
- '👉 Enable it at script.google.com/home/usersettings' +
- '' +
- '' +
- 'After enabling, wait a minute then try again.' +
- '' +
- errorDetailBlock
- : `❌ Deployment failed: ${escapeHtml(err.message)}` +
- errorDetailBlock
- showStatus('error', errorHtml)
+ if (deployedProjects.length > 0) {
+ renderScriptList()
+ const partialLinksHtml = deployedProjects
+ .map((p) => {
+ const safeId = escapeHtml(p.scriptId)
+ return (
+ ``
+ )
+ })
+ .join('')
+ renderConfigCards(deployedProjects)
+ showStatus(
+ 'error',
+ buildDeployErrorHtml(err) +
+ `${deployedProjects.length} script(s) deployed before the error:
` +
+ partialLinksHtml
+ )
+ } else {
+ showStatus('error', buildDeployErrorHtml(err))
+ }
} finally {
btn.disabled = false
btn.innerHTML = 'Deploy to my account'
@@ -2019,6 +1935,143 @@ 3Configure your scripts
// ── Helpers ───────────────────────────────────────────────────────────────────
+ /**
+ * Deploys a single selected script: idempotently finds or creates its
+ * Apps Script project and uploads the manifest, setup.gs and source files.
+ * Returns a `{ scriptId, title, catalogId }` descriptor, or `null` when
+ * the selection is invalid (an error status has already been shown).
+ */
+ async function deployOneScript(catalogId, i, total) {
+ const script = SCRIPT_CATALOG.find((s) => s.id === catalogId)
+ if (!script) {
+ showStatus(
+ 'error',
+ `❌ Unknown script selection: ${escapeHtml(catalogId)}`
+ )
+ return null
+ }
+ const projectTitle = `Petry-Projects – ${script.name}`
+ const progress = total > 1 ? ` (${i + 1}/${total})` : ''
+
+ showStatus(
+ 'info',
+ ` Fetching source files for ${escapeHtml(script.name)}${progress}…`
+ )
+
+ // Fetch source files for the selected script.
+ const files = await fetchScriptFiles(catalogId)
+
+ // ── Idempotent project lookup / creation ────────────────────────
+ let projectId = null
+ let isExistingProject = false
+ const storedId = getStoredScriptId(catalogId, projectTitle)
+ if (storedId) {
+ showStatus(
+ 'info',
+ ` Verifying existing project ${escapeHtml(projectTitle)}${progress}…`
+ )
+ try {
+ await apiFetch(`${APPS_SCRIPT_API}/projects/${storedId}`)
+ projectId = storedId
+ isExistingProject = true
+ } catch {
+ // Project no longer exists — fall through to creation.
+ }
+ }
+
+ if (!projectId) {
+ showStatus(
+ 'info',
+ ` Creating project ${escapeHtml(projectTitle)}${progress}…`
+ )
+ const project = await apiFetch(`${APPS_SCRIPT_API}/projects`, {
+ method: 'POST',
+ body: JSON.stringify({ title: projectTitle }),
+ })
+ projectId = validateScriptId(project.scriptId)
+ storeScriptId(catalogId, projectTitle, projectId)
+ }
+
+ showStatus(
+ 'info',
+ ` Uploading files for ${escapeHtml(projectTitle)}${progress}…`
+ )
+
+ // All scripts use an hourly trigger. Schedule logic lives in config.gs.
+ const setupSource = buildSetupSource(script.triggerFn)
+
+ // Upload source files: preserve user's config.gs when redeploying an existing project.
+ let uploadFiles
+ if (isExistingProject) {
+ const currentContent = await apiFetch(
+ `${APPS_SCRIPT_API}/projects/${projectId}/content`
+ )
+ const newCode = files.find((f) => f.name === 'code')
+ uploadFiles = currentContent.files.map((f) => {
+ if (f.name === 'appsscript')
+ return { ...f, source: APPSSCRIPT_MANIFEST }
+ if (f.name === 'setup') return { ...f, source: setupSource }
+ if (f.name === 'code' && newCode)
+ return { ...f, source: newCode.source }
+ return f // preserve config and any other user-modified files
+ })
+ } else {
+ uploadFiles = [
+ { name: 'appsscript', type: 'JSON', source: APPSSCRIPT_MANIFEST },
+ { name: 'setup', type: 'SERVER_JS', source: setupSource },
+ ...files.map((f) => ({
+ name: f.name,
+ type: 'SERVER_JS',
+ source: f.source,
+ })),
+ ]
+ }
+ await apiFetch(`${APPS_SCRIPT_API}/projects/${projectId}/content`, {
+ method: 'PUT',
+ body: JSON.stringify({ files: uploadFiles }),
+ })
+
+ deployedScripts.set(catalogId, {
+ scriptId: projectId,
+ title: projectTitle,
+ })
+
+ return { scriptId: projectId, title: projectTitle, catalogId }
+ }
+
+ /**
+ * Builds the HTML status message shown when a deployment fails, including
+ * a collapsible error-detail block and special guidance when the Apps
+ * Script API has not been enabled for the account.
+ */
+ function buildDeployErrorHtml(err) {
+ const errMsg = err && err.message ? err.message : ''
+ const isApiDisabled = errMsg.includes('Apps Script API')
+ const errorDetail = (err && err.stack) || errMsg || String(err)
+ const errorDetailBlock =
+ `` +
+ `Error detail
` +
+ `${escapeHtml(errorDetail)}
` +
+ `` +
+ ` `
+ if (isApiDisabled) {
+ return (
+ '❌ Deployment failed: User has not enabled the Apps Script API.' +
+ '' +
+ '👉 Enable it at script.google.com/home/usersettings' +
+ '' +
+ '' +
+ 'After enabling, wait a minute then try again.' +
+ '' +
+ errorDetailBlock
+ )
+ }
+ return `❌ Deployment failed: ${escapeHtml(errMsg)}` + errorDetailBlock
+ }
+
/**
* Fetches the code.gs and config.gs source files for `scriptId` from GitHub.
* Returns an array of `{ name, source }` objects ready for the API.
diff --git a/src/calendar-to-sheets/src/index.js b/src/calendar-to-sheets/src/index.js
index 1eb60e56..c14ee4f1 100644
--- a/src/calendar-to-sheets/src/index.js
+++ b/src/calendar-to-sheets/src/index.js
@@ -106,6 +106,80 @@ function ensureHeader(sheet) {
}
}
+/**
+ * Applies in-place updates for events that already exist in the sheet and
+ * collects rows that need to be inserted.
+ *
+ * @param {Object} sheet - Sheet object
+ * @param {Map} desiredMap - Map of event id -> desired row
+ * @param {Map} existingMap - Map of event id -> { rowIndex, values }
+ * @returns {{updateCount: number, rowsToInsert: Array}}
+ */
+function applyUpdatesAndCollectInserts(sheet, desiredMap, existingMap) {
+ let updateCount = 0
+ const rowsToInsert = []
+
+ for (const [id, row] of desiredMap.entries()) {
+ const ex = existingMap.get(id)
+ if (!ex) {
+ rowsToInsert.push(row)
+ continue
+ }
+ if (!rowsEqual(row, ex.values)) {
+ console.log('[syncCalendarToSheet] Updating row for event:', id)
+ sheet.getRange(ex.rowIndex, 1, 1, row.length).setValues([row])
+ updateCount++
+ }
+ }
+
+ return { updateCount, rowsToInsert }
+}
+
+/**
+ * Collects the 1-based row indices to delete for events that no longer exist,
+ * preserving historical rows that fall outside the sync window or lack valid dates.
+ *
+ * @param {Map} existingMap - Map of event id -> { rowIndex, values }
+ * @param {Map} desiredMap - Map of event id -> desired row
+ * @param {Date} start - Sync window start
+ * @param {Date} end - Sync window end
+ * @returns {number[]} Row indices to delete
+ */
+function collectRowsToDelete(existingMap, desiredMap, start, end) {
+ const toDelete = []
+ for (const [id, ex] of existingMap.entries()) {
+ if (desiredMap.has(id)) continue
+ // Only delete if the row has start/end columns and falls within [start,end]
+ // Otherwise, preserve historical rows outside the sync window
+ const rowStart = ex.values[2] // start is at index 2
+ const rowEnd = ex.values[3] // end is at index 3
+ if (!rowStart || !rowEnd) {
+ // If no valid date columns, don't delete (preserve historical data)
+ console.log(
+ '[syncCalendarToSheet] Preserving event with invalid dates:',
+ id
+ )
+ continue
+ }
+ const rowStartTime = new Date(rowStart)
+ // Only delete if row's event time falls within our sync window
+ if (
+ !isNaN(rowStartTime.getTime()) &&
+ rowStartTime >= start &&
+ rowStartTime <= end
+ ) {
+ console.log('[syncCalendarToSheet] Marking event for deletion:', id)
+ toDelete.push(ex.rowIndex)
+ } else {
+ console.log(
+ '[syncCalendarToSheet] Preserving event outside sync window:',
+ id
+ )
+ }
+ }
+ return toDelete
+}
+
async function syncCalendarToSheet(
calendar,
sheet,
@@ -133,23 +207,11 @@ async function syncCalendarToSheet(
const existingMap = rowsToMap(body)
// Upsert
- let updateCount = 0
- const rowsToInsert = []
-
- for (const [id, row] of desiredMap.entries()) {
- if (existingMap.has(id)) {
- const ex = existingMap.get(id)
- if (!rowsEqual(row, ex.values)) {
- // update
- console.log('[syncCalendarToSheet] Updating row for event:', id)
- const rowIndex = ex.rowIndex
- sheet.getRange(rowIndex, 1, 1, row.length).setValues([row])
- updateCount++
- }
- } else {
- rowsToInsert.push(row)
- }
- }
+ const { updateCount, rowsToInsert } = applyUpdatesAndCollectInserts(
+ sheet,
+ desiredMap,
+ existingMap
+ )
if (rowsToInsert.length > 0) {
console.log(
@@ -179,38 +241,7 @@ async function syncCalendarToSheet(
// Delete rows for events that no longer exist, but only if they fall within
// the synced time window to avoid deleting rows from events outside [start,end]
- const toDelete = []
- for (const [id, ex] of existingMap.entries()) {
- if (!desiredMap.has(id)) {
- // Only delete if the row has start/end columns and falls within [start,end]
- // Otherwise, preserve historical rows outside the sync window
- const rowStart = ex.values[2] // start is at index 2
- const rowEnd = ex.values[3] // end is at index 3
- if (rowStart && rowEnd) {
- const rowStartTime = new Date(rowStart)
- // Only delete if row's event time falls within our sync window
- if (
- !isNaN(rowStartTime) &&
- rowStartTime >= start &&
- rowStartTime <= end
- ) {
- console.log('[syncCalendarToSheet] Marking event for deletion:', id)
- toDelete.push(ex.rowIndex)
- } else {
- console.log(
- '[syncCalendarToSheet] Preserving event outside sync window:',
- id
- )
- }
- } else {
- // If no valid date columns, don't delete (preserve historical data)
- console.log(
- '[syncCalendarToSheet] Preserving event with invalid dates:',
- id
- )
- }
- }
- }
+ const toDelete = collectRowsToDelete(existingMap, desiredMap, start, end)
// delete from bottom to top
console.log('[syncCalendarToSheet] Deleting rows:', toDelete.length)
toDelete.sort((a, b) => b - a).forEach((r) => sheet.deleteRow(r))
diff --git a/src/gmail-to-drive-by-labels/src/index.js b/src/gmail-to-drive-by-labels/src/index.js
index 37524d15..6659899b 100644
--- a/src/gmail-to-drive-by-labels/src/index.js
+++ b/src/gmail-to-drive-by-labels/src/index.js
@@ -71,6 +71,146 @@ function removeExistingThread(body, threadId) {
return true
}
+/**
+ * Determine whether an attachment already exists in the folder by comparing
+ * file size first (fast fail) and then MD5 content hash.
+ *
+ * @param {Object} existingFiles - Drive file iterator from getFilesByName
+ * @param {Object} newFileBlob - Blob of the incoming attachment
+ * @returns {boolean} True if an identical file already exists
+ */
+function isDuplicateAttachment(existingFiles, newFileBlob) {
+ let existingCount = 0
+ // Try getBytes() for GAS blobs, bytes property for test mocks, empty buffer as fallback
+ const newFileBytes = newFileBlob.getBytes
+ ? newFileBlob.getBytes()
+ : newFileBlob.bytes || Buffer.from('')
+ const newFileLength = newFileBytes.length
+ let newHash = null
+
+ while (existingFiles.hasNext()) {
+ existingCount++
+ const existingFile = existingFiles.next()
+ console.log(
+ '[processMessageToDoc] Comparing with existing file',
+ existingCount
+ )
+
+ // Compare sizes first (fast fail)
+ if (existingFile.getSize() !== newFileLength) {
+ console.log('[processMessageToDoc] Size mismatch - different file')
+ continue
+ }
+
+ console.log('[processMessageToDoc] Size match, checking hash')
+ // Deep check: compare MD5 hashes
+ const existingHash = getFileHash(existingFile.getBlob())
+ if (newHash === null) {
+ newHash = getFileHash(newFileBlob)
+ }
+ if (existingHash === newHash) {
+ console.log('[processMessageToDoc] Hash match - duplicate detected')
+ return true
+ }
+ console.log('[processMessageToDoc] Hash mismatch - different content')
+ }
+ return false
+}
+
+/**
+ * Produce a conflict-free file name by inserting a timestamp before the
+ * extension, and apply it to the blob. Used when a same-named file with
+ * different content already exists.
+ *
+ * @param {string} fileName - The original file name
+ * @param {Object} newFileBlob - Blob to rename
+ * @param {Object} options - Optional settings (Utilities, Session for GAS)
+ * @returns {string} The resolved (possibly renamed) file name
+ */
+function resolveAttachmentName(fileName, newFileBlob, options = {}) {
+ const { Utilities, Session } = options || {}
+ console.log('[processMessageToDoc] Name conflict detected, adding timestamp')
+
+ const timeTag =
+ Utilities && Session
+ ? Utilities.formatDate(new Date(), Session.getScriptTimeZone(), '_HHmmss')
+ : '_' + Date.now()
+ const renamed = fileName.replace(/(\.[\w\d_-]+)$/i, timeTag + '$1')
+ const newName = renamed === fileName ? fileName + timeTag : renamed
+
+ if (newFileBlob.setName) {
+ newFileBlob.setName(newName)
+ }
+ console.log('[processMessageToDoc] Renamed to:', newName)
+ return newName
+}
+
+/**
+ * Process a single attachment: skip exact duplicates, otherwise save it
+ * (renaming on name conflicts) and record the result in the document body.
+ *
+ * @param {Object} att - Gmail attachment object
+ * @param {number} attIndex - Zero-based index of this attachment
+ * @param {number} count - Total number of attachments
+ * @param {Object} body - Document body object
+ * @param {Object} folder - Drive folder object
+ * @param {number} currentIndex - Insertion index in the document body
+ * @param {Object} options - Optional settings (Logger, Utilities, Session for GAS)
+ * @returns {number} The updated insertion index after inserting one paragraph
+ */
+function processAttachment(
+ att,
+ attIndex,
+ count,
+ body,
+ folder,
+ currentIndex,
+ options = {}
+) {
+ const { Logger } = options || {}
+ console.log(
+ '[processMessageToDoc] Processing attachment',
+ attIndex + 1,
+ 'of',
+ count,
+ ':',
+ att.getName()
+ )
+
+ let fileName = att.getName()
+ // In GAS environment, copyBlob() creates a copy; in test environment, att itself is the blob
+ const newFileBlob = att.copyBlob ? att.copyBlob() : att
+
+ // Check for duplicates by content hash
+ const existingFiles = folder.getFilesByName(fileName)
+ const hasExistingByName = existingFiles.hasNext()
+ console.log(
+ '[processMessageToDoc] Checking for existing files named:',
+ fileName
+ )
+
+ if (isDuplicateAttachment(existingFiles, newFileBlob)) {
+ if (Logger) {
+ Logger.log('Skipping exact duplicate: ' + fileName)
+ }
+ console.log('[processMessageToDoc] Skipping duplicate:', fileName)
+ body.insertParagraph(currentIndex, '- [DUPLICATE SKIPPED] ' + fileName)
+ return currentIndex + 1
+ }
+
+ // Handle name conflicts (same name, different content); reuse the pre-checked
+ // flag to avoid a second getFilesByName() call (quota + latency).
+ if (hasExistingByName) {
+ fileName = resolveAttachmentName(fileName, newFileBlob, options)
+ }
+
+ console.log('[processMessageToDoc] Saving new file:', fileName)
+ const file = folder.createFile(newFileBlob)
+ body.insertParagraph(currentIndex, '- ' + file.getName())
+ console.log('[processMessageToDoc] File saved successfully')
+ return currentIndex + 1
+}
+
/**
* Process a single message and prepend its content to the document body.
* Returns the number of paragraphs inserted.
@@ -82,7 +222,7 @@ function removeExistingThread(body, threadId) {
* @returns {number} Number of paragraphs inserted
*/
function processMessageToDoc(message, body, folder, options = {}) {
- const { DocumentApp, Utilities, Logger, Session } = options
+ const { DocumentApp, Utilities, Logger } = options
const subject = message.getSubject()
const rawContent = message.getPlainBody()
@@ -123,104 +263,15 @@ function processMessageToDoc(message, body, folder, options = {}) {
body.insertParagraph(currentIndex++, '[Attachments]:')
attachments.forEach((att, attIndex) => {
- console.log(
- '[processMessageToDoc] Processing attachment',
- attIndex + 1,
- 'of',
+ currentIndex = processAttachment(
+ att,
+ attIndex,
attachments.length,
- ':',
- att.getName()
+ body,
+ folder,
+ currentIndex,
+ options
)
-
- let fileName = att.getName()
- // In GAS environment, copyBlob() creates a copy; in test environment, att itself is the blob
- const newFileBlob = att.copyBlob ? att.copyBlob() : att
- let isDuplicate = false
-
- // Check for duplicates by content hash
- const existingFiles = folder.getFilesByName(fileName)
- console.log(
- '[processMessageToDoc] Checking for existing files named:',
- fileName
- )
-
- let existingCount = 0
- while (existingFiles.hasNext()) {
- existingCount++
- const existingFile = existingFiles.next()
- console.log(
- '[processMessageToDoc] Comparing with existing file',
- existingCount
- )
-
- // Compare sizes first (fast fail)
- // Try getBytes() for GAS blobs, bytes property for test mocks, empty buffer as fallback
- const newFileBytes = newFileBlob.getBytes
- ? newFileBlob.getBytes()
- : newFileBlob.bytes || Buffer.from('')
- if (existingFile.getSize() === newFileBytes.length) {
- console.log('[processMessageToDoc] Size match, checking hash')
-
- // Deep check: compare MD5 hashes
- const existingHash = getFileHash(existingFile.getBlob())
- const newHash = getFileHash(newFileBlob)
-
- if (existingHash === newHash) {
- console.log('[processMessageToDoc] Hash match - duplicate detected')
- isDuplicate = true
- break
- } else {
- console.log(
- '[processMessageToDoc] Hash mismatch - different content'
- )
- }
- } else {
- console.log('[processMessageToDoc] Size mismatch - different file')
- }
- }
-
- if (isDuplicate) {
- if (Logger) {
- Logger.log('Skipping exact duplicate: ' + fileName)
- }
- console.log('[processMessageToDoc] Skipping duplicate:', fileName)
- body.insertParagraph(
- currentIndex++,
- '- [DUPLICATE SKIPPED] ' + fileName
- )
- } else {
- // Handle name conflicts (same name, different content)
- if (folder.getFilesByName(fileName).hasNext()) {
- console.log(
- '[processMessageToDoc] Name conflict detected, adding timestamp'
- )
-
- if (Utilities && Session) {
- const timeTag = Utilities.formatDate(
- new Date(),
- Session.getScriptTimeZone(),
- '_HHmmss'
- )
- const newName = fileName.replace(/(\.[\w\d_-]+)$/i, timeTag + '$1')
- fileName = newName === fileName ? fileName + timeTag : newName
- } else {
- // Test environment - simple timestamp
- const timeTag = '_' + Date.now()
- const newName = fileName.replace(/(\.[\w\d_-]+)$/i, timeTag + '$1')
- fileName = newName === fileName ? fileName + timeTag : newName
- }
-
- if (newFileBlob.setName) {
- newFileBlob.setName(fileName)
- }
- console.log('[processMessageToDoc] Renamed to:', fileName)
- }
-
- console.log('[processMessageToDoc] Saving new file:', fileName)
- const file = folder.createFile(newFileBlob)
- body.insertParagraph(currentIndex++, '- ' + file.getName())
- console.log('[processMessageToDoc] File saved successfully')
- }
})
}
@@ -679,6 +730,111 @@ function rebuildAllDocs(configs, rebuildDocFn) {
return completed
}
+/**
+ * Clears the destination document body and advances the rebuild state to the
+ * email-moving phase.
+ *
+ * @param {Object} config - Configuration object
+ * @param {Object} state - Mutable rebuild state object
+ * @param {Object} properties - PropertiesService user properties store
+ * @param {string} stateKey - Key under which state is persisted
+ * @param {Object} DocumentApp - GAS DocumentApp service
+ * @returns {boolean} True if the document was cleared, false to retry next run
+ */
+function clearDocumentPhase(config, state, properties, stateKey, DocumentApp) {
+ console.log('[rebuildDoc] Clearing document:', config.docId)
+ try {
+ const doc = DocumentApp.openById(config.docId)
+ const body = doc.getBody()
+
+ // Clear all content from the document body in a single operation
+ body.setText('')
+ console.log('[rebuildDoc] Document cleared')
+
+ // Move to next phase
+ state.phase = 'move_emails'
+ state.processedCount = 0
+ properties.setProperty(stateKey, JSON.stringify(state))
+ console.log('[rebuildDoc] Saved state, moving to email processing phase')
+ return true
+ } catch (e) {
+ console.error('[rebuildDoc] Error clearing document:', e.message)
+ // Try again next time
+ return false
+ }
+}
+
+/**
+ * Moves a batch of threads from the processed label back to the trigger label,
+ * persisting progress and yielding when approaching the execution time limit.
+ *
+ * @param {Object} params - Phase parameters
+ * @param {Object} params.state - Mutable rebuild state object
+ * @param {Object} params.properties - PropertiesService user properties store
+ * @param {string} params.stateKey - Key under which state is persisted
+ * @param {Object} params.triggerLabel - Gmail trigger label
+ * @param {Object} params.processedLabel - Gmail processed label
+ * @param {number} params.batchSize - Max threads to move per invocation
+ * @param {number} params.startTime - Epoch ms when the run started
+ * @param {number} params.maxExecutionTime - Max run duration in ms
+ * @returns {boolean} True if all threads were moved, false if more runs are needed
+ */
+function moveEmailsPhase({
+ state,
+ properties,
+ stateKey,
+ triggerLabel,
+ processedLabel,
+ batchSize,
+ startTime,
+ maxExecutionTime,
+}) {
+ console.log('[rebuildDoc] Moving emails from processed to trigger label')
+ console.log(
+ '[rebuildDoc] Resuming from:',
+ state.processedCount,
+ 'threads processed'
+ )
+
+ const threads = processedLabel.getThreads()
+ console.log('[rebuildDoc] Found', threads.length, 'threads to move')
+
+ // Process in batches, always from index 0 since we're removing items
+ let batchCount = 0
+ while (batchCount < batchSize && threads.length > 0) {
+ // Check if we're running out of time
+ const elapsed = new Date().getTime() - startTime
+ if (elapsed > maxExecutionTime) {
+ console.log('[rebuildDoc] Approaching time limit, saving progress')
+ state.processedCount += batchCount
+ properties.setProperty(stateKey, JSON.stringify(state))
+ return false // Not complete, run again
+ }
+
+ const thread = threads.shift()
+ processedLabel.removeFromThread(thread)
+ triggerLabel.addToThread(thread)
+ batchCount++
+ }
+
+ console.log('[rebuildDoc] Moved', batchCount, 'threads in this batch')
+ state.processedCount += batchCount
+
+ // Check if we're done
+ const remainingCount = processedLabel.getThreads().length
+ if (remainingCount === 0) {
+ console.log('[rebuildDoc] All threads moved')
+ state.phase = 'complete'
+ properties.setProperty(stateKey, JSON.stringify(state))
+ return true
+ }
+
+ // Still more threads to process
+ console.log('[rebuildDoc] Still', remainingCount, 'threads remaining')
+ properties.setProperty(stateKey, JSON.stringify(state))
+ return false // Not complete, need to run again
+}
+
/**
* Rebuilds a single document by clearing it and moving processed emails back to trigger label.
* Uses batching and state tracking to handle large label sets without timing out.
@@ -724,77 +880,24 @@ function rebuildDoc(config, services) {
const state = rebuildState ? JSON.parse(rebuildState) : { phase: 'clear_doc' }
if (state.phase === 'clear_doc') {
- console.log('[rebuildDoc] Clearing document:', config.docId)
- try {
- const doc = DocumentApp.openById(config.docId)
- const body = doc.getBody()
-
- // Clear all content from the document body in a single operation
- body.setText('')
- console.log('[rebuildDoc] Document cleared')
-
- // Move to next phase
- state.phase = 'move_emails'
- state.processedCount = 0
- properties.setProperty(stateKey, JSON.stringify(state))
- console.log('[rebuildDoc] Saved state, moving to email processing phase')
- } catch (e) {
- console.error('[rebuildDoc] Error clearing document:', e.message)
- // Try again next time
- return false
+ if (!clearDocumentPhase(config, state, properties, stateKey, DocumentApp)) {
+ return false // Try again next time
}
}
// 3. Move emails from processed back to trigger label (batched)
if (state.phase === 'move_emails' && processedLabel) {
- console.log('[rebuildDoc] Moving emails from processed to trigger label')
- console.log(
- '[rebuildDoc] Resuming from:',
- state.processedCount,
- 'threads processed'
- )
-
- const threads = processedLabel.getThreads()
- console.log('[rebuildDoc] Found', threads.length, 'threads to move')
-
- // Process in batches, always from index 0 since we're removing items
- let batchCount = 0
- while (batchCount < BATCH_SIZE && threads.length > 0) {
- // Check if we're running out of time
- const elapsed = new Date().getTime() - startTime
- if (elapsed > MAX_EXECUTION_TIME) {
- console.log('[rebuildDoc] Approaching time limit, saving progress')
- state.processedCount += batchCount
- properties.setProperty(stateKey, JSON.stringify(state))
- return false // Not complete, run again
- }
-
- // Always process index 0 since removing items shrinks the array
- const thread = threads[0]
- processedLabel.removeFromThread(thread)
- triggerLabel.addToThread(thread)
- batchCount++
-
- // Refresh threads array
- threads.splice(0, 1)
- }
-
- console.log('[rebuildDoc] Moved', batchCount, 'threads in this batch')
- state.processedCount += batchCount
-
- // Check if we're done
- if (processedLabel.getThreads().length === 0) {
- console.log('[rebuildDoc] All threads moved')
- state.phase = 'complete'
- properties.setProperty(stateKey, JSON.stringify(state))
- } else {
- // Still more threads to process
- console.log(
- '[rebuildDoc] Still',
- processedLabel.getThreads().length,
- 'threads remaining'
- )
- properties.setProperty(stateKey, JSON.stringify(state))
+ const completed = moveEmailsPhase({
+ state,
+ properties,
+ stateKey,
+ triggerLabel,
+ processedLabel,
+ batchSize: BATCH_SIZE,
+ startTime,
+ maxExecutionTime: MAX_EXECUTION_TIME,
+ })
+ if (!completed) {
return false // Not complete, need to run again
}
}
diff --git a/src/gmail-to-drive-by-labels/tests/integration.test.js b/src/gmail-to-drive-by-labels/tests/integration.test.js
index 076b7979..8ad1d7a6 100644
--- a/src/gmail-to-drive-by-labels/tests/integration.test.js
+++ b/src/gmail-to-drive-by-labels/tests/integration.test.js
@@ -469,6 +469,31 @@ describe('Gmail to Drive integration with prepend behavior', () => {
).toBe(true)
})
+ test('calls getFilesByName only once per attachment when a name conflict exists', () => {
+ // Pre-create a file with same name but different content
+ const existingBlob = createBlob('original content', 'once.txt')
+ folder.createFile(existingBlob)
+
+ const spy = jest.spyOn(folder, 'getFilesByName')
+
+ const newAttachment = createBlob('new content', 'once.txt')
+ const msg = createMessage({
+ subject: 'Test single Drive query',
+ body: 'Test body',
+ date: new Date('2024-01-01T10:00:00Z'),
+ attachments: [newAttachment],
+ })
+
+ processMessageToDoc(msg, body, folder)
+
+ // Should only call getFilesByName once per attachment, not twice
+ expect(spy).toHaveBeenCalledTimes(1)
+
+ // Name conflict should still be resolved: 2 files total
+ const files = folder.__getFiles()
+ expect(files).toHaveLength(2)
+ })
+
test('handles name conflict with Utilities and Session options', () => {
// Pre-create a file with same name but different content
const existingBlob = createBlob('original content', 'conflict.txt')