Skip to content

Instantly share code, notes, and snippets.

@gaabora
Last active December 19, 2024 20:51
Show Gist options
  • Save gaabora/cf092ab524933309ec3350dcf491f991 to your computer and use it in GitHub Desktop.
Save gaabora/cf092ab524933309ec3350dcf491f991 to your computer and use it in GitHub Desktop.
Webflow Variables Export Import Magic
// ==UserScript==
// @name Webflow Variables CSV Export Import Magic
// @namespace http://tampermonkey.net/
// @version 0.6
// @description try to take over the world!
// @author gaabora
// @match https://webflow.com/design/*
// @match https://*.design.webflow.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=webflow.com
// @grant none
// ==/UserScript==
(function () {
'use strict';
class WebflowMagic_VariableCSVExportImport {
TITLE = 'WebflowMagic_VariableCSVExportImport'
UNDEFINED_TYPE_PLACEHOLDER = 'ERROR: UNDEFINED TYPE!'
localOptions = {}
defaultOptions = {
VAR_MENU_BTN_SELECTOR: '[data-automation-id="left-sidebar-variables-button"]',
INSERT_NEAR_ELEMENT_SELECTOR: 'button[data-automation-id="add-variable-button"]',
QUEUE_ITEM_PROCESSING_TIMEOUT_MS: 100,
CSV_SEPARATOR: ',',
CSV_LINEBREAK: '\n',
CSV_HEADERS: [
{ name: 'id', encoder: (varData) => varData.id },
{ name: 'type', encoder: (varData) => varData.type },
{ name: 'name', encoder: (varData) => varData.name },
{
name: 'value',
encoder: (varData) => {
if (varData.value.type === 'ref') return '';
switch (varData.type) {
case 'length':
return varData.value.value.value;
case 'color':
case 'font-family':
return varData.value.value;
default:
return this.UNDEFINED_TYPE_PLACEHOLDER;
}
}
},
{
name: 'unit',
encoder: (varData) => {
if (varData.value.type === 'ref') return '';
switch (varData.type) {
case 'length':
return varData.value.value.unit;
case 'color':
case 'font-family':
return '';
default:
return this.UNDEFINED_TYPE_PLACEHOLDER;
}
}
},
{ name: 'ref', encoder: (varData) => (varData.value.type === 'ref') ? varData.value.value.variableId : '' },
{ name: 'deleted', encoder: (varData) => varData.deleted ? '1' : '' },
],
PARSE_CSV_DATA: (csvData) => {
const tryConvertToNumber = (input) => {
if (typeof input === 'string') {
if (/^[0-9]+$/.test(input)) {
return parseInt(input, 10);
} else if (/^[0-9]+([\.,][0-9]+)?$/.test(input)) {
return parseFloat(input);
}
}
return input;
}
const type = (csvData.ref) ? 'ref' : csvData.type;
const value = (type === 'ref')
? { variableId: csvData.ref }
: (csvData.type === 'length')
? { value: tryConvertToNumber(csvData.value), unit: csvData.unit }
: csvData.value
;
return {
id: csvData.id,
type: csvData.type,
value: { type, value },
name: csvData.name,
deleted: csvData.deleted ? true : false,
}
},
VALIDATE_VAR(importVar, existingVar) {
if (!importVar.name) return `Variable name is empty! [${importVar.id}]`;
if (!importVar.type) return `Variable type is empty! [${importVar.name}]`;
if (!['length','color','font-family'].includes(importVar.type)) return `Variable type '${importVar.type}' is invalid! [${importVar.name}]`;
if (!['length','color','font-family','ref'].includes(importVar.value.type)) return `Variable type '${importVar.value.type}' is invalid! [${importVar.name}]`;
if (importVar.value.type === 'ref') {
if (!importVar.value.value.variableId) return `Variable ref is empty! [${importVar.name}]`;
} else if (importVar.value.type === 'length') {
if (!['px','em','rem','vw','vh','svh','svw','ch'].includes(importVar.value.value.unit)) return `Variable unit '${importVar.value.value.unit}' is invalid! [${importVar.name}]`;
if (typeof importVar.value.value.value !== 'number') return `Variable value '${JSON.stringify(importVar) ?? importVar.value.value.value}' is not valid number! [${importVar.name}]`;
} else {
if (!importVar.value.value) return `Variable value is empty! [${importVar.name}]`;
}
if (existingVar && importVar.type != existingVar.type) return `Variable type change (${existingVar.type} > ${importVar.type}) not allowed! [${importVar.name}]`;
return true;
},
GET_VAR_CHANGES(importVar, existingVar) {
const changes = [];
if (importVar.name !== existingVar.name) {
changes.push(`name: ${JSON.stringify(importVar.name)} > ${JSON.stringify(existingVar.name)}`);
}
if (importVar.value.value?.variableId !== existingVar.value.value?.variableId) {
changes.push(`ref: ${JSON.stringify(importVar.value.value?.variableId)} > ${JSON.stringify(existingVar.value.value?.variableId)}`);
}
if (importVar.value.type === existingVar.value.type && importVar.value.type !== 'ref') switch (importVar.type) {
case 'length':
if (importVar.value.value?.unit !== existingVar.value.value?.unit) changes.push(`unit: ${JSON.stringify(importVar.value.value?.unit)} > ${JSON.stringify(existingVar.value.value?.unit)}`);
if (importVar.value.value?.value !== existingVar.value.value?.value) changes.push(`value: ${JSON.stringify(importVar.value.value?.value)} > ${JSON.stringify(existingVar.value.value?.value)}`);
break;
case 'color':
case 'font-family':
if (importVar.value.value !== existingVar.value.value) changes.push(`value: ${JSON.stringify(importVar.value.value)} > ${JSON.stringify(existingVar.value.value)}`);
break;
default:
break;
}
return changes;
}
}
siteName = null;
fileInput = null;
constructor(options) {
if (options && typeof options === 'object') {
Object.entries(this.defaultOptions).forEach(([option, value]) => {
this.localOptions[option] = options[option] == null ? value : options[option];
});
} else {
this.localOptions = { ...this.defaultOptions };
}
}
addLoadingSpinner() {
this.loadingEl = document.getElementById('WebflowMagicLoading');
if (!this.loadingEl) {
this.loadingEl = document.createElement('div');
this.loadingEl.id = 'WebflowMagicLoading';
const faviconImg = document.createElement('img');
faviconImg.src = '/favicon.ico';
this.loadingEl.appendChild(faviconImg);
document.body.appendChild(this.loadingEl);
var style = document.createElement('style');
style.innerHTML = `
#WebflowMagicLoading {
display: none;
width: 32px;
height: 32px;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: 1s linear 0s infinite normal none running WebflowMagicLoadingSpin;
z-index: 99999;
}
#WebflowMagicLoading img { width: 100%; height: 100%; }
@keyframes WebflowMagicLoadingSpin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
}
}
setLoadingState(state) {
this.loadingEl.style.display = state ? 'block' : 'none';
}
waitForElement(selector, retryTimeout = 1000, giveupTimeout = 30000) {
return new Promise((resolve, reject) => {
let timeElapsed = 0;
const intervalId = setInterval(() => {
const targetElement = document.querySelector(selector);
if (targetElement) {
clearInterval(intervalId);
resolve(targetElement)
} else if (timeElapsed >= giveupTimeout) {
clearInterval(intervalId);
reject(new Error(`Gave up waiting for '${selector}'`));
}
timeElapsed += retryTimeout;
}, retryTimeout);
});
}
async init() {
const varMenuBtnEl = await this.waitForElement(this.localOptions.VAR_MENU_BTN_SELECTOR);
this.addLoadingSpinner();
this.siteName = this.getSiteName();
if (!this.siteName) {
console.error(`${this.TITLE} getSiteName FAILED. Probably there was some update in wf.`);
return;
}
this.createImportCSVFileInput();
varMenuBtnEl.addEventListener('click', () => {
this.addCSVExportImportButtons();
});
}
getSiteName() {
return window.location.pathname.match(/\/design\/(.*)/)?.[1]
?? window.location.hostname.match(/(.*).design.webflow.com/)?.[1];
}
async addCSVExportImportButtons() {
const insertNearEl = await this.waitForElement(this.localOptions.INSERT_NEAR_ELEMENT_SELECTOR);
const importButton = document.createElement('button');
importButton.textContent = 'Import CSV';
importButton.style.color = 'black';
importButton.addEventListener('click', () => {
this.importCSV();
});
const exportButton = document.createElement('button');
exportButton.textContent = 'Export CSV';
exportButton.style.color = 'black';
exportButton.addEventListener('click', () => {
this.exportCSV();
});
const buttonContainer = document.createElement('div');
buttonContainer.style.display = 'flex';
buttonContainer.appendChild(importButton);
buttonContainer.appendChild(exportButton);
insertNearEl.parentNode.insertBefore(buttonContainer, insertNearEl.nextSibling);
}
async getVariables() {
const responseData = await fetch(`/api/sites/${this.siteName}/dom`);
const responseJson = await responseData.json();
return responseJson.variables;
}
async exportCSV() {
this.setLoadingState(true);
const variables = await this.getVariables();
const csvLines = [this.localOptions.CSV_HEADERS.map(el => el.name).join(this.localOptions.CSV_SEPARATOR)];
variables.forEach(varData => {
csvLines.push(this.localOptions.CSV_HEADERS.map(el => el.encoder(varData)).join(this.localOptions.CSV_SEPARATOR));
});
this.downloadCSV(csvLines.join(this.localOptions.CSV_LINEBREAK));
this.setLoadingState(false);
}
downloadCSV(csvContent) {
var link = document.createElement("a");
link.setAttribute('href', 'data:text/csv;charset=utf-8,' + encodeURIComponent(csvContent));
link.setAttribute('download', `${this.siteName}_vars.csv`);
document.body.appendChild(link);
link.click();
link.remove();
}
createImportCSVFileInput() {
this.fileInput = document.createElement('input');
this.fileInput.type = 'file';
this.fileInput.style.display = 'none';
document.body.appendChild(this.fileInput);
this.fileInput.addEventListener('change', (event) => {
this.handleFileSelection(event);
});
}
importCSV() {
this.fileInput.click();
}
handleFileSelection(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
this.handleImportFile(e.target.result);
};
reader.readAsText(file);
}
}
async handleImportFile(csvContent) {
this.setLoadingState(true);
const parsedCsvVarData = this.parseCSV(csvContent);
const importVariables = parsedCsvVarData.map(csvData => this.localOptions.PARSE_CSV_DATA(csvData));
const variables = await this.getVariables();
const log = [];
const findDuplicatesByKey = (array, key, lineNumberOffset = 0, skipEmptyValues = true) => {
const seen = new Map();
const duplicates = [];
array.forEach((item, index) => {
const keyValue = item[key];
if (skipEmptyValues && !keyValue) return;
if (seen.has(keyValue)) {
seen.get(keyValue).push(index + lineNumberOffset);
} else {
seen.set(keyValue, [index + lineNumberOffset]);
}
});
seen.forEach((indexes, value) => {
if (indexes.length > 1) {
duplicates.push(`${value}: ${indexes.join(',')}`);
}
});
return duplicates;
}
const findInvalidRefs = (array, lineNumberOffset = 0) => {
const activeIds = array.map(el => el.id).filter(el => el);
const problems = [];
array.forEach((item, index) => {
if (item.deleted) return;
if (!item.ref) return;
if (!activeIds.includes(item.ref)) {
problems.push(`${item.ref} (${index + lineNumberOffset})`);
}
});
return problems;
}
const LINE_NUMBER_OFFSET = 2;
const dupIdCsvLines = findDuplicatesByKey(importVariables.filter(el => !el.deleted), 'id', LINE_NUMBER_OFFSET);
const dupNameCsvLines = findDuplicatesByKey(importVariables.filter(el => !el.deleted), 'name', LINE_NUMBER_OFFSET);
const invalidRefCsvLines = findInvalidRefs(importVariables.map(el => ({ id: el.id, ref: el.value?.value?.variableId, deleted: el.deleted })), LINE_NUMBER_OFFSET);
if (dupIdCsvLines.length) log.push(`Lines with duplicate ids found: ${dupIdCsvLines.join('; ')}`)
if (dupNameCsvLines.length) log.push(`Lines with duplicate names found: ${dupNameCsvLines.join('; ')}`)
if (invalidRefCsvLines.length) log.push(`Lines with invalid refs found: ${invalidRefCsvLines.join('; ')}`)
const queue = [];
importVariables.forEach((importVar, idx) => {
const existingVar = variables.find((v) => v.id === importVar.id);
if (existingVar) {
if (!existingVar.deleted && importVar.deleted) {
queue.push({action: 'delete', importVar, idx });
} else {
const result = this.localOptions.VALIDATE_VAR(importVar, existingVar);
if (result !== true) {
log.push(result);
return;
}
const changes = this.localOptions.GET_VAR_CHANGES(importVar, existingVar);
if (changes.length) {
console.log(changes);
queue.push({action: 'update', importVar, existingVar, idx });
}
}
} else {
if (importVar.deleted) return;
queue.push({action: 'create', importVar, idx });
}
})
if (log.length) {
this.notify(log.join("\n"));
} else {
// debugger
let response, json;
for (const el of queue) {
switch (el.action) {
case 'create':
response = await this.createVar(this.applyChanges(el.importVar, { id: (el.importVar.id) ? el.importVar.id : `variable-magic-${crypto.randomUUID()}` }));
json = await response.json();
if (!response.ok) el.err = json.err;
console.error({response, json});
break;
case 'update':
response = await this.updateVar(this.applyChanges(el.existingVar, el.importVar));;
json = await response.json();
if (!response.ok) el.err = json.err;
console.error({response, json});
break;
case 'delete':
response = await this.deleteVar(el.importVar);;
json = await response.json();
if (!response.ok) el.err = json.err;
console.error({response, json});
break;
}
await new Promise(resolve => setTimeout(resolve, this.localOptions.QUEUE_ITEM_PROCESSING_TIMEOUT_MS));
}
const errors = queue.filter(el => el.err).map(el => `${el.action} ${el.importVar.id}: ${el.err}`);
if (errors.length) {
this.notify(errors.join("\n"));
}
}
this.setLoadingState(false);
}
notify(message) {
alert(message);
}
parseCSV(csvString) {
const lines = csvString.trim().split('\n');
const headers = lines[0].split(',');
const result = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',');
const obj = {};
for (let j = 0; j < headers.length; j++) {
const header = headers[j].trim();
const value = values[j]
if (header) obj[header] = (typeof value === 'string') ? value.trim() : value;
}
result.push(obj);
}
return result;
}
applyChanges(original, changes) {
const result = { ...original };
if (changes.id) result.id = changes.id;
if (changes.name) result.name = changes.name;
if (changes.deleted) result.deleted = changes.deleted;
if (changes.value) result.value = changes.value;
return result;
/*
interface WfVarRefValue {
type: "ref"
value: { variableId: string }
}
interface WfVarColor {
id: number,
name: string,
type: 'color'
value: {
type: 'color'
value: string
} | WfVarRefValue
deleted: boolean,
}
interface WfVarLength {
id: number,
name: string,
type: 'length'
value: {
type: 'length'
value: {
value: number,
unit: 'px' | 'em' | 'rem' | 'vw' | 'vh' | 'svh' | 'svw' | 'ch'
} | WfVarRefValue
deleted: boolean,
}
interface WfVarFontfamily {
id: number,
name: string,
type: 'font-family'
value: {
type: 'font-family'
value: string
} | WfVarRefValue
deleted: boolean,
}
*/
}
createVar(varData) {
return this.wfQuery(`/api/sites/${this.siteName}/variables`, { method: 'POST', body: JSON.stringify(varData) });
}
updateVar(varData) {
return this.wfQuery(`/api/sites/${this.siteName}/variables/${varData.id}`, { method: 'PATCH', body: JSON.stringify(varData) });
}
deleteVar(varData) {
return this.wfQuery(`/api/sites/${this.siteName}/variables/${varData.id}`, { method: 'DELETE' });
}
wfQuery(url, params) {
const csrf = document.querySelector('meta[name="_csrf"]').content;
const queryPartams = {
method: params.method ?? 'POST',
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'content-type': 'application/json',
'X-XSRF-Token': csrf
},
redirect: 'error',
referrerPolicy: 'strict-origin-when-cross-origin',
}
if (params.method != 'GET') queryPartams.body = params.body ?? '';
return fetch(url, queryPartams);
}
}
window.WMVCSVEI = new WebflowMagic_VariableCSVExportImport();
window.WMVCSVEI.init();
})();
@ajuhpark
Copy link

Hi, I ran into this while looking for a solution to adding alias variables in Webflow. Is there a resource on how I can format my variables and import them into Webflow using this code? Thank you.

@gaabora
Copy link
Author

gaabora commented Dec 19, 2024

there is no, this script made for any userscript browser extension, like Violentmonkey

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment