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 = `
`;
}
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 = `
Path: /
No files available.
`;
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 = `
Vici Lookup
i
Lookup for this input: Protein (UniProt/UniParc/Ensembl/RefSeq), DNA/RNA (ENA/RefSeq/Ensembl), Ligand (PubChem/ChEMBL), or PDB (RCSB).
`;
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 = `
Results
i
Select a hit to fill the target field.
`;
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());
})();