From 44c521ac867026b18433b006649d9e1ebcb15ce4 Mon Sep 17 00:00:00 2001
From: donpetry-bot <281750570+donpetry-bot@users.noreply.github.com>
Date: Tue, 7 Jul 2026 19:26:56 +0000
Subject: [PATCH 1/3] =?UTF-8?q?feat:=20implement=20issue=20#427=20?=
=?UTF-8?q?=E2=80=94=20SonarCloud:=20cognitive=20complexity=20(S3776)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
deploy/index.html | 125 +++++---
src/calendar-to-sheets/src/index.js | 186 +++++++----
src/gmail-to-drive-by-labels/src/index.js | 362 +++++++++++++---------
3 files changed, 408 insertions(+), 265 deletions(-)
diff --git a/deploy/index.html b/deploy/index.html
index 323453b8..11d05edf 100644
--- a/deploy/index.html
+++ b/deploy/index.html
@@ -1887,33 +1887,11 @@
3Configure your scripts
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)
- }
+ const projectId = await resolveDeployProjectId(
+ catalogId,
+ projectTitle,
+ progress
+ )
showStatus(
'info',
@@ -1986,30 +1964,7 @@ 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)
+ showStatus('error', buildDeployErrorHtml(err))
} finally {
btn.disabled = false
btn.innerHTML = 'Deploy to my account'
@@ -2019,6 +1974,74 @@ 3Configure your scripts
// ── Helpers ───────────────────────────────────────────────────────────────────
+ /**
+ * Resolves the Apps Script project id for a catalog entry idempotently:
+ * reuses a stored project when it still exists, otherwise creates a new one
+ * and remembers its id.
+ */
+ async function resolveDeployProjectId(catalogId, projectTitle, progress) {
+ const storedId = getStoredScriptId(catalogId, projectTitle)
+ if (storedId) {
+ showStatus(
+ 'info',
+ ` Verifying existing project ${escapeHtml(projectTitle)}${progress}…`
+ )
+ try {
+ await apiFetch(`${APPS_SCRIPT_API}/projects/${storedId}`)
+ return storedId
+ } catch {
+ // Project no longer exists — fall through to creation.
+ }
+ }
+
+ showStatus(
+ 'info',
+ ` Creating project ${escapeHtml(projectTitle)}${progress}…`
+ )
+ const project = await apiFetch(`${APPS_SCRIPT_API}/projects`, {
+ method: 'POST',
+ body: JSON.stringify({ title: projectTitle }),
+ })
+ const projectId = validateScriptId(project.scriptId)
+ storeScriptId(catalogId, projectTitle, projectId)
+ return projectId
+ }
+
+ /**
+ * Builds the status HTML shown when a deployment fails, with a special
+ * hint when the failure is a disabled Apps Script API, plus a collapsible
+ * error-detail block with a copy button.
+ */
+ function buildDeployErrorHtml(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)}
` +
+ `` +
+ ` `
+ 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(err.message)}` + 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..c9fc9c88 100644
--- a/src/calendar-to-sheets/src/index.js
+++ b/src/calendar-to-sheets/src/index.js
@@ -106,6 +106,116 @@ function ensureHeader(sheet) {
}
}
+/**
+ * Applies desired rows against the sheet: updates changed existing rows and
+ * collects brand-new rows to insert.
+ *
+ * @param {Object} sheet - Sheet object
+ * @param {Map} desiredMap - Map of id -> desired row
+ * @param {Map} existingMap - Map of id -> { rowIndex, values }
+ * @returns {{updateCount: number, rowsToInsert: Array}}
+ */
+function upsertRows(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 }
+}
+
+/**
+ * Appends new rows to the sheet, using a single batched range write when the
+ * sheet supports getLastRow(), otherwise falling back to appendRow().
+ *
+ * @param {Object} sheet - Sheet object
+ * @param {Array} rowsToInsert - Rows to append
+ */
+function insertNewRows(sheet, rowsToInsert) {
+ if (rowsToInsert.length === 0) return
+
+ console.log(
+ '[syncCalendarToSheet] Inserting new events:',
+ rowsToInsert.length
+ )
+ if (typeof sheet.getLastRow === 'function') {
+ sheet
+ .getRange(
+ sheet.getLastRow() + 1,
+ 1,
+ rowsToInsert.length,
+ rowsToInsert[0].length
+ )
+ .setValues(rowsToInsert)
+ } else {
+ rowsToInsert.forEach((row) => sheet.appendRow(row))
+ }
+}
+
+/**
+ * Decides whether an existing row (for an event no longer desired) should be
+ * deleted. Only rows with valid start/end columns whose event time falls within
+ * [start, end] are deleted; everything else is preserved as historical data.
+ *
+ * @param {string} id - Event id (for logging)
+ * @param {Object} ex - Existing row record { rowIndex, values }
+ * @param {Date} start - Sync window start
+ * @param {Date} end - Sync window end
+ * @returns {boolean} True if the row should be deleted
+ */
+function shouldDeleteRow(id, ex, start, end) {
+ const rowStart = ex.values[2] // start is at index 2
+ const rowEnd = ex.values[3] // end is at index 3
+
+ if (!rowStart || !rowEnd) {
+ console.log(
+ '[syncCalendarToSheet] Preserving event with invalid dates:',
+ id
+ )
+ return false
+ }
+
+ const rowStartTime = new Date(rowStart)
+ if (!isNaN(rowStartTime) && rowStartTime >= start && rowStartTime <= end) {
+ console.log('[syncCalendarToSheet] Marking event for deletion:', id)
+ return true
+ }
+
+ console.log('[syncCalendarToSheet] Preserving event outside sync window:', id)
+ return false
+}
+
+/**
+ * Computes the row indexes to delete for events that no longer exist, limited to
+ * the synced time window.
+ *
+ * @param {Map} existingMap - Map of id -> { rowIndex, values }
+ * @param {Map} desiredMap - Map of id -> desired row
+ * @param {Date} start - Sync window start
+ * @param {Date} end - Sync window end
+ * @returns {Array} Row indexes to delete
+ */
+function computeRowsToDelete(existingMap, desiredMap, start, end) {
+ const toDelete = []
+ for (const [id, ex] of existingMap.entries()) {
+ if (!desiredMap.has(id) && shouldDeleteRow(id, ex, start, end)) {
+ toDelete.push(ex.rowIndex)
+ }
+ }
+ return toDelete
+}
+
async function syncCalendarToSheet(
calendar,
sheet,
@@ -133,42 +243,12 @@ 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)
- }
- }
-
- if (rowsToInsert.length > 0) {
- console.log(
- '[syncCalendarToSheet] Inserting new events:',
- rowsToInsert.length
- )
- if (typeof sheet.getLastRow === 'function') {
- sheet
- .getRange(
- sheet.getLastRow() + 1,
- 1,
- rowsToInsert.length,
- rowsToInsert[0].length
- )
- .setValues(rowsToInsert)
- } else {
- rowsToInsert.forEach((row) => sheet.appendRow(row))
- }
- }
+ const { updateCount, rowsToInsert } = upsertRows(
+ sheet,
+ desiredMap,
+ existingMap
+ )
+ insertNewRows(sheet, rowsToInsert)
console.log(
'[syncCalendarToSheet] Updates:',
@@ -179,38 +259,8 @@ 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 = computeRowsToDelete(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..65ab63f3 100644
--- a/src/gmail-to-drive-by-labels/src/index.js
+++ b/src/gmail-to-drive-by-labels/src/index.js
@@ -71,6 +71,88 @@ function removeExistingThread(body, threadId) {
return true
}
+/**
+ * Determine whether an attachment already exists in the folder by comparing
+ * size first (fast fail) and then MD5 hash against every same-named file.
+ *
+ * @param {Object} folder - Drive folder object
+ * @param {string} fileName - The attachment file name to look up
+ * @param {Object} newFileBlob - The attachment blob to compare
+ * @returns {boolean} True if an identical file already exists
+ */
+function isDuplicateAttachment(folder, fileName, newFileBlob) {
+ 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 mismatch - different file')
+ continue
+ }
+
+ 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')
+ return true
+ }
+ console.log('[processMessageToDoc] Hash mismatch - different content')
+ }
+
+ return false
+}
+
+/**
+ * Resolve a non-conflicting file name for a new attachment. When a same-named
+ * file already exists (with different content), a timestamp is inserted before
+ * the extension and the blob is renamed in place.
+ *
+ * @param {Object} folder - Drive folder object
+ * @param {string} fileName - The desired file name
+ * @param {Object} newFileBlob - The attachment blob (renamed in place if needed)
+ * @param {Object} options - Settings (Utilities, Session for GAS)
+ * @returns {string} The resolved file name
+ */
+function resolveAttachmentFileName(folder, fileName, newFileBlob, options) {
+ const { Utilities, Session } = options
+
+ if (!folder.getFilesByName(fileName).hasNext()) {
+ return fileName
+ }
+
+ console.log('[processMessageToDoc] Name conflict detected, adding timestamp')
+ const timeTag =
+ Utilities && Session
+ ? Utilities.formatDate(new Date(), Session.getScriptTimeZone(), '_HHmmss')
+ : '_' + Date.now()
+ const newName = fileName.replace(/(\.[\w\d_-]+)$/i, timeTag + '$1')
+ const resolved = newName === fileName ? fileName + timeTag : newName
+
+ if (newFileBlob.setName) {
+ newFileBlob.setName(resolved)
+ }
+ console.log('[processMessageToDoc] Renamed to:', resolved)
+ return resolved
+}
+
/**
* Process a single message and prepend its content to the document body.
* Returns the number of paragraphs inserted.
@@ -135,51 +217,8 @@ function processMessageToDoc(message, body, folder, 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 (isDuplicateAttachment(folder, fileName, newFileBlob)) {
if (Logger) {
Logger.log('Skipping exact duplicate: ' + fileName)
}
@@ -190,32 +229,12 @@ function processMessageToDoc(message, body, folder, options = {}) {
)
} 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)
- }
-
+ fileName = resolveAttachmentFileName(
+ folder,
+ fileName,
+ newFileBlob,
+ options
+ )
console.log('[processMessageToDoc] Saving new file:', fileName)
const file = folder.createFile(newFileBlob)
body.insertParagraph(currentIndex++, '- ' + file.getName())
@@ -679,6 +698,113 @@ function rebuildAllDocs(configs, rebuildDocFn) {
return completed
}
+/**
+ * Runs the "clear_doc" phase of a rebuild: clears the destination document and
+ * advances the state to "move_emails". No-op when the state is in a later phase.
+ *
+ * @param {Object} state - Mutable rebuild state ({ phase, processedCount })
+ * @param {Object} config - Configuration object
+ * @param {Object} DocumentApp - GAS DocumentApp service
+ * @param {Object} properties - GAS user properties store
+ * @param {string} stateKey - Key under which the state is persisted
+ * @returns {(boolean|null)} A boolean result rebuildDoc should return, or null to continue
+ */
+function clearDocPhase(state, config, DocumentApp, properties, stateKey) {
+ if (state.phase !== 'clear_doc') return null
+
+ 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 null
+ } catch (e) {
+ console.error('[rebuildDoc] Error clearing document:', e.message)
+ // Try again next time
+ return false
+ }
+}
+
+/**
+ * Runs the "move_emails" phase of a rebuild: moves threads from the processed
+ * label back to the trigger label in a time-bounded batch. No-op unless the
+ * state is "move_emails" and a processed label exists.
+ *
+ * @param {Object} state - Mutable rebuild state ({ phase, processedCount })
+ * @param {{triggerLabel: Object, processedLabel: Object}} labels - Gmail labels
+ * @param {Object} properties - GAS user properties store
+ * @param {string} stateKey - Key under which the state is persisted
+ * @param {{BATCH_SIZE: number, MAX_EXECUTION_TIME: number, startTime: number}} limits
+ * @returns {(boolean|null)} A boolean result rebuildDoc should return, or null to continue
+ */
+function moveEmailsPhase(state, labels, properties, stateKey, limits) {
+ const { triggerLabel, processedLabel } = labels
+ if (state.phase !== 'move_emails' || !processedLabel) return null
+
+ const { BATCH_SIZE, MAX_EXECUTION_TIME, startTime } = limits
+
+ 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))
+ return null
+ }
+
+ // Still more threads to process
+ console.log(
+ '[rebuildDoc] Still',
+ processedLabel.getThreads().length,
+ '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.
@@ -718,86 +844,30 @@ function rebuildDoc(config, services) {
)
}
- // 2. Check if we need to clear the document (only on first run)
+ // 2. Load persisted state (first run starts in the clear_doc phase)
const properties = PropertiesService.getUserProperties()
const rebuildState = properties.getProperty(stateKey)
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
- }
- }
-
- // 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))
- return false // Not complete, need to run again
- }
- }
+ // 3. Clear the document (only on first run)
+ const clearResult = clearDocPhase(
+ state,
+ config,
+ DocumentApp,
+ properties,
+ stateKey
+ )
+ if (clearResult !== null) return clearResult
+
+ // 4. Move emails from processed back to trigger label (batched)
+ const moveResult = moveEmailsPhase(
+ state,
+ { triggerLabel, processedLabel },
+ properties,
+ stateKey,
+ { BATCH_SIZE, MAX_EXECUTION_TIME, startTime }
+ )
+ if (moveResult !== null) return moveResult
// If we got here, we're done
properties.deleteProperty(stateKey)
From c1afe93d7b424855ca0faa07c0d66db9f199558e Mon Sep 17 00:00:00 2001
From: donpetry-bot <281750570+donpetry-bot@users.noreply.github.com>
Date: Tue, 7 Jul 2026 19:32:46 +0000
Subject: [PATCH 2/3] fix(reviews): address review comments [skip ci-relay]
---
deploy/index.html | 10 ++++------
src/gmail-to-drive-by-labels/src/index.js | 14 +++++---------
2 files changed, 9 insertions(+), 15 deletions(-)
diff --git a/deploy/index.html b/deploy/index.html
index 11d05edf..4ccbb903 100644
--- a/deploy/index.html
+++ b/deploy/index.html
@@ -2013,9 +2013,9 @@ 3Configure your scripts
* error-detail block with a copy button.
*/
function buildDeployErrorHtml(err) {
- const isApiDisabled =
- err.message && err.message.includes('Apps Script API')
- const errorDetail = err.stack || err.message || String(err)
+ const errMsg = err && err.message ? err.message : String(err || '')
+ const isApiDisabled = errMsg.includes('Apps Script API')
+ const errorDetail = (err && err.stack) || errMsg
const errorDetailBlock =
`` +
`Error detail
` +
@@ -2037,9 +2037,7 @@ 3Configure your scripts
errorDetailBlock
)
}
- return (
- `❌ Deployment failed: ${escapeHtml(err.message)}` + errorDetailBlock
- )
+ return `❌ Deployment failed: ${escapeHtml(errMsg)}` + errorDetailBlock
}
/**
diff --git a/src/gmail-to-drive-by-labels/src/index.js b/src/gmail-to-drive-by-labels/src/index.js
index 65ab63f3..44f6fa14 100644
--- a/src/gmail-to-drive-by-labels/src/index.js
+++ b/src/gmail-to-drive-by-labels/src/index.js
@@ -762,9 +762,9 @@ function moveEmailsPhase(state, labels, properties, stateKey, limits) {
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
+ // Process in batches
let batchCount = 0
- while (batchCount < BATCH_SIZE && threads.length > 0) {
+ while (batchCount < BATCH_SIZE && batchCount < threads.length) {
// Check if we're running out of time
const elapsed = new Date().getTime() - startTime
if (elapsed > MAX_EXECUTION_TIME) {
@@ -774,21 +774,17 @@ function moveEmailsPhase(state, labels, properties, stateKey, limits) {
return false // Not complete, run again
}
- // Always process index 0 since removing items shrinks the array
- const thread = threads[0]
+ const thread = threads[batchCount]
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) {
+ if (batchCount === threads.length) {
console.log('[rebuildDoc] All threads moved')
state.phase = 'complete'
properties.setProperty(stateKey, JSON.stringify(state))
@@ -798,7 +794,7 @@ function moveEmailsPhase(state, labels, properties, stateKey, limits) {
// Still more threads to process
console.log(
'[rebuildDoc] Still',
- processedLabel.getThreads().length,
+ threads.length - batchCount,
'threads remaining'
)
properties.setProperty(stateKey, JSON.stringify(state))
From 31f26327c271848ead58edd1cecaa25b138c099a Mon Sep 17 00:00:00 2001
From: donpetry-bot <281750570+donpetry-bot@users.noreply.github.com>
Date: Tue, 7 Jul 2026 19:36:00 +0000
Subject: [PATCH 3/3] chore: apply manual instructions [skip ci-relay]
---
.github/skills/bmad-retrospective/workflow.md | 1 +
.github/skills/bmad-sprint-status/workflow.md | 1 +
.../bmad-retrospective/workflow.md | 1 +
.../bmad-sprint-status/workflow.md | 1 +
deploy/index.html | 5 +-
deploy/tests/ui.spec.js | 57 +++++++++++++++++++
6 files changed, 64 insertions(+), 2 deletions(-)
diff --git a/.github/skills/bmad-retrospective/workflow.md b/.github/skills/bmad-retrospective/workflow.md
index 5b1c4637..f810f95d 100644
--- a/.github/skills/bmad-retrospective/workflow.md
+++ b/.github/skills/bmad-retrospective/workflow.md
@@ -449,6 +449,7 @@ Alice (Product Owner): "Good thinking - helps us connect what we learned to what
- Deployment or environment setup