const EYE_SVG = ` `; window.VICI_EYE_SVG = EYE_SVG; window.toggleViciLookup = toggleViciLookup; function toggleViciLookup(btn){ if (btn.disabled) return; const block = btn.closest('.molecule-block'); const vici = block?.querySelector('.vici-lookup'); if (!vici) return; const isOpen = btn.classList.contains('active'); if (!isOpen){ viciSlideExpand(vici); btn.classList.add('active'); btn.setAttribute('aria-expanded','true'); } else { viciSlideCollapse(vici); btn.classList.remove('active'); btn.setAttribute('aria-expanded','false'); } } function viciSlideExpand(el){ if (!el) return; el.classList.add('vici-slide'); el.style.display = ''; el.dataset.viciCollapsed = "0"; el.style.overflow = 'hidden'; const targetH = el.scrollHeight || 0; el.style.setProperty('--vici-max', `${targetH}px`); el.style.maxHeight = '0px'; el.style.opacity = '0'; el.classList.remove('open'); el.offsetHeight; requestAnimationFrame(() => { const h = el.scrollHeight || targetH || 0; el.style.setProperty('--vici-max', `${h}px`); el.style.maxHeight = h + 'px'; el.style.opacity = '1'; el.classList.add('open'); }); const onEnd = (ev) => { if (ev.propertyName !== 'max-height') return; el.removeEventListener('transitionend', onEnd); if (el.dataset.viciCollapsed === "0"){ el.style.maxHeight = 'none'; el.style.overflow = 'visible'; } }; el.addEventListener('transitionend', onEnd); } function viciSlideCollapse(el){ if (!el) return; el.classList.add('vici-slide'); el.dataset.viciCollapsed = "1"; const h = el.scrollHeight || 0; el.style.overflow = 'hidden'; el.style.setProperty('--vici-max', `${h}px`); el.style.maxHeight = h + 'px'; el.style.opacity = '1'; requestAnimationFrame(() => { el.style.maxHeight = '0px'; el.style.opacity = '0'; el.classList.remove('open'); }); const onEnd = (ev) => { if (ev.propertyName !== 'max-height') return; el.removeEventListener('transitionend', onEnd); if (el.dataset.viciCollapsed === "1") el.style.display = 'none'; }; el.addEventListener('transitionend', onEnd); clearTimeout(el._viciTimer); el._viciTimer = setTimeout(() => { if (el.dataset.viciCollapsed === "1") el.style.display = 'none'; }, 650); } (function () { const cache = new Map(); const SEARCH_SVG = ` `; const CLEAR_SVG = ` `; const SPINNER_SVG = ` `; const FOLDER_SVG = ` `; const FILE_DOC_SVG = ` `; const NAV_LEFT_SVG = ` `; const NAV_HOME_SVG = ` `; const NAV_RIGHT_SVG = ` `; const ARCHIVE_EXTS = new Set(['zip']); const INJECTABLE_EXTS = new Set([ 'txt','md','json','yaml','yml','csv','tsv', 'fasta','fa','faa','fna','aln', 'pdb','cif','mol2','sdf', 'smi','smiles' ]); let fileModalContextWatcher = null; let fileModalContextCheckBusy = false; const DEFAULT_SIZE = 256; const MAX_SIZE = 512; const STATUS_URL = window.MODAL_STATUS_URL || 'https://ayambabu23--workflow-check-status.modal.run'; let fileModal = null; let fileModalState = null; const CFG = { uniprot: { fastaUrl: (acc) => `https://rest.uniprot.org/uniprotkb/${encodeURIComponent(acc)}.fasta`, searchUrl: (q, size=DEFAULT_SIZE) => `https://rest.uniprot.org/uniprotkb/search?query=${encodeURIComponent(q)}&format=json&size=${size}&fields=accession,id,protein_name,organism_name,length` }, uniparc: { fastaUrl: (upi) => `https://rest.uniprot.org/uniparc/${encodeURIComponent(upi)}.fasta` }, ena: { fastaUrl: (acc) => `https://www.ebi.ac.uk/ena/browser/api/fasta/${encodeURIComponent(acc)}?download=false`, textSearchUrl: (q, size=DEFAULT_SIZE) => `https://www.ebi.ac.uk/ena/browser/api/fasta/textsearch/${encodeURIComponent(q)}?limit=${size}` }, ncbi: { efetchUrl: (db, id) => `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=${db}&id=${encodeURIComponent(id)}&rettype=fasta&retmode=text`, esearchUrl: (db, q, size=DEFAULT_SIZE) => `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?db=${db}&term=${encodeURIComponent(q)}&retmode=json&retmax=${size}` }, ensembl: { fastaUrl: (id, type) => `https://rest.ensembl.org/sequence/id/${encodeURIComponent(id)}?type=${encodeURIComponent(type)}` }, pubchem: { smilesByNameUrl: (name) => `https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/${encodeURIComponent(name)}/property/CanonicalSMILES,IsomericSMILES/JSON`, smilesByCidUrl: (cid) => `https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/${encodeURIComponent(cid)}/property/CanonicalSMILES,IsomericSMILES/JSON` }, chembl: { molUrl: (chemblId) => `https://www.ebi.ac.uk/chembl/api/data/molecule/${encodeURIComponent(chemblId)}.json`, searchUrl: (q, size=DEFAULT_SIZE) => `https://www.ebi.ac.uk/chembl/api/data/molecule/search.json?q=${encodeURIComponent(q)}&limit=${size}` }, rcsb: { searchUrl: `https://search.rcsb.org/rcsbsearch/v2/query`, pdbFileUrl: (id, ext) => `https://files.rcsb.org/download/${encodeURIComponent(id)}.${ext}` } }; function withTimeout(p, ms=12000){ return Promise.race([ p, new Promise((_, rej) => setTimeout(() => rej(new Error('Request timed out')), ms)) ]); } async function fetchText(url, headers){ if (cache.has(url)) return cache.get(url); const res = await withTimeout(fetch(url, { headers: headers || { 'Accept': 'text/plain' } })); if (!res.ok) throw new Error(`HTTP ${res.status}`); const t = await res.text(); cache.set(url, t); return t; } async function fetchJSON(url, opts){ const key = url + (opts ? JSON.stringify(opts) : ''); if (cache.has(key)) return cache.get(key); const res = await withTimeout(fetch(url, opts)); if (!res.ok) throw new Error(`HTTP ${res.status}`); const j = await res.json(); cache.set(key, j); return j; } async function getViciScopePayload(){ const fromWindow = String(window.VICI_USER_ID || '').trim(); if (fromWindow) return { user_id: fromWindow }; const member = await (async () => { try { const ms = window.$memberstackDom; if (!ms?.getCurrentMember) return null; const { data } = await ms.getCurrentMember(); return data || null; } catch { return null; } })(); const memberId = String(member?.id || '').trim(); const scopePayload = (memberId && window.ViciContext?.payloadFor) ? (window.ViciContext.payloadFor(memberId) || {}) : {}; const payloadUserId = String(scopePayload?.user_id || '').trim(); if (payloadUserId) return scopePayload; if (typeof window.getMemberId === 'function'){ const memberId = await window.getMemberId(); if (memberId) return { user_id: String(memberId).trim() }; } return {}; } async function getViciJwt(){ try { const jwt = await window.ViciAuth?.getJwt?.(); return String(jwt || '').trim(); } catch { return ''; } } function resolveEntryNavPath(rawPath, currentPath, section){ let rp = normalizeNavPath(rawPath); const cp = normalizeNavPath(currentPath); const sec = normalizeNavPath(section); if (!rp) return ''; if (sec && (rp === sec || rp.startsWith(sec + '/'))){ rp = normalizeNavPath(rp.slice(sec.length)); } if (!cp) return rp; if (rp === cp || rp.startsWith(cp + '/')) return rp; return joinNavPath(cp, rp); } function prettifyNavPath(path){ const p = normalizeNavPath(path); if (!p) return ''; return p .split('/') .filter(Boolean) .map(seg => prettifyFileName(seg)) .join('/'); } function normalizeNavPath(p){ if (p == null) return ''; let s = String(p).trim(); if (!s || s === '/' || s === '.') return ''; s = s.replace(/^\/+/, '').replace(/\/+$/, ''); return s; } function joinNavPath(base, child){ const b = normalizeNavPath(base); const c = normalizeNavPath(child); if (!b) return c; if (!c) return b; return `${b}/${c}`; } function parentNavPath(path){ const p = normalizeNavPath(path); if (!p) return ''; const parts = p.split('/').filter(Boolean); parts.pop(); return parts.join('/'); } function getFileModalScopeSignature(scopePayload){ const keys = ['user_id','team_id','org_id','workspace_id']; const out = {}; for (const k of keys){ if (scopePayload && scopePayload[k] != null && String(scopePayload[k]).trim() !== ''){ out[k] = scopePayload[k]; } } return JSON.stringify(out); } function prettifyFileName(name){ let s = String(name || ''); if (!s) return s; s = s.replace( /^(.*?)(\.[^.\/]+)?_([a-f0-9-]{12,})(\.[^.\/]+)$/i, (m, base, extBefore, _hash, extAfter) => { const a = String(extBefore || ''); const b = String(extAfter || ''); if (a && a.toLowerCase() === b.toLowerCase()) { return `${base}${a}`; } return `${base}${a}${b}`; } ); s = s.replace(/(\.[^.\/]+)\1$/i, '$1'); return s; } function isArchiveEntry(entry){ return ARCHIVE_EXTS.has(String(entry?.extension || '').toLowerCase()); } function canInjectEntry(entry){ if (!entry || entry.isDir) return false; if (isArchiveEntry(entry)) return false; const ext = String(entry.extension || '').toLowerCase(); if (!ext) return true; return INJECTABLE_EXTS.has(ext); } function getFileIconSvg(entry){ return entry.isDir ? FOLDER_SVG : FILE_DOC_SVG; } function getFileMetaLabel(entry){ if (entry.isDir && entry._archiveLike) return 'ZIP folder'; if (entry.isDir) return 'Folder'; return entry.extension || 'file'; } function getModalDisplayPath(){ if (!fileModalState) return '/'; const section = fileModalState.section || ''; const rawPath = normalizeNavPath(fileModalState.path || ''); if (!section) return '/'; const prettyPath = prettifyNavPath(rawPath); return prettyPath ? `/${section}/${prettyPath}` : `/${section}`; } function getFileModalLocation(){ return { section: fileModalState?.section || null, path: normalizeNavPath(fileModalState?.path || '') }; } function sameFileModalLocation(a, b){ if (!a || !b) return false; return (a.section || null) === (b.section || null) && normalizeNavPath(a.path || '') === normalizeNavPath(b.path || ''); } function ensureFileModalHistory(){ if (!fileModalState) return; if (!Array.isArray(fileModalState.navHistory)) fileModalState.navHistory = []; if (!Number.isInteger(fileModalState.navIndex)) fileModalState.navIndex = -1; } function pushFileModalHistory(loc){ if (!fileModalState) return; ensureFileModalHistory(); const clean = { section: loc?.section || null, path: normalizeNavPath(loc?.path || '') }; const current = fileModalState.navHistory[fileModalState.navIndex]; if (sameFileModalLocation(current, clean)) return; if (fileModalState.navIndex < fileModalState.navHistory.length - 1){ fileModalState.navHistory = fileModalState.navHistory.slice(0, fileModalState.navIndex + 1); } fileModalState.navHistory.push(clean); fileModalState.navIndex = fileModalState.navHistory.length - 1; } function replaceFileModalHistory(loc){ if (!fileModalState) return; ensureFileModalHistory(); const clean = { section: loc?.section || null, path: normalizeNavPath(loc?.path || '') }; if (fileModalState.navIndex < 0 || !fileModalState.navHistory.length){ fileModalState.navHistory = [clean]; fileModalState.navIndex = 0; return; } fileModalState.navHistory[fileModalState.navIndex] = clean; } async function navigateFileModalTo(loc, { trackHistory=true, replaceHistoryEntry=false } = {}){ if (!fileModalState) return; fileModalState.section = loc?.section || null; fileModalState.path = normalizeNavPath(loc?.path || ''); if (trackHistory){ if (replaceHistoryEntry) replaceFileModalHistory(getFileModalLocation()); else pushFileModalHistory(getFileModalLocation()); } await loadFileEntries(); } async function fileModalGoBack(){ if (!fileModalState) return; ensureFileModalHistory(); if (fileModalState.navIndex > 0){ fileModalState.navIndex -= 1; const loc = fileModalState.navHistory[fileModalState.navIndex] || { section:null, path:'' }; fileModalState.section = loc.section || null; fileModalState.path = normalizeNavPath(loc.path || ''); await loadFileEntries(); return; } const { section, path } = getFileModalLocation(); if (section && path){ await navigateFileModalTo({ section, path: parentNavPath(path) }); return; } if (section){ await navigateFileModalTo({ section:null, path:'' }); } } async function fileModalGoForward(){ if (!fileModalState) return; ensureFileModalHistory(); if (fileModalState.navIndex >= fileModalState.navHistory.length - 1) return; fileModalState.navIndex += 1; const loc = fileModalState.navHistory[fileModalState.navIndex] || { section:null, path:'' }; fileModalState.section = loc.section || null; fileModalState.path = normalizeNavPath(loc.path || ''); await loadFileEntries(); } function makeSourceRootEntries(){ return [ { path: '', name: '/editor', displayName: '/editor', isDir: true, extension: '', _virtualSource: 'editor' }, { path: '', name: '/jobs', displayName: '/jobs', isDir: true, extension: '', _virtualSource: 'jobs' } ]; } async function checkFileModalContextChange(){ if (fileModalContextCheckBusy) return; if (!fileModal || fileModal.hidden || !fileModalState) return; fileModalContextCheckBusy = true; try { const scopePayload = await getViciScopePayload(); const sig = getFileModalScopeSignature(scopePayload); if (!fileModalState.scopeSignature){ fileModalState.scopeSignature = sig; return; } if (sig !== fileModalState.scopeSignature){ fileModalState.scopeSignature = sig; fileModalState.section = null; fileModalState.path = ''; fileModalState.navHistory = [{ section:null, path:'' }]; fileModalState.navIndex = 0; await loadFileEntries(); showToast?.('success', 'Context changed. File navigator refreshed.'); } } catch (e){ console.warn('File modal context sync check failed:', e); } finally { fileModalContextCheckBusy = false; } } function startFileModalContextWatcher(){ stopFileModalContextWatcher(); fileModalContextWatcher = setInterval(() => { checkFileModalContextChange(); }, 1200); window.addEventListener('focus', checkFileModalContextChange); document.addEventListener('visibilitychange', checkFileModalContextChange); } function stopFileModalContextWatcher(){ if (fileModalContextWatcher){ clearInterval(fileModalContextWatcher); fileModalContextWatcher = null; } window.removeEventListener('focus', checkFileModalContextChange); document.removeEventListener('visibilitychange', checkFileModalContextChange); } async function fetchNavigationPayload({ scopePayload, path='', download=false }){ const endpoint = String(STATUS_URL || '').replace(/\/+$/,'') + '/'; const jwt = await getViciJwt(); const headers = { 'Content-Type':'application/json' }; if (jwt) headers.Authorization = `Bearer ${jwt}`; const res = await withTimeout(fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ ...(scopePayload || {}), navigation: true, download: !!download, path }) }), 15000); if (!res.ok) throw new Error(`File navigation failed (HTTP ${res.status})`); return res.json(); } async function fetchEditorPayload({ scopePayload, path='' }){ const endpoint = String(STATUS_URL || '').replace(/\/+$/,'') + '/'; const jwt = await getViciJwt(); const headers = { 'Content-Type':'application/json' }; if (jwt) headers.Authorization = `Bearer ${jwt}`; const res = await withTimeout(fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ ...(scopePayload || {}), editor: true, path: path ?? '' }) }), 15000); if (!res.ok) throw new Error(`Editor retrieval failed (HTTP ${res.status})`); return res.json(); } function normalizeFileEntries(payload){ const raw = payload?.files || payload?.entries || payload?.items || payload?.paths || payload?.results?.paths || []; if (!Array.isArray(raw)) return []; const currentPath = normalizeNavPath(fileModalState?.path ?? ''); const currentSection = String(fileModalState?.section || '').toLowerCase(); return raw.map((it) => { if (typeof it === 'string'){ const trimmed = it.trim(); if (!trimmed) return null; const rawName = trimmed.split('/').filter(Boolean).pop() || trimmed; const ext = (rawName.includes('.') ? rawName.split('.').pop() : '').toLowerCase(); const backendSaysDir = trimmed.endsWith('/'); const archiveLike = (!backendSaysDir && currentSection === 'jobs' && ARCHIVE_EXTS.has(ext)); const likelyDirNoExt = !backendSaysDir && !archiveLike && !rawName.includes('.') && !!rawName; const isDir = backendSaysDir || archiveLike || likelyDirNoExt; const path = resolveEntryNavPath(trimmed, currentPath, currentSection); return { path, name: rawName, displayName: prettifyFileName(rawName), isDir, _archiveLike: archiveLike, size: '', extension: isDir && !archiveLike ? '' : ext }; } const rawPath = String(it.path || it.full_path || it.name || '').trim(); if (!rawPath) return null; const rawName = String(it.name || rawPath.split('/').filter(Boolean).pop() || 'item'); const type = String(it.type || it.kind || '').toLowerCase(); const backendSaysDir = type === 'dir' || type === 'directory' || !!it.is_dir || !!it.isDirectory; const ext = (rawName.includes('.') ? rawName.split('.').pop() : '').toLowerCase(); const archiveLike = (!backendSaysDir && currentSection === 'jobs' && ARCHIVE_EXTS.has(ext)); const hasSizeHint = it.size != null || it.bytes != null || it.content_length != null; const explicitFileHint = type === 'file' || !!it.is_file || !!it.isFile; const likelyDirNoExt = !backendSaysDir && !archiveLike && !explicitFileHint && !hasSizeHint && !rawName.includes('.') && !!rawName; const isDir = backendSaysDir || archiveLike || likelyDirNoExt; const normalizedPath = resolveEntryNavPath(rawPath, currentPath, currentSection); return { path: normalizedPath, name: rawName, displayName: prettifyFileName(rawName), isDir, _archiveLike: archiveLike, size: it.size || '', extension: isDir && !archiveLike ? '' : ext }; }).filter(Boolean); } function closeFileModal(){ if (!fileModal) return; fileModal.hidden = true; stopFileModalContextWatcher(); document.documentElement.classList.remove('vici-file-modal-open'); document.body.classList.remove('vici-file-modal-open'); if (fileModalState?.triggerBtn){ try { fileModalState.triggerBtn.focus(); } catch {} } fileModalState = null; } function renderFileBreadcrumbs(){ if (!fileModalState || !fileModal) return; const crumbs = fileModal.querySelector('[data-vici-file-crumbs]'); if (!crumbs) return; ensureFileModalHistory(); const section = fileModalState.section || ''; const path = normalizeNavPath(fileModalState.path || ''); const displayPath = getModalDisplayPath(); const canGoBackHistory = fileModalState.navIndex > 0; const canGoForward = fileModalState.navIndex < (fileModalState.navHistory.length - 1); const canFallbackBack = !!section; const canGoBack = canGoBackHistory || canFallbackBack; crumbs.innerHTML = ''; crumbs.classList.add('vici-file-nav-breadcrumbs-ui'); const navWrap = document.createElement('div'); navWrap.className = 'vici-crumb-nav'; const homeBtn = document.createElement('button'); homeBtn.type = 'button'; homeBtn.className = 'vici-crumb-btn vici-crumb-home'; homeBtn.innerHTML = `${NAV_HOME_SVG}`; homeBtn.setAttribute('aria-label', 'Back to sources'); homeBtn.setAttribute('aria-disabled', section ? 'false' : 'true'); homeBtn.onclick = async () => { if (!fileModalState || !section) return; await navigateFileModalTo({ section: null, path: '' }); }; const backBtn = document.createElement('button'); backBtn.type = 'button'; backBtn.className = 'vici-crumb-btn vici-crumb-back'; backBtn.innerHTML = `${NAV_LEFT_SVG}`; backBtn.setAttribute( 'aria-label', canGoBackHistory ? 'Back' : (path ? 'Parent folder' : (section ? 'Back to sources' : 'Back')) ); backBtn.setAttribute('aria-disabled', canGoBack ? 'false' : 'true'); backBtn.onclick = async () => { if (!fileModalState || !canGoBack) return; await fileModalGoBack(); }; const forwardBtn = document.createElement('button'); forwardBtn.type = 'button'; forwardBtn.className = 'vici-crumb-btn vici-crumb-forward'; forwardBtn.innerHTML = `${NAV_RIGHT_SVG}`; forwardBtn.setAttribute('aria-label', 'Forward'); forwardBtn.setAttribute('aria-disabled', canGoForward ? 'false' : 'true'); forwardBtn.onclick = async () => { if (!fileModalState || !canGoForward) return; await fileModalGoForward(); }; const pathEl = document.createElement('span'); pathEl.className = 'vici-crumb-path'; pathEl.textContent = displayPath; navWrap.append(homeBtn, backBtn, forwardBtn); crumbs.append(navWrap, pathEl); } function renderFileEntries(payload){ if (!fileModalState || !fileModal) return; const list = fileModal.querySelector('[data-vici-file-list]'); const empty = fileModal.querySelector('[data-vici-file-empty]'); const entries = Array.isArray(payload) ? payload : normalizeFileEntries(payload); renderFileBreadcrumbs(); list.innerHTML = ''; empty.hidden = entries.length > 0; for (const entry of entries){ const row = document.createElement('button'); row.type = 'button'; row.className = 'vici-file-nav-item'; row.innerHTML = ` ${getFileIconSvg(entry)} ${entry.displayName || entry.name} ${getFileMetaLabel(entry)} `; row.onclick = () => onPickFileEntry(entry); list.appendChild(row); } } function renderFileModalLoading(message='Loading files'){ if (!fileModal) return; const list = fileModal.querySelector('[data-vici-file-list]'); const empty = fileModal.querySelector('[data-vici-file-empty]'); if (!list) return; if (empty) empty.hidden = true; renderFileBreadcrumbs(); const clean = String(message || 'Loading files').replace(/\.*\s*$/, ''); list.innerHTML = `
${clean}
`; } async function loadFileEntries(){ if (!fileModalState || !fileModal) return; const list = fileModal.querySelector('[data-vici-file-list]'); renderFileModalLoading('Loading files'); if (!fileModalState.section){ renderFileEntries(makeSourceRootEntries()); return; } const scopePayload = await getViciScopePayload(); if (!scopePayload?.user_id){ showToast?.('error', 'Unable to resolve user context for file navigation.'); list.innerHTML = '
Unable to resolve user context.
'; return; } try { fileModalState.scopeSignature = getFileModalScopeSignature(scopePayload); let payload; if (fileModalState.section === 'editor'){ payload = await fetchEditorPayload({ scopePayload, path: fileModalState.path ?? '' }); } else { payload = await fetchNavigationPayload({ scopePayload, path: fileModalState.path ?? '', download: false }); } renderFileEntries(payload); } catch (err){ console.error(err); list.innerHTML = '
Unable to load files.
'; showToast?.('error', err?.message || 'Failed to load files.'); } } async function onPickFileEntry(entry){ if (!fileModalState || !entry) return; if (entry._virtualSource){ await navigateFileModalTo({ section: entry._virtualSource, path: '' }); return; } if (entry.isDir){ await navigateFileModalTo({ section: fileModalState.section || null, path: normalizeNavPath(entry.path) }); return; } if (!canInjectEntry(entry)){ showToast?.('error', 'Only document files can be loaded into the form.'); return; } const scopePayload = await getViciScopePayload(); if (!scopePayload?.user_id){ showToast?.('error', 'Unable to resolve user context for file retrieval.'); return; } renderFileModalLoading('Loading file'); try { let payload; if (fileModalState.section === 'editor'){ payload = await fetchEditorPayload({ scopePayload, path: entry.path }); } else { payload = await fetchNavigationPayload({ scopePayload, path: entry.path, download: true }); } const content = payload?.file_content || payload?.content || payload?.text || ''; const detail = { block: fileModalState.block, path: entry.path, name: entry.displayName || entry.name, fileName: entry.name || '', fileFormat: entry.extension || '', extension: entry.extension || '', fileContent: String(content || '') }; window.dispatchEvent(new CustomEvent('vici:file-selected', { detail })); showToast?.('success', `Loaded ${entry.displayName || entry.name}`); closeFileModal(); } catch (err){ console.error(err); showToast?.('error', err?.message || 'Failed to fetch file.'); } } function ensureFileModal(){ if (fileModal) return fileModal; const host = document.body; fileModal = document.createElement('div'); fileModal.className = 'vici-file-nav-backdrop'; fileModal.hidden = true; fileModal.innerHTML = ` `; host.appendChild(fileModal); fileModal.addEventListener('click', (e)=>{ if (e.target === fileModal) closeFileModal(); }); fileModal.querySelector('[data-vici-file-close]') ?.addEventListener('click', closeFileModal); return fileModal; } async function openFileModalForBlock(block, triggerBtn){ ensureFileModal(); const scopePayload = await getViciScopePayload(); fileModalState = { block, triggerBtn, host: document.body, section: null, path: '', scopeSignature: getFileModalScopeSignature(scopePayload || {}), navHistory: [{ section:null, path:'' }], navIndex: 0 }; fileModal.hidden = false; document.documentElement.classList.add('vici-file-modal-open'); document.body.classList.add('vici-file-modal-open'); startFileModalContextWatcher(); await loadFileEntries(); } function parseFasta(fasta){ const lines = String(fasta||'').trim().split(/\r?\n/); if (!lines[0]?.startsWith('>')) return { header:'', seq:'' }; const header = lines[0].slice(1).trim(); const seq = lines.slice(1).join('').replace(/\s+/g,'').toUpperCase(); return { header, seq }; } function parseFastaHeaders(fasta){ const hits = []; const lines = String(fasta||'').split(/\r?\n/); for (const ln of lines){ if (ln.startsWith('>')){ const m = ln.slice(1).trim().match(/^(\S+)\s*(.*)$/); if (m) hits.push({ id:m[1], label: m[1] + (m[2] ? ` — ${m[2]}` : '') }); } } return hits; } function normalizeQueryToken(q){ let s = String(q||'').trim(); if (!s) return s; if (s.startsWith('>')) s = s.slice(1).trim(); const pipe = s.match(/^(?:sp|tr|uniparc|uniref\d+)\|([A-Za-z0-9_.-]+)\|/i); if (pipe) s = pipe[1]; const uniRef = s.match(/^uniref(?:\d+)?_([A-Za-z0-9]+)$/i); if (uniRef) s = uniRef[1]; return s.trim(); } const AA20 = 'ACDEFGHIKLMNPQRSTVWY'; const RE_UNIPROT_ACC = /^[A-NR-Z0-9]{1,3}[0-9][A-Z0-9]{3}[0-9]([-.][0-9]+)?$/i; const RE_UNIPARC_ID = /^UPI[0-9A-F]{10,}$/i; const RE_ENSEMBL_ID = /^ENS[A-Z0-9]*[PTG]\d{5,}(?:\.\d+)?$/i; const RE_REFSEQ_PROT = /^(?:[NXWYZA]P|[NXWYZA]T|WP|AP|YP|XP|NP|ZP|CP|SP|TP|DP|MP|BP|GP|RP)_[0-9]+(?:\.[0-9]+)?$/i; const RE_REFSEQ_NT = /^(?:NM|NR|XM|XR|NC|NG|NT|NW|NZ)_[0-9]+(?:\.[0-9]+)?$/i; const RE_INSDC_ACC = /^[A-Za-z]{1,4}\d+(\.\d+)?$/; const RE_PDB_ID = /^(pdb_)?[0-9][A-Za-z0-9]{3,11}$/i; const RE_CHEMBL_ID = /^CHEMBL\d+$/i; function isProbablyUniprotAcc(s){ return RE_UNIPROT_ACC.test((s||'').trim()); } function isProbablyUniParc(s){ return RE_UNIPARC_ID.test((s||'').trim()); } function isProbablyEnsembl(s){ return RE_ENSEMBL_ID.test((s||'').trim()); } function isProbablyRefSeqProt(s){return RE_REFSEQ_PROT.test((s||'').trim()); } function isProbablyRefSeqNt(s){ return RE_REFSEQ_NT.test((s||'').trim()); } function isProbablyInsdcAcc(s){ return RE_INSDC_ACC.test((s||'').trim()); } function isProbablyPdbId(s){ return RE_PDB_ID.test((s||'').trim()); } function isProbablyChemblId(s){ return RE_CHEMBL_ID.test((s||'').trim()); } function looksLikeSmiles(q){ const s = (q||'').trim(); if (!s) return false; if (/[=\-#@+\[\]\(\)\\/\.]/.test(s)) return true; if (/\d/.test(s) && /[A-Za-z]/.test(s)) return true; return false; } function applyNucleotideSubtype(seq, subtype){ let s = String(seq||'').toUpperCase(); if (subtype === 'rna') s = s.replace(/T/g,'U'); if (subtype === 'dna') s = s.replace(/U/g,'T'); return s; } async function uniprotGetSeqByAcc(acc){ const fasta = await fetchText(CFG.uniprot.fastaUrl(acc)); const { seq, header } = parseFasta(fasta); if (!seq) throw new Error('No UniProt sequence found.'); return { acc, header, seq, source:'uniprot' }; } async function uniprotSearch(q, size=DEFAULT_SIZE){ const j = await fetchJSON(CFG.uniprot.searchUrl(q, size)); return (j?.results || []).map(r => { const acc = r.primaryAccession || r?.uniProtkbId || r?.accession; const name = r.proteinDescription?.recommendedName?.fullName?.value || r.proteinDescription?.submissionNames?.[0]?.fullName?.value || r?.uniProtkbId || acc; const org = r.organism?.scientificName || ''; const len = r.sequence?.length || r.length || ''; return { id: acc, label: `${acc} — ${name}${org ? ` (${org})` : ''}${len ? ` • ${len} aa` : ''}` }; }).filter(h => h.id); } async function uniparcGetSeqById(upi){ const fasta = await fetchText(CFG.uniparc.fastaUrl(upi)); const { seq, header } = parseFasta(fasta); if (!seq) throw new Error('No UniParc sequence found.'); return { upi, header, seq, source:'uniparc' }; } async function enaGetSeqByAcc(acc){ const fasta = await fetchText(CFG.ena.fastaUrl(acc)); const { seq, header } = parseFasta(fasta); if (!seq) throw new Error('No ENA sequence found.'); return { acc, header, seq, source:'ena' }; } async function enaTextSearch(q, size=DEFAULT_SIZE){ const fasta = await fetchText(CFG.ena.textSearchUrl(q, size)); return parseFastaHeaders(fasta).slice(0, size); } async function ncbiGetFasta(db, id){ const fasta = await fetchText(CFG.ncbi.efetchUrl(db, id)); const { seq, header } = parseFasta(fasta); if (!seq) throw new Error('No NCBI FASTA found.'); return { id, header, seq, source:`ncbi-${db}` }; } async function ncbiSearch(db, q, size=DEFAULT_SIZE){ const j = await fetchJSON(CFG.ncbi.esearchUrl(db, q, size)); const ids = j?.esearchresult?.idlist || []; return ids.map(x => ({ id:x, label:x })); } async function ensemblGetSeqById(id, type){ const fasta = await fetchText( CFG.ensembl.fastaUrl(id, type), { 'Accept': 'text/x-fasta' } ); const { seq, header } = parseFasta(fasta); if (!seq) throw new Error('No Ensembl sequence found.'); return { id, header, seq, source:'ensembl', type }; } async function pubchemGetSmiles(query){ const q = (query||'').trim(); if (!q) throw new Error('Empty query.'); if (looksLikeSmiles(q)) return { smiles: q, source:'input' }; const isCID = /^\d+$/.test(q); const url = isCID ? CFG.pubchem.smilesByCidUrl(q) : CFG.pubchem.smilesByNameUrl(q); const j = await fetchJSON(url).catch(e => { if (String(e.message||'').includes('404')) throw new Error('No PubChem match.'); throw e; }); if (j?.Fault) throw new Error('No PubChem match.'); const props = j?.PropertyTable?.Properties; if (!props || !props.length) throw new Error('No PubChem match.'); const p = props[0] || {}; const smiles = p.CanonicalSMILES || p.IsomericSMILES || p.SMILES || p.smiles || null; if (!smiles) throw new Error('No PubChem SMILES.'); return { smiles, source:'pubchem', cid:p.CID }; } async function chemblGetSmilesById(chemblId){ const j = await fetchJSON(CFG.chembl.molUrl(chemblId)); const smiles = j?.molecule_structures?.canonical_smiles || j?.molecule_structures?.standard_smiles || null; if (!smiles) throw new Error('No ChEMBL SMILES.'); return { chemblId, smiles, source:'chembl' }; } async function chemblSearch(q, size=DEFAULT_SIZE){ const j = await fetchJSON(CFG.chembl.searchUrl(q, size)); const mols = j?.molecules || j?.molecule || j?.page?.molecules || []; return (mols || []).map(m => { const id = m.chembl_id || m.chemblId || m.molecule_chembl_id; const name = m.pref_name || m.molecule_properties?.full_mwt && m.chembl_id ? m.chembl_id : id; return id ? { id, label: `${id}${name && name!==id ? ` — ${name}` : ''}` } : null; }).filter(Boolean).slice(0, size); } async function rcsbSearchPdbIds(q, size=DEFAULT_SIZE){ const body = { query: { type: "terminal", service: "text", parameters: { value: q } }, return_type: "entry", request_options: { paginate: { start: 0, rows: size } } }; const j = await fetchJSON(CFG.rcsb.searchUrl, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }); return (j?.result_set || []).map(r => ({ id: r.identifier, label: r.identifier })); } async function rcsbGetStructureBoth(pdbId){ const id = pdbId.trim(); const out = { pdbId:id, cif:null, pdb:null, source:'rcsb-files' }; try { const cifTxt = await fetchText(CFG.rcsb.pdbFileUrl(id, 'cif')); if (cifTxt && cifTxt.length > 50) out.cif = cifTxt; } catch {} try { const pdbTxt = await fetchText(CFG.rcsb.pdbFileUrl(id, 'pdb')); if (pdbTxt && pdbTxt.length > 50) out.pdb = pdbTxt; } catch {} if (!out.cif && !out.pdb) throw new Error('No PDB structure found.'); return out; } function normalizeModeWithSubtype(mode){ const m = String(mode||'').toLowerCase().trim(); if (m === 'dna') return {mode:'nucleotide', subtype:'dna'}; if (m === 'rna') return {mode:'nucleotide', subtype:'rna'}; if (m === 'nucleotide') return {mode:'nucleotide', subtype:null}; if (m === 'ligand' || m === 'smiles') return {mode:'smiles', subtype:null}; if (m === 'protein') return {mode:'protein', subtype:null}; if (m === 'pdb') return {mode:'pdb', subtype:null}; return {mode:m, subtype:null}; } function normalizeMode(mode){ return normalizeModeWithSubtype(mode).mode; } async function fetchByMode(mode, q, opts={}){ const norm = normalizeModeWithSubtype(mode); const m = norm.mode; const subtype = opts.subtype ?? norm.subtype; const size = Math.min(Math.max(parseInt(opts.size||DEFAULT_SIZE,10) || DEFAULT_SIZE, 1), MAX_SIZE); const rawQuery = (q||'').trim(); const query = normalizeQueryToken(rawQuery); if (!query) throw new Error('Empty query.'); if (m === 'protein') { if (isProbablyUniParc(query)) { const r = await uniparcGetSeqById(query); return { kind:'single', mode:m, value:r.seq, hint:r.header || r.upi, raw:r }; } if (isProbablyEnsembl(query)) { const r = await ensemblGetSeqById(query, 'protein'); return { kind:'single', mode:m, value:r.seq, hint:r.header || r.id, raw:r }; } if (isProbablyUniprotAcc(query)) { const r = await uniprotGetSeqByAcc(query); return { kind:'single', mode:m, value:r.seq, hint:r.header || r.acc, raw:r }; } if (isProbablyRefSeqProt(query)) { const r = await ncbiGetFasta('protein', query); return { kind:'single', mode:m, value:r.seq, hint:r.header || r.id, raw:r }; } let hits = await uniprotSearch(query, size); if (!hits.length){ const nhits = await ncbiSearch('protein', query, size); return { kind:'hits', mode:m, hits: nhits, source:'ncbi-protein' }; } return { kind:'hits', mode:m, hits }; } if (m === 'nucleotide') { if (isProbablyEnsembl(query)) { const r = await ensemblGetSeqById(query, 'cdna'); const val = applyNucleotideSubtype(r.seq, subtype); return { kind:'single', mode:m, value:val, hint:r.header || r.id, raw:r, subtype }; } if (isProbablyRefSeqNt(query)) { const r = await ncbiGetFasta('nuccore', query); const val = applyNucleotideSubtype(r.seq, subtype); return { kind:'single', mode:m, value:val, hint:r.header || r.id, raw:r, subtype }; } if (isProbablyInsdcAcc(query)) { const r = await enaGetSeqByAcc(query); const val = applyNucleotideSubtype(r.seq, subtype); return { kind:'single', mode:m, value:val, hint:r.header || r.acc, raw:r, subtype }; } let hits = await enaTextSearch(query, size); if (!hits.length){ const nhits = await ncbiSearch('nuccore', query, size); return { kind:'hits', mode:m, hits: nhits, source:'ncbi-nuccore' }; } return { kind:'hits', mode:m, hits, subtype }; } if (m === 'smiles') { if (looksLikeSmiles(query)) { return { kind:'single', mode:m, value:query, hint:'input', raw:{smiles:query, source:'input'} }; } if (isProbablyChemblId(query)) { const r = await chemblGetSmilesById(query); return { kind:'single', mode:m, value:r.smiles, hint:r.chemblId, raw:r }; } try { const r = await pubchemGetSmiles(query); return { kind:'single', mode:m, value:r.smiles, hint:r.cid ? `CID ${r.cid}` : r.source, raw:r }; } catch (e) { const hits = await chemblSearch(query, size); return { kind:'hits', mode:m, hits, source:'chembl' }; } } if (m === 'pdb') { if (isProbablyPdbId(query)) { const r = await rcsbGetStructureBoth(query); return { kind:'single', mode:m, value: { cif:r.cif, pdb:r.pdb }, hint: `Loaded ${r.pdbId}${r.cif ? '.cif' : ''}${r.cif && r.pdb ? ' + ' : ''}${r.pdb ? '.pdb' : ''}`, raw:r }; } const hits = await rcsbSearchPdbIds(query, size); return { kind:'hits', mode:m, hits }; } throw new Error(`Unknown mode: ${mode}`); } async function fetchById(mode, id, opts={}){ const norm = normalizeModeWithSubtype(mode); const m = norm.mode; const subtype = opts.subtype ?? norm.subtype; const rawId = (id||'').trim(); const x = normalizeQueryToken(rawId); if (!x) throw new Error('Empty id.'); if (m === 'protein') { if (isProbablyUniParc(x)) { const r = await uniparcGetSeqById(x); return { kind:'single', mode:m, value:r.seq, hint:r.header || r.upi, raw:r }; } if (isProbablyEnsembl(x)) { const r = await ensemblGetSeqById(x, 'protein'); return { kind:'single', mode:m, value:r.seq, hint:r.header || r.id, raw:r }; } if (isProbablyRefSeqProt(x)) { const r = await ncbiGetFasta('protein', x); return { kind:'single', mode:m, value:r.seq, hint:r.header || r.id, raw:r }; } const r = await uniprotGetSeqByAcc(x); return { kind:'single', mode:m, value:r.seq, hint:r.header || r.acc, raw:r }; } if (m === 'nucleotide') { if (isProbablyEnsembl(x)) { const r = await ensemblGetSeqById(x, 'cdna'); const val = applyNucleotideSubtype(r.seq, subtype); return { kind:'single', mode:m, value:val, hint:r.header || r.id, raw:r, subtype }; } if (isProbablyRefSeqNt(x)) { const r = await ncbiGetFasta('nuccore', x); const val = applyNucleotideSubtype(r.seq, subtype); return { kind:'single', mode:m, value:val, hint:r.header || r.id, raw:r, subtype }; } const r = await enaGetSeqByAcc(x); const val = applyNucleotideSubtype(r.seq, subtype); return { kind:'single', mode:m, value:val, hint:r.header || r.acc, raw:r, subtype }; } if (m === 'smiles') { if (isProbablyChemblId(x)) { const r = await chemblGetSmilesById(x); return { kind:'single', mode:m, value:r.smiles, hint:r.chemblId, raw:r }; } const r = await pubchemGetSmiles(x); return { kind:'single', mode:m, value:r.smiles, hint:r.cid ? `CID ${r.cid}` : r.source, raw:r }; } if (m === 'pdb') { const r = await rcsbGetStructureBoth(x); return { kind:'single', mode:m, value: { cif:r.cif, pdb:r.pdb }, hint: `Loaded ${r.pdbId}${r.cif ? '.cif' : ''}${r.cif && r.pdb ? ' + ' : ''}${r.pdb ? '.pdb' : ''}`, raw:r }; } throw new Error(`Unknown mode: ${mode}`); } function resolveTarget(el, targetSpec){ if (!targetSpec) return null; const spec = targetSpec.trim(); if (spec.startsWith('closest:')){ const rest = spec.slice('closest:'.length).trim(); const spaceIdx = rest.indexOf(' '); if (spaceIdx === -1) return null; const ancSel = rest.slice(0, spaceIdx).trim(); const childSel = rest.slice(spaceIdx+1).trim(); const anc = el.closest(ancSel); return anc ? anc.querySelector(childSel) : null; } return document.querySelector(spec); } function setIconState(block, state){ const btn = block._viciBtn || block.querySelector('.vici-lookup-iconbtn'); if (!btn) return; if (state === 'clear'){ btn.classList.remove('is-search'); btn.classList.add('is-clear'); btn.innerHTML = CLEAR_SVG; btn.title = 'Clear'; block.dataset.viciIconState = 'clear'; } else { btn.classList.remove('is-clear'); btn.classList.add('is-search'); btn.innerHTML = SEARCH_SVG; btn.title = 'Lookup'; block.dataset.viciIconState = 'search'; } } function setHint(block, text){ const hint = block._viciHint || block.querySelector('.vici-lookup-hint'); if (!hint) return; hint.textContent = text || ''; if (text) block.classList.add('has-hint'); else block.classList.remove('has-hint'); } function populateResults(block, hits, onPick){ const sel = block._viciSel || block.querySelector('.vici-lookup-results'); if (!sel) return; sel.disabled = false; sel.innerHTML = ''; const ph = document.createElement('option'); ph.value = ''; ph.textContent = hits.length ? 'Select a hit…' : 'No results'; ph.disabled = hits.length === 0; ph.selected = true; sel.appendChild(ph); hits.forEach((h) => { const opt = document.createElement('option'); opt.value = h.id; opt.textContent = h.label || h.id; sel.appendChild(opt); }); sel.onchange = () => { if (!sel.value) return; onPick(sel.value); }; } function populateResultsSingle(block, id, label){ const sel = block._viciSel || block.querySelector('.vici-lookup-results'); if (!sel) return; sel.innerHTML = ''; const opt = document.createElement('option'); opt.value = id || ''; opt.textContent = label || id || 'Fetched'; opt.selected = true; sel.appendChild(opt); sel.disabled = true; sel.onchange = null; } function setBusy(block, busy){ const btn = block._viciBtn || block.querySelector('.vici-lookup-iconbtn'); if (!btn) return; if (busy){ if (!btn.dataset.prevIconState){ btn.dataset.prevIconState = block.dataset.viciIconState || 'search'; } btn.innerHTML = SPINNER_SVG; btn.disabled = true; btn.style.opacity = '0.7'; btn.classList.add('is-busy'); return; } btn.disabled = false; btn.style.opacity = '1'; btn.classList.remove('is-busy'); const prev = btn.dataset.prevIconState || 'search'; delete btn.dataset.prevIconState; const current = block.dataset.viciIconState; const restore = (current === 'clear' || current === 'search') ? current : prev; setIconState(block, restore); } function getModeForBlock(block){ const explicit = (block.dataset.viciMode || '').trim(); const explicitSubtype = (block.dataset.viciSubtype || '').trim() || null; if (explicit){ const nm = normalizeModeWithSubtype(explicit); return { mode: nm.mode, subtype: explicitSubtype ?? nm.subtype }; } const allowed = (block.dataset.viciLookup || '') .split('|').map(s=>s.trim()).filter(Boolean); const first = allowed[0] || 'protein'; const nm2 = normalizeModeWithSubtype(first); return { mode: nm2.mode, subtype: nm2.subtype }; } function blockSize(block){ const n = parseInt(block.dataset.viciSize || '', 10); if (Number.isFinite(n) && n > 0) return Math.min(n, MAX_SIZE); return DEFAULT_SIZE; } function updatePlaceholder(block){ const input = block._viciInput || block.querySelector('.vici-lookup-input'); if (!input) return; const modeObj = getModeForBlock(block); const raw = (modeObj.subtype || modeObj.mode); const map = { protein: 'UniProt / UniParc / Ensembl / RefSeq ID or name', dna: 'ENA / RefSeq / Ensembl DNA ID or term', rna: 'ENA / RefSeq / Ensembl RNA ID or term', nucleotide: 'ENA / RefSeq / Ensembl ID or term', smiles: 'Ligand name / CID / SMILES / ChEMBL ID', ligand: 'Ligand name / CID / SMILES / ChEMBL ID', pdb: 'PDB ID or term' }; input.placeholder = map[raw] || 'Lookup'; } function clearLookup(block, { keepInput=false, keepTarget=false } = {}) { const input = block._viciInput || block.querySelector('.vici-lookup-input'); const targetEl = resolveTarget(block, block.dataset.target); if (!keepInput && input) input.value = ''; if (!keepTarget && targetEl) targetEl.value = ''; if (!keepTarget && targetEl){ try { targetEl.dispatchEvent(new Event('input', { bubbles:true })); targetEl.dispatchEvent(new Event('change', { bubbles:true })); } catch {} } window.updateActionVisibility?.(); setHint(block, ''); populateResults(block, [], ()=>{}); setIconState(block, 'search'); updatePlaceholder(block); block._viciLastQuery = null; block._viciHasHits = false; } function resultInfo(modeObj, out){ const {mode} = modeObj; const raw = out?.raw || {}; if (mode === 'protein') return { id: raw.acc || raw.upi || raw.id || out.hint || 'protein', label: raw.acc || raw.upi || raw.id || out.hint || 'Protein' }; if (mode === 'nucleotide') return { id: raw.acc || raw.id || out.hint || 'nucleotide', label: raw.acc || raw.id || out.hint || 'Nucleotide' }; if (mode === 'smiles') return { id: raw.cid ? String(raw.cid) : (raw.chemblId || 'smiles'), label: raw.cid ? `CID ${raw.cid}` : (raw.chemblId || 'SMILES') }; if (mode === 'pdb') return { id: raw.pdbId || out.hint || 'pdb', label: raw.pdbId || out.hint || 'PDB' }; return { id: out.hint || 'result', label: out.hint || 'Result' }; } function setTargetFileMeta(block, { fileName = '', source = '' } = {}) { const targetEl = resolveTarget(block, block.dataset.target); if (!targetEl) return; const moleculeBlock = targetEl.closest('.molecule-block') || block.closest('.molecule-block'); const nameField = moleculeBlock?.querySelector('.lookup-target-name'); const sourceField = moleculeBlock?.querySelector('.lookup-target-source'); if (nameField) nameField.value = String(fileName || ''); if (sourceField) sourceField.value = String(source || ''); try { nameField?.dispatchEvent(new Event('input', { bubbles: true })); nameField?.dispatchEvent(new Event('change', { bubbles: true })); sourceField?.dispatchEvent(new Event('input', { bubbles: true })); sourceField?.dispatchEvent(new Event('change', { bubbles: true })); } catch {} } async function fillSingle(block, modeObj, out, {preserveHits=false}={}){ const {mode, subtype} = modeObj; const targetEl = resolveTarget(block, block.dataset.target); if (mode === 'pdb') { let chosenContent = ''; let chosenFileName = ''; if (out.value?.cif) { chosenContent = out.value.cif; chosenFileName = `${out.raw?.pdbId || 'structure'}.cif`; } else if (out.value?.pdb) { chosenContent = out.value.pdb; chosenFileName = `${out.raw?.pdbId || 'structure'}.pdb`; } if (targetEl) targetEl.value = chosenContent; setTargetFileMeta(block, { fileName: chosenFileName, source: chosenFileName ? 'vici_lookup' : '' }); } else { if (targetEl) targetEl.value = out.value; } if (targetEl){ try { targetEl.dispatchEvent(new Event('input', { bubbles:true })); targetEl.dispatchEvent(new Event('change', { bubbles:true })); } catch {} } window.updateActionVisibility?.(); setHint(block, out.hint || ''); if (!preserveHits){ const info = resultInfo(modeObj, out); populateResultsSingle(block, info.id, info.label); } setIconState(block, 'clear'); block._viciLastQuery = (block._viciInput?.value || '').trim(); showToast?.('success', `Fetched ${mode}${subtype ? ` (${subtype})` : ''}`); } async function handleAttachedLookup(block){ const input = block._viciInput || block.querySelector('.vici-lookup-input'); const q = (input?.value || '').trim(); if (!q) { showToast?.('error','Enter a query.'); return; } const modeObj = getModeForBlock(block); const {mode, subtype} = modeObj; const size = blockSize(block); setBusy(block, true); try { const out = await fetchByMode(mode, q, { subtype, size }); if (out.kind === 'single'){ await fillSingle(block, modeObj, out, {preserveHits:false}); return; } if (out.hits.length === 1){ const onlyId = out.hits[0].id; const picked = await fetchById(mode, onlyId, { subtype }); await fillSingle(block, modeObj, picked, {preserveHits:false}); return; } populateResults(block, out.hits, async (id)=>{ setBusy(block, true); const picked = await fetchById(mode, id, { subtype }); const sel = block._viciSel || block.querySelector('.vici-lookup-results'); if (sel) sel.value = id; await fillSingle(block, modeObj, picked, {preserveHits:true}); setBusy(block, false); }); block._viciHasHits = out.hits.length > 0; block._viciLastQuery = (block._viciInput?.value || '').trim(); showToast?.('success', `Found ${out.hits.length} hits`); } catch (e){ console.error(e); showToast?.('error', e?.message || 'Lookup failed'); } finally { setBusy(block, false); } } function enhanceLayout(block){ if (block._viciLayout) return; block._viciLayout = true; const input = block.querySelector('.vici-lookup-input') || document.createElement('input'); input.classList.add('vici-lookup-input','styled-field'); const resultsSel = block.querySelector('.vici-lookup-results') || document.createElement('select'); resultsSel.classList.add('vici-lookup-results','styled-field'); resultsSel.removeAttribute('style'); resultsSel.style.display = ''; const hint = block.querySelector('.vici-lookup-hint') || document.createElement('small'); hint.classList.add('vici-lookup-hint','field-hint'); block.innerHTML = ''; block.classList.add('vici-lookup'); const row = document.createElement('div'); row.className = 'vici-lookup-row'; const colSearch = document.createElement('div'); colSearch.className = 'vici-lookup-col'; colSearch.innerHTML = `
`; const helpBtn = colSearch.querySelector('.help-dot'); const helpPop = colSearch.querySelector('.help-popover'); const uid = `help-vici-${Math.random().toString(36).slice(2,8)}`; helpBtn.setAttribute('aria-controls', uid); helpPop.id = uid; colSearch.appendChild(input); const colRes = document.createElement('div'); colRes.className = 'vici-lookup-col'; const resHead = document.createElement('div'); resHead.className = 'field-head'; resHead.innerHTML = ` `; const resHelpBtn = resHead.querySelector('.help-dot'); const resHelpPop = resHead.querySelector('.help-popover'); const uid2 = `help-vici-res-${Math.random().toString(36).slice(2,8)}`; resHelpBtn.setAttribute('aria-controls', uid2); resHelpPop.id = uid2; colRes.append(resHead, resultsSel); const iconBtn = document.createElement('button'); iconBtn.type = 'button'; iconBtn.className = 'vici-lookup-iconbtn is-search'; iconBtn.innerHTML = SEARCH_SVG; iconBtn.title = 'Lookup'; const fileBtn = document.createElement('button'); fileBtn.type = 'button'; fileBtn.className = 'vici-lookup-iconbtn is-files'; fileBtn.innerHTML = FOLDER_SVG; fileBtn.title = 'Open file navigation'; row.append(colSearch, colRes, iconBtn, fileBtn); block.append(row, hint); block._viciInput = input; block._viciSel = resultsSel; block._viciHint = hint; block._viciBtn = iconBtn; block._viciFileBtn = fileBtn; setIconState(block, 'search'); populateResults(block, [], ()=>{}); updatePlaceholder(block); setHint(block, ''); input.addEventListener('input', ()=>{ const now = (input.value||'').trim(); const last = block._viciLastQuery; if (last != null && now !== last){ if (block.dataset.viciIconState === 'clear' || block._viciHasHits){ clearLookup(block, {keepInput:true}); } } }); } function attach(block){ if (block._viciAttached) return; block._viciAttached = true; enhanceLayout(block); const btn = block._viciBtn; btn.addEventListener('click', (e)=>{ e.preventDefault(); if (block.dataset.viciIconState === 'clear'){ clearLookup(block); } else { handleAttachedLookup(block); } }); const input = block._viciInput; input.addEventListener('keydown', (e)=>{ if (e.key === 'Enter'){ e.preventDefault(); if (block.dataset.viciIconState !== 'clear'){ handleAttachedLookup(block); } } }); block._viciFileBtn?.addEventListener('click', (e)=>{ e.preventDefault(); openFileModalForBlock(block, block._viciFileBtn); }); } window.addEventListener('vici:file-selected', (e) => { const block = e.detail?.block; if (!block) return; const targetEl = resolveTarget(block, block.dataset.target); if (!targetEl) return; const moleculeBlock = targetEl.closest('.molecule-block') || block.closest('.molecule-block'); const nameField = moleculeBlock?.querySelector('.lookup-target-name'); const sourceField = moleculeBlock?.querySelector('.lookup-target-source'); // write metadata first so API auto-sync sees it if (nameField) { nameField.value = String(e.detail?.fileName || ''); } if (sourceField) { sourceField.value = 'vici_lookup'; } targetEl.value = String(e.detail?.fileContent || ''); try { nameField?.dispatchEvent(new Event('input', { bubbles: true })); nameField?.dispatchEvent(new Event('change', { bubbles: true })); sourceField?.dispatchEvent(new Event('input', { bubbles: true })); sourceField?.dispatchEvent(new Event('change', { bubbles: true })); targetEl.dispatchEvent(new Event('input', { bubbles: true })); targetEl.dispatchEvent(new Event('change', { bubbles: true })); } catch {} block._viciLastQuery = ''; block._viciHasHits = false; setIconState(block, 'clear'); setHint(block, e.detail?.name ? `Loaded ${e.detail.name}` : 'Loaded file'); window.updateActionVisibility?.(); }); function init(root=document){ root.querySelectorAll('[data-vici-lookup], .vici-lookup').forEach(attach); } function setMode(block, mode, subtype=null){ if (!block) return; if (mode) block.dataset.viciMode = mode; else delete block.dataset.viciMode; if (subtype) block.dataset.viciSubtype = subtype; else delete block.dataset.viciSubtype; clearLookup(block, {keepInput:false}); updatePlaceholder(block); } window.ViciLookup = { fetch: fetchByMode, fetchById, normalizeMode, normalizeModeWithSubtype, attach, init, setMode, clear: (block, opts)=>clearLookup(block, opts) }; document.addEventListener('DOMContentLoaded', () => init()); })();