/**
* MiCompress Add-on — Card UI Builder
*
* Builds Google Workspace Add-on Card-based UI using CardService.
* All UI is rendered as cards in the sidebar/panel.
*/
/**
* A card action that shows a spinner while it runs. Compressing takes several
* seconds, and without visible feedback users click again, which compresses
* already-compressed images a second time.
*/
function action_(functionName) {
return CardService.newAction()
.setFunctionName(functionName)
.setLoadIndicator(CardService.LoadIndicator.SPINNER);
}
/**
* Opens the stored result of the last run in this file (see showLastResult).
*/
function lastResultButton_() {
return CardService.newTextButton()
.setText('📊 Show last result')
.setTextButtonStyle(CardService.TextButtonStyle.TEXT)
.setOnClickAction(action_('showLastResult'));
}
// ===== HOMEPAGE CARDS =====
/**
* Build the main homepage card
* @param {string} hostApp - 'docs', 'slides', or 'generic'
*/
function buildHomepageCard_(hostApp) {
const card = CardService.newCardBuilder();
// Header section
const headerSection = CardService.newCardSection();
const headerImage = CardService.newImage()
.setImageUrl('https://micompress.com/images/addon-assets/banner-220x140.png')
.setAltText('MiCompress');
headerSection.addWidget(headerImage);
const welcomeText = CardService.newTextParagraph()
.setText('🗜️ MiCompress — Image Compressor\n\n' +
'Compress images directly inside your ' +
(hostApp === 'docs' ? 'Google Docs™' : hostApp === 'slides' ? 'Google Slides™' : 'documents') +
' to reduce file size — choose how much quality to trade for size.');
headerSection.addWidget(welcomeText);
card.addSection(headerSection);
// Quick Actions section
const actionsSection = CardService.newCardSection()
.setHeader('🔍 Quick Actions');
// Scan Images button
const scanButton = CardService.newTextButton()
.setText('📷 Scan Images')
.setTextButtonStyle(CardService.TextButtonStyle.FILLED)
.setBackgroundColor('#4285F4')
.setOnClickAction(
action_('scanImages')
);
actionsSection.addWidget(scanButton);
const scanHint = CardService.newTextParagraph()
.setText('Find all images in your document and see their sizes.');
actionsSection.addWidget(scanHint);
actionsSection.addWidget(lastResultButton_());
card.addSection(actionsSection);
// Compression Settings section
const settingsSection = CardService.newCardSection()
.setHeader('⚙️ Compression Settings');
// Preset dropdown
const presetDropdown = CardService.newSelectionInput()
.setType(CardService.SelectionInputType.DROPDOWN)
.setTitle('Compression Level')
.setFieldName('preset')
.addItem('🟢 Lossless (Best Quality)', 'lossless', false)
.addItem('🟡 Recommended (Balanced)', 'recommended', true)
.addItem('🔴 Extreme (Smallest Size)', 'extreme', false);
settingsSection.addWidget(presetDropdown);
// Max width input
const maxWidthInput = CardService.newTextInput()
.setFieldName('maxWidth')
.setTitle('Max Width (px)')
.setHint('Leave empty to keep original dimensions. e.g. 1920')
.setValue('');
settingsSection.addWidget(maxWidthInput);
// Compress All button
const compressButton = CardService.newTextButton()
.setText('🗜️ Compress All Images')
.setTextButtonStyle(CardService.TextButtonStyle.FILLED)
.setBackgroundColor('#34A853')
.setOnClickAction(
// No .setParameters() here: the preset and maxWidth widgets in this same
// section are submitted as form inputs, and compressAllImages reads those.
// A hard-coded parameter would override the user's choice.
action_('compressAllImages')
);
settingsSection.addWidget(compressButton);
card.addSection(settingsSection);
// Info section
const infoSection = CardService.newCardSection()
.setHeader('ℹ️ About');
const infoText = CardService.newTextParagraph()
.setText(
'• Supports: JPG, PNG, WebP, GIF, TIFF\n' +
'• Max file size: 25MB per image\n' +
'• Images are processed securely and deleted immediately\n' +
'• micompress.com\n\n' +
'Google Docs™ and Google Slides™ are trademarks of Google LLC'
);
infoSection.addWidget(infoText);
card.addSection(infoSection);
return card.build();
}
// ===== IMAGE LIST CARD =====
/**
* Build the image list card after scanning
* @param {Array} images - Array of image objects
* @param {string} hostApp - 'docs' or 'slides'
*/
function buildImageListCard_(images, hostApp) {
const card = CardService.newCardBuilder();
// Summary section
const summarySection = CardService.newCardSection();
let totalSize = 0;
images.forEach(function(img) {
totalSize += img.size || 0;
});
const summaryText = CardService.newTextParagraph()
.setText(
'📊 Scan Results\n\n' +
'• Found ' + images.length + ' image(s)\n' +
'• Total size: ' + formatSize_(totalSize) + '\n' +
'• Host: ' + (hostApp === 'docs' ? 'Google Docs™' : 'Google Slides™')
);
summarySection.addWidget(summaryText);
card.addSection(summarySection);
// Image list section
const listSection = CardService.newCardSection()
.setHeader('🖼️ Images Found');
// Show up to 20 images to avoid Card size limits
const maxShow = Math.min(images.length, 20);
for (let i = 0; i < maxShow; i++) {
const img = images[i];
const widget = CardService.newDecoratedText()
.setText(img.name || ('Image ' + (i + 1)))
.setBottomLabel(formatSize_(img.size || 0) + ' • ' + (img.contentType || 'unknown'))
.setWrapText(false);
if (img.width && img.height) {
widget.setTopLabel(img.width + '×' + img.height + 'px');
}
listSection.addWidget(widget);
}
if (images.length > maxShow) {
const moreText = CardService.newTextParagraph()
.setText('...and ' + (images.length - maxShow) + ' more image(s)');
listSection.addWidget(moreText);
}
card.addSection(listSection);
// Compress section with settings
const compressSection = CardService.newCardSection()
.setHeader('🗜️ Compress');
const presetDropdown = CardService.newSelectionInput()
.setType(CardService.SelectionInputType.DROPDOWN)
.setTitle('Compression Level')
.setFieldName('preset')
.addItem('🟢 Lossless', 'lossless', false)
.addItem('🟡 Recommended', 'recommended', true)
.addItem('🔴 Extreme', 'extreme', false);
compressSection.addWidget(presetDropdown);
const maxWidthInput = CardService.newTextInput()
.setFieldName('maxWidth')
.setTitle('Max Width (px)')
.setHint('Optional. e.g. 1920')
.setValue('');
compressSection.addWidget(maxWidthInput);
const compressButton = CardService.newTextButton()
.setText('🗜️ Compress All ' + images.length + ' Images')
.setTextButtonStyle(CardService.TextButtonStyle.FILLED)
.setBackgroundColor('#34A853')
.setOnClickAction(
// See the note on the homepage card's button — the dropdown and text
// input in this section supply preset/maxWidth as form inputs.
action_('compressAllImages')
);
compressSection.addWidget(compressButton);
if (hostApp === 'slides') {
compressSection.addWidget(CardService.newTextParagraph()
.setText('Google Slides reloads the deck while the images are replaced, ' +
'which can reset this panel. If it does, click “Show last result”.'));
}
compressSection.addWidget(lastResultButton_());
card.addSection(compressSection);
return card.build();
}
// ===== RESULTS CARD =====
/**
* Build the compression results card
*
* @param {Array} results - Per-image outcomes from this batch only
* @param {Object} totals - Running totals across every batch so far
* ({original, compressed, success, unchanged, fail})
* @param {Object|null} continuation - Set when the time budget ran out:
* {remaining, parameters} for the "Continue" button
* @param {string} [note] - Optional line shown under the summary
*/
function buildResultsCard_(results, totals, continuation, note) {
const card = CardService.newCardBuilder();
// Summary
const summarySection = CardService.newCardSection();
const totalSaved = totals.original - totals.compressed;
const totalPercent = totals.original > 0 ? Math.round((totalSaved / totals.original) * 100) : 0;
const emoji = continuation ? '⏳' : totalPercent >= 50 ? '🎉' : totalPercent >= 20 ? '✅' : '👍';
const title = continuation ? 'Compression in progress' : 'Compression Complete!';
const summaryText = CardService.newTextParagraph()
.setText(
'' + emoji + ' ' + title + '\n\n' +
'• Images compressed: ' + totals.success + '\n' +
(totals.unchanged > 0 ? '• Already optimized (left as is): ' + totals.unchanged + '\n' : '') +
(totals.fail > 0 ? '• Failed: ' + totals.fail + '\n' : '') +
'• Original total: ' + formatSize_(totals.original) + '\n' +
'• Compressed total: ' + formatSize_(totals.compressed) + '\n' +
'• Saved: ' + formatSize_(totalSaved) + ' (' + totalPercent + '%)'
);
summarySection.addWidget(summaryText);
if (note) {
summarySection.addWidget(CardService.newTextParagraph().setText('' + note + ''));
}
card.addSection(summarySection);
if (continuation) {
const continueSection = CardService.newCardSection();
continueSection.addWidget(CardService.newTextParagraph()
.setText('' + continuation.remaining + ' image(s) left. Google limits each run to 30 seconds, so large documents are compressed in batches.'));
continueSection.addWidget(CardService.newTextButton()
.setText('▶️ Continue (' + continuation.remaining + ' left)')
.setTextButtonStyle(CardService.TextButtonStyle.FILLED)
.setBackgroundColor('#34A853')
.setOnClickAction(
action_('compressAllImages')
.setParameters(continuation.parameters)
));
card.addSection(continueSection);
}
// Individual results (this batch)
if (results.length > 0) {
const detailSection = CardService.newCardSection()
.setHeader('📋 Details');
const maxShow = Math.min(results.length, 15);
for (let i = 0; i < maxShow; i++) {
const r = results[i];
let widget;
if (r.error) {
widget = CardService.newDecoratedText()
.setText('❌ ' + r.name)
.setBottomLabel(r.error)
.setWrapText(true);
} else if (r.unchanged) {
widget = CardService.newDecoratedText()
.setText('➖ ' + r.name)
.setBottomLabel(formatSize_(r.originalSize) + ' • already optimized')
.setWrapText(false);
} else {
widget = CardService.newDecoratedText()
.setText('✅ ' + r.name)
.setBottomLabel(
formatSize_(r.originalSize) + ' → ' + formatSize_(r.compressedSize) +
' (-' + r.savedPercent + '%)'
)
.setWrapText(false);
}
detailSection.addWidget(widget);
}
if (results.length > maxShow) {
detailSection.addWidget(CardService.newTextParagraph()
.setText('...and ' + (results.length - maxShow) + ' more'));
}
card.addSection(detailSection);
}
// Action buttons
const actionSection = CardService.newCardSection();
const homeButton = CardService.newTextButton()
.setText('🏠 Back to Home')
.setTextButtonStyle(continuation ? CardService.TextButtonStyle.TEXT : CardService.TextButtonStyle.FILLED)
.setOnClickAction(
action_('goHome')
);
if (!continuation) homeButton.setBackgroundColor('#4285F4');
actionSection.addWidget(homeButton);
const websiteButton = CardService.newTextButton()
.setText('🌐 Visit MiCompress')
.setTextButtonStyle(CardService.TextButtonStyle.TEXT)
.setOpenLink(
CardService.newOpenLink()
.setUrl('https://micompress.com')
.setOpenAs(CardService.OpenAs.FULL_SIZE)
);
actionSection.addWidget(websiteButton);
card.addSection(actionSection);
return card.build();
}
// ===== HELPER CARDS =====
/**
* Build a "no images found" card
*/
function buildNoImagesCard_() {
const card = CardService.newCardBuilder();
const section = CardService.newCardSection();
const text = CardService.newTextParagraph()
.setText(
'📭 No Images Found\n\n' +
'This document doesn\'t contain any images to compress.\n\n' +
'Try inserting some images first, then run the scan again.'
);
section.addWidget(text);
const homeButton = CardService.newTextButton()
.setText('🏠 Back to Home')
.setTextButtonStyle(CardService.TextButtonStyle.TEXT)
.setOnClickAction(
action_('goHome')
);
section.addWidget(homeButton);
card.addSection(section);
return card.build();
}
/**
* Build an error card
*/
function buildErrorCard_(message) {
const card = CardService.newCardBuilder();
const section = CardService.newCardSection();
const text = CardService.newTextParagraph()
.setText(
'⚠️ Error\n\n' + message + '\n\n' +
'If this persists, please contact support at ' +
'micompress.com/contact'
);
section.addWidget(text);
const retryButton = CardService.newTextButton()
.setText('🔄 Try Again')
.setTextButtonStyle(CardService.TextButtonStyle.TEXT)
.setOnClickAction(
action_('goHome')
);
section.addWidget(retryButton);
card.addSection(section);
return card.build();
}