/* 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 = '
Private collaboration

Project workspace

' + button('Close','close') + '

'; document.body.appendChild(dialog); body = dialog.querySelector('.ws-body'); status = dialog.querySelector('.ws-status'); dialog.addEventListener('cancel', function (e) { if (dirty && !window.confirm('Discard this unsaved draft?')) e.preventDefault(); }); dialog.addEventListener('close', function () { if(dialog.open) return; generation++; dirty = false; current = null; body.replaceChildren(); }); body.addEventListener('input', function (e) { if (e.target.closest('#wsEdit')) dirty = true; }); dialog.addEventListener('click', async function (e) { var b = e.target.closest('[data-ws]'); if (!b || b.disabled) return; var action = b.dataset.ws, value = b.dataset.value, id = current && current.id; if (dirty && ['role','revoke','open','contribute','leave','resend','open-plasmid'].indexOf(action)!==-1 && !window.confirm('Discard this unsaved draft?')) return; message(''); b.disabled = true; try { if (action === 'close') { if (!dirty || window.confirm('Discard this unsaved draft?')) dialog.close(); } if (action === 'list') { if (!dirty || window.confirm('Discard this unsaved draft?')) await list(); } if (action === 'open') await openProject(value); if (action === 'reload') { if (!dirty || window.confirm('Discard this draft and load the latest revision?')) await openProject(id); } if (action === 'accept') { // Straight into the project: accepting and then hunting for // it in a list is one step too many for somebody new here. var accepted = await api(null,'respond',{invitation_id:value,response:'accept'}); if (accepted.project_id) { await openProject(accepted.project_id); message('You joined this project.'); } else await list(); } if (action === 'decline') { await api(null,'respond',{invitation_id:value,response:'decline'}); await list(); message('Invitation declined.'); } if (action === 'revoke') { await api(id,'revoke',{member_id:value}); await openProject(id); message('Access revoked. A pending invitation email is withdrawn too.'); } if (action === 'role') { await api(id,'role',{member_id:value,permission:b.dataset.permission}); await openProject(id); } if (action === 'resend') { if (!window.confirm('Send the invitation to ' + value + ' again? It will last another 30 days.')) return; var again = await api(id,'invite',{email:value, permission:b.dataset.permission, consent:true}); await openProject(id); message(again && again.invite_emailed ? 'Invitation sent again.' : 'Invitation renewed, but the email could not be queued. They can still find it under Shared projects in Turing.'); } if (action === 'leave') { if (!window.confirm('Leave this project? You will lose access to its sequences and results until the owner invites you again.')) return; await api(id,'leave'); await list(); message('You left the project.'); } if (action === 'artifact') { var d = await api(id,'artifact',{kind:b.dataset.kind,artifact_id:value}); var panel=document.getElementById('wsResult'); panel.innerHTML='

'+esc(b.dataset.name||d.artifact.name||d.artifact.label||KIND[d.kind]||'Result')+'

'+renderResult(d); panel.scrollIntoView({block:'nearest',behavior:'smooth'}); } if (action === 'download') message(await download(id, b.dataset.kind, value, b.dataset.name)); if (action === 'open-plasmid') { // A working copy under the acting account, like "Use saved // input": saving it never writes back to its author. var pl = await api(id,'artifact',{kind:'plasmid',artifact_id:value}); if (!window.TDPlasmid || !window.TDPlasmid.openRecord || !window.TDPlasmid.openRecord({ name:(pl.artifact.name || 'Shared plasmid') + ' (shared)', sequence:pl.artifact.sequence, topology:pl.artifact.topology, features:pl.artifact.features})) throw new Error('Could not open this plasmid in the editor.'); dialog.close(); } if (action === 'use') { // Re-authorize on every handoff. Runs stay on the acting user's // account; new outputs are private until explicitly contributed. var latest = await api(id); var p = latest.project; if (!p.sequence_dna && !p.wt_protein) throw new Error('This project has no input sequence.'); if (dirty && !window.confirm('Use the saved input? Your unsaved draft will be discarded.')) return; await window.TDNavigate.workspaceInput(p); dialog.close(); } if (action === 'contribute') await contributePicker(); } catch (err) { message(err.message); } finally { b.disabled = false; } }); dialog.addEventListener('submit', async function (e) { e.preventDefault(); var form=e.target, b=form.querySelector('[type=submit]'); if (!b || b.disabled) return; if(form.id!=='wsEdit' && dirty && !window.confirm('Discard this unsaved project draft?')) return; b.disabled=true; message(''); var data=Object.fromEntries(new FormData(form)), id=current.id; try { if (form.id==='wsEdit') { await api(id,'save',Object.assign(data,{revision:current.revision})); dirty=false; await openProject(id); message('Project saved.'); } if (form.id==='wsInvite') { var inv=await api(id,'invite',Object.assign(data,{consent:form.elements.consent.checked})); await openProject(id); message(inv && inv.invite_emailed ? 'Invitation sent. They will get an email with a link; it expires in 30 days.' : 'Invitation added, but the email could not be queued. They can still find it under Shared projects in Turing.'); } if (form.id==='wsContribute') { var parts=data.result.split(':'); await api(id,'contribute',{kind:parts[0],artifact_id:parts[1],revision:current.revision,consent:form.elements.consent.checked}); await openProject(id); message('Result added to this project.'); } if (form.id==='wsMeasure') { var rows = parseBench(data.rows); var saved = await benchApi(id, rows, current.library_id); await loadBench(id); message('Recorded ' + saved.n + ' measurement' + (saved.n === 1 ? '' : 's') + '. Everyone in the project can see them.'); } } catch (err) { message(err.message); } finally { b.disabled=false; } }); } /* `focus` is the project an emailed link pointed at. Its invitation goes first, as the one thing on the page, because the person who clicked the email came to answer it -- not to read a list. */ async function list(focus) { var ticket=++generation; dirty=false; current=null; message('Loading projects…'); var d=await api(); if(ticket!==generation || !dialog.open) return; message(''); if (focus) { var mine = (d.projects||[]).some(function(p){return p.id===focus;}); var inv = (d.invitations||[]).filter(function(i){return i.construct_id===focus;})[0]; if (!inv && mine) return openProject(focus); if (inv) { body.innerHTML = invitationCard(inv); return; } message('This invitation has been accepted, declined or withdrawn, or it has expired. Ask the person who sent it to invite you again.'); } body.innerHTML='

Invite researchers to a specific project. Sequences and linked results are visible only to accepted collaborators.

' + (d.invitations.length ? '

Invitations

' + d.invitations.map(function(i){return '
'+esc(i.project_name || 'A shared project')+'From '+esc(i.invited_by || 'a researcher')+' · '+role(i.permission)+' access · expires '+esc(day(i.expires_at))+'
'+button('Accept','accept',i.id)+button('Decline','decline',i.id)+'
';}).join('') : '') + '

Your projects and shared work

'+(d.projects.length ? d.projects.map(function(p){return '
'+esc(p.name)+''+role(p.role)+' · '+esc(p.phase)+(p.shared?' · shared':'')+(p.expires_at?' · deleted '+esc(day(p.expires_at)):'')+'
'+button('Open','open',p.id)+'
';}).join('') : '

Save a project in the engine to invite collaborators.

'); } function invitationCard(i) { return '
' + '

Invitation

' + '

'+esc(i.invited_by || 'A researcher')+' invited you to '+esc(i.project_name || 'a project')+'

' + '

'+(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))+'.

' + '
'+button('Accept and open','accept',i.id)+button('Decline','decline',i.id)+button('All projects','list')+'
' + '
'; } function expiryBanner(p) { if (!p.expires_at) return ''; var when = esc(day(p.expires_at)); return '

'+(p.role==='owner' ? 'This project is on the free plan and will be deleted on '+when+', with everyone’s access to it. Keep it with a paid plan — paid work never expires.' : 'This project is on its owner’s free plan and will be deleted on '+when+'. Download anything you need, or ask the owner to keep it.')+'

'; } async function openProject(id) { var ticket=++generation; message('Loading project…'); var d=await api(id); if(ticket!==generation || !dialog.open) return; current=d.project; dirty=false; var p=current, editable=p.role!=='read'; var attrs=editable?'':' readonly'; body.innerHTML='
'+button('All projects','list')+button('Reload latest','reload')+(p.role!=='owner'?button('Leave project','leave'):'')+'

'+esc(p.name)+'

'+role(p.role)+' · revision '+esc(p.revision)+(p.owner_name&&p.role!=='owner'?' · owned by '+esc(p.owner_name):'')+' · private

'+expiryBanner(p)+ '
'+ '
Project input sequencesIf you change DNA, update its protein separately where appropriate. Existing results retain their original inputs.
'+ (editable?'':'')+'
'+button('Use saved input in engine','use')+(editable?button('Add a saved result','contribute'):'')+'

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.

'+ '

Shared results

'+(Array.isArray(d.results) ? namedResults(d.results) : '
'+artifacts(p)+'
')+'
'+ '

Bench results

'+(p.library_id ? '

Loading bench results…

' : '

Bench results can be recorded once the project has a variant library.

')+'
'+ '

People with access

'+(p.owner_name?esc(p.owner_name)+' · ':'')+'Project owner · full control

'+d.members.map(function(m){ return '
'+esc(m.display_name||m.invited_email||'Collaborator')+''+role(m.permission)+' · '+esc(m.state)+(m.state==='pending'&&m.expires_at?' · expires '+esc(day(m.expires_at)):'')+(m.email_status?' · '+esc(EMAIL[m.email_status]||m.email_status):'')+'
'+(p.role==='owner'?'
'+ (m.state!=='expired'?'':'')+ ((m.state==='pending'||m.state==='expired')&&m.invited_email?'':'')+ button('Revoke','revoke',m.id)+'
':'')+'
'; }).join('')+(p.role==='owner'?'
Invite a researcher

We email them a link. It only works for this address, after they verify it, and it expires in 30 days. Someone without an account is taken to sign-up first.

':'')+ '

Project activity

'+(d.activity.length?'
    '+d.activity.map(function(a){return '
  1. '+esc(a.display_name||'Researcher')+' · '+esc({save:'updated the project',invite:'invited a collaborator',role:'changed access',revoke:'revoked access',contribute:'added a result',measure:'recorded bench results',leave:'left the project',invitation_accept:'accepted an invitation',invitation_decline:'declined an invitation'}[a.kind]||a.kind)+''+esc(new Date(a.created_at).toLocaleString())+'
  2. ';}).join('')+'
':'

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 '
'+esc(r.name)+''+esc(KIND[r.kind]||r.kind)+(r.added_by?' · added by '+esc(r.mine?'you':r.added_by):'')+(r.created_at?' · '+esc(day(r.created_at)):'')+'
'+ ''+ ''+ (r.kind==='plasmid'?'':'')+ '
'; }).join(''); } function artifacts(p) { var rows=[]; [['library',p.library_id?[p.library_id]:[]],['plasmid',p.plasmid_ids],['crispr',p.crispr_ids],['primer',p.primer_ids]].forEach(function(pair){(pair[1]||[]).forEach(function(id,i){ rows.push(' '); });}); return rows.join('')||'

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

'+keys.map(function(k){return '';}).join('')+''+rows.slice(0,30).map(function(r){return ''+keys.map(function(k){return '';}).join('')+'';}).join('')+'
'+esc(k.replace(/_/g,' '))+'
'+esc(r[k])+'
'; } if(a.sequence) h+='
Construct sequence
'; if(a.result) h+='
'+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':'')+'

' + '
' + rows.slice(0, 50).map(function (m) { return ''; }).join('') + '
VariantValueOutcomeRecorded byDate
'+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.

') + ''+(mine.length?'Edit your bench results':'Record your bench results')+'
' + '' + '

Outcome is optional: measured, worked, partial or failed. Saving replaces the results you recorded before for this library. Other people’s results are never changed.

' + '
'; } async function contributePicker() { var id=current.id; var r=await fetch('/api/workspace-results'), d=await r.json(); if(!r.ok) throw new Error(d.error||'Could not load saved results.'); if(!current||current.id!==id) return; document.getElementById('wsContribution').innerHTML=d.results.length?'

Adding a variant library replaces the project’s current library link. The previous library remains in its creator’s saved work.

':'

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

You have been invited to a project

'+ '

Sign in, or create a free account, with the email address this invitation was sent to. The invitation will be waiting.

'+ '
'; return; } return show().then(function(){message((res.d&&res.d.error)||'This invitation is not available.');}); }).catch(function(){}); }); })();