Created
March 19, 2024 03:01
-
-
Save frumbert/489a6dd5d6f9eb3aa7f05ed664272af0 to your computer and use it in GitHub Desktop.
A text parser that turns fairly regular plain text into a series of form elements for use in a survey. There is no concept of numbered questions, only responses. Questions can be grouped by adding a 'page:' identifier, which results in subsequent questions being part of a new fieldset, and are only a visual change (does not appear in results). S…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta http-equiv="X-UA-Compatible" content="IE=edge"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Survey</title> | |
<style> | |
div{margin-block-start: 1rem;} | |
[data-type='single'] label:first-of-type, | |
[data-type='matching'] > label:first-of-type, | |
[data-type='dropdown'] label:first-of-type, | |
[data-type='fillin'] label:first-of-type, | |
[data-type='numeric'] label:first-of-type, | |
[data-type='truefalse'] label:first-of-type { | |
display: block; | |
} | |
input[type='radio'][id$='_0'] { margin-inline-start: 0 } | |
[role='control-group'] { display: inline-block; margin-right: 1rem; } | |
[role='control-group'] label { display: block; } | |
[data-type='likert'], [data-type='likert'] td, [data-type='likert'] th { | |
border: 1px solid #00000040; | |
border-collapse: collapse; | |
text-align: center; | |
font-weight: normal; | |
} | |
[data-type='likert'] tbody th { text-align: right; } | |
[data-type='likert'] th, [data-type='likert'] td { padding: .3rem } | |
[data-type='label'] .header { | |
border-top: 1px dashed #00000040; | |
line-height: 2rem; | |
background: #f8f8f8; | |
} | |
[data-type='label'] > *:last-child { | |
border-bottom: 1px dashed #00000040; | |
} | |
</style> | |
</head> | |
<body> | |
<h1>Abstract survey.</h1> | |
<p>There is no concept of numbered questions, only responses. Questions can be grouped by adding a 'page:' identifier, which results in subsequent questions being part of a new fieldset, and are only a visual change (does not appear in results). Statements can be grouped by dichotomy. Lines without a type are ignored. Unanswered questions are reported as empty. There is no concept of required items. Results are in the format statement:answer or statement:answer1,answer2,answerN. The suervey is generated from the data below.</p> | |
<textarea rows="5" cols="100"> | |
truefalse:[Oui,Non] | |
I am fluent in <em>Le français</em> | |
label:The choice presents several dichotomies which are idential for each choice row. | |
choice:[ Dogs , Cats , Horses , , Mice ] | |
Elephants are afraid of | |
Cats are afraid of | |
Mice are afraid of | |
Dogs are afraid of | |
page:dropdowns | |
label:Dropdowns can either be a single value, or multiple values. | |
When using multiple dropdowns, each option can only be selected once. | |
matching : [ Electricity=Light globes , Water=Faucets, Alcohol = Drunkards, Sugar =Doughnuts] | |
Match the item to its filling | |
dropdown : [Wine,Beer,Tequila,Asprin,Caffiene] | |
What is your vice? | |
page:regular items | |
label : some regular form controls are supported too | |
numeric:[50,100] | |
How many roads can a man walk down? | |
How long is a peice of string (in cm) | |
What? This statement has no numeric value. | |
fillin:[1] | |
What is your name | |
fillin:[5] | |
Tell something personal about yourself | |
page:other details | |
label:everything that doesn't match is a comment. the first line of a label has a classname to differentiate it. | |
labels are possible | |
even multiline labels <strong>with tags</strong>. | |
because this line doesn't start with the correct word, this isn't a label and should be ignored. | |
The next tag is also ignored since it doesn't match to a known handler. | |
bokers:[1,2,3] | |
it looks like it should work | |
doesn't it? | |
label: Choices use checkboxes instead of radios and their answers are CSV | |
choices:[Tracks,Roads,Streets,Avenues] | |
Cities have | |
Towns have | |
Villages have | |
page: | |
label: likert questions are presented in a table. | |
likert | |
likert:[Disagree,Neutral,Agree,] | |
Purple is the best colour | |
Cats are mind-controlling humans | |
Water can be dehydrated | |
label: It's just utf-8, so you can use emoji too: | |
likert:[😂,😃,🤨,😟,😭] | |
🌅 | |
🌌 | |
🏞 | |
</textarea> | |
<pre id="output"></pre> | |
<script type="text/javascript"> | |
window.addEventListener("DOMContentLoaded", () => { | |
// https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams | |
// var qs = new URLSearchParams(location.search); | |
// for (const p of qs) { | |
// const d = document.querySelector(`[name=${p[0]}]`); console.log(`[name=${p[0]}]`,d); | |
// if (d && d.value === p[1]) d.checked = true; | |
// } | |
const uid=function(){return ('q'+1e11).replace(/[018]/g, c =>(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16))} | |
const crc32=function(r){for(var a,o=[],c=0;c<256;c++){a=c;for(var f=0;f<8;f++)a=1&a?3988292384^a>>>1:a>>>1;o[c]=a}for(var n=-1,t=0;t<r.length;t++)n=n>>>8^o[255&(n^r.charCodeAt(t))];return(-1^n)>>>0}; | |
const supportedTypes = ['likert','truefalse','choice','choices','matching','dropdown','numeric','choices','fillin','label','page']; | |
const input = document.querySelector("textarea"); // source, should be player.GetVar("TextEntry") | |
const form = document.createElement("form"); | |
form.method = "GET"; | |
form.innerHTML = "<p><input type='submit' name='action' value='submit'></p>" | |
const output = document.createDocumentFragment(); | |
output.appendChild(form); | |
let regex = /^\s*\r?\n/gm; // start-of-line + optional whitespace + one-or-more newlines | |
const questions = input.value | |
.split(regex) // raw input into blocks of lines | |
.filter(((value) => { // validate each item | |
console.log('filtering',value); | |
return value.indexOf(':') !== -1 && supportedTypes.indexOf(value.split(":").map(s=>s.trim())[0]) !== -1; // filter to only supportedTypes | |
})) | |
.map((value) => { // convert valid strings to objects | |
const [type,rest] = value.split(":", 2).map(s=>s.trim()); // type, and everything else | |
const [dichotomies,...lines] = rest.split(/\r?\n/); // choices, and everything else as an array | |
return { | |
type, | |
lines: lines.map(s=>s.trim()).filter(s=>s.length), | |
dichotomies: dichotomies.replace(/^\[+|\]+$/g,'').split(',').map(s=>s.trim()).filter(s=>s.length).map(s=>{return s.indexOf("=")!==-1?s.split("=").map(t=>t.trim()):s}), // purpose: it's clear as mud | |
} | |
}); | |
let fieldset = document.createElement("fieldset"); | |
form.appendChild(fieldset); | |
const inputs = []; | |
for (question of questions) { | |
let tag; | |
switch (question.type) { | |
case 'page': | |
/* | |
value: each question is a new page. if text comes after the colon, use it as a legend | |
*/ | |
fieldset = document.createElement("fieldset"); | |
form.appendChild(fieldset); | |
let legend = question.dichotomies.join(''); | |
if (legend.length) fieldset.appendChild(fragment(`<legend>${legend}</legend>`)); | |
break; | |
case 'likert': | |
/* | |
value: each question has a col (bascially choice with a table layout) | |
col col col | |
question [x] [o] [o] (hidden: n/a) | |
question [x] [o] [o] (hidden: n/a) | |
question [o] [o] [x] (hidden: n/a) | |
*/ | |
tag = [`<table data-type="likert"><thead><tr><td></td>`]; | |
question.dichotomies.forEach(s=>{ tag.push(`<th>${s}</th>`); }); | |
tag.push('</tr></thead><tbody>'); | |
question.lines.forEach(line=>{ | |
const id = uid(); | |
const name = 'q'+crc32(line); | |
tag.push(`<tr><th><input type='hidden' id="${id}" name="${name}" value="${line}">${line}</th>`); | |
question.dichotomies.forEach((s,i)=>{ | |
tag.push(`<td><input type="radio" value="${s}" name="${name}"></td>`); | |
}); | |
tag.push('</tr>'); | |
inputs.push(name); | |
}); | |
tag.push('</tbody></table>'); | |
fieldset.appendChild(fragment(tag.join``)); | |
break; | |
case 'truefalse': | |
/* | |
value: each question has one of two answers | |
question [o] Oui [x] Non (hidden: n/a) | |
question [x] Oui [o] Non (hidden: n/a) | |
*/ | |
for (line of question.lines) { | |
tag = choice(line, question.dichotomies, 'truefalse'); | |
inputs.push(tag.name); | |
fieldset.appendChild(tag.node); | |
} | |
break; | |
case 'choice': | |
/* | |
value: each question has one answer | |
question [o] One [x] Two [o] Three (hidden: n/a) | |
question [o] One [o] Two [o] Three (hidden: n/a) | |
*/ | |
for (line of question.lines) { | |
tag = choice(line, question.dichotomies, 'single'); | |
inputs.push(tag.name); | |
fieldset.appendChild(tag.node); | |
} | |
break; | |
case 'choices': | |
/* | |
value: each question has one or more answers | |
question [x] One [ ] Two [ ] Three (hidden: n/a) | |
question [ ] One [x] Two [x] Three (hidden: n/a) | |
*/ | |
for (line of question.lines) { | |
tag = choices(line, question.dichotomies); | |
inputs.push(tag.name); | |
fieldset.appendChild(tag.node); | |
} | |
break; | |
case 'matching': | |
/* | |
value: how important are these items (each choice can be reused across questions) | |
question [a [v] [b [v] | |
[1 ] [1 ] | |
[2 ] [2 ] | |
*/ | |
for (line of question.lines) { | |
tag = matching(line, question.dichotomies); | |
inputs.push(tag.name); | |
fieldset.appendChild(tag.node); | |
} | |
break; | |
case 'dropdown': | |
/* | |
value: how important are these items (each choice only available once across all questions | |
question [b [v] | |
[c ] | |
[a ] | |
*/ | |
for (line of question.lines) { | |
tag = dropdown(line, question.dichotomies); | |
inputs.push(tag.name); | |
fieldset.appendChild(tag.node); | |
} | |
break; | |
case 'numeric': | |
/* | |
value: each question has a numeric value between n and m | |
question [n..m] | |
question [n..m] | |
*/ | |
for (line of question.lines) { | |
tag = numeric(line, question.dichotomies); | |
inputs.push(tag.name); | |
fieldset.appendChild(tag.node); | |
} | |
break; | |
case 'fillin': | |
/* | |
value: each question has a text response (of line height N) | |
question [ ... ] | |
question [ | |
... | |
... | |
] | |
*/ | |
for (line of question.lines) { | |
tag = fillin(line, question.dichotomies); | |
inputs.push(tag.name); | |
fieldset.appendChild(tag.node); | |
} | |
break; | |
case 'label': | |
/* | |
value: none, just display whatever is after the colon | |
header | |
div | |
div | |
*/ | |
tag = label(question); | |
fieldset.appendChild(tag.node); | |
break; | |
break; | |
} | |
} | |
document.body.appendChild(output); | |
function unique(value,index,array) { | |
return self.indexOf(value)===index; | |
} | |
function plain(text) { | |
const d = document.createElement('div'); | |
d.innerHTML = text; | |
return d.textContent || ''; | |
} | |
form.addEventListener('submit',e => { | |
e.preventDefault(); | |
const source = new URLSearchParams(new FormData(form)); | |
const fields = {}; | |
for (const name of inputs) { | |
if (!name.length) continue; | |
const [fn,...opts] = source.getAll(name); | |
fields[plain(fn)] = opts.join(); | |
} | |
console.log(fields); // SAVE this to a SCORM interaction | |
document.querySelector('#output').textContent = JSON.stringify(fields,null,4); | |
// ?? | |
}); | |
function fragment(string) { | |
var renderer = document.createElement('template'); | |
renderer.innerHTML = string; | |
return renderer.content; | |
} | |
function label(question) { | |
let tag = [`<div data-type="label">`]; | |
tag.push(`<div class='header'>${question.dichotomies.at(0)}</div>`); | |
for (line of question.lines) tag.push(`<div>${line}</div>`); | |
tag.push('</div>'); | |
return { node: fragment(tag.join``) }; | |
} | |
function fillin(label,values) { | |
const id = uid(); | |
const name = 'q'+crc32(label); | |
const value = values.map(s=>parseInt(s,10)).at(0); | |
let tag = [`<div data-type="fillin">`]; | |
tag.push(`<input type='hidden' name="${name}" value="${label}">`); | |
tag.push(`<label for="${id}">${label}</label>`); | |
if (value===1) { | |
tag.push(`<input id="${id}" type="text" name="${name}">`); | |
} else { | |
tag.push(`<textarea rows="${value}" cols="80" id="${id}" name="${name}"></textarea>`); | |
} | |
tag.push('</div>'); | |
return { id, name, node: fragment(tag.join``) }; | |
} | |
function numeric(label,values) { | |
const id = uid(); | |
const name = 'q'+crc32(label); | |
const value = values.map(s=>parseInt(s,10)).reduce((a,b)=>a+b,0) / 2 >> 0; // find half-way between min and max as a whole number | |
let tag = [`<div data-type="numeric">`]; | |
tag.push(`<input type='hidden' name="${name}" value="${label}">`); | |
tag.push(`<label for="${id}">${label}</label>`); | |
tag.push(`<input id="${id}" type="number" value="${value}" min="${values[0]}" max="${values[1]}" name="${name}" size="${values[1].toString().length + 1}">`); | |
tag.push('</div>'); | |
return { id, name, node: fragment(tag.join``) }; | |
} | |
function choice(label,values,subtype) { | |
const id = uid(); | |
const name = 'q'+crc32(label); | |
let tag = [`<div data-type="${subtype}">`]; | |
tag.push(`<input type='hidden' id="${id}" name="${name}" value="${label}">`); | |
tag.push(`<label for="${id}">${label}</label>`); | |
values.forEach((v,i) => { | |
tag.push(`<input type="radio" value="${v}" name="${name}" id="${id}_${i}"><label for="${id}_${i}">${v}</label>`); | |
}); | |
tag.push('</div>'); | |
return { id, name, node: fragment(tag.join``) }; | |
} | |
function choices(label,values) { | |
const id = uid(); | |
const name = 'q'+crc32(label); | |
let tag = ['<div data-type="multiple">']; | |
tag.push(`<input type='hidden' id="${id}" name="${name}" value="${label}">`); | |
tag.push(`<label for="${id}">${label}</label>`); | |
values.forEach((v,i) => { | |
tag.push(`<input type="checkbox" value="${v}" name="${name}" id="${id}_${i}"><label for="${id}_${i}">${v}</label>`); | |
}); | |
tag.push('</div>'); | |
return { id, name, node: fragment(tag.join``) }; | |
} | |
function matching(label,values) { | |
const id = uid(); | |
const name = 'q'+crc32(label); | |
const left = values.map(s=>s[0]); | |
const right = values.map(s=>s[1]); | |
let tag = [`<div data-type="matching">`]; | |
tag.push(`<input type='hidden' id="${id}" name="${name}" value="${label}">`); | |
tag.push(`<label for="${id}">${label}</label>`); | |
left.forEach((v,i)=> { | |
tag.push(`<span role='control-group'>`); | |
tag.push(`<label for="${id}_${i}">${v}</label>`); | |
tag.push(`<select id="${id}_${i}" name="${name}" onchange='checkMatching(this)'>`); | |
tag.push(`<option value="">-</option>`); | |
right.map((item) => { | |
tag.push(`<option value="${item}">${item}</option>`); | |
}); | |
tag.push('</select></span>'); | |
}); | |
tag.push('</div>'); | |
return { id, name, node: fragment(tag.join``) }; | |
} | |
function dropdown(label,values) { | |
const id = uid(); | |
const name = 'q'+crc32(label); | |
let tag = [`<div data-type="dropdown">`]; | |
tag.push(`<input type='hidden' name="${name}" value="${label}">`); | |
tag.push(`<label for="${id}">${label}</label>`); | |
tag.push(`<select id="${id}" name="${name}">`); | |
tag.push(`<option value="">-</option>`); | |
values.forEach((v,i) => { | |
tag.push(`<option value="${v}">${v}</option>`); | |
}); | |
tag.push('</select></div>'); | |
return { id, name, node: fragment(tag.join``) }; | |
} | |
function quid() { // if it's worth doing, it's worth overdoing | |
return ('q'+1e11).replace(/[018]/g, c => | |
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) | |
); | |
} | |
// allow an option to be selected only once across multiple dropdowns | |
window.checkMatching = function(el) { | |
const values = []; | |
const siblings = Array.from(el.closest("[data-type='matching']").querySelectorAll("select")); | |
el.closest("[data-type='matching']").querySelectorAll("option").forEach(o=>o.removeAttribute('disabled')); | |
siblings.forEach(node => { | |
if (node.value.length) values.push(`option[value='${node.value}']`); | |
}); | |
if (values.length) siblings.forEach(node => { | |
Array.from(node.querySelectorAll(values.join())).forEach(o => { !o.selected && o.setAttribute("disabled",true); }); | |
}); | |
} | |
}); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment