Spaces:
Running
Running
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| TDContext β the active construct. | |
| THE INVERSION THIS FILE EXISTS FOR | |
| ---------------------------------- | |
| Until now a tool was a DESTINATION. Eight routes, eight views, each with | |
| its own empty input box, each asking for the sequence again. The "hand-off" | |
| between them was a string copied from one <textarea> into another, and the | |
| backend never learned which project any run belonged to β so "what work | |
| belongs together?" could only be reconstructed afterwards by matching name | |
| strings, which merges every artifact left at its default name and splits a | |
| project the moment you rename something. | |
| Here the construct is the noun and the tools are verbs applied to it. One | |
| selection, held in one place, carried across every view and attached to | |
| every request. Attribution stops being a reconstruction and becomes a | |
| record β which is also what makes a real lineage graph possible later, | |
| without hand-instrumenting each hand-off path. | |
| THREE RULES, and each one is a way this could become worse than what it | |
| replaces: | |
| 1. SIGNED OUT IT MUST BEHAVE EXACTLY AS BEFORE. Public tool access was a | |
| deliberate decision. A visitor with no account keeps a purely local | |
| selection; nothing here may become a precondition for running a tool. | |
| 2. IT MUST NEVER BLOCK. Pasting a new sequence switches the context | |
| silently. The moment this can say "no", it is modal, and modal is worse | |
| than scattered. | |
| 3. IT MUST NEVER CLOBBER. Pre-filling only ever touches an input the user | |
| has left EMPTY. Overwriting something they typed would be the single | |
| fastest way to make people distrust the whole idea. | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| (function () { | |
| 'use strict'; | |
| var LS_KEY = 'td.construct.v1'; | |
| var listeners = []; | |
| var active = null; // {id?, name, dna, protein, identifier, phase} | |
| var known = []; // server-side list, for the switcher | |
| /* ββ state ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function load() { | |
| try { | |
| var raw = localStorage.getItem(LS_KEY); | |
| active = raw ? JSON.parse(raw) : null; | |
| } catch (e) { active = null; } | |
| } | |
| function persist() { | |
| try { | |
| if (active) localStorage.setItem(LS_KEY, JSON.stringify(active)); | |
| else localStorage.removeItem(LS_KEY); | |
| } catch (e) { /* private mode β the session still works, just not across reloads */ } | |
| } | |
| function notify() { | |
| listeners.forEach(function (fn) { | |
| try { fn(active); } catch (e) { console.error(e); } | |
| }); | |
| } | |
| function get() { return active; } | |
| function id() { return active && active.id ? active.id : null; } | |
| /* ββ the pending source ββββββββββββββββββββββββββββββββββββββββββ | |
| A hand-off knows something the save that follows it does not: WHICH | |
| artifact the sequence came from, and which region. That fact is alive | |
| only for the moment between "send this plasmid region to CRISPR" and | |
| "save these guides", so it is parked here and attached to the save. | |
| Without it every edge would point at the construct root β true, but the | |
| least useful true thing we could record. | |
| Deliberately cleared whenever the construct changes: a source that | |
| outlives its context would attribute new work to an unrelated parent, | |
| and a confidently wrong edge is worse than a vague one. */ | |
| var pendingSource = null; | |
| function setSource(src) { | |
| pendingSource = (src && src.kind && src.id) ? { | |
| kind: String(src.kind), id: String(src.id), | |
| relation: src.relation || 'derived_from', | |
| detail: src.detail || {}, | |
| } : null; | |
| } | |
| function source() { return pendingSource; } | |
| function set(c, opts) { | |
| var changed = !active || !c || active.id !== c.id || active.dna !== c.dna; | |
| active = c || null; | |
| if (changed) pendingSource = null; // never let a source outlive its context | |
| persist(); | |
| notify(); | |
| if (!(opts && opts.quiet) && active) { | |
| toast('Working on ' + active.name); | |
| } | |
| } | |
| function clear() { set(null); notify(); } | |
| function subscribe(fn) { listeners.push(fn); try { fn(active); } catch (e) {} } | |
| function toast(msg) { | |
| if (typeof window.showToast === 'function') window.showToast(msg, 'info'); | |
| } | |
| /* ββ adopting a sequence ββββββββββββββββββββββββββββββββββββββββββ */ | |
| // Called whenever a sequence enters the app by ANY route β pasted, fetched, | |
| // imported, or handed over from another tool. Switches silently; creates | |
| // server-side only when signed in. Rule 2: this never refuses. | |
| function adopt(seq, label, opts) { | |
| opts = opts || {}; | |
| var dna = String(seq || '').replace(/\s+/g, '').toUpperCase(); | |
| if (dna.length < 12) return Promise.resolve(active); | |
| // Same sequence = same work. Do not spawn a second project for it. | |
| if (active && active.dna === dna) return Promise.resolve(active); | |
| var local = { | |
| id: null, | |
| name: label || defaultName(dna), | |
| dna: dna, | |
| protein: opts.protein || '', | |
| identifier: opts.identifier || '', | |
| phase: 'Design', | |
| }; | |
| set(local, { quiet: !!opts.quiet }); | |
| // Signed out this is where it ends, and that is a complete experience: | |
| // the selection still threads every view for this session. | |
| return fetch('/api/constructs', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| sequence_dna: dna, wt_protein: local.protein, | |
| name: local.name, wt_identifier: local.identifier, | |
| }), | |
| }).then(function (r) { | |
| if (!r.ok) return null; // 401 signed out β expected, not an error | |
| return r.json(); | |
| }).then(function (d) { | |
| if (!d || !d.ok || !d.construct) return active; | |
| // Server wins on identity, the local copy wins on payload: the | |
| // list endpoint deliberately does not ship the sequence back. | |
| active = Object.assign({}, local, { | |
| id: d.construct.id, | |
| name: d.construct.name || local.name, | |
| phase: d.construct.phase || 'Design', | |
| }); | |
| persist(); notify(); refreshList(); | |
| return active; | |
| }).catch(function () { return active; }); | |
| } | |
| function defaultName(dna) { | |
| // Never "Untitled". A name you cannot tell apart from four others is | |
| // the failure mode the name-string grouping already suffers from. | |
| return 'Construct ' + dna.slice(0, 6) + 'β¦' + dna.slice(-4); | |
| } | |
| /* ββ the server-side list, for the switcher βββββββββββββββββββββββ */ | |
| function refreshList() { | |
| return fetch('/api/constructs').then(function (r) { | |
| return r.ok ? r.json() : null; | |
| }).then(function (d) { | |
| known = (d && d.constructs) || []; | |
| notify(); | |
| return known; | |
| }).catch(function () { return known; }); | |
| } | |
| function list() { return known; } | |
| function open(constructId) { | |
| return fetch('/api/constructs/' + encodeURIComponent(constructId)) | |
| .then(function (r) { return r.ok ? r.json() : null; }) | |
| .then(function (d) { | |
| if (!d || !d.construct) return null; | |
| var c = d.construct; | |
| set({ | |
| id: c.id, name: c.name, dna: c.sequence_dna || '', | |
| protein: c.wt_protein || '', identifier: c.wt_identifier || '', | |
| phase: c.phase || 'Design', | |
| }); | |
| return active; | |
| }).catch(function () { return null; }); | |
| } | |
| function rename(name) { | |
| if (!active) return Promise.resolve(null); | |
| active.name = String(name || '').slice(0, 120) || active.name; | |
| persist(); notify(); | |
| if (!active.id) return Promise.resolve(active); | |
| return fetch('/api/constructs/' + encodeURIComponent(active.id), { | |
| method: 'PATCH', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ name: active.name }), | |
| }).then(function () { refreshList(); return active; }) | |
| .catch(function () { return active; }); | |
| } | |
| /* ββ the interceptor ββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| // ONE place, not twenty. Editing every tool's fetch call by hand would | |
| // have meant instrumenting ~20 sites and still missing whichever one gets | |
| // added next month β and a lineage with a silent hole in it is worse than | |
| // no lineage, because you cannot tell which is which. | |
| var TOOL_PREFIXES = ['/api/crispr', '/api/primers', '/api/plasmid', | |
| '/api/design', '/api/dna', '/api/compiler', | |
| '/api/de/', '/api/align', '/api/structure']; | |
| var nativeFetch = window.fetch.bind(window); | |
| function isToolCall(url) { | |
| var u = String(url || ''); | |
| for (var i = 0; i < TOOL_PREFIXES.length; i++) { | |
| if (u.indexOf(TOOL_PREFIXES[i]) === 0) return true; | |
| } | |
| return false; | |
| } | |
| window.fetch = function (input, init) { | |
| try { | |
| var url = (typeof input === 'string') ? input | |
| : (input && input.url) || ''; | |
| var cid = id(); | |
| if (cid && init && init.method && | |
| String(init.method).toUpperCase() === 'POST' && | |
| isToolCall(url) && typeof init.body === 'string') { | |
| var body = JSON.parse(init.body); | |
| if (body && typeof body === 'object' && !Array.isArray(body)) { | |
| var touched = false; | |
| if (!body.construct_id) { body.construct_id = cid; touched = true; } | |
| // Only on a SAVE: that is the call that mints the artifact | |
| // the edge will point at. Attaching it to every tool call | |
| // would record an edge per keystroke-triggered request. | |
| if (pendingSource && !body.derived_from && | |
| /\/save$/.test(String(url).split('?')[0])) { | |
| body.derived_from = pendingSource; | |
| touched = true; | |
| } | |
| if (touched) { | |
| init = Object.assign({}, init, { body: JSON.stringify(body) }); | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| // A body that is not JSON, or is not ours to touch. Attribution is | |
| // never worth breaking the request the user actually made. | |
| } | |
| return nativeFetch(input, init); | |
| }; | |
| /* ββ pre-filling a tool view ββββββββββββββββββββββββββββββββββββββ */ | |
| // Rule 3: only ever fills an input the user left EMPTY. | |
| var TOOL_INPUTS = { | |
| crispr: 'crisprInput', | |
| primers: 'primerTemplate', | |
| design: 'pasteArea', | |
| compiler: 'tcWindow', | |
| dna: 'dnaReference', | |
| plasmid: 'plasmidInput', | |
| }; | |
| function prefill(route) { | |
| if (!active || !active.dna) return false; | |
| var elId = TOOL_INPUTS[route]; | |
| if (!elId) return false; | |
| var el = document.getElementById(elId); | |
| if (!el || (el.value || '').trim()) return false; // never clobber | |
| el.value = active.dna; | |
| el.dispatchEvent(new Event('input', { bubbles: true })); | |
| return true; | |
| } | |
| load(); | |
| window.TDContext = { | |
| get: get, id: id, set: set, clear: clear, subscribe: subscribe, | |
| adopt: adopt, open: open, rename: rename, list: list, | |
| refreshList: refreshList, prefill: prefill, | |
| setSource: setSource, source: source, | |
| TOOL_INPUTS: TOOL_INPUTS, | |
| }; | |
| // The list is only meaningful signed in; a 401 here is normal and silent. | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', function () { refreshList(); }); | |
| } else { refreshList(); } | |
| }()); | |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| The chip + switcher. | |
| Rule 3 has a UI corollary: switching and clearing must be one click and | |
| always visible. A context you cannot see or change is worse than no | |
| context β you end up editing the wrong object and only find out later. | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| (function () { | |
| 'use strict'; | |
| var C = window.TDContext; | |
| if (!C) return; | |
| function esc(s) { | |
| return String(s == null ? '' : s).replace(/[&<>"']/g, function (m) { | |
| return ({ '&': '&', '<': '<', '>': '>', | |
| '"': '"', "'": ''' })[m]; | |
| }); | |
| } | |
| function bp(n) { return n ? n.toLocaleString() + ' bp' : ''; } | |
| function init() { | |
| var chip = document.getElementById('ctxChip'); | |
| var menu = document.getElementById('ctxMenu'); | |
| if (!chip || !menu) return; | |
| C.subscribe(function (c) { | |
| chip.hidden = !c; | |
| document.body.setAttribute('data-ctx', c ? 'on' : 'off'); | |
| if (!c) return; | |
| var nm = document.getElementById('ctxName'); | |
| var mt = document.getElementById('ctxMeta'); | |
| if (nm) nm.textContent = c.name || 'Construct'; | |
| if (mt) { | |
| // The un-saved case is stated, not hidden. A signed-out user | |
| // whose selection lives only in this browser should know that. | |
| mt.textContent = [bp((c.dna || '').length), | |
| c.phase || '', | |
| c.id ? '' : 'this browser only'] | |
| .filter(Boolean).join(' Β· '); | |
| } | |
| }); | |
| function close() { | |
| menu.hidden = true; | |
| chip.setAttribute('aria-expanded', 'false'); | |
| } | |
| function render() { | |
| var cur = C.get(); | |
| var rows = C.list() || []; | |
| var h = '<div class="ctx-menu-hd">Constructs</div>'; | |
| if (!rows.length) { | |
| h += '<div class="ctx-menu-empty">No saved projects yet. ' | |
| + 'Paste or fetch a sequence and it becomes one.</div>'; | |
| } | |
| rows.forEach(function (r) { | |
| var on = cur && cur.id === r.id; | |
| h += '<button class="ctx-item' + (on ? ' is-on' : '') + '" role="menuitem"' | |
| + ' data-open="' + esc(r.id) + '">' | |
| + '<span class="ctx-item-nm">' + esc(r.name) + '</span>' | |
| + '<span class="ctx-item-mt mono">' + esc(r.phase || 'Design') | |
| + (r.n_plasmids ? ' Β· ' + r.n_plasmids + 'p' : '') | |
| + (r.n_crispr ? ' Β· ' + r.n_crispr + 'g' : '') | |
| + (r.n_primers ? ' Β· ' + r.n_primers + 'pr' : '') | |
| + '</span></button>'; | |
| }); | |
| h += '<div class="ctx-menu-sep"></div>'; | |
| if (cur) { | |
| h += '<button class="ctx-act" role="menuitem" data-rename="1">Renameβ¦</button>'; | |
| h += '<button class="ctx-act" role="menuitem" data-clear="1">' | |
| + 'Work without a construct</button>'; | |
| } | |
| menu.innerHTML = h; | |
| } | |
| chip.addEventListener('click', function (e) { | |
| e.stopPropagation(); | |
| if (!menu.hidden) { close(); return; } | |
| render(); | |
| menu.hidden = false; | |
| chip.setAttribute('aria-expanded', 'true'); | |
| C.refreshList().then(function () { if (!menu.hidden) render(); }); | |
| }); | |
| menu.addEventListener('click', function (e) { | |
| var t = e.target.closest('[data-open],[data-clear],[data-rename]'); | |
| if (!t) return; | |
| e.stopPropagation(); | |
| if (t.dataset.open) { C.open(t.dataset.open); close(); return; } | |
| if (t.dataset.clear) { | |
| // Deliberately NOT called "delete". Clearing the selection | |
| // must never read as destroying the work. | |
| C.clear(); close(); return; | |
| } | |
| if (t.dataset.rename) { | |
| var cur = C.get(); | |
| var next = window.prompt('Rename this construct', cur && cur.name); | |
| if (next) C.rename(next); | |
| close(); | |
| } | |
| }); | |
| document.addEventListener('click', function () { if (!menu.hidden) close(); }); | |
| document.addEventListener('keydown', function (e) { | |
| if (e.key === 'Escape' && !menu.hidden) close(); | |
| }); | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', init); | |
| } else { init(); } | |
| }()); | |