/* Private shared projects. No polling, persistent sequence cache or tool calls on open. */
(function () {
'use strict';
// The site that wraps the engine. Sign-in and sign-up live there, never
// inside this frame, and are opened in the TOP window.
var SITE = 'https://turingdna.com';
var KIND = {library:'Variant library', plasmid:'Plasmid', crispr:'CRISPR design', primer:'Primer set'};
var EMAIL = {queued:'Email queued', sent:'Email sent', failed:'Email failed — check the address and resend', opened:'Opened the invitation link'};
var OUTCOMES = ['measured','worked','partial','failed'];
var dialog, body, status, current, generation = 0, dirty = false;
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); }
function role(r) { return r === 'edit' ? 'Editor' : r === 'owner' ? 'Owner' : 'Viewer'; }
function day(t) { var d = new Date(t); return isNaN(d) ? '' : d.toLocaleDateString(undefined, {day:'numeric', month:'short', year:'numeric'}); }
function button(text, action, value) { return ''; }
async function api(id, action, data) {
var options = action ? {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(Object.assign({}, data, {action:action}))} : {};
var r = await fetch('/api/workspaces' + (id ? '/' + encodeURIComponent(id) : ''), options);
var d = await r.json();
if (!r.ok) throw new Error(d.error || 'Could not load this project.');
return d;
}
async function benchApi(id, rows, libraryId) {
// The server checks library_id against the one the project links and
// writes that one; sending it is how a stale page fails loudly
// instead of recording against a library the project has moved off.
var r = await fetch('/api/workspaces/' + encodeURIComponent(id) + '/measurements', rows ?
{method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({measurements:rows, library_id:libraryId})} : {});
var d = await r.json();
if (!r.ok) throw new Error(d.error || 'Could not load bench results.');
return d;
}
function message(text) { status.textContent = text || ''; }
function saveFile(blob, filename) {
var url = URL.createObjectURL(blob), a = document.createElement('a'); a.href = url; a.download = filename; a.click();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
}
function stem(name, kind) { return String(name || kind).replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 60) || kind; }
/* Spreadsheet-safe CSV. A text cell that starts with = + - @ is read as a
formula by Excel and Sheets; a shared result is somebody else's data,
so those cells are quoted inert. Numbers pass through untouched. */
function csv(rows) {
var keys = [];
rows.forEach(function (r) { Object.keys(r || {}).forEach(function (k) { if (keys.indexOf(k) < 0 && (r[k] == null || typeof r[k] !== 'object')) keys.push(k); }); });
function cell(v) {
if (v == null) return '';
if (typeof v !== 'number' && /^[=+\-@]/.test(String(v)) && isNaN(Number(v))) v = "'" + v;
v = String(v);
return /[",\r\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v;
}
return [keys.map(cell).join(',')].concat(rows.map(function (r) { return keys.map(function (k) { return cell(r[k]); }).join(','); })).join('\r\n');
}
/* Each result in the format the next tool expects: GenBank for a plasmid
(the engine's own exporter, so features survive), CSV for anything
tabular, JSON only when the result has no table in it. */
async function download(id, kind, artifactId, name) {
var d = await api(id, 'artifact', {kind:kind, artifact_id:artifactId}), a = d.artifact, base = stem(name || a.name || a.label, kind);
if (kind === 'plasmid' && a.sequence) {
var r = await fetch('/api/plasmid/export', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:a.name || base, sequence:a.sequence, topology:a.topology, features:a.features || [], format:'genbank'})});
if (!r.ok) { var e = {}; try { e = await r.json(); } catch (_) {} throw new Error(e.error || 'Could not export this plasmid.'); }
saveFile(await r.blob(), base + '.gb'); return 'GenBank file downloaded.';
}
var rows = kind === 'library' ? a.variants : kind === 'crispr' ? a.guides : null;
if (Array.isArray(rows) && rows.length && rows[0] && typeof rows[0] === 'object') {
saveFile(new Blob([csv(rows)], {type:'text/csv'}), base + '.csv'); return 'CSV downloaded.';
}
saveFile(new Blob([JSON.stringify(a, null, 2)], {type:'application/json'}), base + '.json'); return 'Result downloaded.';
}
function mount() {
if (dialog) return;
dialog = document.createElement('dialog'); dialog.className = 'ws-dialog'; dialog.setAttribute('aria-labelledby','wsTitle');
dialog.innerHTML = 'Project workspace
Invite researchers to a specific project. Sequences and linked results are visible only to accepted collaborators.
' + (d.invitations.length ? 'Save a project in the engine to invite collaborators.
'); } function invitationCard(i) { return 'Invitation
' + ''+(i.permission==='edit' ? 'As an editor you can update the project input, add your own results and record bench measurements.' : 'As a viewer you can see and download the sequence and results, and record bench measurements.')+'
' + 'Nothing in your own account is shared by joining. Runs you start stay private until you add them to the project. Expires '+esc(day(i.expires_at))+'.
' + ''+role(p.role)+' · revision '+esc(p.revision)+(p.owner_name&&p.role!=='owner'?' · owned by '+esc(p.owner_name):'')+' · private
'+expiryBanner(p)+ 'Engine runs use your own account and credits. New results remain private until you add them here. Private chats and unrelated work are never shared.
'+ 'Loading bench results…
' : 'Bench results can be recorded once the project has a variant library.
')+''+(p.owner_name?esc(p.owner_name)+' · ':'')+'Project owner · full control
'+d.members.map(function(m){ return 'No collaboration activity yet.
'); message(''); if (p.library_id) loadBench(id).catch(function (e) { var box=document.getElementById('wsBench'); if (box && current && current.id===id) box.innerHTML=''+esc(e.message)+'
'; }); } function namedResults(results) { if (!results.length) return 'No linked results yet.
'; return results.map(function(r){ var label = r.kind==='plasmid' ? 'GenBank' : (r.kind==='library'||r.kind==='crispr') ? 'CSV' : 'JSON'; var attrs = ' data-value="'+esc(r.id)+'" data-kind="'+esc(r.kind)+'" data-name="'+esc(r.name)+'"'; return 'No linked results yet.
'; } function renderResult(d) { var a=d.artifact, rows=a.variants||a.guides||a.features, h=''; if(Array.isArray(rows)&&rows.length&&typeof rows[0]==='object'&&rows[0]) { var keys=Object.keys(rows[0]).filter(function(k){return typeof rows[0][k]!=='object';}).slice(0,8); h=''+esc(rows.length)+' recorded entries · showing up to 30
| '+esc(k.replace(/_/g,' '))+' | ';}).join('')+'
|---|
| '+esc(r[k])+' | ';}).join('')+'
'+esc(JSON.stringify(a.result,null,2))+''; return h||'
Download the recorded result to inspect its complete data.
'; } /* One measurement per line: variant, value, outcome. Read from the right, because a variant can itself contain commas ("S1109T,W1126L") and the last two fields cannot: an outcome is one of four words and a value is a number. A header row is skipped. */ function parseBench(text) { var rows = []; String(text || '').split(/\r?\n/).forEach(function (line, n) { var parts = line.split(line.indexOf('\t') >= 0 ? '\t' : ',').map(function (s) { return s.trim(); }); while (parts.length && parts[parts.length - 1] === '') parts.pop(); if (!parts.length || !parts.join('')) return; var outcome = 'measured', value = null; if (parts.length > 1 && OUTCOMES.indexOf(parts[parts.length - 1].toLowerCase()) >= 0) outcome = parts.pop().toLowerCase(); if (parts.length > 1 && parts[parts.length - 1] !== '' && !isNaN(Number(parts[parts.length - 1]))) value = Number(parts.pop()); var mutations = parts.join(',').trim(); if (n === 0 && value === null && /mutation|variant/i.test(mutations)) return; if (!mutations) throw new Error('Line ' + (n + 1) + ' has no variant.'); rows.push({mutations: mutations, measured_value: value, outcome: outcome}); }); if (!rows.length) throw new Error('Paste at least one measurement: variant, value, outcome.'); return rows; } async function loadBench(id) { var d = await benchApi(id), box = document.getElementById('wsBench'); if (!box || !current || current.id !== id) return; var rows = d.measurements || [], mine = rows.filter(function (m) { return m.mine; }); var people = {}; rows.forEach(function (m) { people[m.measured_by_name || 'someone'] = 1; }); box.innerHTML = (rows.length ? ''+rows.length+' measurement'+(rows.length===1?'':'s')+' from '+Object.keys(people).length+' '+(Object.keys(people).length===1?'person':'people')+' · newest first'+(rows.length>50?' · showing 50':'')+'
' + '| Variant | Value | Outcome | Recorded by | Date |
|---|---|---|---|---|
| '+esc(m.mutations)+' | '+esc(m.measured_value == null ? '' : m.measured_value)+' | '+esc(m.outcome)+' | '+esc(m.mine ? 'You' : (m.measured_by_name || 'Collaborator'))+' | '+esc(day(m.created_at))+' |
No bench results yet. Whoever runs the assay can record them here, and the designer sees them at once.
') + 'No saved results to contribute yet. Run a tool and save its result first.
'; } async function show(id, opts) { mount(); if(!dialog.open) dialog.showModal(); try { if (opts && opts.invitation) await list(id); else if(id) await openProject(id); else await list(); } catch(e) {message(e.message);} } window.TDWorkspaces={show:show}; var entry=document.getElementById('workspaceCollaboration'); if(entry) entry.addEventListener('click',function(){show();}); /* The research-use acceptance comes first. It is a gate a new account must click through (app.js opens it before this file runs), and an emailed invitation is exactly how new accounts arrive: a modal dialog opened on top of it covered the one button that dismisses it. */ function afterConsent(fn) { var gate = document.getElementById('firstUseModal'); if (!gate || gate.hidden) return fn(); var watch = new MutationObserver(function () { if (gate.hidden) { watch.disconnect(); fn(); } }); watch.observe(gate, {attributes:true, attributeFilter:['hidden']}); } var deepLink=new URLSearchParams(location.search).get('workspace'); if(deepLink === 'invitations') afterConsent(function(){ show(); }); else if(deepLink && /^[0-9a-f-]{36}$/i.test(deepLink)) afterConsent(function(){ show(deepLink); }); /* Arriving from an emailed invitation. The token in the link grants nothing on its own: the server requires the signed-in caller's VERIFIED email to match the address it was sent to. Claiming is idempotent, so a mail client that prefetches the link cannot consume it, and landing here signed out is not an error -- sign in and the same link still works. The project is NOT opened directly. Until the invitation is accepted the caller is not a member, so 'get' refuses by design -- which is what every first click used to land on: an error over an empty dialog, with the Accept button somewhere else. The invitation card comes first. SIGNED OUT IS THE NORMAL CASE, not the edge one: an invitation is addressed to somebody who may have no account at all. The site's /app/ page sends signed-out visitors to sign-in before the engine loads; this is the fallback for when it could not, and it offers both doors. */ function stashInvite(t){ try{ sessionStorage.setItem('td.invite',t); }catch(e){} } function takeInvite(){ try{ var t=sessionStorage.getItem('td.invite'); if(t) sessionStorage.removeItem('td.invite'); return t; }catch(e){ return null; } } var inviteToken=new URLSearchParams(location.search).get('invite')||takeInvite(); if(inviteToken && /^[0-9a-f]{32,80}$/i.test(inviteToken)) afterConsent(function(){ fetch('/api/invites/claim',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({token:inviteToken})}) .then(function(r){return r.json().then(function(d){return {ok:r.ok,status:r.status,d:d};}, function(){return {ok:r.ok,status:r.status,d:{}};});}) .then(function(res){ if(res.ok){ if(res.d.kind==='project'&&res.d.target_id) return show(res.d.target_id,{invitation:true}); return show(); } if(res.status===401){ stashInvite(inviteToken); mount(); if(!dialog.open) dialog.showModal(); var next=encodeURIComponent('/app/?invite='+inviteToken); body.innerHTML='Invitation
Sign in, or create a free account, with the email address this invitation was sent to. The invitation will be waiting.
'+ '