Skip to content

Instantly share code, notes, and snippets.

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();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment