(() => { '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) => ` ${group.items.map(([value, label]) => ` `).join('')} `).join(''); } function renderBody({ nodeId, nodeData, editor }) { const e = utils.escapeHtml; const incomingEdges = getIncomingConversionEdges(editor, nodeId); const hasIncomingUpstream = incomingEdges.length > 0; return `
This is the Convert node name. The workflow name is set at the top of the workflow sidebar.
Off = top ranked only. On = all upstream outputs.
Drop source file here or click to upload
Upload source content to convert.
`; } 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' }) }); })();