/** * 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(); }