sellquiz
Version:
An open source domain-specific language for online assessment
670 lines (613 loc) • 26.2 kB
text/typescript
/******************************************************************************
* SELL - SIMPLE E-LEARNING LANGUAGE *
* *
* Copyright (c) 2019-2021 TH Köln *
* Author: Andreas Schwenk, contact@compiler-construction.com *
* *
* Partly funded by: Digitale Hochschule NRW *
* https://www.dh.nrw/kooperationen/hm4mint.nrw-31 *
* *
* GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 *
* *
* This library is licensed as described in LICENSE, which you should have *
* received as part of this distribution. *
* *
* This software is distributed on "AS IS" basis, WITHOUT WARRENTY OF ANY *
* KIND, either impressed or implied. *
******************************************************************************/
import { symtype, SellSymbol } from './symbol.js';
import { SellToken, Lexer } from './lex.js';
import { ParseText } from './parse-text.js';
import { ParseCode } from './parse-code.js';
import { ParseCodeSym } from './parse-code-sym.js';
import { ParseIM } from './parse-im.js';
import { ParseIM_Input } from './parse-im-input.js';
import { ParseProg } from './parse-prog.js';
import { Evaluate } from './evaluate.js';
import { MatrixInput } from './matinput.js';
import { getHtmlChildElementRecursive } from './help.js';
//import { check_symbol_svg } from './img.js'
import { sellassert } from './sellassert.js';
import { GET_STR } from './lang.js';
export enum SellInputElementType {
UNKNOWN = "unknown",
TEXTFIELD = "textfield", // one input box
COMPLEX_NUMBER = "complex_number", // two separate input boxes for real and imag
CHECKBOX = "checkbox", // boolean checkbox
VECTOR = "vector", // vector with n input boxes (also used for sets)
MATRIX = "matrix", // matrix with m*n input boxes
PROGRAMMING = "programming"
}
export class SellInput {
htmlElementId: string = "";
htmlElementInputType: SellInputElementType = SellInputElementType.UNKNOWN;
htmlElementId_feedback: string = "";
solutionVariableId: string = "";
solutionVariableRef: SellSymbol = null;
//solutionVariableMathtype: symtype = symtype.T_UNKNOWN;
// linearized input: one element for scalars, n elemens for vectors, m*n elements for matrices (row-major)
studentAnswer: Array<string> = [];
evaluationFeedbackStr: string = "";
correct: boolean = false;
// only used for matrix based mathtypes
matrixInput: MatrixInput = null;
// only used for vector based mathtypes
vectorLength: number = 1;
// evaluation of e.g. programming tasks is done asynchronesouly.
// As long as the evaluation is ongoing, evaluationInProgress is set true.
evaluationInProgress: boolean = false;
codeMirror : any = null; // IDE instance; only used for programming tasks
}
export class SellQuestion {
idx: number = 0;
src: string = '';
html: string = '';
titleHtml: string = '';
bodyHtml: string = '';
bodyHtmlElement: HTMLElement = null;
symbols: { [key: string]: SellSymbol } = {};
solutionSymbols: { [key: string]: SellSymbol } = {};
solutionSymbolsMustDiffFirst: { [key: string]: string } = {};
lastParsedInputSymbol: SellSymbol = null;
stack: Array<SellSymbol> = [];
inputs : Array<SellInput> = [];
generalFeedbackStr : string = "";
allAnswersCorrect : boolean = false;
// TODO: move parse method and other methods here
}
export class SellQuiz {
// subclases
evaluate: Evaluate = null;
textParser: ParseText = null;
codeParser: ParseCode = null;
codeSymParser: ParseCodeSym = null;
imParser: ParseIM = null;
imInputParser: ParseIM_Input = null;
progParser: ParseProg = null;
ELEMENT_TYPE_INPUT: string = 'input';
ELEMENT_TYPE_SPAN: string = 'span';
// preferences
debug: boolean = false;
log: string = '';
language: string = 'en';
generateInputFieldHtmlCode: boolean = true;
servicePath: string = './services/';
// questions
questions: Array<SellQuestion> = [];
q: SellQuestion = null; // current question
qidx: number = 0; // current question index
html: string = '';
variablesJsonStr: string = ''; // TODO!!
// lexer (remark: if attributes are changed, then methods backupLexer,
// and replayLexer must also be changed)
tokens: Array<SellToken> = [];
tk: string = '';
tk2: string = '';
tk_line: number = 0;
tk_col: number = 0;
tkIdx: number = 0;
id: string = '';
// parsing states
parseWhitespaces: boolean = false;
parsingInlineCode: boolean = false; // true while parsing solution code after '#'
// style states
isBoldFont: boolean = false;
isItalicFont: boolean = false;
isItemize: boolean = false;
isItemizeItem: boolean = false;
singleMultipleChoiceFeedbackHTML: string = ''; // written at end of line
// matrix inputs
//matrixInputs: Array<SellMatrixInput> = [];
resizableRows: boolean = false;
resizableCols: boolean = false;
// unique id counter
uniqueIDCtr: number = 0;
editButton: boolean = false;
constructor(debug = false) {
// instantiate evaluation class
this.evaluate = new Evaluate(this);
// instantiate parser classes
this.textParser = new ParseText(this);
this.codeParser = new ParseCode(this);
this.codeSymParser = new ParseCodeSym(this);
this.imParser = new ParseIM(this);
this.imInputParser = new ParseIM_Input(this);
this.progParser = new ParseProg(this);
}
// TODO:
createIDE(sellInput : SellInput, htmlElement : Element, lang="Java", height=75) {
// dev info: the CodeMirror editor is not included here directly for two reasons:
// (a.) many users will use SELL without programming questions
// (b.) CodeMirror can not be used in combination with node.js (DOM-environment not present)
console.log("ERROR: Obviously your quiz includes a programming task. Please also include sellquiz.ide.min.js in your HTML file");
}
importQuestions(sellCode : string) : boolean {
sellCode = sellCode.split('STOP')[0];
let sellCodeLines = sellCode.split("\n");
let code = '';
let codeStartRow = 0;
for(let i=0; i<sellCodeLines.length; i++) {
let line = sellCodeLines[i];
if(line.startsWith("%%%")) {
if(!this.importQuestion(code, codeStartRow))
return false;
code = '';
codeStartRow = i+1;
} else {
code += line + '\n';
}
}
if(!this.importQuestion(code, codeStartRow))
return false;
// TODO:
/*if(this.environment == "moodle") {
for(let i=0; i<this.questions.length; i++) {
let q = this.questions[i];
this.variablesJsonStr = JSON.stringify({"symbols":q.symbols, "solutionSymbols":q.solutionSymbols}); // TODO: overwritten for every question!!
let bp = 1337;
}
}*/
return true;
}
importQuestion(src : string, codeStartRow = 0): boolean {
this.resizableRows = false;
this.resizableCols = false;
this.tokens = [];
this.tk = '';
this.tk_line = 0;
this.tk_col = 0;
this.tk2 = ''; // look ahead 2
this.tkIdx = 0;
this.id = ''; // last identifier
this.qidx = this.questions.length;
this.q = new SellQuestion();
this.q.idx = this.qidx;
this.q.src = src;
this.questions.push(this.q);
let lines = src.split('\n');
//let indent1_last = false;
//let indent2_last = false;
let last_indent = 0;
let code_block = false; // inline code (NOT to be confused with SELL-code)
for (let i = 0; i < lines.length; i++) {
if (!code_block && lines[i].startsWith('```'))
code_block = true;
//let indent2 = lines[i].startsWith('\t\t') || lines[i].startsWith(' ');
//let indent1 = lines[i].startsWith('\t') || lines[i].startsWith(' ');
//if (indent2)
// indent1 = false;
let indent = 0;
if(lines[i].startsWith('\t\t') || lines[i].startsWith(' '))
indent = 2;
else if(lines[i].startsWith('\t') || lines[i].startsWith(' '))
indent = 1;
let line_str = lines[i].split('%')[0]; // remove comments
if (line_str.length == 0) // empty line
continue;
let lineTokens = Lexer.tokenize(line_str);
if (lineTokens.length == 0)
continue
lineTokens.push(new SellToken('§EOL', i + 1, -1)); // end of line
if (!code_block) {
/*if (!indent1 && indent1_last)
this.tokens.push(new SellToken('§CODE_END', i + 1, -1));
if (indent1 && !indent1_last)
this.tokens.push(new SellToken('§CODE_START', i + 1, -1));*/
if(last_indent==0 && indent==1)
this.tokens.push(new SellToken('§CODE_START', i + 1, -1));
else if(last_indent==0 && indent==2) {
this.tokens.push(new SellToken('§CODE_START', i + 1, -1));
this.tokens.push(new SellToken('§CODE2_START', i + 1, -1));
}
else if(last_indent==1 && indent==2)
this.tokens.push(new SellToken('§CODE2_START', i + 1, -1));
else if(last_indent==2 && indent==1)
this.tokens.push(new SellToken('§CODE2_END', i + 1, -1));
else if(last_indent==2 && indent==0) {
this.tokens.push(new SellToken('§CODE2_END', i + 1, -1));
this.tokens.push(new SellToken('§CODE_END', i + 1, -1));
}
else if(last_indent==1 && indent==0)
this.tokens.push(new SellToken('§CODE_END', i + 1, -1));
}
for (let j = 0; j < lineTokens.length; j++) {
this.tokens.push(lineTokens[j]);
this.tokens[this.tokens.length - 1].line = codeStartRow + i + 1;
}
//indent1_last = indent1;
//indent2_last = indent2;
last_indent = indent;
if (code_block && lines[i].endsWith('```'))
code_block = false;
}
this.tokens.push(new SellToken('§END', -1, -1));
//console.log(this.tokens);
//this.helper.printTokenList(this.tokens);
this.tkIdx = 0;
this.next();
try {
this.parse();
} catch (e) {
this.log += e + '\n';
this.log += 'Error: compilation failed';
return false;
}
if (this.tk !== '§END')
this.err('Error: remaining tokens: "' + this.tk + '"...');
this.log += '... compilation succeeded!\n';
// --- permutate patterns '§['...']§' (shuffles single/multiple choice answers) ---
// TODO: does NOT work for multiple groups of multiple-choice/single-choice
let options = [];
let n = this.q.html.length;
let tmpHtml = '';
// fill options-array and replace occurring patterns '§['...']§' by
// '§i', with i := index of current option (0<=i<k, with k the total
// number of options)
for (let i = 0; i < n; i++) {
let ch = this.q.html[i];
let ch2 = i + 1 < n ? this.q.html[i + 1] : '';
if (ch == '§' && ch2 == '[') {
tmpHtml += '§' + options.length;
options.push('');
for (let j = i + 2; j < n; j++) {
ch = this.q.html[j];
ch2 = j + 1 < n ? this.q.html[j + 1] : '';
if (ch == ']' && ch2 == '§') {
i = j + 1;
break;
}
options[options.length - 1] += ch;
}
} else
tmpHtml += ch;
}
// shuffle options
let k = options.length;
for (let l = 0; l < k; l++) {
let i = Lexer.randomInt(0, k);
let j = Lexer.randomInt(0, k);
let tmp = options[i];
options[i] = options[j];
options[j] = tmp;
}
// reconstruct question-html
for (let l = 0; l < k; l++)
tmpHtml = tmpHtml.replace('§' + l, options[l]);
this.q.html = tmpHtml;
// --- set HTML ---
this.html += this.q.html + '\n\n';
return true;
}
backupQuestion(questionID : number) : string {
let q = this.getQuestionByIdx(questionID);
if(q == null)
return null;
let backup = {};
// source and generated HTML
backup["source_code"] = q.src;
backup["title_html"] = q.titleHtml;
backup["body_html"] = q.bodyHtml;
// variables
backup["variables"] = [];
for(let symid in q.symbols)
backup["variables"].push(q.symbols[symid].exportDictionary(symid));
backup["solution_variables"] = [];
for(let symid in q.solutionSymbols)
backup["solution_variables"].push(q.solutionSymbols[symid].exportDictionary(symid));
// TODO: mustDiffFrist, ....
// input fields
backup["input_fields"] = [];
for(let i=0; i<q.inputs.length; i++) {
let input = q.inputs[i];
let f = {};
f["element_type"] = input.htmlElementInputType;
f["element_id"] = input.htmlElementId;
f["element_id__feedback"] = input.htmlElementId_feedback;
f["correct"] = input.correct;
f["feedback_message"] = input.evaluationFeedbackStr;
f["student_answer_string"] = input.studentAnswer;
f["solution_variable_id"] = input.solutionVariableId;
backup["input_fields"].push(f);
}
// global evaluation feedback
backup["general_feedback"] = {};
backup["general_feedback"]["all_answers_correct"] = q.allAnswersCorrect;
backup["general_feedback"]["feedback_message"] = q.generalFeedbackStr;
// stringify
return JSON.stringify(backup, null, 4);
}
getQuestionInputFields(questionID : number) : any {
let inputFields = [];
let backupStr = this.backupQuestion(questionID);
let backup = JSON.parse(backupStr);
for(let i=0; i<backup["input_fields"].length; i++) {
inputFields.push({
"element_id": backup["input_fields"][i]["element_id"],
"element_type": backup["input_fields"][i]["element_type"],
"solution_variable_id": backup["input_fields"][i]["solution_variable_id"]
});
}
return inputFields;
}
createQuestionFromBackup(backupStr : string) : number {
let backup = JSON.parse(backupStr);
// TODO: check, if backup string is consistent
let q = new SellQuestion();
q.idx = this.questions.length;
// source and generated HTML
q.src = backup["source_code"];
q.titleHtml = backup["title_html"];
q.bodyHtml = backup["body_html"];
// variables
q.symbols = {};
for(let i=0; i<backup["variables"].length; i++) {
let v = backup["variables"][i];
let sym = new SellSymbol();
sym.importDictionary(v);
q.symbols[v["id"]] = sym;
}
q.solutionSymbols = {};
for(let i=0; i<backup["solution_variables"].length; i++) {
let v = backup["solution_variables"][i];
let sym = new SellSymbol();
sym.importDictionary(v);
q.solutionSymbols[v["id"]] = sym;
}
// input fields
q.inputs = [];
for(let i=0; i<backup["input_fields"].length; i++) {
let input = new SellInput();
let f = backup["input_fields"][i];
input.htmlElementInputType = f["element_type"];
input.htmlElementId = f["element_id"];
input.htmlElementId_feedback = f["element_id__feedback"];
input.correct = f["correct"];
input.evaluationFeedbackStr = f["feedback_message"];
input.studentAnswer = f["student_answer_string"];
input.solutionVariableId = f["solution_variable_id"];
q.inputs.push(input);
}
// global evaluation feedback
q.allAnswersCorrect = backup["general_feedback"]["all_answers_correct"];
q.generalFeedbackStr = backup["general_feedback"]["feedback_message"];
// push question and return index
this.questions.push(q);
return q.idx;
}
getElementByIdAndType(id : string, type) {
// TODO:!!!!!
/*if (this.environment == "mumie") {
// https://www.integral-learning.de/platform/
let inputField;
inputField = Array.from(document.getElementsByTagName(type))
.filter((inputFields) => inputFields.id === id)
.find((inputFields) => inputFields.offsetParent !== null);
return inputField ? inputField : document.getElementById(id);
} else {
// standalone version
return document.getElementById(id);
}*/
return document.getElementById(id);
}
backupLexer() { // backup lexer (used e.g. in code-loops)
return {
'tk': this.tk,
'tk_line': this.tk_line,
'tk_col': this.tk_col,
'tk2': this.tk2,
'tkIdx': this.tkIdx,
'id': this.id
}
}
replayLexer(lexState) { // replay lexer backup (used e.g. in code-loops)
this.tk = lexState['tk'];
this.tk_line = lexState['tk_line'];
this.tk_col = lexState['tk_col'];
this.tk2 = lexState['tk2'];
this.tkIdx = lexState['tkIdx'];
this.id = lexState['id'];
}
createUniqueID() {
return "ID" + (this.uniqueIDCtr++);
}
updateMatrixInputs(questionID : number) : boolean {
let q = this.getQuestionByIdx(questionID);
if(q == null)
return false;
for(let i=0; i<q.inputs.length; i++) {
let input = q.inputs[i];
if(input.matrixInput != null) // TODO: better compare input.htmlElementInputType??
input.matrixInput.updateHTML();
}
return true;
}
createProgrammingTaskEditors(questionID : number) : boolean {
let q = this.getQuestionByIdx(questionID);
if(q == null)
return false;
for(let i=0; i<q.inputs.length; i++) {
let input = q.inputs[i];
if(input.htmlElementInputType == SellInputElementType.PROGRAMMING) {
let textarea = getHtmlChildElementRecursive(q.bodyHtmlElement, input.htmlElementId);
let proglang = '';
if(input.solutionVariableRef.value.type.startsWith("Java"))
proglang = 'java';
else if(input.solutionVariableRef.value.type.startsWith("Python"))
proglang = 'python';
else
alert('UNIMPLMENTED: createProgrammingTaskEditors: unimplemented type '
+ input.solutionVariableRef.value.type);
this.createIDE(input, textarea, 'java', 250); // TODO: make height adjustable
}
}
return true;
}
updateMatrixDims(questionID : number, htmlElementId : string,
deltaRows : number, deltaCols : number) : boolean {
let q = this.getQuestionByIdx(questionID);
if(q == null)
return false;
for(let i=0; i<q.inputs.length; i++) {
let input = q.inputs[i];
if(input.matrixInput != null && input.htmlElementId == htmlElementId)
input.matrixInput.resize(deltaRows, deltaCols);
}
return true;
}
next() {
// look-ahead 1
if (this.tkIdx >= this.tokens.length) {
this.tk = '§END';
this.tk_line = -1;
this.tk_col = -1;
}
else {
this.tk = this.tokens[this.tkIdx].str;
this.tk_line = this.tokens[this.tkIdx].line;
this.tk_col = this.tokens[this.tkIdx].col;
}
// look-ahead 2
if (this.tkIdx + 1 >= this.tokens.length)
this.tk2 = '§END';
else
this.tk2 = this.tokens[this.tkIdx + 1].str;
this.tkIdx++;
if (!this.parseWhitespaces && this.tk === ' ')
this.next();
// lexer hack for parsing inline code: e.g. ['a','_','b'] -> ['a_b']
if (this.parsingInlineCode && Lexer.isIdentifier(this.tk)) {
while (this.parsingInlineCode && this.tkIdx < this.tokens.length - 1 && this.tk !== '§END' && (this.tokens[this.tkIdx].str === '_' || Lexer.isIdentifier(this.tokens[this.tkIdx].str) || Lexer.isInteger(this.tokens[this.tkIdx].str))) {
this.tk += this.tokens[this.tkIdx].str;
this.tk_line = this.tokens[this.tkIdx].line;
this.tk_col = this.tokens[this.tkIdx].col;
this.tkIdx++;
}
}
if (!this.parseWhitespaces && this.tk === ' ')
this.next();
}
err(msg : string) {
throw 'Error:' + this.tk_line + ':' + this.tk_col + ': ' + msg;
}
terminal(t) {
if (this.tk === t)
this.next();
else {
if(t == "§EOL")
this.err("expected linebreak, got '" + this.tk + "'");
else
this.err("expected '" + t + "', got '" + this.tk.replace('§EOL', 'linebreak') + "'");
}
}
ident() {
if (this.isIdent()) {
this.id = this.tk;
this.next();
} else
this.err("expected identifier");
}
isIdent() {
return Lexer.isIdentifier(this.tk);
}
isNumber() {
return !isNaN(this.tk as any);
}
isInt() {
return Lexer.isInteger(this.tk);
}
is(s : string) {
return this.tk === s;
}
is2(s : string) {
return this.tk2 === s;
}
isNumberInt(v) {
return Math.abs(v - Math.round(v)) < 1e-6;
}
pushSym(type : symtype, value : any, precision = 1e-9) {
this.q.stack.push(new SellSymbol(type, value, precision));
}
charToHTML(c) {
switch (c) {
case '§EOL': return '<br/>\n';
default: return c;
}
}
// sell =
// title { code | text };
parse() {
this.textParser.parseTitle();
this.q.html = "";
while (!this.is('§END')) {
if (this.is("§CODE_START"))
this.codeParser.parseCode();
else
this.textParser.parseText();
}
this.q.bodyHtml = this.q.html;
this.createHighLevelHTML();
}
createHighLevelHTML() {
// create high-level HTML:
this.q.html = '<div id="sell_question_html_element_' + this.q.idx + '" class="card border-dark">\n';
this.q.html += '<div class="card-body px-3 py-2">\n'; // ** begin body
this.q.html += ' <span class="h2 py-1 my-1">' + this.q.titleHtml + '</span><br/>\n';
this.q.html += ' <a name="question-' + (this.questions.length-1) + '"></a>\n';
this.q.html += '<div class="py-1">';
this.q.html += this.q.bodyHtml;
this.q.html += '</div>';
this.q.html += '<span>';
// submit button
//this.q.html += '<input type="image" id="button-evaluate" onclick="sellquiz.autoEvaluateQuiz(' + this.qidx + ', \'sell_question_html_element_' + this.q.idx + '\');" height="28px" src=\"' + check_symbol_svg + '\" title="evaluate"></input>';
let evalStr = GET_STR('evaluate', this.language, false);
this.q.html += '<button type="button" class="btn btn-primary" onclick="sellquiz.autoEvaluateQuiz(' + this.qidx + ', \'sell_question_html_element_' + this.q.idx + '\');">' + evalStr + '</button>'
// edit button
if(this.editButton) {
this.q.html += ' <button type="button" class="btn btn-primary" onclick="editSellQuestion(' + this.qidx + ')">Edit</button>';
}
// general feedback
this.q.html += ' <span id="general_feedback"></span>';
// end
this.q.html += '</span>';
this.q.html += '</div>\n'; // ** end body (begins in keyword 'TITLE')
this.q.html += '</div>\n'; // *** end of card
this.q.html += '<br/>';
}
getQuestionByIdx(idx: number): SellQuestion {
if (idx < 0 || idx >= this.questions.length)
return null;
return this.questions[idx];
}
enableInputFields(questionID : number, enable : boolean = true) {
let q = this.getQuestionByIdx(questionID);
if(q == null)
return false;
if(q.bodyHtmlElement == null)
sellassert(false, "enableInputFields(): q.bodyHtmlElement was not set");
for(let i=0; i<q.inputs.length; i++) {
let element = getHtmlChildElementRecursive(q.bodyHtmlElement, q.inputs[i].htmlElementId);
(<HTMLInputElement>element).disabled = !enable;
}
return true;
}
} // end of class Sell