Last active
December 19, 2024 20:51
-
-
Save gaabora/cf092ab524933309ec3350dcf491f991 to your computer and use it in GitHub Desktop.
Webflow Variables Export Import Magic
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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
there is no, this script made for any userscript browser extension, like Violentmonkey