(() => {
'use strict';
const utils = window.WorkflowModelUtils;
const registry = window.WorkflowModels;
if (!utils || !registry) {
console.error('WorkflowModelUtils / WorkflowModels must load before Convert adapter.');
return;
}
function normalizeConversionFormat(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '');
}
const CONVERSION_CIF_FORMATS = new Set(['cif', 'mmcif']);
const CONVERSION_SEQUENCE_FORMATS = new Set(['fasta', 'fa', 'fna', 'faa', 'fastq', 'gb', 'genbank', 'embl', 'seq', 'txt']);
const CONVERSION_LIGAND_FORMATS = new Set(['sdf', 'mol', 'mol2', 'smiles', 'smi', 'inchi', 'xyz']);
const CONVERSION_TABULAR_FORMATS = new Set(['csv', 'tsv', 'json']);
function getConversionCategory(format) {
const fmt = normalizeConversionFormat(format);
if (!fmt) return 'other';
if (fmt === 'pdb' || fmt === 'ent' || fmt === 'brk' || CONVERSION_CIF_FORMATS.has(fmt)) return 'structure';
if (CONVERSION_SEQUENCE_FORMATS.has(fmt)) return 'sequence';
if (CONVERSION_LIGAND_FORMATS.has(fmt)) return 'ligand';
if (CONVERSION_TABULAR_FORMATS.has(fmt)) return 'tabular';
return 'other';
}
function getConversionFamily(fromFormat, toFormat) {
const fromCat = getConversionCategory(fromFormat);
const toCat = getConversionCategory(toFormat);
if (fromCat === 'structure' && toCat === 'structure') return 'structure_reformat';
if (fromCat === 'sequence' && toCat === 'structure') return 'sequence_to_structure';
if (fromCat === 'structure' && toCat === 'sequence') return 'structure_to_sequence';
if (fromCat === 'ligand' && toCat === 'structure') return 'ligand_to_structure';
if (fromCat === 'structure' && toCat === 'ligand') return 'structure_to_ligand';
if (fromCat === 'ligand' && toCat === 'ligand') return 'ligand_reformat';
if (fromCat === 'tabular' || toCat === 'tabular') return 'tabular_export';
return `${fromCat}_to_${toCat}`;
}
function toBackendStructureFormat(format, fallback = 'pdb') {
const fmt = normalizeConversionFormat(format);
if (!fmt) return fallback;
return CONVERSION_CIF_FORMATS.has(fmt) ? 'cif' : 'pdb';
}
function buildAcceptAttrForFormat(format) {
const fmt = normalizeConversionFormat(format);
switch (fmt) {
case 'pdb':
return '.pdb,.ent,.brk,.txt';
case 'cif':
case 'mmcif':
return '.cif,.mmcif,.txt';
case 'fasta':
case 'fa':
case 'fna':
case 'faa':
return '.fasta,.fa,.fna,.faa,.txt';
case 'fastq':
return '.fastq,.fq,.txt';
case 'gb':
case 'genbank':
return '.gb,.gbk,.genbank,.txt';
case 'embl':
return '.embl,.txt';
case 'seq':
return '.seq,.txt';
case 'txt':
return '.txt';
case 'sdf':
return '.sdf,.txt';
case 'mol':
return '.mol,.txt';
case 'mol2':
return '.mol2,.txt';
case 'smiles':
case 'smi':
return '.smi,.smiles,.txt';
case 'inchi':
return '.inchi,.txt';
case 'xyz':
return '.xyz,.txt';
case 'csv':
return '.csv,.txt';
case 'tsv':
return '.tsv,.txt';
case 'json':
return '.json,.txt';
default:
return '';
}
}
function getFileFormatFromName(name) {
const s = String(name || '').trim();
if (!s) return '';
const lower = s.toLowerCase();
if (lower.endsWith('.tar.gz')) return 'tar.gz';
if (lower.endsWith('.tar.bz2')) return 'tar.bz2';
if (lower.endsWith('.tar.xz')) return 'tar.xz';
const dot = s.lastIndexOf('.');
if (dot <= 0 || dot >= s.length - 1) return '';
return s.slice(dot + 1).toLowerCase();
}
function getConversionLookupFallbackName(fromFormat) {
const fmt = normalizeConversionFormat(fromFormat) || 'pdb';
return `conversion_source.${fmt}`;
}
function getIncomingConversionEdges(editor, nodeId) {
if (!editor?.export) return [];
try {
const exp = editor.export();
const data = exp?.drawflow?.[editor.module || 'Home']?.data || {};
const edges = [];
Object.values(data).forEach((sourceNode) => {
const outputs = sourceNode?.outputs || {};
Object.entries(outputs).forEach(([outputKey, outputData]) => {
const connections = Array.isArray(outputData?.connections) ? outputData.connections : [];
connections.forEach((connection) => {
if (String(connection.node) === String(nodeId)) {
edges.push({
from_node_id: String(sourceNode.id),
to_node_id: String(connection.node),
from_port: outputKey,
to_port: connection.output
});
}
});
});
});
return edges;
} catch {
return [];
}
}
function createDefaultData({ autoName = 'convert_1' } = {}) {
return {
default_name: autoName,
name: autoName,
from_format: 'pdb',
to_format: 'cif',
use_all_upstream_models: false,
file_content: '',
file_name: '',
input_source: '',
file_size_bytes: ''
/*
TEMPORARILY DISABLED UNTIL BACKEND SUPPORTS MORE THAN:
- from
- to
- file_content
- file_name
molecule_kind: 'auto',
sequence_alphabet: 'auto',
structure_mode: 'auto',
output_naming: 'match_input',
preserve_chain_ids: true,
keep_hetero_atoms: true,
extract_first_model_only: true,
sanitize_output: true,
generate_3d_if_needed: false
*/
};
}
function renderFormatOptions(selectedValue) {
const selected = normalizeConversionFormat(selectedValue);
const groups = [
{
label: 'Structure',
items: [
['pdb', 'PDB (.pdb)'],
['cif', 'CIF (.cif)'],
['mmcif', 'mmCIF (.mmcif)']
]
},
{
label: 'Sequence',
items: [
['fasta', 'FASTA (.fasta)'],
['fa', 'FA (.fa)'],
['fna', 'FNA (.fna)'],
['fastq', 'FASTQ (.fastq)'],
['gb', 'GenBank (.gb)'],
['embl', 'EMBL (.embl)'],
['seq', 'SEQ (.seq)'],
['txt', 'TXT (.txt)']
]
},
{
label: 'Small molecule',
items: [
['sdf', 'SDF (.sdf)'],
['mol', 'MOL (.mol)'],
['mol2', 'MOL2 (.mol2)'],
['smiles', 'SMILES (.smiles)'],
['smi', 'SMI (.smi)'],
['inchi', 'InChI (.inchi)'],
['xyz', 'XYZ (.xyz)']
]
},
{
label: 'Table / metadata',
items: [
['csv', 'CSV (.csv)'],
['tsv', 'TSV (.tsv)'],
['json', 'JSON (.json)']
]
}
];
return groups.map((group) => `
`).join('');
}
function renderBody({ nodeId, nodeData, editor }) {
const e = utils.escapeHtml;
const incomingEdges = getIncomingConversionEdges(editor, nodeId);
const hasIncomingUpstream = incomingEdges.length > 0;
return `
`;
}
function setConversionSourceUploadOpen(mount, isOpen, { immediate = false } = {}) {
const wrap = mount.querySelector('[data-conversion-source-wrap]');
if (!wrap) return;
wrap.setAttribute('aria-hidden', isOpen ? 'false' : 'true');
if (isOpen) {
wrap.classList.add('is-open');
if (immediate) {
wrap.style.maxHeight = 'none';
wrap.style.opacity = '1';
wrap.style.transform = 'translateY(0)';
return;
}
wrap.style.overflow = 'hidden';
wrap.style.maxHeight = '0px';
wrap.style.opacity = '0';
wrap.style.transform = 'translateY(-6px)';
requestAnimationFrame(() => {
wrap.style.maxHeight = `${wrap.scrollHeight}px`;
wrap.style.opacity = '1';
wrap.style.transform = 'translateY(0)';
});
const onEnd = (e) => {
if (e.target !== wrap || e.propertyName !== 'max-height') return;
wrap.style.maxHeight = 'none';
wrap.removeEventListener('transitionend', onEnd);
};
wrap.addEventListener('transitionend', onEnd);
return;
}
const startHeight = wrap.scrollHeight;
wrap.style.overflow = 'hidden';
if (immediate || !startHeight) {
wrap.classList.remove('is-open');
wrap.style.maxHeight = '0px';
wrap.style.opacity = '0';
wrap.style.transform = 'translateY(-6px)';
return;
}
wrap.style.maxHeight = wrap.style.maxHeight === 'none'
? `${startHeight}px`
: (wrap.style.maxHeight || `${startHeight}px`);
wrap.style.opacity = '1';
wrap.style.transform = 'translateY(0)';
wrap.offsetHeight;
wrap.classList.remove('is-open');
wrap.style.maxHeight = '0px';
wrap.style.opacity = '0';
wrap.style.transform = 'translateY(-6px)';
}
function bind({ nodeId, node, mount, editor, patchNodeData }) {
const listeners = [];
const controllers = [];
function on(el, type, fn, options) {
if (!el) return;
el.addEventListener(type, fn, options);
listeners.push(() => el.removeEventListener(type, fn, options));
}
function getField(key) {
return mount.querySelector(`[data-field-key="${key}"]`);
}
function patch(patchObj, shouldSave = false) {
patchNodeData(nodeId, patchObj, { saveHistory: shouldSave });
}
function canonicalizeNameField() {
const nameEl = getField('name');
if (!nameEl) return;
const safe = utils.canonicalizeName(nameEl.value || '', { max: 64, fallback: '' });
if (safe !== nameEl.value) {
nameEl.value = safe;
}
patch({ name: safe }, true);
}
function syncSourceFormatUi() {
const fromFormat = normalizeConversionFormat(getField('from_format')?.value || 'pdb');
const block = mount.querySelector('.conversion-source-block');
const fileInput = block?.querySelector('.lookup-drop-input');
const titleEl = block?.querySelector('.drop-zone__title');
const dropZoneEl = block?.querySelector('.drop-zone');
const lookupPanel = block?.querySelector('.vici-lookup');
const accept = buildAcceptAttrForFormat(fromFormat);
if (fileInput) {
if (accept) fileInput.setAttribute('accept', accept);
else fileInput.removeAttribute('accept');
}
if (lookupPanel) {
lookupPanel.dataset.viciMode = getConversionCategory(fromFormat) === 'sequence' ? 'protein' : 'pdb';
}
if (titleEl && !dropZoneEl?.classList.contains('is-filled')) {
const label = (fromFormat || 'source').toUpperCase();
titleEl.textContent = `Drop ${label} file here or click to upload`;
}
}
function syncUpstreamUi({ immediate = false } = {}) {
const useAllField = getField('use_all_upstream_models');
const upstreamWrap = mount.querySelector('[data-conversion-upstream-wrap]');
const incomingEdges = getIncomingConversionEdges(editor, nodeId);
const hasIncomingUpstream = incomingEdges.length > 0;
setConversionSourceUploadOpen(mount, !hasIncomingUpstream, { immediate });
if (upstreamWrap) {
upstreamWrap.style.display = hasIncomingUpstream ? '' : 'none';
upstreamWrap.setAttribute('aria-hidden', hasIncomingUpstream ? 'false' : 'true');
}
if (useAllField) {
useAllField.disabled = !hasIncomingUpstream;
if (!hasIncomingUpstream && useAllField.checked) {
useAllField.checked = false;
patch({ use_all_upstream_models: false }, true);
}
}
}
function wireTextField(key) {
const el = getField(key);
if (!el) return;
on(el, 'input', () => {
patch({ [key]: el.value }, false);
});
on(el, 'change', () => {
patch({ [key]: el.value }, true);
});
}
function wireCheckboxField(key, afterChange) {
const el = getField(key);
if (!el) return;
on(el, 'change', () => {
patch({ [key]: !!el.checked }, true);
if (typeof afterChange === 'function') afterChange();
});
}
function syncLookupState(controller) {
const contentField = getField('file_content');
const fileNameField = getField('file_name');
const sourceField = getField('input_source');
const sizeField = getField('file_size_bytes');
const sync = (save = false) => {
const text = String(contentField?.value || '');
const fromFormat = normalizeConversionFormat(getField('from_format')?.value || 'pdb');
if (!text.trim()) {
if (fileNameField) fileNameField.value = '';
if (sourceField) sourceField.value = '';
if (sizeField) sizeField.value = '';
controller?.refresh?.();
syncSourceFormatUi();
patch({
file_content: '',
file_name: '',
input_source: '',
file_size_bytes: ''
}, save);
return;
}
if (sourceField && !sourceField.value) sourceField.value = 'vici_lookup';
if (fileNameField && !String(fileNameField.value || '').trim()) {
fileNameField.value = getConversionLookupFallbackName(fromFormat);
}
if (sizeField && !String(sizeField.value || '').trim()) {
sizeField.value = String(new Blob([text]).size);
}
controller?.refresh?.();
syncSourceFormatUi();
patch({
file_content: text,
file_name: fileNameField?.value || '',
input_source: sourceField?.value || 'vici_lookup',
file_size_bytes: sizeField?.value || ''
}, save);
};
on(contentField, 'input', () => sync(false));
on(contentField, 'change', () => sync(true));
sync(false);
}
function buildSourceController() {
const block = mount.querySelector('.conversion-source-block');
if (!block) return null;
const controller = utils.createDropZoneController({
scope: block,
block,
dropZone: '.drop-zone',
fileInput: '.lookup-drop-input',
metaEl: '.drop-zone__meta',
removeBtn: '.conversion-remove-file',
contentField: '[data-field-key="file_content"]',
filenameField: '[data-field-key="file_name"]',
sourceField: '[data-field-key="input_source"]',
sizeField: '[data-field-key="file_size_bytes"]',
emptyMetaText: 'Upload source content to convert.',
readFile: (file) => utils.readTextFile(file),
beforeRead: () => true,
renderFilledMeta: ({ state }) => ({
label: state.filename || 'Loaded source content',
sizeBytes: state.sizeBytes,
content: state.content,
includeLines: true,
includeChars: true,
extraBits: state.source === 'vici_lookup' ? ['Vici Lookup'] : ['Upload']
}),
onSet: ({ text, meta }) => {
patch({
file_content: text,
file_name: meta.name || '',
input_source: meta.source || '',
file_size_bytes: meta.sizeBytes != null ? String(meta.sizeBytes) : ''
}, true);
syncSourceFormatUi();
},
onClear: ({ extra }) => {
patch({
file_content: '',
file_name: '',
input_source: '',
file_size_bytes: ''
}, true);
if (extra?.fromRemoveButton) {
utils.clearViciLookupForBlock(block);
window.showToast?.('success', 'Source file removed.');
}
syncSourceFormatUi();
}
});
const fileInput = block.querySelector('.lookup-drop-input');
const accept = buildAcceptAttrForFormat(getField('from_format')?.value || 'pdb');
if (fileInput) {
if (accept) fileInput.setAttribute('accept', accept);
else fileInput.removeAttribute('accept');
}
controller?.bind?.();
syncLookupState(controller);
return controller;
}
wireTextField('from_format');
wireTextField('to_format');
wireCheckboxField('use_all_upstream_models');
const nameEl = getField('name');
on(nameEl, 'input', () => patch({ name: nameEl.value }, false));
on(nameEl, 'change', () => patch({ name: nameEl.value }, true));
on(nameEl, 'blur', canonicalizeNameField);
const fromEl = getField('from_format');
on(fromEl, 'change', () => syncSourceFormatUi());
controllers.push(buildSourceController());
syncSourceFormatUi();
syncUpstreamUi({ immediate: true });
window.ViciLookup?.init?.(mount);
return () => {
controllers.forEach((controller) => controller?.destroy?.());
listeners.splice(0).forEach((off) => {
try { off(); } catch {}
});
};
}
function validate({ node, nodeData, edges }) {
const errors = [];
const nodeName = utils.canonicalizeName(nodeData?.name || nodeData?.default_name || '', { max: 64, fallback: '' });
if (!nodeName) {
errors.push('Convert node name is required.');
} else if (!utils.SAFE_NAME_RE.test(nodeName)) {
errors.push('Convert node name must be 3-64 chars using a-z, 0-9, _ or - and start/end with letter or digit.');
}
const fromFormat = normalizeConversionFormat(nodeData?.from_format || '');
const toFormat = normalizeConversionFormat(nodeData?.to_format || '');
if (!fromFormat) errors.push('Source format is required.');
if (!toFormat) errors.push('Target format is required.');
const incomingEdges = Array.isArray(edges)
? edges.filter((edge) => String(edge.to_node_id) === String(node.id))
: [];
const useUpstreamNode = incomingEdges.length > 0;
if (!useUpstreamNode) {
const fileContent = String(nodeData?.file_content || '').trim();
if (!fileContent) errors.push('Source file is required when no upstream node is connected.');
}
return errors;
}
function buildExecutionNode({ node, nodeData, edges }) {
const nodeName = utils.canonicalizeName(
nodeData?.name || nodeData?.default_name || '',
{ max: 64, fallback: 'convert_1' }
);
const fromFormat = normalizeConversionFormat(nodeData?.from_format || 'pdb') || 'pdb';
const toFormat = normalizeConversionFormat(nodeData?.to_format || 'cif') || 'cif';
const incomingEdges = Array.isArray(edges)
? edges.filter((edge) => String(edge.to_node_id) === String(node.id))
: [];
const useUpstreamNode = incomingEdges.length > 0;
const useAllUpstreamModels = useUpstreamNode && !!nodeData?.use_all_upstream_models;
const conversion = {
class: 'Conversion',
name: nodeName,
from: toBackendStructureFormat(fromFormat, 'pdb'),
to: toBackendStructureFormat(toFormat, 'cif'),
input_format: fromFormat,
declared_input_format: fromFormat,
output_format: toFormat,
requested_extension: toFormat,
requested_pair: `${fromFormat}_to_${toFormat}`,
source_category: getConversionCategory(fromFormat),
target_category: getConversionCategory(toFormat),
conversion_family: getConversionFamily(fromFormat, toFormat),
input_source: useUpstreamNode ? 'upstream' : (String(nodeData?.input_source || '').trim() || 'upload')
/*
TEMPORARILY DISABLED UNTIL BACKEND SUPPORTS MORE THAN:
- from
- to
- file_content
- file_name
molecule_kind: nodeData?.molecule_kind || 'auto',
sequence_alphabet: nodeData?.sequence_alphabet || 'auto',
structure_mode: nodeData?.structure_mode || 'auto',
output_naming: nodeData?.output_naming || 'match_input',
preserve_chain_ids: !!nodeData?.preserve_chain_ids,
keep_hetero_atoms: !!nodeData?.keep_hetero_atoms,
extract_first_model_only: !!nodeData?.extract_first_model_only,
sanitize_output: !!nodeData?.sanitize_output,
generate_3d_if_needed: !!nodeData?.generate_3d_if_needed
*/
};
if (useUpstreamNode) {
if (!incomingEdges.length) {
throw new Error('Use upstream node is enabled, but no upstream connection was found.');
}
conversion.upstream_mode = useAllUpstreamModels ? 'all' : 'top_ranked';
conversion.upstream_node_ids = incomingEdges.map((edge) => String(edge.from_node_id));
} else {
const fileContent = String(nodeData?.file_content || '');
const fileName = String(nodeData?.file_name || '').trim() || `input.${fromFormat}`;
const fileFormat = getFileFormatFromName(fileName) || fromFormat;
if (!fileContent.trim()) {
throw new Error('Source file is required when no upstream node is connected.');
}
conversion.file_content = fileContent;
conversion.file_name = fileName;
conversion.file_format = fileFormat;
}
return {
node_id: String(node.id),
node_type: 'Conversion',
node_name: nodeName,
payload: {
conversion
}
};
}
registry.register('Convert', {
type: 'Convert',
title: 'Convert',
tutorialUrl: '/blog',
tabs: ['basic'],
presets: [],
createDefaultData,
renderBody,
bind,
validate,
buildExecutionNode,
resetData: ({ node }) => createDefaultData({
autoName: node?.data?.default_name || node?.data?.name || 'convert_1'
})
});
})();