/** * MiCompress Google Workspace Add-on * Main entry points and configuration * * This add-on compresses images inside Google Docs and Slides * by sending them to the MiCompress API for processing. */ // ===== CONFIGURATION ===== const CONFIG = { API_BASE: 'https://micompress.com/api/addon', MAX_FILE_SIZE: 25 * 1024 * 1024, // 25MB // Card-service callbacks are killed after 30 seconds. A run stops starting // new images once elapsed time plus the slowest image so far would pass this // budget, and hands the rest to a "Continue" button. One 4MB PNG measured // ~10s end to end in Slides (blob read + upload + replace), so "budget not // yet spent" alone could still overrun. TIME_BUDGET_MS: 20 * 1000, }; /** * Content type → file extension. * * The server picks its OUTPUT encoder from the uploaded file's extension, so * the name we send decides the result. Docs/Slides blobs are almost always * unnamed, and the old `blob.getName() || 'image.png'` fallback therefore * labelled every photo as PNG — which made the server re-encode JPEGs as PNG, * come out larger, and fall back to returning the original at 0% saved. * Deriving the name from the content type is what keeps JPEG staying JPEG. */ const CONTENT_TYPE_EXT = { 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/pjpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/tiff': 'tiff', 'image/bmp': 'bmp', 'image/x-ms-bmp': 'bmp', 'image/x-icon': 'ico', 'image/vnd.microsoft.icon': 'ico', 'image/heic': 'heic', 'image/heif': 'heif', 'image/avif': 'avif', 'image/svg+xml': 'svg', }; const VALID_PRESETS = ['lossless', 'recommended', 'extreme']; /** * Get API key from Script Properties (lazy load to avoid init errors) */ function getApiKey_() { const key = PropertiesService.getScriptProperties().getProperty('ADDON_API_KEY'); if (!key) { throw new Error('ADDON_API_KEY not set. Copy your API key from micompress.com/account, then add it under Project Settings → Script Properties as ADDON_API_KEY'); } return key; } // ===== HOMEPAGE TRIGGERS ===== /** * Default homepage trigger (generic) */ function onHomepage(e) { return buildHomepageCard_('generic'); } /** * Docs-specific homepage trigger */ function onDocsHomepage(e) { return buildHomepageCard_('docs'); } /** * Slides-specific homepage trigger */ function onSlidesHomepage(e) { return buildHomepageCard_('slides'); } // ===== CORE ACTIONS ===== /** * Scan images in the current document/presentation */ function scanImages(e) { requireConsent_(); try { const hostApp = detectHostApp_(e); let images = []; if (hostApp === 'docs') { images = scanDocsImages(); } else if (hostApp === 'slides') { images = scanSlidesImages(); } else { return pushCard_(buildErrorCard_('Unsupported application. Please use this add-on in Google Docs or Slides.')); } if (images.length === 0) { return pushCard_(buildNoImagesCard_()); } return pushCard_(buildImageListCard_(images, hostApp)); } catch (err) { rethrowIfAuthError_(err); console.error('scanImages error:', err); return pushCard_(buildErrorCard_('Failed to scan images: ' + err.message)); } } /** * Compress all images in the document, in time-boxed batches. * * A callback has 30 seconds, which an image-heavy deck easily exceeds, so each * run stops once CONFIG.TIME_BUDGET_MS is spent and offers a "Continue" button. * The button carries the position of the next unprocessed image plus the running * totals as action parameters. Positions count every image in document order, * and replacement keeps each image in its slot, so the next run's scan lines up * with this one and already-compressed images are never re-encoded a second time. */ function compressAllImages(e) { const startedAt = Date.now(); requireConsent_(); try { // The preset dropdown and max-width box are FORM widgets, so their values // arrive in commonEventObject.formInputs. Action parameters are the // fallback, which is what a "Continue" run (no form on its card) relies on. const preset = readPreset_(e); const maxWidth = readMaxWidth_(e); if (maxWidth === 'invalid') { return pushCard_(buildErrorCard_('Max Width must be a number between 100 and 10000 pixels — or left empty to keep the original dimensions.')); } // What the run memo matches on: re-running with another preset or a new // max width is a deliberate request and must compress again. const settingsKey = preset + '|' + (maxWidth || ''); const params = (e && e.commonEventObject && e.commonEventObject.parameters) || {}; const startPosition = parseInt(params.startPosition || '0', 10) || 0; const totals = { original: parseInt(params.totalOriginal || '0', 10) || 0, compressed: parseInt(params.totalCompressed || '0', 10) || 0, success: parseInt(params.successCount || '0', 10) || 0, unchanged: parseInt(params.unchangedCount || '0', 10) || 0, fail: parseInt(params.failCount || '0', 10) || 0, }; const hostApp = detectHostApp_(e); let images = []; if (hostApp === 'docs') { images = scanDocsImages(); } else if (hostApp === 'slides') { images = scanSlidesImages(); } const pending = images.filter(function(img) { return img.position >= startPosition; }); if (pending.length === 0 && startPosition === 0) { return pushCard_(buildNoImagesCard_()); } const memo = loadRunMemo_(hostApp); const results = []; let nextPosition = null; let slowestImageMs = 0; let alreadyDone = 0; let replacedThisRun = 0; for (let i = 0; i < pending.length; i++) { const img = pending[i]; // Predict with the slowest image so far (see CONFIG.TIME_BUDGET_MS). if (i > 0 && Date.now() - startedAt + slowestImageMs > CONFIG.TIME_BUDGET_MS) { nextPosition = img.position; break; } const imageStartedAt = Date.now(); try { if (!img.blob) { totals.fail++; results.push({ name: img.name, error: 'Could not extract image data' }); continue; } // Same image, same settings as an earlier run: compressing it again would // only cost quality. This is what makes a second click harmless. const sourceHash = blobHash_(img.blob); if (memo.done[sourceHash] === settingsKey) { alreadyDone++; totals.unchanged++; results.push({ name: img.name, unchanged: true, originalSize: img.size }); continue; } const compressed = compressViaApi_(img.blob, preset, maxWidth); if (!compressed.success) { totals.fail++; results.push({ name: img.name, error: compressed.error }); continue; } // The server hands back the original when it cannot make the image // smaller. Swapping it in anyway only churns the document (and, in Docs, // rebuilds the element), so leave such images alone. if (compressed.compressedSize >= compressed.originalSize || compressed.savedPercent <= 0) { memo.done[sourceHash] = settingsKey; totals.unchanged++; results.push({ name: img.name, unchanged: true, originalSize: compressed.originalSize }); continue; } let placed = null; if (hostApp === 'docs') { placed = replaceDocsImage(img.element, compressed.blob); } else if (hostApp === 'slides') { placed = replaceSlidesImage(img.element, compressed.blob); } rememberCompressed_(memo, [compressed.blob, placed], settingsKey); replacedThisRun++; totals.original += compressed.originalSize; totals.compressed += compressed.compressedSize; totals.success++; results.push({ name: img.name, originalSize: compressed.originalSize, compressedSize: compressed.compressedSize, savedPercent: compressed.savedPercent, }); } catch (imgErr) { totals.fail++; results.push({ name: img.name, error: imgErr.message }); } finally { slowestImageMs = Math.max(slowestImageMs, Date.now() - imageStartedAt); } } let continuation = null; if (nextPosition !== null) { continuation = { remaining: pending.filter(function(img) { return img.position >= nextPosition; }).length, parameters: { startPosition: String(nextPosition), preset: preset, maxWidth: maxWidth ? String(maxWidth) : '', totalOriginal: String(totals.original), totalCompressed: String(totals.compressed), successCount: String(totals.success), unchangedCount: String(totals.unchanged), failCount: String(totals.fail), }, }; } console.log('compressAllImages: host=%s replaced=%s skipped=%s in %sms', hostApp, replacedThisRun, alreadyDone, Date.now() - startedAt); // Nothing left to do because the previous click already did it: Slides // sometimes drops a finished run's results card (the execution completes in // ~13 s, well inside the 30 s limit, yet the panel never updates), so the // user clicks again. Show that run's real results instead of "already // optimized". const recent = memo.last && memo.last.settings === settingsKey && Date.now() - memo.last.at < LAST_RUN_REUSE_MS; if (!continuation && startPosition === 0 && alreadyDone > 0 && totals.success === 0 && totals.fail === 0 && recent) { saveRunMemo_(hostApp, memo); return pushCard_(buildResultsCard_(memo.last.results, memo.last.totals, null, 'These images were compressed by your previous click.')); } // Kept for "Show last result" (see showLastResult): in Slides the card this // run returns is usually lost, so every batch that changed something is // stored, Continue button included. if (replacedThisRun > 0) { memo.last = { at: Date.now(), settings: settingsKey, totals: totals, results: results.slice(0, 15), continuation: continuation, }; } saveRunMemo_(hostApp, memo); // A continuation run replaces the previous results card instead of // stacking one card per batch. const card = buildResultsCard_(results, totals, continuation); return startPosition > 0 ? updateCard_(card) : pushCard_(card); } catch (err) { rethrowIfAuthError_(err); console.error('compressAllImages error:', err); return pushCard_(buildErrorCard_('Compression failed: ' + err.message)); } } /** * "Show last result" button. * * In Google Slides, replacing images makes the editor reload the deck, and * that reload resets this panel to the card that was showing before the click * without asking the script again (no execution is logged). The results card a * compress run returns is therefore usually never displayed, however long the * run waits (flushing with saveAndClose() and sleeping were both tried on * 2026-10-27 and failed). This button sits on the cards the panel falls back * to and only reads the stored outcome, so its own response always shows. */ function showLastResult(e) { requireConsent_(); const hostApp = detectHostApp_(e); const memo = loadRunMemo_(hostApp); const last = memo.last; if (!last || !last.totals) { return CardService.newActionResponseBuilder() .setNotification(CardService.newNotification() .setText('No compression has run in this file yet.')) .build(); } const minutes = Math.max(0, Math.round((Date.now() - last.at) / 60000)); const when = minutes < 1 ? 'just now' : minutes + ' min ago'; return pushCard_(buildResultsCard_(last.results || [], last.totals, last.continuation || null, 'Result of the last compression in this file (' + when + ').')); } /** * "Back to Home" button: return to the homepage card at the root of the stack. */ function goHome(e) { return CardService.newActionResponseBuilder() .setNavigation(CardService.newNavigation().popToRoot()) .build(); } // ===== NAVIGATION ===== // // Widget action callbacks must answer with an ActionResponse. Handing back a // bare Card (as the homepage trigger does) is not a valid action response. function pushCard_(card) { return CardService.newActionResponseBuilder() .setNavigation(CardService.newNavigation().pushCard(card)) .build(); } function updateCard_(card) { return CardService.newActionResponseBuilder() .setNavigation(CardService.newNavigation().updateCard(card)) .build(); } // ===== API COMMUNICATION ===== /** * Send an image blob to the MiCompress API for compression * * @param {Blob} blob - Image blob to compress * @param {string} preset - Compression preset * @param {number|null} maxWidth - Max width for resize * @returns {Object} Result with success, blob, sizes, etc. */ function compressViaApi_(blob, preset, maxWidth) { const imageBytes = blob.getBytes(); // Check file size if (imageBytes.length > CONFIG.MAX_FILE_SIZE) { return { success: false, error: 'File too large (max 25MB)' }; } // Name the blob from its content type, NOT from getName() — see CONTENT_TYPE_EXT. // UrlFetchApp also needs a name to build the multipart part correctly. blob.setName(blobFileName_(blob)); // Build payload as a JS object — UrlFetchApp automatically creates // proper multipart/form-data with correct binary encoding for Blob values const payload = { image: blob, preset: preset || 'recommended', }; if (maxWidth) { payload.maxWidth = maxWidth.toString(); } // Make API request const options = { method: 'post', payload: payload, headers: { 'X-Addon-Key': getApiKey_(), // Anonymous, stable per-user id. Once the add-on is published from the // Marketplace every install shares ONE ADDON_API_KEY, so without this the // server's rate limit would be a single global bucket for all users. 'X-Addon-User': getUserKey_(), }, muteHttpExceptions: true, }; const response = UrlFetchApp.fetch(CONFIG.API_BASE + '/compress', options); const responseCode = response.getResponseCode(); if (responseCode !== 200) { let errorMsg = 'API error: ' + responseCode; try { const errorBody = JSON.parse(response.getContentText()); errorMsg = errorBody.error || errorMsg; } catch (e) {} console.error('compressViaApi_ failed:', responseCode, response.getContentText()); return { success: false, error: errorMsg }; } // Parse response headers for metadata const headers = response.getAllHeaders(); const originalSize = parseInt(headers['X-Original-Size'] || headers['x-original-size'] || '0'); const compressedSize = parseInt(headers['X-Compressed-Size'] || headers['x-compressed-size'] || '0'); const savedPercent = parseInt(headers['X-Saved-Percent'] || headers['x-saved-percent'] || '0'); const outputFormat = headers['X-Output-Format'] || headers['x-output-format'] || 'png'; // Get compressed image blob const compressedBlob = response.getBlob(); compressedBlob.setName('compressed.' + (outputFormat === 'jpeg' ? 'jpg' : outputFormat)); return { success: true, blob: compressedBlob, originalSize: originalSize, compressedSize: compressedSize, savedPercent: savedPercent, format: outputFormat, }; } // ===== UTILITY FUNCTIONS ===== /** * Make sure the user has granted every manifest scope before touching the file. * * Opening the sidebar needs no scope, so a user who never authorized (or who * unticked a box on the granular consent screen) only finds out on the first * button press. requireAllScopes() ends the execution and has Google render * its own "Authorize access" prompt instead. */ function requireConsent_() { ScriptApp.requireScopes(ScriptApp.AuthMode.FULL, ACCOUNT_SCOPES); } /** * Every scope in the manifest. Kept as an explicit list (rather than * requireAllScopes) so a scope granted per file later on, such as Drive's * drive.file in addon/drive-parked/, cannot lock Docs and Slides users in an * endless "Permission required" loop. */ const ACCOUNT_SCOPES = [ 'https://www.googleapis.com/auth/documents.currentonly', 'https://www.googleapis.com/auth/presentations.currentonly', 'https://www.googleapis.com/auth/script.external_request', ]; /** * Error cards must not swallow permission failures: Google only shows the * authorization prompt when the exception escapes the callback. Catching it and * rendering "You do not have permission to call DocumentApp..." leaves the user * stuck with no way to grant access. */ function rethrowIfAuthError_(err) { const msg = String((err && err.message) || err); if (/do not have permission|authori[sz]ation is required|requires? authori[sz]ation/i.test(msg)) { throw err; } } /** * Detect which host application we're running in. * * The event object says so directly (commonEventObject.hostApp). Probing the * services is only a fallback, and it has to check for null: inside Slides, * DocumentApp.getActiveDocument() returns null rather than throwing, so a bare * try/catch would report every presentation as a document. */ function detectHostApp_(e) { const host = e && e.commonEventObject && e.commonEventObject.hostApp; if (host === 'DOCS') return 'docs'; if (host === 'SLIDES') return 'slides'; try { if (DocumentApp.getActiveDocument()) return 'docs'; } catch (err) {} try { if (SlidesApp.getActivePresentation()) return 'slides'; } catch (err) {} return 'unknown'; } /** * Read one value out of a card's form inputs. * * Both DROPDOWN selections and TextInput values surface as * formInputs[field].stringInputs.value[0]. * * @param {Object} e - The Apps Script event object * @param {string} fieldName - The widget's field name * @returns {string|null} The submitted value, or null when the field is absent */ function getFormValue_(e, fieldName) { try { const inputs = (e && e.commonEventObject && e.commonEventObject.formInputs) || {}; const field = inputs[fieldName]; if (field && field.stringInputs && field.stringInputs.value && field.stringInputs.value.length) { return field.stringInputs.value[0]; } } catch (err) { console.error('getFormValue_ error:', err); } return null; } /** * Resolve the compression preset: form input first, then action parameter. * Anything unrecognised falls back to 'recommended'. */ function readPreset_(e) { const params = (e && e.commonEventObject && e.commonEventObject.parameters) || {}; const value = getFormValue_(e, 'preset') || params.preset || 'recommended'; return VALID_PRESETS.indexOf(value) >= 0 ? value : 'recommended'; } /** * Resolve the max-width resize option. * * @returns {number|null|string} The width in px, null when left empty, * or the string 'invalid' when the user typed something unusable. */ function readMaxWidth_(e) { const params = (e && e.commonEventObject && e.commonEventObject.parameters) || {}; const raw = String(getFormValue_(e, 'maxWidth') || params.maxWidth || '').trim(); if (!raw) return null; const width = parseInt(raw, 10); if (isNaN(width) || String(width) !== raw || width < 100 || width > 10000) { return 'invalid'; } return width; } /** * Build the filename to upload for an image blob. * * The extension decides which encoder the server uses, so it is derived from * the content type. The blob's own name is only a fallback for the rare case * of an unknown content type; the server sniffs magic bytes as a final guard. */ function blobFileName_(blob) { const contentType = String(blob.getContentType() || '').toLowerCase().split(';')[0].trim(); const ext = CONTENT_TYPE_EXT[contentType]; if (ext) { return 'image.' + ext; } const name = blob.getName(); if (name && name.lastIndexOf('.') > 0) { return name; } return 'image.jpg'; } /** * Anonymous per-user identifier for server-side rate limiting. * * getTemporaryActiveUserKey() is stable for the same user in the same script * and reveals no identity, which is what we want: metering without collecting * personal data. */ function getUserKey_() { try { return Session.getTemporaryActiveUserKey() || 'anonymous'; } catch (err) { return 'anonymous'; } } // ===== RUN MEMO ===== // // Per user and per file, in CacheService (needs no OAuth scope): which images // MiCompress produced, keyed by an MD5 of their bytes, with the preset used, // plus the last run's results. Kept for 6 hours; a cache failure only disables // the guard, never the compression. const RUN_MEMO_TTL_S = 6 * 60 * 60; const RUN_MEMO_MAX_HASHES = 300; const LAST_RUN_REUSE_MS = 15 * 60 * 1000; function runMemoKey_(hostApp) { try { if (hostApp === 'docs') return 'mcRun:' + DocumentApp.getActiveDocument().getId(); if (hostApp === 'slides') return 'mcRun:' + SlidesApp.getActivePresentation().getId(); } catch (err) {} return null; } function loadRunMemo_(hostApp) { const key = runMemoKey_(hostApp); let memo = { done: {}, last: null }; if (key) { try { const raw = CacheService.getUserCache().get(key); if (raw) memo = JSON.parse(raw); memo.done = memo.done || {}; } catch (err) { /* empty memo */ } } // Non-enumerable: travels with the memo but is not stored in it. Object.defineProperty(memo, 'key', { value: key, enumerable: false }); return memo; } function saveRunMemo_(hostApp, memo) { const key = memo.key !== undefined ? memo.key : runMemoKey_(hostApp); if (!key) return; try { const hashes = Object.keys(memo.done); if (hashes.length > RUN_MEMO_MAX_HASHES) { const kept = {}; hashes.slice(-RUN_MEMO_MAX_HASHES).forEach(function(h) { kept[h] = memo.done[h]; }); memo.done = kept; } CacheService.getUserCache().put(key, JSON.stringify(memo), RUN_MEMO_TTL_S); } catch (err) { console.error('saveRunMemo_ error:', err); } } /** * Record the images a run produced. Both the blob we uploaded and the image as * the editor stores it are hashed: Slides hands back the same bytes on the next * scan, while Docs may re-encode, so the read-back copy is the one that matches. */ function rememberCompressed_(memo, blobSources, settingsKey) { blobSources.forEach(function(source) { if (!source) return; try { const blob = typeof source.getBlob === 'function' ? source.getBlob() : source; memo.done[blobHash_(blob)] = settingsKey; } catch (err) { /* best effort */ } }); } function blobHash_(blob) { return Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, blob.getBytes()) .map(function(b) { return ((b + 256) % 256).toString(16).padStart(2, '0'); }) .join(''); } /** * Format file size for display */ function formatSize_(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; }