Spaces:
Running
Running
Download dee/static/workspaces.js from WINTER4000/syntheogenesis: direct link, hf CLI and curl.
- Browser
- Download file 32.8 kB
-
https://huggingface.co/spaces/WINTER4000/syntheogenesis/resolve/main/dee/static/workspaces.js
- Command line
-
hf download hf://spaces/WINTER4000/syntheogenesis/dee/static/workspaces.js
-
curl -L -o workspaces.js https://huggingface.co/spaces/WINTER4000/syntheogenesis/resolve/main/dee/static/workspaces.js
32.8 kB
| /* 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 '<button type="button" data-ws="' + action + '" data-value="' + esc(value || '') + '">' + esc(text) + '</button>'; } | |
| 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 = '<header><div><span class="ws-eyebrow">Private collaboration</span><h2 id="wsTitle">Project workspace</h2></div>' + button('Close','close') + '</header><p class="ws-status" role="status" aria-live="polite"></p><div class="ws-body"></div>'; | |
| 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='<h3>'+esc(b.dataset.name||d.artifact.name||d.artifact.label||KIND[d.kind]||'Result')+'</h3>'+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='<p>Invite researchers to a specific project. Sequences and linked results are visible only to accepted collaborators.</p>' + | |
| (d.invitations.length ? '<h3>Invitations</h3>' + d.invitations.map(function(i){return '<article class="ws-row"><div><strong>'+esc(i.project_name || 'A shared project')+'</strong><small>From '+esc(i.invited_by || 'a researcher')+' · '+role(i.permission)+' access · expires '+esc(day(i.expires_at))+'</small></div><div>'+button('Accept','accept',i.id)+button('Decline','decline',i.id)+'</div></article>';}).join('') : '') + | |
| '<h3>Your projects and shared work</h3>'+(d.projects.length ? d.projects.map(function(p){return '<article class="ws-row"><div><strong>'+esc(p.name)+'</strong><small>'+role(p.role)+' · '+esc(p.phase)+(p.shared?' · shared':'')+(p.expires_at?' · deleted '+esc(day(p.expires_at)):'')+'</small></div>'+button('Open','open',p.id)+'</article>';}).join('') : '<p>Save a project in the engine to invite collaborators.</p>'); | |
| } | |
| function invitationCard(i) { | |
| return '<section class="ws-invite" aria-labelledby="wsInviteTitle">' + | |
| '<p class="ws-eyebrow">Invitation</p>' + | |
| '<h3 id="wsInviteTitle">'+esc(i.invited_by || 'A researcher')+' invited you to '+esc(i.project_name || 'a project')+'</h3>' + | |
| '<p>'+(i.permission==='edit' ? 'As an <strong>editor</strong> you can update the project input, add your own results and record bench measurements.' : 'As a <strong>viewer</strong> you can see and download the sequence and results, and record bench measurements.')+'</p>' + | |
| '<p class="ws-note">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))+'.</p>' + | |
| '<div class="ws-actions">'+button('Accept and open','accept',i.id)+button('Decline','decline',i.id)+button('All projects','list')+'</div>' + | |
| '</section>'; | |
| } | |
| function expiryBanner(p) { | |
| if (!p.expires_at) return ''; | |
| var when = esc(day(p.expires_at)); | |
| return '<p class="ws-banner" role="note">'+(p.role==='owner' | |
| ? 'This project is on the free plan and will be deleted on <strong>'+when+'</strong>, with everyone’s access to it. <a href="'+SITE+'/pricing/" target="_top" rel="noopener">Keep it with a paid plan</a> — paid work never expires.' | |
| : 'This project is on its owner’s free plan and will be deleted on <strong>'+when+'</strong>. Download anything you need, or ask the owner to keep it.')+'</p>'; | |
| } | |
| 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='<div class="ws-actions">'+button('All projects','list')+button('Reload latest','reload')+(p.role!=='owner'?button('Leave project','leave'):'')+'</div><h3>'+esc(p.name)+'</h3><p>'+role(p.role)+' · revision '+esc(p.revision)+(p.owner_name&&p.role!=='owner'?' · owned by '+esc(p.owner_name):'')+' · private</p>'+expiryBanner(p)+ | |
| '<form id="wsEdit"><label>Project name<input name="name" maxlength="120" required value="'+esc(p.name)+'"'+attrs+'></label><label>Phase<select name="phase"'+(editable?'':' disabled')+'>'+['Design','Build','Edit','Learn'].map(function(x){return '<option'+(p.phase===x?' selected':'')+'>'+x+'</option>';}).join('')+'</select></label>'+ | |
| '<details><summary>Project input sequences</summary><label>DNA<textarea name="sequence_dna" spellcheck="false"'+attrs+'>'+esc(p.sequence_dna)+'</textarea></label><label>Protein<textarea name="wt_protein" spellcheck="false"'+attrs+'>'+esc(p.wt_protein)+'</textarea></label><small>If you change DNA, update its protein separately where appropriate. Existing results retain their original inputs.</small></details>'+ | |
| (editable?'<button type="submit">Save changes</button>':'')+'</form><div class="ws-actions">'+button('Use saved input in engine','use')+(editable?button('Add a saved result','contribute'):'')+'</div><p class="ws-note">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.</p><div id="wsContribution"></div>'+ | |
| '<h3>Shared results</h3>'+(Array.isArray(d.results) ? namedResults(d.results) : '<div class="ws-results">'+artifacts(p)+'</div>')+'<div id="wsResult"></div>'+ | |
| '<h3>Bench results</h3><div id="wsBench">'+(p.library_id ? '<p class="ws-note">Loading bench results…</p>' : '<p class="ws-note">Bench results can be recorded once the project has a variant library.</p>')+'</div>'+ | |
| '<h3>People with access</h3><p>'+(p.owner_name?esc(p.owner_name)+' · ':'')+'Project owner · full control</p>'+d.members.map(function(m){ | |
| return '<article class="ws-row"><div><strong>'+esc(m.display_name||m.invited_email||'Collaborator')+'</strong><small>'+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):'')+'</small></div>'+(p.role==='owner'?'<div>'+ | |
| (m.state!=='expired'?'<button type="button" data-ws="role" data-value="'+esc(m.id)+'" data-permission="'+(m.permission==='read'?'edit':'read')+'">Make '+(m.permission==='read'?'editor':'viewer')+'</button>':'')+ | |
| ((m.state==='pending'||m.state==='expired')&&m.invited_email?'<button type="button" data-ws="resend" data-value="'+esc(m.invited_email)+'" data-permission="'+esc(m.permission)+'">Resend</button>':'')+ | |
| button('Revoke','revoke',m.id)+'</div>':'')+'</article>'; | |
| }).join('')+(p.role==='owner'?'<details><summary>Invite a researcher</summary><form id="wsInvite"><label>Email<input type="email" name="email" maxlength="254" required autocomplete="off"></label><label>Access<select name="permission"><option value="read">Viewer — read and download</option><option value="edit">Editor — update input and add results</option></select></label><label class="ws-consent"><input name="consent" type="checkbox" required> I permit this researcher to access and use this project, its sequences and linked results, with the selected permissions.</label><p class="ws-note">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.</p><button type="submit">Invite</button></form></details>':'')+ | |
| '<h3>Project activity</h3>'+(d.activity.length?'<ol>'+d.activity.map(function(a){return '<li>'+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)+'<small>'+esc(new Date(a.created_at).toLocaleString())+'</small></li>';}).join('')+'</ol>':'<p>No collaboration activity yet.</p>'); | |
| message(''); | |
| if (p.library_id) loadBench(id).catch(function (e) { var box=document.getElementById('wsBench'); if (box && current && current.id===id) box.innerHTML='<p class="ws-note">'+esc(e.message)+'</p>'; }); | |
| } | |
| function namedResults(results) { | |
| if (!results.length) return '<p>No linked results yet.</p>'; | |
| 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 '<article class="ws-row"><div><strong>'+esc(r.name)+'</strong><small>'+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)):'')+'</small></div><div>'+ | |
| '<button type="button" data-ws="artifact"'+attrs+'>View</button>'+ | |
| '<button type="button" data-ws="download"'+attrs+' aria-label="Download '+esc(r.name)+' as '+label+'">'+label+'</button>'+ | |
| (r.kind==='plasmid'?'<button type="button" data-ws="open-plasmid"'+attrs+'>Open in plasmid editor</button>':'')+ | |
| '</div></article>'; | |
| }).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('<span><button type="button" data-ws="artifact" data-value="'+esc(id)+'" data-kind="'+pair[0]+'">View '+pair[0]+' result '+(i+1)+'</button> <button type="button" data-ws="download" data-value="'+esc(id)+'" data-kind="'+pair[0]+'" aria-label="Download '+pair[0]+' result '+(i+1)+'">Download</button></span>'); });}); | |
| return rows.join('')||'<p>No linked results yet.</p>'; | |
| } | |
| 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='<p>'+esc(rows.length)+' recorded entries · showing up to 30</p><div class="ws-table"><table><thead><tr>'+keys.map(function(k){return '<th>'+esc(k.replace(/_/g,' '))+'</th>';}).join('')+'</tr></thead><tbody>'+rows.slice(0,30).map(function(r){return '<tr>'+keys.map(function(k){return '<td>'+esc(r[k])+'</td>';}).join('')+'</tr>';}).join('')+'</tbody></table></div>'; | |
| } | |
| if(a.sequence) h+='<details><summary>Construct sequence</summary><textarea readonly aria-label="Construct sequence">'+esc(a.sequence)+'</textarea></details>'; | |
| if(a.result) h+='<pre class="ws-result-data">'+esc(JSON.stringify(a.result,null,2))+'</pre>'; | |
| return h||'<p>Download the recorded result to inspect its complete data.</p>'; | |
| } | |
| /* 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 | |
| ? '<p>'+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':'')+'</p>' + | |
| '<div class="ws-table"><table><thead><tr><th>Variant</th><th>Value</th><th>Outcome</th><th>Recorded by</th><th>Date</th></tr></thead><tbody>' + | |
| rows.slice(0, 50).map(function (m) { return '<tr><td>'+esc(m.mutations)+'</td><td>'+esc(m.measured_value == null ? '' : m.measured_value)+'</td><td>'+esc(m.outcome)+'</td><td>'+esc(m.mine ? 'You' : (m.measured_by_name || 'Collaborator'))+'</td><td>'+esc(day(m.created_at))+'</td></tr>'; }).join('') + | |
| '</tbody></table></div>' | |
| : '<p class="ws-note">No bench results yet. Whoever runs the assay can record them here, and the designer sees them at once.</p>') + | |
| '<details'+(rows.length?'':' open')+'><summary>'+(mine.length?'Edit your bench results':'Record your bench results')+'</summary><form id="wsMeasure">' + | |
| '<label>One per line: variant, value, outcome<textarea name="rows" spellcheck="false" placeholder="S1109T/W1126L, 7.86, worked M1169K, 2.10, failed">'+esc(mine.map(function (m) { return m.mutations + ', ' + (m.measured_value == null ? '' : m.measured_value) + ', ' + m.outcome; }).join('\n'))+'</textarea></label>' + | |
| '<p class="ws-note">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.</p>' + | |
| '<button type="submit">Save bench results</button></form></details>'; | |
| } | |
| 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?'<form id="wsContribute"><label>Your saved result<select name="result">'+d.results.map(function(x){return '<option value="'+esc(x.kind+':'+x.id)+'">'+esc(x.name)+' · '+esc(x.kind)+'</option>';}).join('')+'</select></label><label class="ws-consent"><input type="checkbox" name="consent" required> Share the complete result, including its molecular data, with this project’s collaborators.</label><p class="ws-note">Adding a variant library replaces the project’s current library link. The previous library remains in its creator’s saved work.</p><button type="submit">Add to project</button></form>':'<p>No saved results to contribute yet. Run a tool and save its result first.</p>'; | |
| } | |
| 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='<section class="ws-invite"><p class="ws-eyebrow">Invitation</p><h3>You have been invited to a project</h3>'+ | |
| '<p>Sign in, or create a free account, with the email address this invitation was sent to. The invitation will be waiting.</p>'+ | |
| '<div class="ws-actions"><a class="ws-link-btn" href="'+SITE+'/signup/?invite='+esc(inviteToken)+'" target="_top">Create an account</a>'+ | |
| '<a class="ws-link-btn ws-link-btn--quiet" href="'+SITE+'/signin/?next='+esc(next)+'" target="_top">Sign in</a></div></section>'; | |
| return; | |
| } | |
| return show().then(function(){message((res.d&&res.d.error)||'This invitation is not available.');}); | |
| }).catch(function(){}); | |
| }); | |
| })(); | |