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 ({ '&': '&amp;', '<': '&lt;', '>': '&gt;',
                      '"': '&quot;', "'": '&#39;' })[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(); }
}());