Download app.js from Hugdrigo/scholarflow-research-studio: direct link, hf CLI and curl.
- Browser
- Download file 16.9 kB
-
https://huggingface.co/spaces/Hugdrigo/scholarflow-research-studio/resolve/main/app.js
- Command line
-
hf download hf://spaces/Hugdrigo/scholarflow-research-studio/app.js
-
curl -L -o app.js https://huggingface.co/spaces/Hugdrigo/scholarflow-research-studio/resolve/main/app.js
16.9 kB
| ; | |
| /** ScholarFlow Project #19: fully local, deterministic research organizer. */ | |
| (() => { | |
| const KEY = 'scholarflow-project-19-v1'; | |
| const PAGES = ['overview','library','screening','matrix','gaps','export']; | |
| const STATUSES = ['include','maybe','exclude','unscreened']; | |
| const DEFAULTS = Array.isArray(window.SCHOLARFLOW_SEED) ? window.SCHOLARFLOW_SEED : []; | |
| const q = s => document.querySelector(s); | |
| const qa = s => Array.from(document.querySelectorAll(s)); | |
| const clone = obj => JSON.parse(JSON.stringify(obj)); | |
| let data = readRecords(); | |
| let currentPage = 'overview'; | |
| let toastTimeout; | |
| function escapeHTML(s){return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));} | |
| function display(s){return escapeHTML(s == null || s === '' ? 'Not provided' : s);} | |
| function safeStatus(status){return STATUSES.includes(status) ? status : 'unscreened';} | |
| function recordFrom(raw,index){ | |
| if(!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`Record ${index+1} must be an object.`); | |
| if(typeof raw.title !== 'string' || !raw.title.trim()) throw new Error(`Record ${index+1} needs a title.`); | |
| const t = k => String(raw[k] ?? '').slice(0,k==='findings'||k==='limitations'||k==='notes'?2500:500); | |
| const y = Number(raw.year); const n = raw.participants == null || raw.participants === '' ? null : Number(raw.participants); | |
| return {id:t('id') || `IMP-${index+1}`,title:t('title'),authors:t('authors'),year:Number.isInteger(y)&&y>=1900&&y<=2100?y:null,method:t('method')||'Other',region:t('region')||'Not specified',language:t('language')||'Not specified',participants:Number.isInteger(n)&&n>=0?n:null,topic:t('topic'),findings:t('findings'),limitations:t('limitations'),source:t('source'),synthetic:raw.synthetic===true,status:safeStatus(raw.status),notes:t('notes')}; | |
| } | |
| function readRecords(){try{let raw=localStorage.getItem(KEY);if(!raw)return clone(DEFAULTS);let parsed=JSON.parse(raw);return Array.isArray(parsed)?parsed.map(recordFrom):clone(DEFAULTS);}catch{return clone(DEFAULTS);}} | |
| function persist(){try{localStorage.setItem(KEY,JSON.stringify(data));}catch{toast('Browser storage is unavailable. Export your work to keep it.');}} | |
| function toast(message){const el=q('#toast');el.textContent=message;el.classList.add('show');clearTimeout(toastTimeout);toastTimeout=setTimeout(()=>el.classList.remove('show'),3400);} | |
| function showPage(page){if(!PAGES.includes(page))return;currentPage=page;qa('.navitem').forEach(n=>{let yes=n.dataset.page===page;n.classList.toggle('active',yes);n.setAttribute('aria-current',yes?'page':'false');});qa('.page').forEach(n=>n.classList.toggle('active',n.id===`page-${page}`));let subtitles={overview:['Research, organized.<br><em>Evidence, clarified.</em>','A practical workspace for screening studies, synthesizing findings, and documenting research gaps.'],library:['Every study.<br><em>One organized library.</em>','Search, inspect and curate records with traceable metadata.'],screening:['Make every decision<br><em>auditable.</em>','Screen research records with consistent decisions and notes.'],matrix:['Compare your evidence.<br><em>Side by side.</em>','Review the included records across common dimensions.'],gaps:['Look for gaps.<br><em>Describe, don’t assume.</em>','Explore descriptive imbalances in your included records.'],export:['Keep your work.<br><em>Own your evidence.</em>','Download reproducible datasets and screening logs.']};q('#page-title').innerHTML=subtitles[page][0];q('#page-subtitle').textContent=subtitles[page][1];renderPage(page);} | |
| function countBy(items,key){return items.reduce((out,r)=>{const name=String(r[key]||'Not specified');out[name]=(out[name]||0)+1;return out;},{});} | |
| function barList(counts,css=''){let entries=Object.entries(counts).sort((a,b)=>b[1]-a[1]||a[0].localeCompare(b[0]));let total=entries.reduce((n,a)=>n+a[1],0);if(!entries.length)return '<div class="empty">No records to analyze.</div>';return entries.map(([name,n])=>`<div class="bar-row"><div class="bar-meta"><span>${escapeHTML(name)}</span><span>${n} <span class="muted">· ${Math.round(100*n/total)}%</span></span></div><div class="track"><div class="fill ${css}" style="width:${100*n/total}%"></div></div></div>`).join('');} | |
| function fieldCompleteness(r){return [r.source,r.method,r.findings,r.limitations,r.participants].filter(x=>x!==null && x!==undefined && String(x).trim()!=='' && !String(x).startsWith('SYNTHETIC')).length;} | |
| function renderOverview(){const included=data.filter(x=>x.status==='include');const done=data.filter(x=>x.status!=='unscreened');const synthetic=data.filter(x=>x.synthetic);q('#metrics').innerHTML=[['Records',data.length,'Current library'],['Screened',done.length,`${data.length?Math.round(100*done.length/data.length):0}% of all records`],['Included',included.length,'Manually selected'],['Synthetic',synthetic.length,'Demo records only']].map(([name,n,note])=>`<article class="metric"><div class="metric-label">${name}</div><div class="metric-value">${n}</div><div class="metric-note">${note}</div></article>`).join('');q('#method-bars').innerHTML=barList(countBy(data,'method'));q('#screen-bars').innerHTML=barList(countBy(data,'status'),'violet');} | |
| function refreshMethods(){const select=q('#filter-method'),v=select.value;const methods=[...new Set(data.map(r=>r.method))].sort();select.innerHTML='<option value="all">All methods</option>'+methods.map(m=>`<option value="${escapeHTML(m)}">${escapeHTML(m)}</option>`).join('');select.value=methods.includes(v)?v:'all';} | |
| function renderLibrary(){refreshMethods();let term=q('#search').value.trim().toLowerCase(),method=q('#filter-method').value,status=q('#filter-status').value;const list=data.filter(r=>(method==='all'||r.method===method)&&(status==='all'||r.status===status)&&[r.title,r.authors,r.topic,r.region,r.findings,r.source].some(v=>String(v||'').toLowerCase().includes(term)));q('#library-count').textContent=`${list.length} / ${data.length} RECORDS`;q('#library-list').innerHTML=list.length?list.map(r=>`<article class="study-card"><div class="study-info"><h3 class="study-title">${escapeHTML(r.title)}</h3><div class="study-meta">${display(r.authors)} · ${display(r.year)} · ${escapeHTML(r.method)} · ${escapeHTML(r.region)} · ${escapeHTML(r.language)}</div><p class="study-finding">${display(r.findings)}</p><span class="small-pill">${r.synthetic?'SYNTHETIC':'USER SUPPLIED'}</span></div><div class="study-actions"><span class="small-pill ${r.status}">${escapeHTML(r.status.toUpperCase())}</span><button class="tiny-btn" data-delete="${escapeHTML(r.id)}">Delete</button></div></article>`).join(''):'<div class="empty-box">No matching studies. Adjust filters or add a record.</div>';} | |
| function renderScreening(){q('#screening-list').innerHTML=data.length?data.map(r=>`<article class="screen-record" data-id="${escapeHTML(r.id)}"><div class="screen-title">${escapeHTML(r.title)}</div><div class="screen-meta">${display(r.year)} · ${escapeHTML(r.method)} · ${escapeHTML(r.region)} ${r.synthetic?' · SYNTHETIC RECORD':''}</div><div class="decision-bar">${STATUSES.map(st=>`<button class="decision ${r.status===st?'chosen':''}" data-decision="${st}" data-id="${escapeHTML(r.id)}" data-value="${st}" aria-pressed="${r.status===st}">${({include:'✓ Include',maybe:'? Maybe',exclude:'× Exclude',unscreened:'◌ Unscreened'})[st]}</button>`).join('')}</div><textarea data-notes="${escapeHTML(r.id)}" aria-label="Reviewer notes for ${escapeHTML(r.title)}" placeholder="Reviewer rationale and eligibility notes…">${escapeHTML(r.notes)}</textarea></article>`).join(''):'<div class="empty-box">The study library is empty. Add studies to begin screening.</div>';} | |
| function renderMatrix(){const included=data.filter(r=>r.status==='include');q('#matrix-empty').hidden=included.length!==0;q('#matrix-body').innerHTML=included.map(r=>`<tr><td>${escapeHTML(r.title)} ${r.synthetic?'<br><span class="small-pill">SYNTHETIC</span>':''}</td><td>${display(r.year)}</td><td>${escapeHTML(r.method)}</td><td>${escapeHTML(r.region)}</td><td>${escapeHTML(r.language)}</td><td>${display(r.participants)}</td><td>${fieldCompleteness(r)} / 5 populated fields</td><td>${escapeHTML(r.source||'Unverified')}</td></tr>`).join('');} | |
| function renderGaps(){const included=data.filter(r=>r.status==='include');q('#region-bars').innerHTML=barList(countBy(included,'region'));q('#language-bars').innerHTML=barList(countBy(included,'language'),'violet');let insights=[];if(!included.length)insights.push(['No included evidence','Screen records and mark relevant studies as Included to begin descriptive gap analysis.']);else{const m=countBy(included,'method'),regions=countBy(included,'region'),langs=countBy(included,'language');if(Object.keys(m).length<3)insights.push(['Limited methodological mix',`Your included set covers only ${Object.keys(m).length} method categor${Object.keys(m).length===1?'y':'ies'}. Review whether diverse designs are relevant to your question.`]);if(Object.keys(regions).length<3)insights.push(['Narrow regional coverage',`Only ${Object.keys(regions).length} geographic categor${Object.keys(regions).length===1?'y is':'ies are'} represented. Check your search strategy for geographical scope.`]);if(Object.keys(langs).length<3)insights.push(['Language coverage to inspect',`Your included set contains ${Object.keys(langs).length} research language categor${Object.keys(langs).length===1?'y':'ies'}. Consider justified multilingual searches.`]);const missing=included.filter(x=>fieldCompleteness(x)<4);if(missing.length)insights.push(['Metadata completeness',`${missing.length} included record(s) have fewer than 4 of the 5 tracked metadata fields populated. A populated field does not guarantee validity.`]);const synthetic=included.filter(x=>x.synthetic).length;if(synthetic)insights.unshift(['Demonstration-only evidence',`${synthetic} included record(s) are synthetic. They must be replaced before any academic or policy evidence claim.`]);if(included.length<10)insights.push(['Small included set',`Only ${included.length} records are included. Counts here should not be generalized to the published literature.`]);}if(!insights.length)insights.push(['No predefined gaps flagged','This means the simple thresholds did not flag a coverage imbalance. It is not proof that a literature gap is absent.']);q('#gap-insights').innerHTML=insights.map(([h,p])=>`<div class="insight"><h3>${escapeHTML(h)}</h3><p>${escapeHTML(p)}</p></div>`).join('');} | |
| function renderPage(name){({overview:renderOverview,library:renderLibrary,screening:renderScreening,matrix:renderMatrix,gaps:renderGaps,export:()=>{}})[name]();} | |
| function download(filename,content,type){let blob=new Blob([content],{type}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=filename;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);toast(`Prepared ${filename}`);} | |
| function metadata(){return {project:'ScholarFlow Research Intelligence Studio',project_number:19,schema_version:'1.0',exported_at:new Date().toISOString(),warning:'Synthetic demonstration records are not publications or empirical findings. Verify external references independently.',records:data};} | |
| function csvCell(v){const s=String(v==null?'':v);return '"'+s.replace(/"/g,'""')+'"';} | |
| function exportCSV(){const columns=['id','title','authors','year','method','region','language','participants','topic','findings','limitations','source','synthetic','status','notes'];download('scholarflow-19-studies.csv',[columns.join(','),...data.map(r=>columns.map(k=>csvCell(r[k])).join(','))].join('\r\n'),'text/csv;charset=utf-8');} | |
| function exportMarkdown(){const date=new Date().toISOString().slice(0,10);const lines=['# ScholarFlow Project 19: Screening Review',`Export date: ${date}`,'','> **Integrity warning:** Initial demo records are synthetic. Their findings do not constitute actual research evidence.','',`Records: ${data.length} | Included: ${data.filter(r=>r.status==='include').length} | Synthetic: ${data.filter(r=>r.synthetic).length}`,''];for(const r of data){lines.push(`## ${r.title}`,`- ID: ${r.id}` ,`- Decision: ${r.status}`,`- Synthetic: ${r.synthetic?'YES':'No (user entry, not independently verified)'}`,`- Author(s): ${r.authors||'Not provided'}`,`- Year: ${r.year??'Not provided'}`,`- Method: ${r.method}`,`- Region: ${r.region}`,`- Language: ${r.language}`,`- Participants: ${r.participants??'Not provided'}`,`- Source: ${r.source||'Not provided'}`,`- Field completeness: ${fieldCompleteness(r)}/5 (not a quality grade)`,`- Findings: ${r.findings||'Not provided'}`,`- Limitations: ${r.limitations||'Not provided'}`,`- Reviewer notes: ${r.notes||'Not provided'}`,'');}download('scholarflow-19-review.md',lines.join('\n'),'text/markdown;charset=utf-8');} | |
| function addRecord(form){const f=new FormData(form);const r={id:`USR-${Date.now()}-${Math.random().toString(36).slice(2,7)}`,title:String(f.get('title')||'').trim(),authors:String(f.get('authors')||'').trim(),year:Number(f.get('year'))||null,method:String(f.get('method')),region:String(f.get('region')||'').trim()||'Not specified',language:String(f.get('language')||'').trim()||'Not specified',participants:f.get('participants')===''?null:Number(f.get('participants')),source:String(f.get('source')||'').trim(),topic:String(f.get('topic')||'').trim(),findings:String(f.get('findings')||'').trim(),limitations:String(f.get('limitations')||'').trim(),synthetic:false,status:'unscreened',notes:''};if(!r.title)return;data.unshift(r);persist();q('#study-dialog').close();form.reset();showPage('library');toast('Study added. Verify source details independently.');} | |
| q('#nav').addEventListener('click',e=>{const b=e.target.closest('[data-page]');if(b)showPage(b.dataset.page);}); | |
| document.addEventListener('click',e=>{const b=e.target.closest('[data-goto]');if(b)showPage(b.dataset.goto);}); | |
| q('#add-record-top').addEventListener('click',()=>q('#study-dialog').showModal()); | |
| q('#close-dialog').addEventListener('click',()=>q('#study-dialog').close());q('#cancel-dialog').addEventListener('click',()=>q('#study-dialog').close()); | |
| q('#study-form').addEventListener('submit',e=>{e.preventDefault();if(e.target.reportValidity())addRecord(e.target);}); | |
| q('#dismiss-notice').addEventListener('click',()=>{q('.notice').hidden=true;}); | |
| for(const selector of ['#search','#filter-method','#filter-status'])q(selector).addEventListener(selector==='#search'?'input':'change',renderLibrary); | |
| q('#library-list').addEventListener('click',e=>{const b=e.target.closest('[data-delete]');if(!b)return;const r=data.find(x=>x.id===b.dataset.delete);if(r&&confirm(`Delete “${r.title}”? This cannot be undone unless you exported a backup.`)){data=data.filter(x=>x.id!==r.id);persist();renderLibrary();toast('Record deleted.');}}); | |
| q('#screening-list').addEventListener('click',e=>{const b=e.target.closest('[data-decision]');if(!b)return;const r=data.find(x=>x.id===b.dataset.id);if(r){r.status=safeStatus(b.dataset.decision);persist();renderScreening();toast('Screening decision saved.');}}); | |
| q('#screening-list').addEventListener('input',e=>{if(!e.target.matches('[data-notes]'))return;const r=data.find(x=>x.id===e.target.dataset.notes);if(r){r.notes=e.target.value.slice(0,2500);persist();}}); | |
| q('#export-json').addEventListener('click',()=>download('scholarflow-19-workspace.json',JSON.stringify(metadata(),null,2),'application/json')); | |
| q('#export-csv').addEventListener('click',exportCSV);q('#export-md').addEventListener('click',exportMarkdown); | |
| q('#import-json').addEventListener('change',async e=>{const file=e.target.files?.[0];if(!file)return;try{if(file.size>3_000_000)throw new Error('Maximum import size is 3 MB.');const obj=JSON.parse(await file.text());const arr=Array.isArray(obj)?obj:obj.records;if(!Array.isArray(arr)||arr.length>2000)throw new Error('Expected an array of up to 2,000 records.');const parsed=arr.map(recordFrom);if(new Set(parsed.map(r=>r.id)).size!==parsed.length)throw new Error('Record IDs must be unique.');if(confirm(`Replace the current library of ${data.length} records with ${parsed.length} imported records?`)){data=parsed;persist();showPage('overview');toast(`Imported ${parsed.length} records.`);}}catch(err){toast(`Import failed: ${err.message}`);}finally{e.target.value='';}}); | |
| q('#restore-sample').addEventListener('click',()=>{if(confirm('Replace your current records and notes with the synthetic demo dataset?')){data=clone(DEFAULTS);persist();showPage('overview');toast('Synthetic demo restored.');}}); | |
| q('#clear-library').addEventListener('click',()=>{if(confirm('Erase all current records and notes from this browser? Export first if you need a backup.')){data=[];persist();showPage('overview');toast('Library cleared.');}}); | |
| showPage('overview'); | |
| })(); | |