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 ( + `
` + + `
✅ Deployed successfully
` + + `` + + `${escapeHtml(p.title)} ↗` + + `
` + ) + }) + .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')