a",n=d.getElementsByTagName("*")||[],r=d.getElementsByTagName("a")[0],!r||!r.style||!n.length)return t;s=a.createElement("select"),u=s.appendChild(a.createElement("option")),o=d.getElementsByTagName("input")[0],r.style.cssText="top:1px;float:left;opacity:.5",t.getSetAttribute="t"!==d.className,t.leadingWhitespace=3===d.firstChild.nodeType,t.tbody=!d.getElementsByTagName("tbody").length,t.htmlSerialize=!!d.getElementsByTagName("link").length,t.style=/top/.test(r.getAttribute("style")),t.hrefNormalized="/a"===r.getAttribute("href"),t.opacity=/^0.5/.test(r.style.opacity),t.cssFloat=!!r.style.cssFloat,t.checkOn=!!o.value,t.optSelected=u.selected,t.enctype=!!a.createElement("form").enctype,t.html5Clone="<:nav>"!==a.createElement("nav").cloneNode(!0).outerHTML,t.inlineBlockNeedsLayout=!1,t.shrinkWrapBlocks=!1,t.pixelPosition=!1,t.deleteExpando=!0,t.noCloneEvent=!0,t.reliableMarginRight=!0,t.boxSizingReliable=!0,o.checked=!0,t.noCloneChecked=o.cloneNode(!0).checked,s.disabled=!0,t.optDisabled=!u.disabled;try{delete d.test}catch(h){t.deleteExpando=!1}o=a.createElement("input"),o.setAttribute("value",""),t.input=""===o.getAttribute("value"),o.value="t",o.setAttribute("type","radio"),t.radioValue="t"===o.value,o.setAttribute("checked","t"),o.setAttribute("name","t"),l=a.createDocumentFragment(),l.appendChild(o),t.appendChecked=o.checked,t.checkClone=l.cloneNode(!0).cloneNode(!0).lastChild.checked,d.attachEvent&&(d.attachEvent("onclick",function(){t.noCloneEvent=!1}),d.cloneNode(!0).click());for(f in{submit:!0,change:!0,focusin:!0})d.setAttribute(c="on"+f,"t"),t[f+"Bubbles"]=c in e||d.attributes[c].expando===!1;d.style.backgroundClip="content-box",d.cloneNode(!0).style.backgroundClip="",t.clearCloneStyle="content-box"===d.style.backgroundClip;for(f in x(t))break;return t.ownLast="0"!==f,x(function(){var n,r,o,s="padding:0;margin:0;border:0;display:block;box-sizing:content-box;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;",l=a.getElementsByTagName("body")[0];l&&(n=a.createElement("div"),n.style.cssText="border:0;width:0;height:0;position:absolute;top:0;left:-9999px;margin-top:1px",l.appendChild(n).appendChild(d),d.innerHTML="
").append(x.parseHTML(e)).find(i):e)}).complete(r&&function(e,t){s.each(r,o||[e.responseText,t,e])}),this},x.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){x.fn[t]=function(e){return this.on(t,e)}}),x.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:yn,type:"GET",isLocal:Cn.test(mn[1]),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":Dn,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/xml/,html:/html/,json:/json/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":x.parseJSON,"text xml":x.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?_n(_n(e,x.ajaxSettings),t):_n(x.ajaxSettings,e)},ajaxPrefilter:Hn(An),ajaxTransport:Hn(jn),ajax:function(e,n){"object"==typeof e&&(n=e,e=t),n=n||{};var r,i,o,a,s,l,u,c,p=x.ajaxSetup({},n),f=p.context||p,d=p.context&&(f.nodeType||f.jquery)?x(f):x.event,h=x.Deferred(),g=x.Callbacks("once memory"),m=p.statusCode||{},y={},v={},b=0,w="canceled",C={readyState:0,getResponseHeader:function(e){var t;if(2===b){if(!c){c={};while(t=Tn.exec(a))c[t[1].toLowerCase()]=t[2]}t=c[e.toLowerCase()]}return null==t?null:t},getAllResponseHeaders:function(){return 2===b?a:null},setRequestHeader:function(e,t){var n=e.toLowerCase();return b||(e=v[n]=v[n]||e,y[e]=t),this},overrideMimeType:function(e){return b||(p.mimeType=e),this},statusCode:function(e){var t;if(e)if(2>b)for(t in e)m[t]=[m[t],e[t]];else C.always(e[C.status]);return this},abort:function(e){var t=e||w;return u&&u.abort(t),k(0,t),this}};if(h.promise(C).complete=g.add,C.success=C.done,C.error=C.fail,p.url=((e||p.url||yn)+"").replace(xn,"").replace(kn,mn[1]+"//"),p.type=n.method||n.type||p.method||p.type,p.dataTypes=x.trim(p.dataType||"*").toLowerCase().match(T)||[""],null==p.crossDomain&&(r=En.exec(p.url.toLowerCase()),p.crossDomain=!(!r||r[1]===mn[1]&&r[2]===mn[2]&&(r[3]||("http:"===r[1]?"80":"443"))===(mn[3]||("http:"===mn[1]?"80":"443")))),p.data&&p.processData&&"string"!=typeof p.data&&(p.data=x.param(p.data,p.traditional)),qn(An,p,n,C),2===b)return C;l=p.global,l&&0===x.active++&&x.event.trigger("ajaxStart"),p.type=p.type.toUpperCase(),p.hasContent=!Nn.test(p.type),o=p.url,p.hasContent||(p.data&&(o=p.url+=(bn.test(o)?"&":"?")+p.data,delete p.data),p.cache===!1&&(p.url=wn.test(o)?o.replace(wn,"$1_="+vn++):o+(bn.test(o)?"&":"?")+"_="+vn++)),p.ifModified&&(x.lastModified[o]&&C.setRequestHeader("If-Modified-Since",x.lastModified[o]),x.etag[o]&&C.setRequestHeader("If-None-Match",x.etag[o])),(p.data&&p.hasContent&&p.contentType!==!1||n.contentType)&&C.setRequestHeader("Content-Type",p.contentType),C.setRequestHeader("Accept",p.dataTypes[0]&&p.accepts[p.dataTypes[0]]?p.accepts[p.dataTypes[0]]+("*"!==p.dataTypes[0]?", "+Dn+"; q=0.01":""):p.accepts["*"]);for(i in p.headers)C.setRequestHeader(i,p.headers[i]);if(p.beforeSend&&(p.beforeSend.call(f,C,p)===!1||2===b))return C.abort();w="abort";for(i in{success:1,error:1,complete:1})C[i](p[i]);if(u=qn(jn,p,n,C)){C.readyState=1,l&&d.trigger("ajaxSend",[C,p]),p.async&&p.timeout>0&&(s=setTimeout(function(){C.abort("timeout")},p.timeout));try{b=1,u.send(y,k)}catch(N){if(!(2>b))throw N;k(-1,N)}}else k(-1,"No Transport");function k(e,n,r,i){var c,y,v,w,T,N=n;2!==b&&(b=2,s&&clearTimeout(s),u=t,a=i||"",C.readyState=e>0?4:0,c=e>=200&&300>e||304===e,r&&(w=Mn(p,C,r)),w=On(p,w,C,c),c?(p.ifModified&&(T=C.getResponseHeader("Last-Modified"),T&&(x.lastModified[o]=T),T=C.getResponseHeader("etag"),T&&(x.etag[o]=T)),204===e||"HEAD"===p.type?N="nocontent":304===e?N="notmodified":(N=w.state,y=w.data,v=w.error,c=!v)):(v=N,(e||!N)&&(N="error",0>e&&(e=0))),C.status=e,C.statusText=(n||N)+"",c?h.resolveWith(f,[y,N,C]):h.rejectWith(f,[C,N,v]),C.statusCode(m),m=t,l&&d.trigger(c?"ajaxSuccess":"ajaxError",[C,p,c?y:v]),g.fireWith(f,[C,N]),l&&(d.trigger("ajaxComplete",[C,p]),--x.active||x.event.trigger("ajaxStop")))}return C},getJSON:function(e,t,n){return x.get(e,t,n,"json")},getScript:function(e,n){return x.get(e,t,n,"script")}}),x.each(["get","post"],function(e,n){x[n]=function(e,r,i,o){return x.isFunction(r)&&(o=o||i,i=r,r=t),x.ajax({url:e,type:n,dataType:o,data:r,success:i})}});function Mn(e,n,r){var i,o,a,s,l=e.contents,u=e.dataTypes;while("*"===u[0])u.shift(),o===t&&(o=e.mimeType||n.getResponseHeader("Content-Type"));if(o)for(s in l)if(l[s]&&l[s].test(o)){u.unshift(s);break}if(u[0]in r)a=u[0];else{for(s in r){if(!u[0]||e.converters[s+" "+u[0]]){a=s;break}i||(i=s)}a=a||i}return a?(a!==u[0]&&u.unshift(a),r[a]):t}function On(e,t,n,r){var i,o,a,s,l,u={},c=e.dataTypes.slice();if(c[1])for(a in e.converters)u[a.toLowerCase()]=e.converters[a];o=c.shift();while(o)if(e.responseFields[o]&&(n[e.responseFields[o]]=t),!l&&r&&e.dataFilter&&(t=e.dataFilter(t,e.dataType)),l=o,o=c.shift())if("*"===o)o=l;else if("*"!==l&&l!==o){if(a=u[l+" "+o]||u["* "+o],!a)for(i in u)if(s=i.split(" "),s[1]===o&&(a=u[l+" "+s[0]]||u["* "+s[0]])){a===!0?a=u[i]:u[i]!==!0&&(o=s[0],c.unshift(s[1]));break}if(a!==!0)if(a&&e["throws"])t=a(t);else try{t=a(t)}catch(p){return{state:"parsererror",error:a?p:"No conversion from "+l+" to "+o}}}return{state:"success",data:t}}x.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/(?:java|ecma)script/},converters:{"text script":function(e){return x.globalEval(e),e}}}),x.ajaxPrefilter("script",function(e){e.cache===t&&(e.cache=!1),e.crossDomain&&(e.type="GET",e.global=!1)}),x.ajaxTransport("script",function(e){if(e.crossDomain){var n,r=a.head||x("head")[0]||a.documentElement;return{send:function(t,i){n=a.createElement("script"),n.async=!0,e.scriptCharset&&(n.charset=e.scriptCharset),n.src=e.url,n.onload=n.onreadystatechange=function(e,t){(t||!n.readyState||/loaded|complete/.test(n.readyState))&&(n.onload=n.onreadystatechange=null,n.parentNode&&n.parentNode.removeChild(n),n=null,t||i(200,"success"))},r.insertBefore(n,r.firstChild)},abort:function(){n&&n.onload(t,!0)}}}});var Fn=[],Bn=/(=)\?(?=&|$)|\?\?/;x.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Fn.pop()||x.expando+"_"+vn++;return this[e]=!0,e}}),x.ajaxPrefilter("json jsonp",function(n,r,i){var o,a,s,l=n.jsonp!==!1&&(Bn.test(n.url)?"url":"string"==typeof n.data&&!(n.contentType||"").indexOf("application/x-www-form-urlencoded")&&Bn.test(n.data)&&"data");return l||"jsonp"===n.dataTypes[0]?(o=n.jsonpCallback=x.isFunction(n.jsonpCallback)?n.jsonpCallback():n.jsonpCallback,l?n[l]=n[l].replace(Bn,"$1"+o):n.jsonp!==!1&&(n.url+=(bn.test(n.url)?"&":"?")+n.jsonp+"="+o),n.converters["script json"]=function(){return s||x.error(o+" was not called"),s[0]},n.dataTypes[0]="json",a=e[o],e[o]=function(){s=arguments},i.always(function(){e[o]=a,n[o]&&(n.jsonpCallback=r.jsonpCallback,Fn.push(o)),s&&x.isFunction(a)&&a(s[0]),s=a=t}),"script"):t});var Pn,Rn,Wn=0,$n=e.ActiveXObject&&function(){var e;for(e in Pn)Pn[e](t,!0)};function In(){try{return new e.XMLHttpRequest}catch(t){}}function zn(){try{return new e.ActiveXObject("Microsoft.XMLHTTP")}catch(t){}}x.ajaxSettings.xhr=e.ActiveXObject?function(){return!this.isLocal&&In()||zn()}:In,Rn=x.ajaxSettings.xhr(),x.support.cors=!!Rn&&"withCredentials"in Rn,Rn=x.support.ajax=!!Rn,Rn&&x.ajaxTransport(function(n){if(!n.crossDomain||x.support.cors){var r;return{send:function(i,o){var a,s,l=n.xhr();if(n.username?l.open(n.type,n.url,n.async,n.username,n.password):l.open(n.type,n.url,n.async),n.xhrFields)for(s in n.xhrFields)l[s]=n.xhrFields[s];n.mimeType&&l.overrideMimeType&&l.overrideMimeType(n.mimeType),n.crossDomain||i["X-Requested-With"]||(i["X-Requested-With"]="XMLHttpRequest");try{for(s in i)l.setRequestHeader(s,i[s])}catch(u){}l.send(n.hasContent&&n.data||null),r=function(e,i){var s,u,c,p;try{if(r&&(i||4===l.readyState))if(r=t,a&&(l.onreadystatechange=x.noop,$n&&delete Pn[a]),i)4!==l.readyState&&l.abort();else{p={},s=l.status,u=l.getAllResponseHeaders(),"string"==typeof l.responseText&&(p.text=l.responseText);try{c=l.statusText}catch(f){c=""}s||!n.isLocal||n.crossDomain?1223===s&&(s=204):s=p.text?200:404}}catch(d){i||o(-1,d)}p&&o(s,c,p,u)},n.async?4===l.readyState?setTimeout(r):(a=++Wn,$n&&(Pn||(Pn={},x(e).unload($n)),Pn[a]=r),l.onreadystatechange=r):r()},abort:function(){r&&r(t,!0)}}}});var Xn,Un,Vn=/^(?:toggle|show|hide)$/,Yn=RegExp("^(?:([+-])=|)("+w+")([a-z%]*)$","i"),Jn=/queueHooks$/,Gn=[nr],Qn={"*":[function(e,t){var n=this.createTween(e,t),r=n.cur(),i=Yn.exec(t),o=i&&i[3]||(x.cssNumber[e]?"":"px"),a=(x.cssNumber[e]||"px"!==o&&+r)&&Yn.exec(x.css(n.elem,e)),s=1,l=20;if(a&&a[3]!==o){o=o||a[3],i=i||[],a=+r||1;do s=s||".5",a/=s,x.style(n.elem,e,a+o);while(s!==(s=n.cur()/r)&&1!==s&&--l)}return i&&(a=n.start=+a||+r||0,n.unit=o,n.end=i[1]?a+(i[1]+1)*i[2]:+i[2]),n}]};function Kn(){return setTimeout(function(){Xn=t}),Xn=x.now()}function Zn(e,t,n){var r,i=(Qn[t]||[]).concat(Qn["*"]),o=0,a=i.length;for(;a>o;o++)if(r=i[o].call(n,t,e))return r}function er(e,t,n){var r,i,o=0,a=Gn.length,s=x.Deferred().always(function(){delete l.elem}),l=function(){if(i)return!1;var t=Xn||Kn(),n=Math.max(0,u.startTime+u.duration-t),r=n/u.duration||0,o=1-r,a=0,l=u.tweens.length;for(;l>a;a++)u.tweens[a].run(o);return s.notifyWith(e,[u,o,n]),1>o&&l?n:(s.resolveWith(e,[u]),!1)},u=s.promise({elem:e,props:x.extend({},t),opts:x.extend(!0,{specialEasing:{}},n),originalProperties:t,originalOptions:n,startTime:Xn||Kn(),duration:n.duration,tweens:[],createTween:function(t,n){var r=x.Tween(e,u.opts,t,n,u.opts.specialEasing[t]||u.opts.easing);return u.tweens.push(r),r},stop:function(t){var n=0,r=t?u.tweens.length:0;if(i)return this;for(i=!0;r>n;n++)u.tweens[n].run(1);return t?s.resolveWith(e,[u,t]):s.rejectWith(e,[u,t]),this}}),c=u.props;for(tr(c,u.opts.specialEasing);a>o;o++)if(r=Gn[o].call(u,e,c,u.opts))return r;return x.map(c,Zn,u),x.isFunction(u.opts.start)&&u.opts.start.call(e,u),x.fx.timer(x.extend(l,{elem:e,anim:u,queue:u.opts.queue})),u.progress(u.opts.progress).done(u.opts.done,u.opts.complete).fail(u.opts.fail).always(u.opts.always)}function tr(e,t){var n,r,i,o,a;for(n in e)if(r=x.camelCase(n),i=t[r],o=e[n],x.isArray(o)&&(i=o[1],o=e[n]=o[0]),n!==r&&(e[r]=o,delete e[n]),a=x.cssHooks[r],a&&"expand"in a){o=a.expand(o),delete e[r];for(n in o)n in e||(e[n]=o[n],t[n]=i)}else t[r]=i}x.Animation=x.extend(er,{tweener:function(e,t){x.isFunction(e)?(t=e,e=["*"]):e=e.split(" ");var n,r=0,i=e.length;for(;i>r;r++)n=e[r],Qn[n]=Qn[n]||[],Qn[n].unshift(t)},prefilter:function(e,t){t?Gn.unshift(e):Gn.push(e)}});function nr(e,t,n){var r,i,o,a,s,l,u=this,c={},p=e.style,f=e.nodeType&&nn(e),d=x._data(e,"fxshow");n.queue||(s=x._queueHooks(e,"fx"),null==s.unqueued&&(s.unqueued=0,l=s.empty.fire,s.empty.fire=function(){s.unqueued||l()}),s.unqueued++,u.always(function(){u.always(function(){s.unqueued--,x.queue(e,"fx").length||s.empty.fire()})})),1===e.nodeType&&("height"in t||"width"in t)&&(n.overflow=[p.overflow,p.overflowX,p.overflowY],"inline"===x.css(e,"display")&&"none"===x.css(e,"float")&&(x.support.inlineBlockNeedsLayout&&"inline"!==ln(e.nodeName)?p.zoom=1:p.display="inline-block")),n.overflow&&(p.overflow="hidden",x.support.shrinkWrapBlocks||u.always(function(){p.overflow=n.overflow[0],p.overflowX=n.overflow[1],p.overflowY=n.overflow[2]}));for(r in t)if(i=t[r],Vn.exec(i)){if(delete t[r],o=o||"toggle"===i,i===(f?"hide":"show"))continue;c[r]=d&&d[r]||x.style(e,r)}if(!x.isEmptyObject(c)){d?"hidden"in d&&(f=d.hidden):d=x._data(e,"fxshow",{}),o&&(d.hidden=!f),f?x(e).show():u.done(function(){x(e).hide()}),u.done(function(){var t;x._removeData(e,"fxshow");for(t in c)x.style(e,t,c[t])});for(r in c)a=Zn(f?d[r]:0,r,u),r in d||(d[r]=a.start,f&&(a.end=a.start,a.start="width"===r||"height"===r?1:0))}}function rr(e,t,n,r,i){return new rr.prototype.init(e,t,n,r,i)}x.Tween=rr,rr.prototype={constructor:rr,init:function(e,t,n,r,i,o){this.elem=e,this.prop=n,this.easing=i||"swing",this.options=t,this.start=this.now=this.cur(),this.end=r,this.unit=o||(x.cssNumber[n]?"":"px")},cur:function(){var e=rr.propHooks[this.prop];return e&&e.get?e.get(this):rr.propHooks._default.get(this)},run:function(e){var t,n=rr.propHooks[this.prop];return this.pos=t=this.options.duration?x.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),n&&n.set?n.set(this):rr.propHooks._default.set(this),this}},rr.prototype.init.prototype=rr.prototype,rr.propHooks={_default:{get:function(e){var t;return null==e.elem[e.prop]||e.elem.style&&null!=e.elem.style[e.prop]?(t=x.css(e.elem,e.prop,""),t&&"auto"!==t?t:0):e.elem[e.prop]},set:function(e){x.fx.step[e.prop]?x.fx.step[e.prop](e):e.elem.style&&(null!=e.elem.style[x.cssProps[e.prop]]||x.cssHooks[e.prop])?x.style(e.elem,e.prop,e.now+e.unit):e.elem[e.prop]=e.now}}},rr.propHooks.scrollTop=rr.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},x.each(["toggle","show","hide"],function(e,t){var n=x.fn[t];x.fn[t]=function(e,r,i){return null==e||"boolean"==typeof e?n.apply(this,arguments):this.animate(ir(t,!0),e,r,i)}}),x.fn.extend({fadeTo:function(e,t,n,r){return this.filter(nn).css("opacity",0).show().end().animate({opacity:t},e,n,r)},animate:function(e,t,n,r){var i=x.isEmptyObject(e),o=x.speed(t,n,r),a=function(){var t=er(this,x.extend({},e),o);(i||x._data(this,"finish"))&&t.stop(!0)};return a.finish=a,i||o.queue===!1?this.each(a):this.queue(o.queue,a)},stop:function(e,n,r){var i=function(e){var t=e.stop;delete e.stop,t(r)};return"string"!=typeof e&&(r=n,n=e,e=t),n&&e!==!1&&this.queue(e||"fx",[]),this.each(function(){var t=!0,n=null!=e&&e+"queueHooks",o=x.timers,a=x._data(this);if(n)a[n]&&a[n].stop&&i(a[n]);else for(n in a)a[n]&&a[n].stop&&Jn.test(n)&&i(a[n]);for(n=o.length;n--;)o[n].elem!==this||null!=e&&o[n].queue!==e||(o[n].anim.stop(r),t=!1,o.splice(n,1));(t||!r)&&x.dequeue(this,e)})},finish:function(e){return e!==!1&&(e=e||"fx"),this.each(function(){var t,n=x._data(this),r=n[e+"queue"],i=n[e+"queueHooks"],o=x.timers,a=r?r.length:0;for(n.finish=!0,x.queue(this,e,[]),i&&i.stop&&i.stop.call(this,!0),t=o.length;t--;)o[t].elem===this&&o[t].queue===e&&(o[t].anim.stop(!0),o.splice(t,1));for(t=0;a>t;t++)r[t]&&r[t].finish&&r[t].finish.call(this);delete n.finish})}});function ir(e,t){var n,r={height:e},i=0;for(t=t?1:0;4>i;i+=2-t)n=Zt[i],r["margin"+n]=r["padding"+n]=e;return t&&(r.opacity=r.width=e),r}x.each({slideDown:ir("show"),slideUp:ir("hide"),slideToggle:ir("toggle"),fadeIn:{opacity:"show"},fadeOut:{opacity:"hide"},fadeToggle:{opacity:"toggle"}},function(e,t){x.fn[e]=function(e,n,r){return this.animate(t,e,n,r)}}),x.speed=function(e,t,n){var r=e&&"object"==typeof e?x.extend({},e):{complete:n||!n&&t||x.isFunction(e)&&e,duration:e,easing:n&&t||t&&!x.isFunction(t)&&t};return r.duration=x.fx.off?0:"number"==typeof r.duration?r.duration:r.duration in x.fx.speeds?x.fx.speeds[r.duration]:x.fx.speeds._default,(null==r.queue||r.queue===!0)&&(r.queue="fx"),r.old=r.complete,r.complete=function(){x.isFunction(r.old)&&r.old.call(this),r.queue&&x.dequeue(this,r.queue)},r},x.easing={linear:function(e){return e},swing:function(e){return.5-Math.cos(e*Math.PI)/2}},x.timers=[],x.fx=rr.prototype.init,x.fx.tick=function(){var e,n=x.timers,r=0;for(Xn=x.now();n.length>r;r++)e=n[r],e()||n[r]!==e||n.splice(r--,1);n.length||x.fx.stop(),Xn=t},x.fx.timer=function(e){e()&&x.timers.push(e)&&x.fx.start()},x.fx.interval=13,x.fx.start=function(){Un||(Un=setInterval(x.fx.tick,x.fx.interval))},x.fx.stop=function(){clearInterval(Un),Un=null},x.fx.speeds={slow:600,fast:200,_default:400},x.fx.step={},x.expr&&x.expr.filters&&(x.expr.filters.animated=function(e){return x.grep(x.timers,function(t){return e===t.elem}).length}),x.fn.offset=function(e){if(arguments.length)return e===t?this:this.each(function(t){x.offset.setOffset(this,e,t)});var n,r,o={top:0,left:0},a=this[0],s=a&&a.ownerDocument;if(s)return n=s.documentElement,x.contains(n,a)?(typeof a.getBoundingClientRect!==i&&(o=a.getBoundingClientRect()),r=or(s),{top:o.top+(r.pageYOffset||n.scrollTop)-(n.clientTop||0),left:o.left+(r.pageXOffset||n.scrollLeft)-(n.clientLeft||0)}):o},x.offset={setOffset:function(e,t,n){var r=x.css(e,"position");"static"===r&&(e.style.position="relative");var i=x(e),o=i.offset(),a=x.css(e,"top"),s=x.css(e,"left"),l=("absolute"===r||"fixed"===r)&&x.inArray("auto",[a,s])>-1,u={},c={},p,f;l?(c=i.position(),p=c.top,f=c.left):(p=parseFloat(a)||0,f=parseFloat(s)||0),x.isFunction(t)&&(t=t.call(e,n,o)),null!=t.top&&(u.top=t.top-o.top+p),null!=t.left&&(u.left=t.left-o.left+f),"using"in t?t.using.call(e,u):i.css(u)}},x.fn.extend({position:function(){if(this[0]){var e,t,n={top:0,left:0},r=this[0];return"fixed"===x.css(r,"position")?t=r.getBoundingClientRect():(e=this.offsetParent(),t=this.offset(),x.nodeName(e[0],"html")||(n=e.offset()),n.top+=x.css(e[0],"borderTopWidth",!0),n.left+=x.css(e[0],"borderLeftWidth",!0)),{top:t.top-n.top-x.css(r,"marginTop",!0),left:t.left-n.left-x.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent||s;while(e&&!x.nodeName(e,"html")&&"static"===x.css(e,"position"))e=e.offsetParent;return e||s})}}),x.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(e,n){var r=/Y/.test(n);x.fn[e]=function(i){return x.access(this,function(e,i,o){var a=or(e);return o===t?a?n in a?a[n]:a.document.documentElement[i]:e[i]:(a?a.scrollTo(r?x(a).scrollLeft():o,r?o:x(a).scrollTop()):e[i]=o,t)},e,i,arguments.length,null)}});function or(e){return x.isWindow(e)?e:9===e.nodeType?e.defaultView||e.parentWindow:!1}x.each({Height:"height",Width:"width"},function(e,n){x.each({padding:"inner"+e,content:n,"":"outer"+e},function(r,i){x.fn[i]=function(i,o){var a=arguments.length&&(r||"boolean"!=typeof i),s=r||(i===!0||o===!0?"margin":"border");return x.access(this,function(n,r,i){var o;return x.isWindow(n)?n.document.documentElement["client"+e]:9===n.nodeType?(o=n.documentElement,Math.max(n.body["scroll"+e],o["scroll"+e],n.body["offset"+e],o["offset"+e],o["client"+e])):i===t?x.css(n,r,s):x.style(n,r,i,s)},n,a?i:t,a,null)}})}),x.fn.size=function(){return this.length},x.fn.andSelf=x.fn.addBack,"object"==typeof module&&module&&"object"==typeof module.exports?module.exports=x:(e.jQuery=e.$=x,"function"==typeof define&&define.amd&&define("jquery",[],function(){return x}))})(window);
diff --git a/public/lib/psiturk.js b/public/lib/psiturk.js
deleted file mode 100644
index 13c457dfb..000000000
--- a/public/lib/psiturk.js
+++ /dev/null
@@ -1,362 +0,0 @@
-/*
- * Requires:
- * jquery
- * backbone
- * underscore
- */
-
-
-/****************
- * Internals *
- ***************/
-
-// Sets up global notifications pub/sub
-// Notifications get submitted here (via trigger) and subscribed to (via on)
-Backbone.Notifications = {};
-_.extend(Backbone.Notifications, Backbone.Events);
-
-
-/*******
- * API *
- ******/
-var PsiTurk = function(uniqueId, adServerLoc, mode) {
- mode = mode || "live"; // defaults to live mode in case user doesn't pass this
- var self = this;
-
- /****************
- * TASK DATA *
- ***************/
- var TaskData = Backbone.Model.extend({
- urlRoot: "/sync", // Fetch will GET from this url, while Save will PUT to this url, with mimetype 'application/JSON'
- id: uniqueId,
- adServerLoc: adServerLoc,
- mode: mode,
-
- defaults: {
- condition: 0,
- counterbalance: 0,
- assignmentId: 0,
- workerId: 0,
- hitId: 0,
- currenttrial: 0,
- bonus: 0,
- data: [],
- questiondata: {},
- eventdata: [],
- useragent: "",
- mode: ""
- },
-
- initialize: function() {
- this.set({ useragent: navigator.userAgent });
- this.set({ mode: this.mode });
- this.addEvent('initialized', null);
- this.addEvent('window_resize', [window.innerWidth, window.innerHeight]);
-
- this.listenTo(Backbone.Notifications, '_psiturk_lostfocus', function() { this.addEvent('focus', 'off'); });
- this.listenTo(Backbone.Notifications, '_psiturk_gainedfocus', function() { this.addEvent('focus', 'on'); });
- this.listenTo(Backbone.Notifications, '_psiturk_windowresize', function(newsize) { this.addEvent('window_resize', newsize); });
- },
-
- addTrialData: function(trialdata) {
- trialdata = {"uniqueid":this.id, "current_trial":this.get("currenttrial"), "dateTime":(new Date().getTime()), "trialdata":trialdata};
- var data = this.get('data');
- data.push(trialdata);
- this.set('data', data);
- this.set({"currenttrial": this.get("currenttrial")+1});
- },
-
- addUnstructuredData: function(field, response) {
- var qd = this.get("questiondata");
- qd[field] = response;
- this.set("questiondata", qd);
- },
-
- getTrialData: function() {
- return this.get('data');
- },
-
- getEventData: function() {
- return this.get('eventdata');
- },
-
- getQuestionData: function() {
- return this.get('questiondata');
- },
-
- addEvent: function(eventtype, value) {
- var interval,
- ed = this.get('eventdata'),
- timestamp = new Date().getTime();
-
- if (eventtype == 'initialized') {
- interval = 0;
- } else {
- interval = timestamp - ed[ed.length-1]['timestamp'];
- }
-
- ed.push({'eventtype': eventtype, 'value': value, 'timestamp': timestamp, 'interval': interval});
- this.set('eventdata', ed);
- }
- });
-
-
- /*****************************************************
- * INSTRUCTIONS
- * - a simple, default instruction player
- ******************************************************/
- var Instructions = function(parent, pages, callback) {
-
- var self = this;
- var psiturk = parent;
- var currentscreen = 0, timestamp;
- var instruction_pages = pages;
- var complete_fn = callback;
-
- var loadPage = function() {
-
- // show the page
- psiturk.showPage(instruction_pages[currentscreen]);
-
- // connect event handler to previous button
- if(currentscreen != 0) { // can't do this if first page
- $('.instructionsnav').on('click.psiturk.instructionsnav.prev', '.previous', function() {
- prevPageButtonPress();
- });
- }
-
- // connect event handler to continue button
- $('.instructionsnav').on('click.psiturk.instructionsnav.next', '.continue', function() {
- nextPageButtonPress();
- });
-
- // Record the time that an instructions page is first presented
- timestamp = new Date().getTime();
-
- };
-
- var prevPageButtonPress = function () {
-
- // Record the response time
- var rt = (new Date().getTime()) - timestamp;
- viewedscreen = currentscreen;
- currentscreen = currentscreen - 1;
- if (currentscreen < 0) {
- currentscreen = 0; // can't go back that far
- } else {
- psiturk.recordTrialData({"phase":"INSTRUCTIONS", "template":pages[viewedscreen], "indexOf":viewedscreen, "action":"PrevPage", "viewTime":rt});
- loadPage(instruction_pages[currentscreen]);
- }
-
- }
-
- var nextPageButtonPress = function() {
-
- // Record the response time
- var rt = (new Date().getTime()) - timestamp;
- viewedscreen = currentscreen;
- currentscreen = currentscreen + 1;
-
- if (currentscreen == instruction_pages.length) {
- psiturk.recordTrialData({"phase":"INSTRUCTIONS", "template":pages[viewedscreen], "indexOf":viewedscreen, "action":"FinishInstructions", "viewTime":rt});
- finish();
- } else {
- psiturk.recordTrialData({"phase":"INSTRUCTIONS", "template":pages[viewedscreen], "indexOf":viewedscreen, "action":"NextPage", "viewTime":rt});
- loadPage(instruction_pages[viewedscreen]);
- }
-
- };
-
- var finish = function() {
-
- // unbind all instruction related events
- $('.continue').unbind('click.psiturk.instructionsnav.next');
- $('.previous').unbind('click.psiturk.instructionsnav.prev');
-
- // Record that the user has finished the instructions and
- // moved on to the experiment. This changes their status code
- // in the database.
- psiturk.finishInstructions();
-
- // Move on to the experiment
- complete_fn();
- };
-
-
-
- /* public interface */
- self.getIndicator = function() {
- return {"currently_viewing":{"indexOf":currentscreen, "template":pages[currentscreen]}, "instruction_deck":{"total_pages":instruction_pages.length, "templates":instruction_pages}};
- }
-
- self.loadFirstPage = function () { loadPage(); }
-
- // log instruction are starting
- psiturk.recordTrialData({"phase":"INSTRUCTIONS", "templates":pages, "action":"Begin"});
-
- return self;
- };
-
- /* PUBLIC METHODS: */
- self.preloadImages = function(imagenames) {
- $(imagenames).each(function() {
- image = new Image();
- image.src = this;
- });
- };
-
- self.preloadPages = function(pagenames) {
- // Synchronously preload pages.
- $(pagenames).each(function() {
- $.ajax({
- url: this,
- success: function(page_html) { self.pages[this.url] = page_html;},
- dataType: "html",
- async: false
- });
- });
- };
- // Get HTML file from collection and pass on to a callback
- self.getPage = function(pagename) {
- if (!(pagename in self.pages)){
- throw new Error(
- ["Attemping to load page before preloading: ",
- pagename].join(""));
- };
- return self.pages[pagename];
- };
-
-
- // Add a line of data with any number of columns
- self.recordTrialData = function(trialdata) {
- taskdata.addTrialData(trialdata);
- };
-
- // Add data value for a named column. If a value already
- // exists for that column, it will be overwritten
- self.recordUnstructuredData = function(field, value) {
- taskdata.addUnstructuredData(field, value);
- };
-
- self.getTrialData = function() {
- return taskdata.getTrialData();
- };
-
- self.getEventData = function() {
- return taskdata.getEventData();
- };
-
- self.getQuestionData = function() {
- return taskdata.getQuestionData();
- };
-
- // Add bonus to task data
- self.computeBonus = function(url, callback) {
- $.ajax(url, {
- type: "GET",
- data: {uniqueId: self.taskdata.id},
- success: callback
- });
- };
-
- // Save data to server
- self.saveData = function(callbacks) {
- taskdata.save(undefined, callbacks);
- };
-
- self.startTask = function () {
- self.saveData();
-
- $.ajax("inexp", {
- type: "POST",
- data: {uniqueId: self.taskdata.id}
- });
-
- if (self.taskdata.mode != 'debug') { // don't block people from reloading in debug mode
- // Provide opt-out
- $(window).on("beforeunload", function(){
- self.saveData();
-
- $.ajax("quitter", {
- type: "POST",
- data: {uniqueId: self.taskdata.id}
- });
- //var optoutmessage = "By leaving this page, you opt out of the experiment.";
- //alert(optoutmessage);
- return "By leaving or reloading this page, you opt out of the experiment. Are you sure you want to leave the experiment?";
- });
- }
-
- };
-
- // Notify app that participant has begun main experiment
- self.finishInstructions = function(optmessage) {
- Backbone.Notifications.trigger('_psiturk_finishedinstructions', optmessage);
- };
-
- self.teardownTask = function(optmessage) {
- Backbone.Notifications.trigger('_psiturk_finishedtask', optmessage);
- };
-
- self.completeHIT = function() {
- self.teardownTask();
- // save data one last time here?
- window.location= self.taskdata.adServerLoc + "?uniqueId=" + self.taskdata.id + "&mode=" + self.taskdata.mode;
- }
-
- self.doInstructions = function(pages, callback) {
- instructionController = new Instructions(self, pages, callback);
- instructionController.loadFirstPage();
- };
-
- self.getInstructionIndicator = function() {
- if (instructionController!=undefined) {
- return instructionController.getIndicator();
- }
- }
-
- // To be fleshed out with backbone views in the future.
- var replaceBody = function(x) { $('body').html(x); };
-
- self.showPage = _.compose(replaceBody, self.getPage);
-
- /* initialized local variables */
-
- var taskdata = new TaskData();
- taskdata.fetch({async: false});
-
- /* DATA: */
- self.pages = {};
- self.taskdata = taskdata;
-
-
- /* Backbone stuff */
- Backbone.Notifications.on('_psiturk_finishedinstructions', self.startTask);
- Backbone.Notifications.on('_psiturk_finishedtask', function(msg) { $(window).off("beforeunload"); });
-
-
- $(window).blur( function() {
- Backbone.Notifications.trigger('_psiturk_lostfocus');
- });
-
- $(window).focus( function() {
- Backbone.Notifications.trigger('_psiturk_gainedfocus');
- });
-
- // track changes in window size
- var triggerResize = function() {
- Backbone.Notifications.trigger('_psiturk_windowresize', [window.innerWidth, window.innerHeight]);
- };
-
- // set up the window resize trigger
- var to = false;
- $(window).resize(function(){
- if(to !== false)
- clearTimeout(to);
- to = setTimeout(triggerResize, 200);
- });
-
- return self;
-};
-
-// vi: noexpandtab nosmartindent shiftwidth=4 tabstop=4
diff --git a/public/lib/underscore-min.js b/public/lib/underscore-min.js
deleted file mode 100644
index ef9ef9f40..000000000
--- a/public/lib/underscore-min.js
+++ /dev/null
@@ -1,6 +0,0 @@
-// Underscore.js 1.5.1
-// http://underscorejs.org
-// (c) 2009-2013 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
-// Underscore may be freely distributed under the MIT license.
-!function(){var n=this,t=n._,r={},e=Array.prototype,u=Object.prototype,i=Function.prototype,a=e.push,o=e.slice,c=e.concat,l=u.toString,f=u.hasOwnProperty,s=e.forEach,p=e.map,v=e.reduce,h=e.reduceRight,d=e.filter,g=e.every,m=e.some,y=e.indexOf,b=e.lastIndexOf,x=Array.isArray,_=Object.keys,w=i.bind,j=function(n){return n instanceof j?n:this instanceof j?(this._wrapped=n,void 0):new j(n)};"undefined"!=typeof exports?("undefined"!=typeof module&&module.exports&&(exports=module.exports=j),exports._=j):n._=j,j.VERSION="1.5.1";var A=j.each=j.forEach=function(n,t,e){if(null!=n)if(s&&n.forEach===s)n.forEach(t,e);else if(n.length===+n.length){for(var u=0,i=n.length;i>u;u++)if(t.call(e,n[u],u,n)===r)return}else for(var a in n)if(j.has(n,a)&&t.call(e,n[a],a,n)===r)return};j.map=j.collect=function(n,t,r){var e=[];return null==n?e:p&&n.map===p?n.map(t,r):(A(n,function(n,u,i){e.push(t.call(r,n,u,i))}),e)};var E="Reduce of empty array with no initial value";j.reduce=j.foldl=j.inject=function(n,t,r,e){var u=arguments.length>2;if(null==n&&(n=[]),v&&n.reduce===v)return e&&(t=j.bind(t,e)),u?n.reduce(t,r):n.reduce(t);if(A(n,function(n,i,a){u?r=t.call(e,r,n,i,a):(r=n,u=!0)}),!u)throw new TypeError(E);return r},j.reduceRight=j.foldr=function(n,t,r,e){var u=arguments.length>2;if(null==n&&(n=[]),h&&n.reduceRight===h)return e&&(t=j.bind(t,e)),u?n.reduceRight(t,r):n.reduceRight(t);var i=n.length;if(i!==+i){var a=j.keys(n);i=a.length}if(A(n,function(o,c,l){c=a?a[--i]:--i,u?r=t.call(e,r,n[c],c,l):(r=n[c],u=!0)}),!u)throw new TypeError(E);return r},j.find=j.detect=function(n,t,r){var e;return O(n,function(n,u,i){return t.call(r,n,u,i)?(e=n,!0):void 0}),e},j.filter=j.select=function(n,t,r){var e=[];return null==n?e:d&&n.filter===d?n.filter(t,r):(A(n,function(n,u,i){t.call(r,n,u,i)&&e.push(n)}),e)},j.reject=function(n,t,r){return j.filter(n,function(n,e,u){return!t.call(r,n,e,u)},r)},j.every=j.all=function(n,t,e){t||(t=j.identity);var u=!0;return null==n?u:g&&n.every===g?n.every(t,e):(A(n,function(n,i,a){return(u=u&&t.call(e,n,i,a))?void 0:r}),!!u)};var O=j.some=j.any=function(n,t,e){t||(t=j.identity);var u=!1;return null==n?u:m&&n.some===m?n.some(t,e):(A(n,function(n,i,a){return u||(u=t.call(e,n,i,a))?r:void 0}),!!u)};j.contains=j.include=function(n,t){return null==n?!1:y&&n.indexOf===y?n.indexOf(t)!=-1:O(n,function(n){return n===t})},j.invoke=function(n,t){var r=o.call(arguments,2),e=j.isFunction(t);return j.map(n,function(n){return(e?t:n[t]).apply(n,r)})},j.pluck=function(n,t){return j.map(n,function(n){return n[t]})},j.where=function(n,t,r){return j.isEmpty(t)?r?void 0:[]:j[r?"find":"filter"](n,function(n){for(var r in t)if(t[r]!==n[r])return!1;return!0})},j.findWhere=function(n,t){return j.where(n,t,!0)},j.max=function(n,t,r){if(!t&&j.isArray(n)&&n[0]===+n[0]&&n.length<65535)return Math.max.apply(Math,n);if(!t&&j.isEmpty(n))return-1/0;var e={computed:-1/0,value:-1/0};return A(n,function(n,u,i){var a=t?t.call(r,n,u,i):n;a>e.computed&&(e={value:n,computed:a})}),e.value},j.min=function(n,t,r){if(!t&&j.isArray(n)&&n[0]===+n[0]&&n.length<65535)return Math.min.apply(Math,n);if(!t&&j.isEmpty(n))return 1/0;var e={computed:1/0,value:1/0};return A(n,function(n,u,i){var a=t?t.call(r,n,u,i):n;ae||r===void 0)return 1;if(e>r||e===void 0)return-1}return n.indexi;){var o=i+a>>>1;r.call(e,n[o])=0})})},j.difference=function(n){var t=c.apply(e,o.call(arguments,1));return j.filter(n,function(n){return!j.contains(t,n)})},j.zip=function(){for(var n=j.max(j.pluck(arguments,"length").concat(0)),t=new Array(n),r=0;n>r;r++)t[r]=j.pluck(arguments,""+r);return t},j.object=function(n,t){if(null==n)return{};for(var r={},e=0,u=n.length;u>e;e++)t?r[n[e]]=t[e]:r[n[e][0]]=n[e][1];return r},j.indexOf=function(n,t,r){if(null==n)return-1;var e=0,u=n.length;if(r){if("number"!=typeof r)return e=j.sortedIndex(n,t),n[e]===t?e:-1;e=0>r?Math.max(0,u+r):r}if(y&&n.indexOf===y)return n.indexOf(t,r);for(;u>e;e++)if(n[e]===t)return e;return-1},j.lastIndexOf=function(n,t,r){if(null==n)return-1;var e=null!=r;if(b&&n.lastIndexOf===b)return e?n.lastIndexOf(t,r):n.lastIndexOf(t);for(var u=e?r:n.length;u--;)if(n[u]===t)return u;return-1},j.range=function(n,t,r){arguments.length<=1&&(t=n||0,n=0),r=arguments[2]||1;for(var e=Math.max(Math.ceil((t-n)/r),0),u=0,i=new Array(e);e>u;)i[u++]=n,n+=r;return i};var M=function(){};j.bind=function(n,t){var r,e;if(w&&n.bind===w)return w.apply(n,o.call(arguments,1));if(!j.isFunction(n))throw new TypeError;return r=o.call(arguments,2),e=function(){if(!(this instanceof e))return n.apply(t,r.concat(o.call(arguments)));M.prototype=n.prototype;var u=new M;M.prototype=null;var i=n.apply(u,r.concat(o.call(arguments)));return Object(i)===i?i:u}},j.partial=function(n){var t=o.call(arguments,1);return function(){return n.apply(this,t.concat(o.call(arguments)))}},j.bindAll=function(n){var t=o.call(arguments,1);if(0===t.length)throw new Error("bindAll must be passed function names");return A(t,function(t){n[t]=j.bind(n[t],n)}),n},j.memoize=function(n,t){var r={};return t||(t=j.identity),function(){var e=t.apply(this,arguments);return j.has(r,e)?r[e]:r[e]=n.apply(this,arguments)}},j.delay=function(n,t){var r=o.call(arguments,2);return setTimeout(function(){return n.apply(null,r)},t)},j.defer=function(n){return j.delay.apply(j,[n,1].concat(o.call(arguments,1)))},j.throttle=function(n,t,r){var e,u,i,a=null,o=0;r||(r={});var c=function(){o=r.leading===!1?0:new Date,a=null,i=n.apply(e,u)};return function(){var l=new Date;o||r.leading!==!1||(o=l);var f=t-(l-o);return e=this,u=arguments,0>=f?(clearTimeout(a),a=null,o=l,i=n.apply(e,u)):a||r.trailing===!1||(a=setTimeout(c,f)),i}},j.debounce=function(n,t,r){var e,u=null;return function(){var i=this,a=arguments,o=function(){u=null,r||(e=n.apply(i,a))},c=r&&!u;return clearTimeout(u),u=setTimeout(o,t),c&&(e=n.apply(i,a)),e}},j.once=function(n){var t,r=!1;return function(){return r?t:(r=!0,t=n.apply(this,arguments),n=null,t)}},j.wrap=function(n,t){return function(){var r=[n];return a.apply(r,arguments),t.apply(this,r)}},j.compose=function(){var n=arguments;return function(){for(var t=arguments,r=n.length-1;r>=0;r--)t=[n[r].apply(this,t)];return t[0]}},j.after=function(n,t){return function(){return--n<1?t.apply(this,arguments):void 0}},j.keys=_||function(n){if(n!==Object(n))throw new TypeError("Invalid object");var t=[];for(var r in n)j.has(n,r)&&t.push(r);return t},j.values=function(n){var t=[];for(var r in n)j.has(n,r)&&t.push(n[r]);return t},j.pairs=function(n){var t=[];for(var r in n)j.has(n,r)&&t.push([r,n[r]]);return t},j.invert=function(n){var t={};for(var r in n)j.has(n,r)&&(t[n[r]]=r);return t},j.functions=j.methods=function(n){var t=[];for(var r in n)j.isFunction(n[r])&&t.push(r);return t.sort()},j.extend=function(n){return A(o.call(arguments,1),function(t){if(t)for(var r in t)n[r]=t[r]}),n},j.pick=function(n){var t={},r=c.apply(e,o.call(arguments,1));return A(r,function(r){r in n&&(t[r]=n[r])}),t},j.omit=function(n){var t={},r=c.apply(e,o.call(arguments,1));for(var u in n)j.contains(r,u)||(t[u]=n[u]);return t},j.defaults=function(n){return A(o.call(arguments,1),function(t){if(t)for(var r in t)n[r]===void 0&&(n[r]=t[r])}),n},j.clone=function(n){return j.isObject(n)?j.isArray(n)?n.slice():j.extend({},n):n},j.tap=function(n,t){return t(n),n};var S=function(n,t,r,e){if(n===t)return 0!==n||1/n==1/t;if(null==n||null==t)return n===t;n instanceof j&&(n=n._wrapped),t instanceof j&&(t=t._wrapped);var u=l.call(n);if(u!=l.call(t))return!1;switch(u){case"[object String]":return n==String(t);case"[object Number]":return n!=+n?t!=+t:0==n?1/n==1/t:n==+t;case"[object Date]":case"[object Boolean]":return+n==+t;case"[object RegExp]":return n.source==t.source&&n.global==t.global&&n.multiline==t.multiline&&n.ignoreCase==t.ignoreCase}if("object"!=typeof n||"object"!=typeof t)return!1;for(var i=r.length;i--;)if(r[i]==n)return e[i]==t;var a=n.constructor,o=t.constructor;if(a!==o&&!(j.isFunction(a)&&a instanceof a&&j.isFunction(o)&&o instanceof o))return!1;r.push(n),e.push(t);var c=0,f=!0;if("[object Array]"==u){if(c=n.length,f=c==t.length)for(;c--&&(f=S(n[c],t[c],r,e)););}else{for(var s in n)if(j.has(n,s)&&(c++,!(f=j.has(t,s)&&S(n[s],t[s],r,e))))break;if(f){for(s in t)if(j.has(t,s)&&!c--)break;f=!c}}return r.pop(),e.pop(),f};j.isEqual=function(n,t){return S(n,t,[],[])},j.isEmpty=function(n){if(null==n)return!0;if(j.isArray(n)||j.isString(n))return 0===n.length;for(var t in n)if(j.has(n,t))return!1;return!0},j.isElement=function(n){return!(!n||1!==n.nodeType)},j.isArray=x||function(n){return"[object Array]"==l.call(n)},j.isObject=function(n){return n===Object(n)},A(["Arguments","Function","String","Number","Date","RegExp"],function(n){j["is"+n]=function(t){return l.call(t)=="[object "+n+"]"}}),j.isArguments(arguments)||(j.isArguments=function(n){return!(!n||!j.has(n,"callee"))}),"function"!=typeof/./&&(j.isFunction=function(n){return"function"==typeof n}),j.isFinite=function(n){return isFinite(n)&&!isNaN(parseFloat(n))},j.isNaN=function(n){return j.isNumber(n)&&n!=+n},j.isBoolean=function(n){return n===!0||n===!1||"[object Boolean]"==l.call(n)},j.isNull=function(n){return null===n},j.isUndefined=function(n){return n===void 0},j.has=function(n,t){return f.call(n,t)},j.noConflict=function(){return n._=t,this},j.identity=function(n){return n},j.times=function(n,t,r){for(var e=Array(Math.max(0,n)),u=0;n>u;u++)e[u]=t.call(r,u);return e},j.random=function(n,t){return null==t&&(t=n,n=0),n+Math.floor(Math.random()*(t-n+1))};var I={escape:{"&":"&","<":"<",">":">",'"':""","'":"'","/":"/"}};I.unescape=j.invert(I.escape);var T={escape:new RegExp("["+j.keys(I.escape).join("")+"]","g"),unescape:new RegExp("("+j.keys(I.unescape).join("|")+")","g")};j.each(["escape","unescape"],function(n){j[n]=function(t){return null==t?"":(""+t).replace(T[n],function(t){return I[n][t]})}}),j.result=function(n,t){if(null==n)return void 0;var r=n[t];return j.isFunction(r)?r.call(n):r},j.mixin=function(n){A(j.functions(n),function(t){var r=j[t]=n[t];j.prototype[t]=function(){var n=[this._wrapped];return a.apply(n,arguments),z.call(this,r.apply(j,n))}})};var N=0;j.uniqueId=function(n){var t=++N+"";return n?n+t:t},j.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};var q=/(.)^/,B={"'":"'","\\":"\\","\r":"r","\n":"n"," ":"t","\u2028":"u2028","\u2029":"u2029"},D=/\\|'|\r|\n|\t|\u2028|\u2029/g;j.template=function(n,t,r){var e;r=j.defaults({},r,j.templateSettings);var u=new RegExp([(r.escape||q).source,(r.interpolate||q).source,(r.evaluate||q).source].join("|")+"|$","g"),i=0,a="__p+='";n.replace(u,function(t,r,e,u,o){return a+=n.slice(i,o).replace(D,function(n){return"\\"+B[n]}),r&&(a+="'+\n((__t=("+r+"))==null?'':_.escape(__t))+\n'"),e&&(a+="'+\n((__t=("+e+"))==null?'':__t)+\n'"),u&&(a+="';\n"+u+"\n__p+='"),i=o+t.length,t}),a+="';\n",r.variable||(a="with(obj||{}){\n"+a+"}\n"),a="var __t,__p='',__j=Array.prototype.join,"+"print=function(){__p+=__j.call(arguments,'');};\n"+a+"return __p;\n";try{e=new Function(r.variable||"obj","_",a)}catch(o){throw o.source=a,o}if(t)return e(t,j);var c=function(n){return e.call(this,n,j)};return c.source="function("+(r.variable||"obj")+"){\n"+a+"}",c},j.chain=function(n){return j(n).chain()};var z=function(n){return this._chain?j(n).chain():n};j.mixin(j),A(["pop","push","reverse","shift","sort","splice","unshift"],function(n){var t=e[n];j.prototype[n]=function(){var r=this._wrapped;return t.apply(r,arguments),"shift"!=n&&"splice"!=n||0!==r.length||delete r[0],z.call(this,r)}}),A(["concat","join","slice"],function(n){var t=e[n];j.prototype[n]=function(){return z.call(this,t.apply(this._wrapped,arguments))}}),j.extend(j.prototype,{chain:function(){return this._chain=!0,this},value:function(){return this._wrapped}})}.call(this);
-//# sourceMappingURL=underscore-min.map
\ No newline at end of file
diff --git a/src/App/App.jsx b/src/App/App.jsx
index 2a35c09cb..6f5d7f49c 100644
--- a/src/App/App.jsx
+++ b/src/App/App.jsx
@@ -2,12 +2,11 @@ import React from "react";
// Import css styling
import "@fortawesome/fontawesome-free/css/all.css";
-import "bootstrap/dist/css/bootstrap.css";
import "./index.css";
// Import configurations and utilities
-import { config, SETTINGS, turkUniqueId } from "../config/main";
-import * as trigger from "../config/trigger";
+import { ENV, SETTINGS } from "../config/";
+import { trigger } from "../config/trigger";
import { getProlificId, getSearchParam } from "../lib/utils";
// Import deployment functions
@@ -33,9 +32,6 @@ export default function App() {
// Manage error state of the app
const [isError, setIsError] = React.useState(false);
- // Manage the psiturk object
- const [psiturk, setPsiturk] = React.useState(false);
-
// Manage the data used in the experiment
const [participantID, setParticipantID] = React.useState("");
const [studyID, setStudyID] = React.useState("");
@@ -52,14 +48,22 @@ export default function App() {
async function setUpHoneycomb() {
// For testing and debugging purposes
console.log({
- "Honeycomb Configuration": config,
+ "Honeycomb Configuration": ENV,
"Task Settings": SETTINGS,
});
+ // TEMP: Testing to ensure the config is setup correctly
+ console.log(
+ "ENVIRONMENT",
+ import.meta.env.PACKAGE_NAME,
+ import.meta.env.PACKAGE_VERSION,
+ import.meta.env
+ );
+
// If on desktop
- if (config.USE_ELECTRON) {
+ if (ENV.USE_ELECTRON) {
// TODO @brown-ccv #443 : Pass NODE_ENV here as well
- await window.electronAPI.setConfig(config); // Pass config to Electron ipcMain
+ await window.electronAPI.setConfig(ENV); // Pass config to Electron ipcMain
await window.electronAPI.setTrigger(trigger); // Pass trigger to Electron ipcMain
// Fill in login fields based on environment variables (may still be blank)
@@ -68,24 +72,18 @@ export default function App() {
if (credentials.studyID) setStudyID(credentials.studyID);
setMethod("desktop");
} else {
- // If MTURK
- if (config.USE_MTURK) {
- /* eslint-disable */
- window.lodash = _.noConflict();
- setPsiturk(new PsiTurk(turkUniqueId, "/complete"));
- setMethod("mturk");
- handleLogin("mturk", turkUniqueId);
- /* eslint-enable */
- } else if (config.USE_PROLIFIC) {
+ // TODO @brown-ccv #227: Deprecate USE_PROLIFIC, always get URL
+ // TODO @brown-ccv #416: Match USE_PROLIFIC variable names, include session
+ if (ENV.USE_PROLIFIC) {
const pID = getProlificId();
- if (config.USE_FIREBASE && pID) {
+ if (ENV.USE_FIREBASE && pID) {
setMethod("firebase");
handleLogin("prolific", pID);
} else {
// Error - Prolific must be used with Firebase
setIsError(true);
}
- } else if (config.USE_FIREBASE) {
+ } else if (ENV.USE_FIREBASE) {
// Fill in login fields based on query parameters (may still be blank)
const maybeStudyID = getSearchParam("studyID");
const maybeParticipantID = getSearchParam("participantID");
@@ -122,10 +120,6 @@ export default function App() {
const desktopUpdateFunction = async (data) => {
await window.electronAPI.on_data_update(data);
};
- // Save the trial data to PsiTurk
- const psiturkUpdateFunction = (data) => {
- psiturk.recordTrialData(data);
- };
/** EXPERIMENT FINISH FUNCTIONS */
@@ -139,16 +133,6 @@ export default function App() {
const desktopFinishFunction = async () => {
await window.electronAPI.on_finish();
};
- // Complete the PsiTurk experiment
- const psiturkFinishFunction = () => {
- const completePsiturk = async () => {
- psiturk.saveData({
- success: () => psiturk.completeHIT(),
- error: () => setIsError(true),
- });
- };
- completePsiturk();
- };
/**
* Callback function executed when the user logs in.
@@ -173,14 +157,12 @@ export default function App() {
{
desktop: desktopUpdateFunction,
firebase: firebaseUpdateFunction,
- mturk: psiturkUpdateFunction,
default: defaultFunction,
}[currentMethod]
}
dataFinishFunction={
{
desktop: desktopFinishFunction,
- mturk: psiturkFinishFunction,
firebase: firebaseFinishFunction,
default: defaultFinishFunction,
}[currentMethod]
diff --git a/src/App/components/Error.jsx b/src/App/components/Error.jsx
index 1f561212f..4cbc36f7f 100644
--- a/src/App/components/Error.jsx
+++ b/src/App/components/Error.jsx
@@ -9,9 +9,7 @@ import React from "react";
export default function Error() {
return (
-
- Please ask your task provider to enable firebase.
-
+
Please ask your task provider to enable firebase.
);
}
diff --git a/src/App/components/JsPsychExperiment.jsx b/src/App/components/JsPsychExperiment.jsx
index 31d4ef8f2..c39ec0c79 100644
--- a/src/App/components/JsPsychExperiment.jsx
+++ b/src/App/components/JsPsychExperiment.jsx
@@ -2,9 +2,10 @@ import { initJsPsych } from "jspsych";
import PropTypes from "prop-types";
import React from "react";
-import { config, taskVersion } from "../../config/main";
+import { ENV } from "../../config/";
import { buildTimeline, jsPsychOptions } from "../../experiment";
import { initParticipant } from "../deployments/firebase";
+import { getJsPsych } from "../../lib/utils";
// ID used to identify the DOM element that holds the experiment.
const EXPERIMENT_ID = "experiment-window";
@@ -15,47 +16,65 @@ export default function JsPsychExperiment({
dataUpdateFunction,
dataFinishFunction,
}) {
+ const [jsPsych, setJsPsych] = React.useState();
+
/**
* Create the instance of JsPsych whenever the studyID or participantID changes, which occurs then the user logs in.
*
* This instance of jsPsych is passed to any trials that need it when the timeline is built.
*/
- const jsPsych = React.useMemo(() => {
- // Start date of the experiment - used as the UID of the session
- const startDate = new Date().toISOString();
-
- // Write the initial record to Firestore
- if (config.USE_FIREBASE) initParticipant(studyID, participantID, startDate);
+ // TODO: The initialization of jsPsych should really happen onSubmit?
+ // TODO: JsPsychExperiment gets the instance of jsPsych and the timeline and just runs it?
+ React.useEffect(() => {
+ async function initializeJsPsych() {
+ // Start date of the experiment - used as the UID of the session
+ // TODO @brown-ccv #307: Use ISO 8061 date? Doesn't include the punctuation so it's safe for file names
+ const startDate = new Date().toISOString();
- const jsPsych = initJsPsych({
- // Combine necessary Honeycomb options with custom ones (src/timelines/main.js)
- ...jsPsychOptions,
- display_element: EXPERIMENT_ID,
- on_data_update: (data) => {
- jsPsychOptions.on_data_update && jsPsychOptions.on_data_update(data); // Call custom on_data_update function (if provided)
- dataUpdateFunction(data); // Call Honeycomb's on_data_update function
- },
- on_finish: (data) => {
- jsPsychOptions.on_finish && jsPsychOptions.on_finish(data); // Call custom on_finish function (if provided)
- dataFinishFunction(data); // Call Honeycomb's on_finish function
- },
- });
+ // Write the initial record to Firestore
+ if (ENV.USE_FIREBASE) initParticipant(studyID, participantID, startDate);
- // Adds experiment data into jsPsych directly. These properties will be added to all trials
- jsPsych.data.addProperties({
- study_id: studyID,
- participant_id: participantID,
- start_date: startDate,
- task_version: taskVersion,
- });
+ const tempJsPsych = initJsPsych({
+ // Combine necessary Honeycomb options with custom ones (src/timelines/main.js)
+ ...jsPsychOptions,
+ display_element: EXPERIMENT_ID,
+ on_data_update: (data) => {
+ jsPsychOptions.on_data_update && jsPsychOptions.on_data_update(data); // Call custom on_data_update function (if provided)
+ dataUpdateFunction(data); // Call Honeycomb's on_data_update function
+ },
+ on_finish: (data) => {
+ jsPsychOptions.on_finish && jsPsychOptions.on_finish(data); // Call custom on_finish function (if provided)
+ dataFinishFunction(data); // Call Honeycomb's on_finish function
+ },
+ });
- return jsPsych;
+ // Adds experiment data into jsPsych directly. These properties will be added to all trials
+ tempJsPsych.data.addProperties({
+ app_name: import.meta.env.PACKAGE_NAME,
+ app_version: import.meta.env.PACKAGE_VERSION,
+ // TODO: This does NOT work when using Firebase as electronAPI isn't set up
+ // TODO: Can I just get the file from here anyways?
+ // app_commit: await window.electronAPI.getCommit(),
+ study_id: studyID,
+ participant_id: participantID,
+ start_date: startDate,
+ });
+ setJsPsych(tempJsPsych);
+ }
+ initializeJsPsych();
}, [studyID, participantID]);
- /** Build and run the experiment timeline */
+ /**
+ * Builds and runs the experiment timeline, which occurs whenever an instance of JsPsych is created
+ * NOTE: We must check if jsPsych is defined because it hasn't been created on first render
+ */
React.useEffect(() => {
- const timeline = buildTimeline(jsPsych, studyID, participantID);
- jsPsych.run(timeline);
+ if (jsPsych) {
+ // set up jsPsych object as global variable
+ window.jsPsych = jsPsych;
+ const timeline = buildTimeline(studyID, participantID);
+ getJsPsych().run(timeline);
+ }
}, [jsPsych]);
return ;
diff --git a/src/App/components/Login.jsx b/src/App/components/Login.jsx
index 49bddadb7..8979708af 100644
--- a/src/App/components/Login.jsx
+++ b/src/App/components/Login.jsx
@@ -1,6 +1,5 @@
import PropTypes from "prop-types";
-import React, { useEffect } from "react";
-import { Button, Form } from "react-bootstrap";
+import React from "react";
export default function Login({
initialStudyID,
@@ -11,23 +10,31 @@ export default function Login({
// State variables for login screen
const [participantID, setParticipantID] = React.useState(initialParticipantID);
const [studyID, setStudyID] = React.useState(initialStudyID);
+
+ // State variable for handling errors
const [isError, setIsError] = React.useState(false);
+ // State variable for handling loading states
+ const [isLoading, setIsLoading] = React.useState(false);
+
// Update local participantID if it changes upstream
- useEffect(() => {
+ React.useEffect(() => {
setParticipantID(initialParticipantID);
}, [initialParticipantID]);
// Update local studyID if it changes upstream
- useEffect(() => {
+ React.useEffect(() => {
setStudyID(initialStudyID);
}, [initialStudyID]);
// Function used to validate and log in participant
function handleSubmit(e) {
e.preventDefault();
+ setIsLoading(true);
+
// Logs user in if a valid participant/study id combination is given
validationFunction(studyID, participantID).then((isValid) => {
+ setIsLoading(false);
setIsError(!isValid);
if (isValid) handleLogin(studyID, participantID);
});
@@ -36,37 +43,31 @@ export default function Login({
return (
-
- Participant ID
- setParticipantID(e.target.value)}
- />
-
-
- Study ID
- setStudyID(e.target.value)}
- />
-
-
-
+
{isError ? (
-
- No matching experiment found for this participant and study
+
+ Unable to begin the study. Is your login information correct?
) : null}
diff --git a/src/App/deployments/firebase.js b/src/App/deployments/firebase.js
index a3298ab64..887582f6a 100644
--- a/src/App/deployments/firebase.js
+++ b/src/App/deployments/firebase.js
@@ -1,31 +1,27 @@
-// TODO @brown-ccv #183: Upgrade to modular SDK instead of compat
-import firebase from "firebase/compat/app";
-import "firebase/compat/firestore";
+import { initializeApp } from "firebase/app";
+import {
+ getFirestore,
+ connectFirestoreEmulator,
+ doc,
+ setDoc,
+ addDoc,
+ collection,
+} from "firebase/firestore";
// Initialize Firebase and Firestore
-firebase.initializeApp({
- apiKey: process.env.REACT_APP_API_KEY,
- authDomain: process.env.REACT_APP_AUTH_DOMAIN,
- projectId: process.env.REACT_APP_PROJECT_ID ?? "no-firebase",
- storageBucket: process.env.REACT_APP_STORAGE_BUCKET,
- messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID,
- appId: process.env.REACT_APP_APP_ID,
+const APP = initializeApp({
+ apiKey: import.meta.env.VITE_API_KEY,
+ authDomain: import.meta.env.VITE_AUTH_DOMAIN,
+ projectId: import.meta.env.VITE_PROJECT_ID ?? "no-firebase",
+ storageBucket: import.meta.env.VITE_STORAGE_BUCKET,
+ messagingSenderId: import.meta.env.VITE_MESSAGING_SENDER_ID,
+ appId: import.meta.env.VITE_APP_ID,
});
-export const db = firebase.firestore();
+export const DB = getFirestore(APP);
// Use emulator if on localhost
-if (window.location.hostname === "localhost") db.useEmulator("localhost", 8080);
-
-// Get a reference to the Firebase document at
-// "/participant_responses/{studyID}/participants/{participantID}"
-function getParticipantRef(studyID, participantID) {
- return db.doc(`participant_responses/${studyID}/participants/${participantID}`);
-}
-
-// Get a reference to the Firebase document at
-// "/participant_responses/{studyID}/participants/{participantID}/data/{startDate}"
-export function getExperimentRef(studyID, participantID, startDate) {
- return db.doc(`${getParticipantRef(studyID, participantID).path}/data/${startDate}`);
+if (window.location.hostname === "localhost") {
+ connectFirestoreEmulator(DB, "127.0.0.1", 8080);
}
/**
@@ -37,7 +33,7 @@ export function getExperimentRef(studyID, participantID, startDate) {
export async function validateParticipant(studyID, participantID) {
try {
// .get() will fail on an invalid path
- await getParticipantRef(studyID, participantID).get();
+ await getParticipantRef(studyID, participantID);
return true;
} catch (error) {
console.error("Unable to validate the experiment:\n", error);
@@ -56,11 +52,10 @@ export async function validateParticipant(studyID, participantID) {
export async function initParticipant(studyID, participantID, startDate) {
try {
const experiment = getExperimentRef(studyID, participantID, startDate);
- await experiment.set({
- // TODO @brown-ccv #394: Write GIT SHA here
- // TODO @brown-ccv #394: Store participantID and studyID here, not on each trial
- start_time: startDate,
+ await setDoc(experiment, {
+ // TODO @brown-ccv #394: Don't handle any of this here? Let everything be done in jsPsych
// TODO @brown-ccv #394: app_version and app_platform are deprecated
+ start_time: startDate,
app_version: window.navigator.appVersion,
app_platform: window.navigator.platform,
});
@@ -81,11 +76,38 @@ export async function addToFirebase(data) {
const studyID = data.study_id;
const participantID = data.participant_id;
const startDate = data.start_date;
-
try {
const experiment = getExperimentRef(studyID, participantID, startDate);
- await experiment.collection("trials").add(data);
+ await addDoc(collection(DB, `${experiment.path}/trials`), {
+ data,
+ });
} catch (error) {
console.error("Unable to add trial:\n", error);
}
}
+
+// -------------------- HELPERS --------------------
+
+/**
+ * Get a reference to a given Firebase document
+ * @param {string} studyID The ID of the study in Firebase
+ * @param {string} participantID The ID of the participant on the given study in Firebase
+ * @returns "/participant_responses/{studyID}/participants/{participantID}"
+ */
+async function getParticipantRef(studyID, participantID) {
+ return doc(DB, `participant_responses/${studyID}/participants/${participantID}`);
+}
+
+/**
+ * Get a reference to a given Firebase document
+ * @param {string} studyID The ID of the study in Firebase
+ * @param {string} participantID The ID of the participant on the given study in Firebase
+ * @param {string} startDate The start date of the experiment, used as its ID
+ * @returns "/participant_responses/{studyID}/participants/{participantID}/data/{startDate}"
+ */
+export function getExperimentRef(studyID, participantID, startDate) {
+ return doc(
+ DB,
+ `participant_responses/${studyID}/participants/${participantID}/data/${startDate}`
+ );
+}
diff --git a/src/App/index.css b/src/App/index.css
index 02542f7dd..257be7336 100644
--- a/src/App/index.css
+++ b/src/App/index.css
@@ -42,3 +42,43 @@ body,
.width-100 {
width: 100%;
}
+
+.form-input {
+ width: 100%;
+ margin: 8px 0 2.8vh 0;
+ padding: 13px 0 13px 14px;
+ border-radius: 8px;
+ border: 1px solid rgb(222 226 230);
+}
+
+.login-btn {
+ font-size: 18px;
+ width: 102%;
+ background-color: rgb(13 110 253);
+ padding: 1.8vh 0;
+ color: white;
+ border-radius: 8px;
+ border: 1px solid rgb(222 226 230);
+}
+
+.login-btn:hover {
+ background-color: rgb(8 81 200);
+ transition: 0.2s;
+ cursor: pointer;
+}
+
+.alert-danger {
+ color: #721c24;
+ background-color: #f8d7da;
+ border: 1px solid #f5c6cb;
+ padding: 0.75rem 1.25rem;
+ margin-bottom: 1rem;
+ border-radius: 0.25rem;
+}
+
+.align-items-center-col {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
diff --git a/public/electron/serialPort.js b/src/Electron/lib/serialport.js
similarity index 91%
rename from public/electron/serialPort.js
rename to src/Electron/lib/serialport.js
index f195875e3..55610d35e 100644
--- a/public/electron/serialPort.js
+++ b/src/Electron/lib/serialport.js
@@ -1,4 +1,4 @@
-const SerialPort = require("serialport");
+import SerialPort from "serialport";
// TODO @brown-ccv #460: Test connections with MockBindings (e.g. CONTINUE_ANYWAY) https://serialport.io/docs/api-binding-mock
@@ -36,7 +36,7 @@ function getDevice(portList, comVendorName, productId) {
* @returns The SerialPort device
*/
// TODO @brown-ccv #460: This should fail, not return false
-async function getPort(comVendorName, productId) {
+export async function getPort(comVendorName, productId) {
let portList;
try {
portList = await SerialPort.list();
@@ -59,11 +59,6 @@ async function getPort(comVendorName, productId) {
* @param {SerialPort} port A SerialPort device
* @param {number} event_code The numeric code to write to the device
*/
-async function sendToPort(port, event_code) {
+export async function sendToPort(port, event_code) {
port.write(Buffer.from([event_code]));
}
-
-module.exports = {
- getPort,
- sendToPort,
-};
diff --git a/public/electron/main.js b/src/Electron/main.js
similarity index 60%
rename from public/electron/main.js
rename to src/Electron/main.js
index 92259c9c1..b9366f11c 100644
--- a/public/electron/main.js
+++ b/src/Electron/main.js
@@ -1,46 +1,48 @@
/** ELECTRON MAIN PROCESS */
+import fs from "node:fs";
+import path from "node:path";
+import { execSync } from "node:child_process";
-const url = require("url");
-const path = require("node:path");
-const fs = require("node:fs");
+import { BrowserWindow, app, dialog, ipcMain } from "electron";
+import log from "electron-log";
-const { app, BrowserWindow, ipcMain, dialog } = require("electron");
-const log = require("electron-log");
-const _ = require("lodash");
+import { getPort, sendToPort } from "./lib/serialport";
-const { getPort, sendToPort } = require("./serialPort");
-
-// TODO @brown-ccv #460: Add serialport's MockBinding for the "Continue Anyway": https://serialport.io/docs/guide-testing
-
-// Early exit when installing on Windows: https://www.electronforge.io/config/makers/squirrel.windows#handling-startup-events
-if (require("electron-squirrel-startup")) app.quit();
-
-// Initialize the logger for any renderer process
-log.initialize({ preload: true });
+/* global MAIN_WINDOW_VITE_DEV_SERVER_URL MAIN_WINDOW_VITE_NAME */
+// TODO: If we can get eslint to play nice we can remove this
+// TODO @RobertGemmaJr: Do more testing with the environment variables - are home/clinic being built correctly?
// TODO @brown-ccv #192: Handle data writing to desktop in a utility process
// TODO @brown-ccv #192: Handle video data writing to desktop in a utility process
-// TODO @brown-ccv #398: Separate log files for each run through
-// TODO @brown-ccv #429: Use app.getPath('temp') for temporary JSON file
/************ GLOBALS ***********/
-const GIT_VERSION = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../version.json")));
-// TODO @brown-ccv #436 : Use app.isPackaged() to determine if running in dev or prod
-const ELECTRON_START_URL = process.env.ELECTRON_START_URL;
-
-let CONFIG; // Honeycomb configuration object
-let CONTINUE_ANYWAY; // Whether to continue the experiment with no hardware connected (option is only available in dev mode)
+const IS_DEV = import.meta.env.DEV && !app.isPackaged;
+let CONTINUE_ANYWAY; // Whether to continue the experiment with no hardware connected
-let TEMP_FILE; // Path to the temporary output file
-let OUT_PATH; // Path to the final output folder (on the Desktop)
-let OUT_FILE; // Name of the final output file
+const DATA_DIR = path.resolve(app.getPath("userData")); // Path to the apps data directory
+// TODO @brown-ccv: Is there a way to make this configurable without touching code?
+const OUT_DIR = path.resolve(app.getPath("desktop"), app.getName()); // Path to the final output folder
+let FILE_PATH; // Relative path to the data file.
+let CONFIG; // Honeycomb configuration object
let TRIGGER_CODES; // Trigger codes and IDs for the EEG machine
let TRIGGER_PORT; // Port that the EEG machine is talking through
+// TODO: Fix the security policy instead of ignoring
+process.env["ELECTRON_DISABLE_SECURITY_WARNINGS"] = "true";
+
/************ APP LIFECYCLE ***********/
+// Early exit when installing on Windows: https://www.electronforge.io/config/makers/squirrel.windows#handling-startup-events
+if (require("electron-squirrel-startup")) app.quit();
+
+// Initialize the logger
+// TODO @brown-ccv #398: Handle logs in app.getPath('logs')
+// TODO @brown-ccv #398: Separate log files for each run through
+// TODO @brown-ccv #398: Spy on the renderer process too?
+log.initialize({ preload: true });
+
/**
* Executed when the app is initialized
* @windows Builds the Electron window
@@ -53,6 +55,7 @@ app.whenReady().then(() => {
ipcMain.on("setConfig", handleSetConfig);
ipcMain.on("setTrigger", handleSetTrigger);
ipcMain.handle("getCredentials", handleGetCredentials);
+ ipcMain.handle("getCommit", handleGetCommit);
ipcMain.on("onDataUpdate", handleOnDataUpdate);
ipcMain.handle("onFinish", handleOnFinish);
ipcMain.on("photodiodeTrigger", handlePhotodiodeTrigger);
@@ -85,13 +88,14 @@ app.on("window-all-closed", () => {
app.on("before-quit", () => {
log.info("Attempting to quit application");
try {
- JSON.parse(fs.readFileSync(TEMP_FILE));
+ JSON.parse(fs.readFileSync(getDataPath()));
} catch (error) {
if (error instanceof TypeError) {
- // TEMP_FILE is undefined at this point
+ // The JSON file has not been created yet
log.warn("Application quit before the participant started the experiment");
} else if (error instanceof SyntaxError) {
// Trials are still being written (i.e. hasn't hit handleOnFinish function)
+ // NOTE: The error occurs because the file is not a valid JSON document
log.warn("Application quit while the participant was completing the experiment");
} else {
log.error("Electron encountered an error while quitting:");
@@ -123,9 +127,9 @@ function handleSetConfig(event, config) {
* @param {Event} event The Electron renderer event
* @param {Object} trigger The metadata for the event code trigger
* @param {string} trigger.comName The COM name of the serial port
- * @param {Object} trigger.eventCodes The list of possible event codes to be triggered
* @param {string} trigger.productID The name of the product connected to the serial port
* @param {string} trigger.vendorID The name of the vendor connected to the serial prot
+ * @param {Object} trigger.settings The list of possible event with relative event codes to be triggered
*/
function handleSetTrigger(event, trigger) {
TRIGGER_CODES = trigger;
@@ -133,23 +137,46 @@ function handleSetTrigger(event, trigger) {
}
/**
- * Checks for REACT_APP_STUDY_ID and REACT_APP_PARTICIPANT_ID environment variables
+ * Checks for VITE_STUDY_ID and VITE_PARTICIPANT_ID environment variables
* Note that studyID and participantID are undefined when the environment variables are not given
* @returns An object containing a studyID and participantID
*/
function handleGetCredentials() {
- const studyID = process.env.REACT_APP_STUDY_ID;
- const participantID = process.env.REACT_APP_PARTICIPANT_ID;
+ const studyID = process.env.VITE_STUDY_ID;
+ const participantID = process.env.VITE_PARTICIPANT_ID;
if (studyID) log.info("Received study from ENV: ", studyID);
if (participantID) log.info("Received participant from ENV: ", participantID);
return { studyID, participantID };
}
+/**
+ * Retrieves the Git Commit SHA and Branch of the repository
+ * A version.json file is created during build-time that can be read from
+ * @returns An object containing the git commit sha and branch of the codebase
+ */
+async function handleGetCommit() {
+ try {
+ if (!IS_DEV) {
+ // Get the Git Commit SHA and Branch of the repository
+ return {
+ sha: execSync("git rev-parse HEAD").toString().trim(),
+ ref: execSync("git branch --show-current").toString().trim(),
+ };
+ } else {
+ // Load the Git Commit SHA and Branch that was created at build-time
+ return JSON.parse(fs.readFileSync(path.resolve(__dirname, "version.json")));
+ }
+ } catch (e) {
+ log.error("Unable to determine git version");
+ log.error(e);
+ }
+}
+
/**
* @returns {Boolean} Whether or not the EEG machine is connected to the computer
*/
function handleCheckSerialPort() {
- setUpPort().then(() => handleEventSend(TRIGGER_CODES.eventCodes.test_connect));
+ setUpPort().then(() => handleEventSend(TRIGGER_CODES.settings.test_connect.code));
}
/**
@@ -159,7 +186,10 @@ function handleCheckSerialPort() {
*/
function handlePhotodiodeTrigger(event, code) {
if (code !== undefined) {
- log.info(`Event: ${_.invert(TRIGGER_CODES.eventCodes)[code]}, code: ${code}`);
+ const eventName = Object.keys(TRIGGER_CODES.settings).find(
+ (key) => TRIGGER_CODES.settings[key].code === code
+ );
+ log.info(`Event: ${eventName}, code: ${code}`);
handleEventSend(code);
} else {
log.warn("Photodiode event triggered but no code was sent");
@@ -169,46 +199,41 @@ function handlePhotodiodeTrigger(event, code) {
/**
* Receives the trial data and writes it to a temp file in AppData
* The out path/file and writable stream are initialized if isn't yet
- * The temp file is written at ~/userData/[appName]/TempData/[studyID]/[participantID]/
+ * The temp file is written at ~/userData/[appName]/data/[mode]/[studyID]/[participantID]/[start_date].json
* @param {Event} event The Electron renderer event
* @param {Object} data The trial data
*/
+// TODO @brown-ccv #397: Handle FILE_PATH creation when user logs in, not here
function handleOnDataUpdate(event, data) {
const { participant_id, study_id, start_date, trial_index } = data;
- // Set the output path and file name if they are not set yet
- if (!OUT_PATH) {
- // The final OUT_FILE will be nested inside subfolders on the Desktop
- OUT_PATH = path.resolve(app.getPath("desktop"), app.getName(), study_id, participant_id);
- // TODO @brown-ccv #307: ISO 8061 data string? Doesn't include the punctuation
- OUT_FILE = `${start_date}.json`.replaceAll(":", "_"); // (":" are replaced to prevent issues with invalid file names);
- }
-
- // Create the temporary folder & file if it hasn't been created
- // TODO @brown-ccv #397: Initialize file stream on login, not here
- if (!TEMP_FILE) {
- // The tempFile is nested inside "TempData" in the user's local app data folder
- const tempPath = path.resolve(app.getPath("userData"), "TempData", study_id, participant_id);
- fs.mkdirSync(tempPath, { recursive: true });
- TEMP_FILE = path.resolve(tempPath, OUT_FILE);
-
- // Write initial bracket
- fs.appendFileSync(TEMP_FILE, "{");
- log.info("Temporary file created at ", TEMP_FILE);
-
- // Write useful information and the beginning of the trials array
- fs.appendFileSync(TEMP_FILE, `"start_time": "${start_date}",`);
- fs.appendFileSync(TEMP_FILE, `"git_version": ${JSON.stringify(GIT_VERSION)},`);
- fs.appendFileSync(TEMP_FILE, `"trials": [`);
+ // The data file has not been created yet
+ if (!FILE_PATH) {
+ // Build the relative file path to the file
+ FILE_PATH = path.join(
+ "data",
+ import.meta.env.MODE,
+ study_id,
+ participant_id,
+ // TODO @brown-ccv #307: Use ISO 8061 date? Doesn't include the punctuation (here and in Firebase)
+ `${start_date}.json`.replaceAll(":", "_") // (":" are replaced to prevent issues with invalid file names
+ );
+
+ // Create the data file in userData
+ const dataPath = getDataPath();
+ fs.mkdirSync(path.dirname(dataPath), { recursive: true });
+ fs.writeFileSync(dataPath, "[");
+ log.info("Data file created at ", dataPath);
}
- // Prepend comma for all trials except first
- if (trial_index > 0) fs.appendFileSync(TEMP_FILE, ",");
+ const dataPath = getDataPath();
+ // TODO @brown-ccv #397: I can set a constant for the full path once the stream is created elsewhere
// Write trial data
- fs.appendFileSync(TEMP_FILE, JSON.stringify(data));
+ if (trial_index > 0) fs.appendFileSync(dataPath, ","); // Prepend comma if needed
+ fs.appendFileSync(dataPath, JSON.stringify(data));
- log.info(`Trial ${trial_index} successfully written to TempData`);
+ log.info(`Trial ${trial_index} successfully written`);
}
/**
@@ -218,47 +243,50 @@ function handleOnDataUpdate(event, data) {
function handleOnFinish() {
log.info("Experiment Finished");
+ const dataPath = getDataPath();
+ const outPath = getOutPath();
+
// Finish writing JSON
- fs.appendFileSync(TEMP_FILE, "]}");
- log.info("Finished writing experiment data to TempData");
+ fs.appendFileSync(dataPath, "]");
+ log.info(`Finished writing experiment data to ${dataPath}`);
- // Move temp file to the output location
- const filePath = path.resolve(OUT_PATH, OUT_FILE);
try {
- fs.mkdirSync(OUT_PATH, { recursive: true });
- fs.copyFileSync(TEMP_FILE, filePath);
- log.info("Successfully saved experiment data to ", filePath);
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
+ fs.copyFileSync(dataPath, outPath);
+ log.info("Successfully saved experiment data to ", outPath);
} catch (e) {
- log.error.error("Unable to save file: ", filePath);
- log.error.error(e);
+ log.error("Unable to save file: ", outPath);
+ log.error(e);
}
app.quit();
}
// Save webm video file
// TODO @brown-ccv #342: Rolling save of webm video, remux to mp4 at the end?
+// TODO @brown-ccv #301: Handle video recordings with jsPsych
function handleSaveVideo(event, data) {
// Video file is the same as OUT_FILE except it's mp4, not json
- const filePath = path.join(
- path.dirname(OUT_FILE),
- path.basename(OUT_FILE, path.extname(OUT_FILE)) + ".webm"
+ const outPath = getOutPath();
+ const videoFile = path.join(
+ path.dirname(outPath),
+ path.basename(outPath, path.extname(outPath)) + ".webm"
);
- log.info(filePath);
-
// Save video file to the desktop
+ // TODO @brown-ccv #301: The outputted video is broken
try {
// Note the video data is sent to the main process as a base64 string
const videoData = Buffer.from(data.split(",")[1], "base64");
- fs.mkdirSync(OUT_PATH, { recursive: true });
+ // TODO: This should already have been created?
+ fs.mkdirSync(path.dirname(videoFile), { recursive: true });
// TODO @brown-ccv #342: Convert to mp4 before final save? https://gist.github.com/AVGP/4c2ce4ab3c67760a0f30a9d54544a060
- fs.writeFileSync(path.join(OUT_PATH, filePath), videoData);
+ fs.writeFileSync(videoFile, videoData);
} catch (e) {
- log.error.error("Unable to save file: ", filePath);
+ log.error.error("Unable to save video file: ", videoFile);
log.error.error(e);
}
- log.info("Successfully saved video file to ", filePath);
+ log.info("Successfully saved video file: ", videoFile);
}
/********** HELPERS **********/
@@ -268,52 +296,36 @@ function handleSaveVideo(event, data) {
* In production it loads the local bundle created by the build process
*/
function createWindow() {
- let mainWindow;
- let appURL;
-
- if (ELECTRON_START_URL) {
- // Running in development
-
- // Load app from localhost (This allows hot-reloading)
- appURL = ELECTRON_START_URL;
-
- // Create a 1500x900 window with the dev tools open
- mainWindow = new BrowserWindow({
- icon: "./favicon.ico",
- webPreferences: { preload: path.join(__dirname, "preload.js") },
- width: 1500,
- height: 900,
- });
+ // Create the browser window
+ const mainWindow = new BrowserWindow({
+ icon: "./favicon.ico",
+ webPreferences: { preload: path.join(__dirname, "preload.js") },
+ width: 1500,
+ height: 900,
+ // TODO @brown-ccv: Settings for preventing the menu bar from ever showing up
+ menuBarVisible: IS_DEV,
+ fullscreen: !IS_DEV,
+ });
+ if (IS_DEV) mainWindow.webContents.openDevTools();
- // Open the dev tools
- mainWindow.webContents.openDevTools();
+ // Load the renderer process (index.html)
+ if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
+ mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
} else {
- // Running in production
-
- // Load app from the local bundle created by the build process
- appURL = url.format({
- // Moves from path of the electron file (/public/electron/main.js) to build folder (build/index.html)
- // TODO @brown-ccv #424: electron-forge should only be packaging the build folder (package.json needs to point to that file?)
- pathname: path.join(__dirname, "../../build/index.html"),
- protocol: "file:",
- slashes: true,
- });
-
- // Create a fullscreen window with the menu bar hidden
- mainWindow = new BrowserWindow({
- icon: "./favicon.ico",
- webPreferences: { preload: path.join(__dirname, "preload.js") },
- fullscreen: true,
- menuBarVisible: false,
- });
-
- // Hide the menu bar
- mainWindow.setMenuBarVisibility(false);
+ // TODO @brown-ccv: JsPsych protections for loading from a file://
+ mainWindow.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`));
}
+ log.info("Loaded Renderer process");
+}
+
+/** Returns the absolute path to the JSON file stored in userData */
+function getDataPath() {
+ return path.resolve(DATA_DIR, FILE_PATH);
+}
- // Load web contents at the given URL
- log.info("Loading URL: ", appURL);
- mainWindow.loadURL(appURL);
+/** Returns the absolute path to the outputted JSON file */
+function getOutPath() {
+ return path.resolve(OUT_DIR, FILE_PATH);
}
/** SERIAL PORT SETUP & COMMUNICATION (EVENT MARKER) */
@@ -353,7 +365,7 @@ async function setUpPort() {
buttons: [
"OK",
// Allow continuation when running in development mode
- ...(ELECTRON_START_URL ? ["Continue Anyway"] : []),
+ ...(IS_DEV ? ["Continue Anyway"] : []),
],
defaultId: 0,
})
@@ -400,7 +412,7 @@ function handleEventSend(code) {
"Quit",
"Retry",
// Allow continuation when running in development mode
- ...(ELECTRON_START_URL ? ["Continue Anyway"] : []),
+ ...(IS_DEV ? ["Continue Anyway"] : []),
],
detail: "heres some detail",
});
diff --git a/public/electron/preload.js b/src/Electron/preload.js
similarity index 87%
rename from public/electron/preload.js
rename to src/Electron/preload.js
index eaf8423ad..c0e9c37e8 100644
--- a/public/electron/preload.js
+++ b/src/Electron/preload.js
@@ -1,4 +1,4 @@
-const { contextBridge, ipcRenderer } = require("electron");
+import { contextBridge, ipcRenderer } from "electron";
/** Load bridges between the main and renderer processes when the preload process is first loaded */
process.once("loaded", () => {
@@ -6,6 +6,7 @@ process.once("loaded", () => {
setConfig: (config) => ipcRenderer.send("setConfig", config),
setTrigger: (triggerCodes) => ipcRenderer.send("setTrigger", triggerCodes),
getCredentials: () => ipcRenderer.invoke("getCredentials"),
+ getCommit: () => ipcRenderer.invoke("getCommit"),
on_data_update: (data) => ipcRenderer.send("onDataUpdate", data),
on_finish: () => ipcRenderer.invoke("onFinish"),
photodiodeTrigger: (data) => ipcRenderer.send("photodiodeTrigger", data),
diff --git a/src/config/env.js b/src/config/env.js
new file mode 100644
index 000000000..4c5f5811b
--- /dev/null
+++ b/src/config/env.js
@@ -0,0 +1,21 @@
+import { getProlificId } from "../lib/utils";
+
+const USE_ELECTRON = window.electronAPI !== undefined; // Whether or not the experiment is running in Electron (local app)
+const USE_PROLIFIC = getProlificId() !== null; // Whether or not the experiment is running with Prolific
+const USE_FIREBASE = import.meta.env.VITE_FIREBASE === "true"; // Whether or not the experiment is running in Firebase (web app)
+const USE_CAMERA = import.meta.env.VITE_VIDEO === "true" && USE_ELECTRON; // Whether or not to use video recording
+const USE_EVENT_CODES = import.meta.env.VITE_USE_EEG === "true" && USE_ELECTRON; // Whether or not the EEG/event marker is available (TODO @brown-ccv: This is only used for sending event codes)
+const USE_PHOTODIODE = import.meta.env.VITE_USE_PHOTODIODE === "true" && USE_ELECTRON; // whether or not the photodiode is in use
+
+// Configuration object for Honeycomb
+
+export default {
+ // Deployments
+ USE_ELECTRON,
+ USE_PROLIFIC,
+ USE_FIREBASE,
+ // Equipment
+ USE_PHOTODIODE,
+ USE_EVENT_CODES,
+ USE_CAMERA,
+};
diff --git a/src/config/index.js b/src/config/index.js
new file mode 100644
index 000000000..950eddced
--- /dev/null
+++ b/src/config/index.js
@@ -0,0 +1,11 @@
+// Re-export the language object
+// TODO @brown-ccv #373: Save language in Firebase
+import language from "./language.json";
+// Re-export the settings object
+// TODO @brown-ccv #374: Save settings in Firebase
+import settings from "./settings.json";
+import ENV from "./env.js";
+
+export const LANGUAGE = language;
+export const SETTINGS = settings;
+export { ENV };
diff --git a/src/config/main.js b/src/config/main.js
deleted file mode 100644
index 3cd22ff9a..000000000
--- a/src/config/main.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * This is the main configuration file where universal and default settings should be placed.
- * These setting can then be imported anywhere in the app
- */
-import { initJsPsych } from "jspsych";
-
-import packageInfo from "../../package.json";
-import { getProlificId } from "../lib/utils";
-
-import language from "./language.json";
-import settings from "./settings.json";
-
-// TODO @brown-ccv #363: Separate into index.js (for exporting) and env.js
-
-// Re-export the package name and version
-export const taskName = packageInfo.name;
-export const taskVersion = packageInfo.version;
-
-// Re-export the language object
-// TODO @brown-ccv #373: Save language in Firebase
-export const LANGUAGE = language;
-// Re-export the settings object
-// TODO @brown-ccv #374: Save settings in Firebase
-export const SETTINGS = settings;
-
-/**
- *
- * As of jspsych 7, we instantiate jsPsych where needed instead of importing it globally.
- * The instance here gives access to utils in jsPsych.turk, for awareness of the mturk environment, if any.
- * The actual task and related utils will use a different instance of jsPsych created after login.
- * TODO @brown-ccv #395: Use instance from jsPsychExperiment
- */
-const jsPsych = initJsPsych();
-
-// Whether or not the experiment is running on mechanical turk
-// TODO @brown-ccv #395: Deprecate PsiTurk and MTurk
-const turkInfo = jsPsych.turk.turkInfo();
-const USE_MTURK = !turkInfo.outsideTurk;
-export const turkUniqueId = `${turkInfo.workerId}:${turkInfo.assignmentId}`; // ID of the user in mechanical turk
-
-const USE_ELECTRON = window.electronAPI !== undefined; // Whether or not the experiment is running in Electron (local app)
-const USE_PROLIFIC = (getProlificId() && !USE_MTURK) || false; // Whether or not the experiment is running with Prolific
-const USE_FIREBASE = process.env.REACT_APP_FIREBASE === "true"; // Whether or not the experiment is running in Firebase (web app)
-
-const USE_VOLUME = process.env.REACT_APP_VOLUME === "true"; // Whether or not to use audio cues in the task
-const USE_CAMERA = process.env.REACT_APP_VIDEO === "true" && USE_ELECTRON; // Whether or not to use video recording
-// TODO @brown-ccv #341: Remove USE_EEG - separate variables for USE_PHOTODIODE and USE_EVENT_MARKER
-const USE_EEG = process.env.REACT_APP_USE_EEG === "true" && USE_ELECTRON; // Whether or not the EEG/event marker is available (TODO @brown-ccv: This is only used for sending event codes)
-const USE_PHOTODIODE = process.env.REACT_APP_USE_PHOTODIODE === "true" && USE_ELECTRON; // whether or not the photodiode is in use
-
-// Configuration object for Honeycomb
-export const config = {
- USE_PHOTODIODE,
- USE_EEG,
- USE_ELECTRON,
- USE_MTURK,
- USE_VOLUME,
- USE_CAMERA,
- USE_PROLIFIC,
- USE_FIREBASE,
-};
diff --git a/src/config/settings.json b/src/config/settings.json
index d2a587e19..e42f523e6 100644
--- a/src/config/settings.json
+++ b/src/config/settings.json
@@ -2,7 +2,9 @@
"fixation": {
"default_duration": 1000,
"randomize_duration": true,
- "durations": [250, 500, 750, 1000, 1250, 1500, 1750, 2000]
+ "durations": [250, 500, 750, 1000, 1250, 1500, 1750, 2000],
+ "code": 1,
+ "numBlinks": 1
},
"honeycomb": {
"randomize_order": true,
@@ -16,6 +18,16 @@
"stimulus": "assets/images/orange.png",
"correct_response": "j"
}
- ]
+ ],
+ "code": 2,
+ "numBlinks": 2
+ },
+ "open_task": {
+ "code": 18,
+ "numBlinks": 18
+ },
+ "test_connect": {
+ "code": 32,
+ "numBlinks": 32
}
}
diff --git a/src/config/trigger.js b/src/config/trigger.js
index eeae5b792..9102a56e5 100644
--- a/src/config/trigger.js
+++ b/src/config/trigger.js
@@ -1,19 +1,22 @@
+import settings from "./settings.json"; // includes event codes for each event
+
// TODO @brown-ccv #333: Nest this data under "trigger_box" equipment in config.json
// teensyduino
export const vendorID = "16c0";
// Default if process.env.EVENT_MARKER_PRODUCT_ID is not set
-export const productID = process.env.EVENT_MARKER_PRODUCT_ID || "";
+// export const productID = process.env.EVENT_MARKER_PRODUCT_ID || "";
+export const productID = import.meta.env.EVENT_MARKER_PRODUCT_ID || "";
// Default if process.env.EVENT_MARKER_COM_NAME is not set
-export const comName = process.env.EVENT_MARKER_COM_NAME || "COM3";
+// export const comName = process.env.EVENT_MARKER_COM_NAME || "COM3";
+export const comName = import.meta.env.EVENT_MARKER_COM_NAME || "COM3";
-/** Custom codes for specific task events - used to identify the trials */
-// TODO @brown-ccv #354: Each event should have a code, name, and numBlinks
-export const eventCodes = {
- fixation: 1, // Fixation trial
- honeycomb: 2, // Main reaction-time trial for the Honeycomb task
- open_task: 18, // Opening task for setting up the experiment
- test_connect: 32, // Initial test connection
+// TODO: We should think of a cleaner way of exporting all this
+export const trigger = {
+ vendorID,
+ productID,
+ comName,
+ settings,
};
diff --git a/src/experiment/honeycomb.js b/src/experiment/honeycomb.js
index b38355e2c..24249d621 100644
--- a/src/experiment/honeycomb.js
+++ b/src/experiment/honeycomb.js
@@ -31,21 +31,20 @@ export const honeycombOptions = {
* Take a look at how the code here compares to the jsPsych documentation!
* See the jsPsych documentation for more: https://www.jspsych.org/7.3/tutorials/rt-task/
*
- * @param {Object} jsPsych The jsPsych instance being used to run the task
* @returns {Object} A jsPsych timeline object
*/
-export function buildHoneycombTimeline(jsPsych) {
+export const buildHoneycombTimeline = () => {
// Build the trials that make up the start procedure
- const startProcedure = buildStartProcedure(jsPsych);
+ const startProcedure = buildStartProcedure();
// Build the trials that make up the task procedure
- const honeycombProcedure = buildHoneycombProcedure(jsPsych);
+ const honeycombProcedure = buildHoneycombProcedure();
// Builds the trial needed to debrief the participant on their performance
- const debriefTrial = buildDebriefTrial(jsPsych);
+ const debriefTrial = buildDebriefTrial;
// Builds the trials that make up the end procedure
- const endProcedure = buildEndProcedure(jsPsych);
+ const endProcedure = buildEndProcedure();
const timeline = [
startProcedure,
@@ -56,4 +55,4 @@ export function buildHoneycombTimeline(jsPsych) {
endProcedure,
];
return timeline;
-}
+};
diff --git a/src/experiment/index.js b/src/experiment/index.js
index 1e340292b..50d8fc252 100644
--- a/src/experiment/index.js
+++ b/src/experiment/index.js
@@ -1,6 +1,6 @@
/**
* ! Your timeline and options should be built in a newly created file, not this one
- * TODO @brown-ccv: Link "Quick Start" step once's it's built into the docs
+ * https://brown-ccv.github.io/honeycomb-docs/docs/quick_start#2-add-a-file-for-the-task
*/
import { buildHoneycombTimeline, honeycombOptions } from "./honeycomb";
@@ -25,13 +25,13 @@ export const jsPsychOptions = honeycombOptions;
* @param {string} participantID The ID of the participant that was just logged in
* @returns The timeline for JsPsych to run
*/
-export function buildTimeline(jsPsych, studyID, participantID) {
+export function buildTimeline(studyID, participantID) {
console.log(`Building timeline for participant ${participantID} on study ${studyID}`);
/**
* ! Your timeline should be built in a newly created function, not this one
- * TODO @brown-ccv: Link "Quick Start" step once's it's built into the docs
+ * https://brown-ccv.github.io/honeycomb-docs/docs/quick_start#2-add-a-file-for-the-task
*/
- const timeline = buildHoneycombTimeline(jsPsych);
+ const timeline = buildHoneycombTimeline();
return timeline;
}
diff --git a/src/experiment/procedures/endProcedure.js b/src/experiment/procedures/endProcedure.js
index d95fc43f9..dba2e5c59 100644
--- a/src/experiment/procedures/endProcedure.js
+++ b/src/experiment/procedures/endProcedure.js
@@ -1,4 +1,4 @@
-import { config } from "../../config/main";
+import { ENV } from "../../config/";
import { buildCameraEndTrial } from "../trials/camera";
import { conclusionTrial } from "../trials/conclusion";
import { exitFullscreenTrial } from "../trials/fullscreen";
@@ -8,15 +8,14 @@ import { exitFullscreenTrial } from "../trials/fullscreen";
* 1) Trial used to complete the user's camera recording is displayed
* 2) The experiment exits fullscreen
*
- * @param {Object} jsPsych The jsPsych instance being used to run the task
* @returns {Object} A jsPsych (nested) timeline object
*/
-export function buildEndProcedure(jsPsych) {
+export const buildEndProcedure = () => {
const procedure = [];
// Conditionally add the camera breakdown trials
- if (config.USE_CAMERA) {
- procedure.push(buildCameraEndTrial(jsPsych));
+ if (ENV.USE_CAMERA) {
+ procedure.push(buildCameraEndTrial);
}
// Add the other trials needed to end the experiment
@@ -24,4 +23,4 @@ export function buildEndProcedure(jsPsych) {
// Return the block as a nested timeline
return { timeline: procedure };
-}
+};
diff --git a/src/experiment/procedures/honeycombProcedure.js b/src/experiment/procedures/honeycombProcedure.js
index 46620bcf7..35b51d488 100644
--- a/src/experiment/procedures/honeycombProcedure.js
+++ b/src/experiment/procedures/honeycombProcedure.js
@@ -1,9 +1,9 @@
import imageKeyboardResponse from "@jspsych/plugin-image-keyboard-response";
-import { config, SETTINGS } from "../../config/main";
-import { eventCodes } from "../../config/trigger";
+import { ENV, SETTINGS } from "../../config/";
import { pdSpotEncode, photodiodeGhostBox } from "../../lib/markup/photodiode";
import { buildFixationTrial } from "../trials/fixation";
+import { getJsPsych } from "../../lib/utils";
/**
* Builds the block of trials that form the core of the Honeycomb experiment
@@ -12,14 +12,11 @@ import { buildFixationTrial } from "../trials/fixation";
*
* Note that the block is conditionally rendered and repeated based on the task settings
*
- * @param {Object} jsPsych The jsPsych instance being used to run the task
* @returns {Object} A jsPsych (nested) timeline object
*/
-export function buildHoneycombProcedure(jsPsych) {
+export const buildHoneycombProcedure = () => {
const honeycombSettings = SETTINGS.honeycomb;
-
- const fixationTrial = buildFixationTrial(jsPsych);
-
+ const fixationTrial = buildFixationTrial;
/**
* Displays a colored circle and waits for participant to response with a keyboard press
*
@@ -31,26 +28,26 @@ export function buildHoneycombProcedure(jsPsych) {
const taskTrial = {
type: imageKeyboardResponse,
// Display the image passed as a timeline variable
- stimulus: jsPsych.timelineVariable("stimulus"),
+ stimulus: getJsPsych().timelineVariable("stimulus"),
prompt: function () {
// Conditionally displays the photodiodeGhostBox
- if (config.USE_PHOTODIODE) return photodiodeGhostBox;
+ if (ENV.USE_PHOTODIODE) return photodiodeGhostBox;
else return null;
},
// Possible choices are the correct_responses from the task settings
choices: honeycombSettings.timeline_variables.map((variable) => variable.correct_response),
data: {
// Record the correct_response passed as a timeline variable
- code: eventCodes.honeycomb,
- correct_response: jsPsych.timelineVariable("correct_response"),
+ code: honeycombSettings.code,
+ correct_response: getJsPsych().timelineVariable("correct_response"),
},
on_load: function () {
// Conditionally flashes the photodiode when the trial first loads
- if (config.USE_PHOTODIODE) pdSpotEncode(eventCodes.honeycomb);
+ if (ENV.USE_PHOTODIODE) pdSpotEncode(honeycombSettings.code);
},
// Add a boolean value ("correct") to the data - if the user responded with the correct key or not
on_finish: function (data) {
- data.correct = jsPsych.pluginAPI.compareKeys(data.response, data.correct_response);
+ data.correct = getJsPsych().pluginAPI.compareKeys(data.response, data.correct_response);
},
};
@@ -70,4 +67,4 @@ export function buildHoneycombProcedure(jsPsych) {
timeline: [fixationTrial, taskTrial],
};
return honeycombBlock;
-}
+};
diff --git a/src/experiment/procedures/startProcedure.js b/src/experiment/procedures/startProcedure.js
index 5bb67cd38..b929eee1a 100644
--- a/src/experiment/procedures/startProcedure.js
+++ b/src/experiment/procedures/startProcedure.js
@@ -1,4 +1,4 @@
-import { config } from "../../config/main";
+import { ENV } from "../../config/";
import { buildCameraStartTrial } from "../trials/camera";
import { enterFullscreenTrial } from "../trials/fullscreen";
@@ -15,23 +15,22 @@ import { introductionTrial } from "../trials/introduction";
* 4) Trials used to set up a photodiode and trigger box are displayed (if applicable)
* 5) Trials used to set up the user's camera are displayed (if applicable)
*
- * @param {Object} jsPsych The jsPsych instance being used to run the task
* @returns {Object} A jsPsych (nested) timeline object
*/
-export function buildStartProcedure(jsPsych) {
+export const buildStartProcedure = () => {
const procedure = [nameTrial, enterFullscreenTrial, introductionTrial];
// Conditionally add the photodiode setup trials
- if (config.USE_PHOTODIODE) {
+ if (ENV.USE_PHOTODIODE) {
procedure.push(holdUpMarkerTrial);
procedure.push(initPhotodiodeTrial);
}
// Conditionally add the camera setup trials
- if (config.USE_CAMERA) {
- procedure.push(buildCameraStartTrial(jsPsych));
+ if (ENV.USE_CAMERA) {
+ procedure.push(buildCameraStartTrial);
}
// Return the block as a nested timeline
return { timeline: procedure };
-}
+};
diff --git a/src/experiment/trials/adjustVolume.js b/src/experiment/trials/adjustVolume.js
index 537e5a922..bfb8bb12c 100644
--- a/src/experiment/trials/adjustVolume.js
+++ b/src/experiment/trials/adjustVolume.js
@@ -1,5 +1,5 @@
import htmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response";
-import { LANGUAGE } from "../../config/main";
+import { LANGUAGE } from "../../config/";
import { div, h1 } from "../../lib/markup/tags";
/** Trial that prompts the user to adjust the volume on their computer */
diff --git a/src/experiment/trials/beep.js b/src/experiment/trials/beep.js
index 2ab8e216f..5aa63318c 100644
--- a/src/experiment/trials/beep.js
+++ b/src/experiment/trials/beep.js
@@ -1,6 +1,5 @@
import audioKeyboardResponse from "@jspsych/plugin-audio-keyboard-response";
-// TODO @brown-ccv #401: Remove "USE_VOLUME" environment variable
export const beepTrial = {
type: audioKeyboardResponse,
stimulus: "assets/audio/beep.mp3",
diff --git a/src/experiment/trials/camera.js b/src/experiment/trials/camera.js
index 427ae7303..d253b1aea 100644
--- a/src/experiment/trials/camera.js
+++ b/src/experiment/trials/camera.js
@@ -2,119 +2,114 @@ import htmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response";
import htmlButtonResponse from "@jspsych/plugin-html-button-response";
import initializeCamera from "@jspsych/plugin-initialize-camera";
-import { LANGUAGE, config } from "../../config/main";
+import { LANGUAGE, ENV } from "../../config/";
import { div, h1, p, tag } from "../../lib/markup/tags";
+import { getJsPsych } from "../../lib/utils";
const WEBCAM_ID = "webcam";
/**
* A trial that begins recording the participant using their computer's default camera
- * @param {Object} jsPsych The jsPsych instance being used to run the task
- * @returns {Object} A jsPsych trial object
+ *
+ * @type {Object} A jsPsych trial object
*/
-// TODO @brown-ccv #301: Use jsPsych extension, deprecate this function
+// TODO @brown-ccv #301: Use jsPsych extension, deprecate this variable
// TODO @brown-ccv #343: We should be able to make this work on both electron and browser?
// TODO @brown-ccv #301: Rolling save to the deployment (webm is a subset of mkv)
-export function buildCameraStartTrial(jsPsych) {
- return {
- timeline: [
- {
- // Prompts user permission for camera device
- type: initializeCamera,
- include_audio: true,
- mime_type: "video/webm",
+export const buildCameraStartTrial = {
+ timeline: [
+ {
+ // Prompts user permission for camera device
+ type: initializeCamera,
+ include_audio: true,
+ mime_type: "video/webm",
+ },
+ {
+ // Helps participant center themselves inside the camera
+ type: htmlButtonResponse,
+ stimulus: function () {
+ const videoMarkup = tag("video", "", {
+ id: WEBCAM_ID,
+ width: 640,
+ height: 480,
+ autoplay: true,
+ });
+ const cameraStartMarkup = p(LANGUAGE.trials.camera.start);
+ const trialMarkup = div(cameraStartMarkup + videoMarkup, {
+ class: "align-items-center-col",
+ });
+ return div(trialMarkup);
},
- {
- // Helps participant center themselves inside the camera
- type: htmlButtonResponse,
- stimulus: function () {
- const videoMarkup = tag("video", "", {
- id: WEBCAM_ID,
- width: 640,
- height: 480,
- autoplay: true,
- });
- const cameraStartMarkup = p(LANGUAGE.trials.camera.start);
- const trialMarkup = div(cameraStartMarkup + videoMarkup, {
- // TODO @brown-ccv #344: Get rid of bootstrap (this is just centering it)
- class: "d-flex flex-column align-items-center",
- });
- return div(trialMarkup);
- },
- choices: [LANGUAGE.prompts.continue.button],
- response_ends_trial: true,
- on_start: function () {
- // Initialize and store the camera feed
- if (!config.USE_ELECTRON) {
- throw new Error("video recording is only available when running inside Electron");
- }
+ choices: [LANGUAGE.prompts.continue.button],
+ response_ends_trial: true,
+ on_start: function () {
+ // Initialize and store the camera feed
+ if (!ENV.USE_ELECTRON) {
+ throw new Error("video recording is only available when running inside Electron");
+ }
- const cameraRecorder = jsPsych.pluginAPI.getCameraRecorder();
- if (!cameraRecorder) {
- console.error("Camera is not initialized, no data will be recorded.");
- return;
- }
- const cameraChunks = [];
+ const cameraRecorder = getJsPsych().pluginAPI.getCameraRecorder();
+ if (!cameraRecorder) {
+ console.error("Camera is not initialized, no data will be recorded.");
+ return;
+ }
+ const cameraChunks = [];
- // Push data whenever available
- cameraRecorder.addEventListener("dataavailable", (event) => {
- if (event.data.size > 0) cameraChunks.push(event.data);
- });
+ // Push data whenever available
+ cameraRecorder.addEventListener("dataavailable", (event) => {
+ if (event.data.size > 0) cameraChunks.push(event.data);
+ });
- // Saves the raw data feed from the participants camera (executed on cameraRecorder.stop()).
- cameraRecorder.addEventListener("stop", () => {
- const blob = new Blob(cameraChunks, { type: cameraRecorder.mimeType });
+ // Saves the raw data feed from the participants camera (executed on cameraRecorder.stop()).
+ cameraRecorder.addEventListener("stop", () => {
+ const blob = new Blob(cameraChunks, { type: cameraRecorder.mimeType });
- // Pass video data to Electron as a base64 encoded string
- const reader = new FileReader();
- reader.readAsDataURL(blob);
- reader.onloadend = () => {
- window.electronAPI.saveVideo(reader.result);
- };
- });
- },
- on_load: function () {
- // Assign camera feed to the