Spaces:
Running
Running
File size: 18,076 Bytes
7443b93 10a3564 7443b93 10a3564 7443b93 10a3564 7443b93 10a3564 7443b93 10a3564 7443b93 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 | /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
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(); }
}());
|