/* CodeQ: an online programming tutor.
Copyright (C) 2015 UL FRI
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see . */
/**
* Creates a hint handler, displaying hints inside the provided jqHints
.
*/
(function () {
"use strict";
// constants
var firstCharacterPos = {'line': 0, 'ch': 0},
sel_no_scroll = {'scroll': false};
codeq.makeHinter = function (jqHints, jqEditor, editor, trNamespace, problemDef, commonDef, activityHandler) {
var hintCounter = 0, // for generating unique class-names
hintCleaners = [],
popoverHintCleaners = [], // we require separate cleaners for popups, because they are rebuilt when the editor's DOM changes
popoverCreators = [], // functions that rebuild popover handlers after the editor's DOM changes
planIdx = 0,
dictionary = [],
jqHintsContainer = jqHints.parent(),
hintProblemDefs = problemDef.hint_type,
hintCommonDefs = commonDef.hint_type,
hintProblemTr = problemDef.hint,
hintCommonTr = commonDef.hint,
planDef = problemDef.plan,
templatePath = [problemDef.language, problemDef.group, problemDef.problem],
clearHints = function () {
var i;
for (i = hintCleaners.length - 1; i >= 0; i--) {
hintCleaners[i]();
}
hintCleaners.length = 0;
for (i = popoverHintCleaners.length - 1; i >= 0; i--) {
popoverHintCleaners[i]();
}
popoverHintCleaners.length = 0;
$('div.hints > div.feedback > button.display-hints').off().remove();
},
addMark = function (start, end, style) {
var posStart = editor.posFromIndex(start),
posEnd = editor.posFromIndex(end),
doc = editor.getDoc(),
mark = doc.markText(posStart, posEnd, {'className': 'editor-mark _emark_' + hintCounter + ' ' + style}),
result = {'start': posStart, 'end': posEnd, 'mark': mark, 'className': '_emark_' + hintCounter};
hintCleaners.push(function () { mark.clear(); mark = null; doc = null; result.mark = null; });
hintCounter++;
return result;
},
prepareStaticHintContent = function (hintContent, indices, hintId) {
var content = {},
langs = codeq.availableLangs,
skippedLangs = [],
Nhints = 0, // count hints and remember the count
haveIndices = indices instanceof Array,
i, lang, j, node, tmpContent;
langLoop:
for (j = langs.length - 1; j >= 0; j--) {
lang = langs[j];
node = hintContent[lang];
if (!node) {
// no translation for this language, mark for later
skippedLangs.push(lang);
}
else {
if (haveIndices) {
// traverse node's indices
for (i = 0; i < indices.length; i++) {
node = node[indices[i]];
if (!node) {
// index out of bounds, mark this language for later
codeq.log.error('Cannot reference hint ' + hintId + ' with indices ' + indices);
skippedLangs.push(lang);
continue langLoop;
}
}
}
if (typeof node === 'string') {
// we have a single string (= a simplex message), put it into an array
content[lang] = [node];
if (Nhints < 1) Nhints = 1;
}
else if (node instanceof Array) {
// we already have an array
if (node.length == 0) {
if (haveIndices) codeq.log.error('Hint ' + hintId + ' with indices ' + indices + ' for language ' + lang + ' contains an empty array');
else codeq.log.error('Hint ' + hintId + ' for language ' + lang + ' contains an empty array');
skippedLangs.push(lang);
continue;
}
// verify that each array element is a string or an object with a message
for (i = node.length - 1; i >= 0; i--) {
tmpContent = node[i];
if (typeof tmpContent === 'string') {
// this is okay
}
else if (tmpContent && (typeof tmpContent === 'object')) {
if (typeof tmpContent.message !== 'string') {
if (haveIndices) tmpContent.message = 'There is no message defined for hint ' + hintId + 'with indices ' + indices + ' in language ' + lang + ' at index ' + i;
else tmpContent.message = 'There is no message defined for hint ' + hintId + ' in language ' + lang + ' at index ' + i;
codeq.log.error(tmpContent.message);
}
}
else {
// not a string or an object with a message
if (haveIndices) tmpContent = 'There is no message defined for hint ' + hintId + 'with indices ' + indices + ' in language ' + lang + ' at index ' + i;
else tmpContent = 'There is no message defined for hint ' + hintId + ' in language ' + lang + ' at index ' + i;
node[i] = tmpContent;
codeq.log.error(tmpContent);
}
}
content[lang] = node;
if (Nhints < node.length) Nhints = node.length;
}
else if (node && (typeof node === 'object') && (typeof node.message === 'string')) {
// we have a single object with a message (= a complex message), put it into an array
content[lang] = [node];
if (Nhints < 1) Nhints = 1;
}
else {
if (haveIndices) codeq.log.error('Hint ' + hintId + ' with indices ' + indices + ' did not result in a terminal node for language ' + lang + ', but: ' + node);
else codeq.log.error('Hint ' + hintId + ' probably needs indices because it does not have a terminal node for language ' + lang + ', but: ' + node);
skippedLangs.push(lang);
}
}
}
if (Nhints === 0) {
// provide error feedback on display when there is no hint translation available in any language
if (haveIndices) tmpContent = ['No hints found for hint ' + hintId + ' with indices ' + indices];
else tmpContent = ['No hints found for hint ' + hintId];
codeq.log.error(tmpContent[0]);
for (j = langs.length - 1; j >= 0; j--) {
content[langs[j]] = tmpContent;
}
Nhints = 1;
}
else if (skippedLangs.length > 0) {
// choose a default content and assign it to skipped languages
lang = 'en'; // try English first
tmpContent = content[lang];
if (!tmpContent) {
// if no English exists, find one that does
for (lang in content) {
if (!content.hasOwnProperty(lang)) continue;
tmpContent = content[lang];
if (tmpContent) break;
}
}
codeq.log.error('Translations in languages ' + skippedLangs.join(', ') + ' are missing or erroneous for hint ' + hintId + ', replacing their content with translation for ' + lang);
// assign the default content to skipped languages
for (j = skippedLangs.length - 1; j >= 0; j--) {
content[skippedLangs[j]] = tmpContent;
}
}
content.hintLength = Nhints;
return content;
},
ta = function (trObj) { // an object of the form: {'en': 'english content', 'sl': 'slovenska vsebina'}
var result = ['data-dict="', trNamespace, '" data-tkey="', dictionary.length, '"'].join('');
dictionary.push(trObj);
return result;
},
typeHandlers = {
'static': function (template, hint, box) {
var content = prepareStaticHintContent(template, hint.indices, hint.id),
args = hint ? hint.args : null,
hintIndex = 0,
trButton = {},
Nhints = content.hintLength,
nextJqHint = function () {
var trContent = {},
langs = codeq.availableLangs,
lang, i, msg, jq, deltaHeight;
for (i = langs.length - 1; i >= 0; i--) {
lang = langs[i];
try {
msg = content[lang][hintIndex];
if (typeof msg === 'string') {
trContent[lang] = codeq.template.process(msg, templatePath, args);
trButton[lang] = codeq.tr.translate('btn_more', lang) || 'More...';
}
else {
trContent[lang] = codeq.template.process(msg.message, templatePath, args);
trButton[lang] = msg.linkText;
}
}
catch (e) {
msg = 'Error processing hint ' + hint.id + ' at index ' + hintIndex + ' for language ' + lang + ': ' + e;
codeq.log.error(msg, e);
trContent[lang] = msg;
}
}
jq = $('');
hintIndex++;
if (jqButton) {
if (hintIndex < Nhints) {
jqButton.before(jq);
codeq.tr.translateDom(jqButton);
}
else {
jqButton.remove();
jqContainer.append(jq);
}
}
else {
jqContainer.append(jq);
}
codeq.tr.translateDom(jq);
},
jqContainer, jqButton;
if (Nhints > 1) {
// hint sequence
jqContainer = $('');
jqButton = $('');
box.append(jqContainer);
jqContainer.append(jqButton);
jqButton.on('click', function () {
nextJqHint();
});
}
else {
// a single hint
jqContainer = box;
jqButton = null;
}
nextJqHint();
// no hint cleaner here, a static hint remains on the screen
},
'popup': function (template, hint, box) {
codeq.log.debug('Processing popup hint');
var args = hint.args,
style = hint.style || '',
mark = addMark(hint.start, hint.end, style), // add the mark
langs = codeq.availableLangs,
translations = {},
lang, i, content, htmlPrefix, instFunc;
// execute templates for all languages
for (i = langs.length - 1; i >= 0; i--) {
lang = langs[i];
content = template[lang];
if (typeof content === 'string') {
translations[lang] = codeq.template.process(content, templatePath, args);
}
else {
translations[lang] = 'No translation in ' + lang + ' available for popup hint ' + hint.id;
codeq.log.error(translations[lang]);
}
}
// construct the wrapper element for the translation mechanism
htmlPrefix = '
';
// create the popover after all the DOM modifications have been made, otherwise only the last made popover can be triggered
instFunc = function () {
var jqMark = jqEditor.find('.' + mark.className);
jqMark.popover({
'content': function () {
// decide on what to display only after the popup is triggered, so we can choose the correct translation
return htmlPrefix + translations[codeq.settings['gui_lang']] + '
';
},
'html': true,
'placement': 'auto bottom',
'trigger': 'hover focus click',
'container': 'body'
});
// remove the popup on next hint pack
popoverHintCleaners.push(function () { if (jqMark) { jqMark.popover('destroy'); jqMark = null; } });
};
popoverCreators.push(instFunc);
return instFunc;
},
'dropdown': function (template, hint, box) {
codeq.log.debug('Processing dropdown hint');
var completion = null, // the completion object, created in showHint()
close = function () {
if (completion) {
completion.close();
completion = null;
}
};
if (/*(editor.listSelections().length > 1) ||*/ editor.somethingSelected()) {
// showHint() doesn't work if a selection is activeparts
editor.setSelection(firstCharacterPos, firstCharacterPos, sel_no_scroll); // deselect anything
}
editor.showHint({
'hint': function () {
var hints = {
list: hint.choices,
from: editor.posFromIndex(hint.start),
to: editor.posFromIndex(hint.end)
};
completion = editor.state.completionActive;
return hints;
},
completeOnSingleClick: true,
completeSingle: false
});
hintCleaners.push(close);
}
},
// process and append [hints] to the element [box]
appendHints = function (hints, box) {
var finalizers = [],
i, hint, hintDef, hintContent, hintType, t, fn, ret;
activityHandler.queueTrace({'typ': 'hint', 'feedback': hints});
for (i = 0; i < hints.length; i++) {
hint = hints[i];
hintDef = hintProblemDefs[hint.id] || hintCommonDefs[hint.id];
if (!hintDef) {
codeq.log.error('Undefined hint: ' + hint.id);
continue;
}
hintContent = hintProblemTr[hint.id] || hintCommonTr[hint.id];
if (!hintContent) {
codeq.log.error('Hint without content: ' + hint.id);
continue;
}
t = typeof hintDef;
if (t === 'string') hintType = hintDef; // currently a hint type is a string
else if ((t === 'object') && (hintDef !== null)) hintType = hintDef.type; // but in future we may use an object, if a definition becomes more complex
else {
codeq.log.error('Cannot determine the type of hint ' + hint.id + ' from: ' + hintDef);
continue;
}
fn = typeHandlers[hintType];
if (!fn) codeq.log.error('Unsupported hint type: ' + hintType);
else {
ret = fn(hintContent, hint, box);
if (typeof ret === 'function') finalizers.push(ret);
}
}
// invoke any finalizers
for (i = 0; i < finalizers.length; i++) {
finalizers[i]();
}
},
/**
* When the editor updates its DOM, we have to re-register any popup hints.
*/
onEditorUpdate = function () {
var i;
for (i = 0; i < popoverHintCleaners.length; i++) popoverHintCleaners[i]();
for (i = 0; i < popoverCreators.length; i++) popoverCreators[i]();
};
codeq.tr.registerDictionary(trNamespace, dictionary);
editor.on('update', onEditorUpdate);
return {
/**
* Display the next "planning" hint and return whether there are
* any more available.
*/
'planNext': function () {
if (planIdx < planDef.length) {
var jqHintBox = $('');
activityHandler.queueTrace({'typ': 'plan', 'index': planIdx});
typeHandlers['static'](planDef[planIdx], {'id': 'plan'}, jqHintBox);
jqHints.prepend(jqHintBox);
jqHintsContainer.scrollTop(0);
planIdx++;
}
return planIdx < planDef.length;
},
'hasNextPlan': function () {
return planIdx < planDef.length;
},
/**
* Processes and display appropriately the server hints.
* TODO: sort hints so static and popup hints come first, and a (single) drop-down hint last
*
* @param {ServerHint[]} hints an array of hints from the server
*/
'handle': function (hints) {
var i, hint, hintDef, hintContent,
n_correct = 0, n_all = 0,
jqHintBox = $(''),
jqHintBtn;
// display the test_results hint first if found
for (i = 0; i < hints.length; i++) {
hint = hints[i];
if (hint.id === 'test_results') {
activityHandler.queueTrace({'typ': 'test', 'feedback': hint});
n_correct = hint.args.passed
n_all = hint.args.total
hintContent = hintProblemTr[hint.id] || hintCommonTr[hint.id];
typeHandlers['static'](hintContent, hint, jqHintBox);
hints.splice(i, 1);
break;
}
}
// display remaining hints
if (hints.length > 0) {
if (n_all == 0 || n_correct == n_all) {
// no test_results or program correct: show all hints immediately
appendHints(hints, jqHintBox);
}
else {
// otherwise, hide hints behind a button
jqHintBtn = $('');
codeq.tr.translateDom(jqHintBtn);
jqHintBtn.on('click', function (e) {
jqHintBtn.off().remove();
appendHints(hints, jqHintBox);
});
jqHintBox.append(jqHintBtn);
// experiment: Prolog hints
activityHandler.queueTrace({'typ': 'experiment', 'data': 'hint button shown'});
// end of experiment: Prolog hints
}
}
jqHints.prepend(jqHintBox);
jqHintsContainer.scrollTop(0);
},
'clear': clearHints,
'destroy': function () {
editor.off('update', onEditorUpdate);
clearHints();
codeq.tr.unregisterDictionary(trNamespace);
jqHints.empty();
jqHints = null;
jqEditor = null;
jqHintsContainer = null;
editor = null;
}
};
};
})();