// Shows the UI
figma.showUI(__html__, { width: 400, height: 600 });
// Helper to log to UI
function log(msg, level = 'info') {
figma.ui.postMessage({ type: 'log', message: msg, level: level });
}
// Helper to find a page or create it
function getOrCreatePage(name) {
let page = figma.root.children.find(p => p.name === name);
if (!page) {
page = figma.createPage();
page.name = name;
log(`Created new page: "${name}"`, 'info');
}
return page;
}
// Helper to find a component by name (recursive search or search in local components)
// Note: In a real plugin, you might need to search libraries, but here we assume local components
// or components available in the file.
async function findComponent(name) {
// Search local components first
// This is a simplified search. In a large file, this might be slow.
// We can optimize by caching components map.
const localComponents = figma.root.findAll(n => n.type === 'COMPONENT' || n.type === 'COMPONENT_SET');
for (const node of localComponents) {
if (node.name === name) return node;
if (node.type === 'COMPONENT_SET') {
const variant = node.children.find(c => c.name === name); // This might need robust variant matching
if (variant) return variant;
}
}
return null;
}
// Helper to load image
async function createImagePaint(imageBytes, scaleMode = 'FILL') {
const image = figma.createImage(imageBytes);
return {
type: 'IMAGE',
scaleMode: scaleMode,
imageHash: image.hash,
};
}
async function processRows(mode, data, images) {
const { rows, metadata, headers } = data;
log(`Starting ${mode === 'create' ? 'Automation' : 'Image Replacement'}...`, 'info');
log(`Received ${rows.length} rows and ${Object.keys(images).length} images.`, 'info');
const pageY = {
'Ports': 0,
'ATF': 0,
'BTF': 0,
'BTF_Banner': 0
};
const SPACING = 50;
// Store created/updated instances to export later
const processedInstances = [];
// Helper to generate name
// Format: Cycle_CodeName_ProductName_Color_Variation_FeatureAssociation_BTF/ATF_Dimension.PNG
const generateName = (row, type, dimensionOverride = null) => {
const parts = [];
// 1. Cycle
if (metadata.cycle) parts.push(metadata.cycle);
// 2. CodeName
if (metadata.codeName) parts.push(metadata.codeName);
// 3. ProductName
if (metadata.productName) parts.push(metadata.productName);
// 4. Color
if (metadata.color) parts.push(metadata.color);
// 5. Variation
// "if there’s data on C column... add this; if not, omit"
const label = row[0]; // FeatureAssociation
const variation = row[2];
if (variation) {
parts.push(variation);
}
// 6. FeatureAssociation (Col A)
if (label) parts.push(label);
// 7. BTF/ATF
// "id the asset being exported Is from data on column E, it should be “ATF”; all others, “BTF”"
parts.push(type === 'ATF' ? 'ATF' : 'BTF');
// 8. Dimension
if (label === 'Ports') {
parts.push('2000x1000');
} else if (dimensionOverride) {
parts.push(dimensionOverride);
} else {
parts.push('UnknownDim');
}
return parts.join('_');
};
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
// New Column Mapping:
// New Column Mapping (Indices 0-11 based on request):
// A=0: Label (Feature Number)
// C=2: Variation
// F=5: ATF Trigger
// G=6: BTF Trigger
// H=7: BTF_Banner Trigger
// I=8: Image Filename
// J=9: Screen Replacement Filename
// K=10: Overlay Component
// L=11: Template
const label = row[0];
const variation = row[2];
const colATF = row[5]; // ATF (Col F)
const colBTF = row[6]; // BTF (Col G)
const colBTFBanner = row[7]; // BTF_Banner (Col H)
const imageFilename = row[8]; // Image (Col I)
const screenRepFilename = row[9]; // Screen Replacement (Col J)
const overlayName = row[10]; // Overlay (Col K)
const template = row[11]; // Template (Col L)
// Helper to find image data
const getImageData = (name) => {
if (!name) return null;
let key = Object.keys(images).find(k => k === name);
if (!key) key = Object.keys(images).find(k => k.includes(name));
return key ? images[key] : null;
};
const imageBytes = getImageData(imageFilename);
const screenRepBytes = getImageData(screenRepFilename);
// If replacing images, we generally need at least one image to do something,
// or we might be just updating overlays?
// User: "only with the images provided... If there's a screen replacement... grab file name".
// If NO main image but YES screen replacement, we proceed? Yes.
if (mode === 'replace' && !imageBytes && !screenRepBytes) {
continue;
}
// Determine Scale Mode
// "If template (Col L) is RS or LF -> FIT. All others -> FILL"
let scaleMode = 'FILL';
if (template === 'RS' || template === 'LF') {
scaleMode = 'FIT';
}
// Helper to process a row item
const processItem = async (pageName, componentBaseName, type, dimension, isPorts = false) => {
const page = getOrCreatePage(pageName);
let targetInstance = null;
let instanceName = "";
if (isPorts) {
// Ports Logic: Find existing "PB3.0_Ports" (or already renamed one)
const generatedName = generateName(row, 'BTF');
// 1. Try to find if we already processed/renamed it
targetInstance = page.findOne(n => n.name === generatedName);
// 2. If not, find the template "PB3.0_Ports"
if (!targetInstance) {
targetInstance = page.findOne(n => n.name === 'PB3.0_Ports');
}
if (targetInstance) {
if (mode === 'create') {
targetInstance.name = generatedName; // Rename for export consistency
log(`Found/Renamed PB3.0_Ports: ${generatedName}`, 'info');
} else {
log(`Found PB3.0_Ports: ${targetInstance.name}`, 'info');
}
processedInstances.push(targetInstance);
} else {
log(`Warning: Target 'PB3.0_Ports' not found on page ${pageName}`, 'warning');
return;
}
instanceName = targetInstance.name;
} else {
// Standard Logic for other items
instanceName = generateName(row, type, dimension);
if (mode === 'replace') {
// Find existing instance/frame/group by name
targetInstance = page.findOne(n => n.name === instanceName);
if (targetInstance) {
log(`Found existing asset: ${instanceName}`, 'info');
processedInstances.push(targetInstance);
} else {
log(`Warning: Asset "${instanceName}" not found for replacement.`, 'warning');
return;
}
} else {
// Create Mode
const component = await findComponent(componentBaseName);
if (!component) {
log(`Error: Component "${componentBaseName}" not found.`, 'error');
return;
}
let instance = component.createInstance();
instance = instance.detachInstance();
instance.name = instanceName;
log(`Created instance: ${instanceName}`, 'success');
// Fill Image
if (imageBytes) {
const imageNode = instance.findOne(n => n.name === "Image");
if (imageNode) {
imageNode.fills = [await createImagePaint(imageBytes, scaleMode)];
log(` - Filled image (Mode: ${scaleMode})`, 'info');
}
}
// Overlay Logic
if (overlayName) {
const overlayComp = await findComponent(overlayName);
if (overlayComp) {
let overlayInst = overlayComp.createInstance();
overlayInst = overlayInst.detachInstance();
const imageNode = instance.findOne(n => n.name === "Image");
if (imageNode) {
// ... (Overlay placement logic) ...
let currentNode = imageNode;
let dx = 0;
let dy = 0;
while (currentNode && currentNode.id !== instance.id) {
dx += currentNode.x;
dy += currentNode.y;
currentNode = currentNode.parent;
}
// Scaling
const maxW = instance.width * 0.20;
if (overlayInst.width > maxW) {
const scale = maxW / overlayInst.width;
overlayInst.resize(maxW, overlayInst.height * scale);
}
// Centering
const centerX = dx + (imageNode.width - overlayInst.width) / 2;
const centerY = dy + (imageNode.height - overlayInst.height) / 2;
overlayInst.x = centerX;
overlayInst.y = centerY;
// Group
page.appendChild(instance);
page.appendChild(overlayInst);
const group = figma.group([instance, overlayInst], page);
group.name = instanceName;
group.y = pageY[pageName];
pageY[pageName] += group.height + SPACING;
processedInstances.push(group);
log(` - Added overlay and grouped`, 'success');
return; // Done for this item
}
}
}
// If no overlay
page.appendChild(instance);
instance.y = pageY[pageName];
pageY[pageName] += instance.height + SPACING;
processedInstances.push(instance);
targetInstance = instance;
}
}
// Update Main Image
if (imageBytes && targetInstance) {
// Search for "Image" layer. Might be direct child or inside a group/frame.
const imageNode = targetInstance.findOne(n => n.name === "Image");
if (imageNode) {
imageNode.fills = [await createImagePaint(imageBytes, scaleMode)];
// Center logic (Ports only - from previous code)
if (isPorts && imageNode.parent && imageNode.parent.type === 'FRAME') {
const parent = imageNode.parent;
imageNode.x = (parent.width - imageNode.width) / 2;
imageNode.y = (parent.height - imageNode.height) / 2;
}
log(` - Updated Main Image for ${instanceName}`, 'success');
} else {
log(` - Warning: "Image" layer not found in ${instanceName}`, 'warning');
}
}
// Update Screen Replacement
if (screenRepBytes && targetInstance) {
// "place it on Figma on top of all the main image element"
// Strategy: Find "Image" node. Create new Rectangle/Frame named "ScreenReplacement"
// same size/pos as "Image" (or fitting?), insert it AFTER "Image" in parent so it renders on top.
const imageNode = targetInstance.findOne(n => n.name === "Image");
if (imageNode) {
const parent = imageNode.parent;
if (parent) {
// Check if we already have a replacement layer to update
let screenLayer = parent.children.find(c => c.name === "ScreenReplacement");
if (!screenLayer) {
// Create new
screenLayer = figma.createRectangle();
screenLayer.name = "ScreenReplacement";
parent.appendChild(screenLayer); // Appends to end (top)
// Match dimensions/position of Image node
screenLayer.resize(imageNode.width, imageNode.height);
screenLayer.x = imageNode.x;
screenLayer.y = imageNode.y;
// Ensure it is strictly above ImageNode in Z-order
// (parent.appendChild puts it at top, which is fine, but let's be safe if other things exist)
// If we want it *just* on top of Image, we could use insertChild.
// But "on top of all the main image" implies potentially covering it.
}
screenLayer.fills = [await createImagePaint(screenRepBytes, "FIT")]; // Using FIT for screens usually? default to FIT or FILL? User didnt specify. FIT is safer for screens.
log(` - Applied Screen Replacement for ${instanceName}`, 'success');
}
} else {
log(` - Warning: Cannot do Screen Replacement, "Image" node not found.`, 'warning');
}
}
// Handle Overlay (Only Create Mode usually, per previous logic "does not create instances" in replace)
// If Create Mode AND Overlay exists
if (mode === 'create' && overlayName && !isPorts) { // Ports didnt have overlay logic before
const overlayComp = await findComponent(overlayName);
if (overlayComp) {
const overlayInst = overlayComp.createInstance();
const detachedOverlay = overlayInst.detachInstance(); // Detach per previous logic pattern
// Place overlay logic (centering over "Image" node)
const imageNode = targetInstance.findOne(n => n.name === "Image");
if (imageNode) {
// Calculate position relative to Page is tricky if we don't group first.
// But previous logic grouped them.
// Let's adopt previous logic: Group [Instance, Overlay]
// We need to calculate absolute positions?
// Or just center overlay over image node?
// Scaling: Max 20% of instance width
const maxW = targetInstance.width * 0.20;
if (detachedOverlay.width > maxW) {
const scale = maxW / detachedOverlay.width;
detachedOverlay.resize(maxW, detachedOverlay.height * scale);
}
// Find global pos of ImageNode relative to targetInstance
// imageNode is inside targetInstance.
let dx = 0;
let dy = 0;
let curr = imageNode;
while (curr && curr.id !== targetInstance.id) {
dx += curr.x;
dy += curr.y;
curr = curr.parent;
}
detachedOverlay.x = targetInstance.x + dx + (imageNode.width - detachedOverlay.width) / 2;
detachedOverlay.y = targetInstance.y + dy + (imageNode.height - detachedOverlay.height) / 2;
// Add to page
page.appendChild(detachedOverlay);
// Group
const group = figma.group([targetInstance, detachedOverlay], page);
group.name = instanceName;
// Update processing list (remove individual, add group)
const idx = processedInstances.indexOf(targetInstance);
if (idx > -1) processedInstances[idx] = group; // Replace tracker
log(` - Added Overlay: ${overlayName}`, 'success');
}
}
}
};
// Dispatch processing based on columns
log(`Processing Row ${i + 12}: ${label}`, 'info');
// 1. Ports
if (label === 'Ports') {
await processItem('PB3.0_Ports', 'PB3.0_Ports', 'BTF', null, true);
}
// 2. ATF (Col F)
if (colATF) {
let compName = '';
if (['RSC', 'LSC', 'B'].includes(template)) compName = 'ATF_B';
else if (template === 'LS') compName = 'ATF_LS';
else if (template === 'RS') compName = 'ATF_RS';
const dim = colATF;
if (compName) await processItem('ATF', compName, 'ATF', dim);
}
// 3. BTF (Col G)
if (colBTF) {
let compName = '';
if (['RSC', 'LSC', 'B'].includes(template)) compName = 'BTF_B';
else if (template === 'LS') compName = 'BTF_LS';
else if (template === 'RS') compName = 'BTF_RS';
const dim = colBTF;
if (compName) await processItem('BTF', compName, 'BTF', dim);
}
// 4. BTF_Banner (Col H)
if (colBTFBanner) {
let compName = '';
if (colBTFBanner === '1464x250') compName = 'BTFB_Thin';
else if (colBTFBanner === '1464x600') {
if (template === 'B') compName = 'BTFB_B';
else if (template === 'LS') compName = 'BTFB_LS';
else if (template === 'RS') compName = 'BTFB_RS';
else if (template === 'LSC') compName = 'BTFB_LSC';
else if (template === 'RSC') compName = 'BTFB_RSC';
}
const dim = colBTFBanner;
if (compName) await processItem('BTF_Banner', compName, 'BTF', dim);
}
}
// Save created instances ID for export (only if we want to track them)
// In replace mode, we might want to track them too?
figma.clientStorage.setAsync('lastCreatedInstanceIds', processedInstances.map(n => n.id));
figma.ui.postMessage({ type: 'complete' });
}
figma.ui.onmessage = async (msg) => {
if (msg.type === 'run-automation' || msg.type === 'replace-images') {
const mode = msg.type === 'run-automation' ? 'create' : 'replace';
try {
await processRows(mode, msg.data, msg.images);
} catch (err) {
log(`Error: ${err.message}`, 'error');
console.error(err);
figma.ui.postMessage({ type: 'error', message: err.message });
}
}
if (msg.type === 'export-zip') {
try {
log('Preparing assets for export...', 'info');
// New Export Logic: Scan specific pages for assets
// This allows exporting without running automation in the current session
const targetPageNames = ['PB3.0_Ports', 'ATF', 'BTF', 'BTF_Banner'];
const nodesToExport = [];
for (const name of targetPageNames) {
const page = figma.root.children.find(p => p.name === name);
if (page) {
// Get all top-level exportable nodes (Frames, Instances, Groups)
const children = page.children.filter(node =>
node.visible &&
(node.type === 'FRAME' || node.type === 'INSTANCE' || node.type === 'GROUP' || node.type === 'COMPONENT') &&
node.name.toLowerCase() !== 'background reference'
);
nodesToExport.push(...children);
if (children.length > 0) {
log(`Found ${children.length} assets on page "${name}"`, 'info');
}
}
}
if (nodesToExport.length === 0) {
log('No assets found on target pages (Ports, ATF, BTF, BTF_Banner).', 'warning');
return;
}
const exportData = [];
for (const node of nodesToExport) {
// Export as PNG
// Using 2x scale as per previous logic
const bytes = await node.exportAsync({ format: 'PNG', constraint: { type: 'SCALE', value: 2 } });
exportData.push({
name: `${node.name}.png`,
bytes: bytes
});
}
figma.ui.postMessage({ type: 'export-ready', files: exportData });
log(`Sent ${exportData.length} files to UI for zipping.`, 'success');
} catch (err) {
log(`Export Error: ${err.message}`, 'error');
}
}
};