/* CodeQ: an online programming tutor. Copyright (C) 2015-2017 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) { if (!template) return; 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 || 'highlight', langs = codeq.availableLangs, translations = {}, lang, i, content, htmlPrefix, instFunc; // highlight given range in the editor var mark = addMark(hint.start, hint.end, style); // 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 { // any missing translation → no popup // TODO support highlight-only hints instead return; } } // construct the wrapper element for the translation mechanism htmlPrefix = ''; }, '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, 'shown': true}); 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]; 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, fn; // 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 activityHandler.queueTrace({'typ': 'hint', 'feedback': hints, 'shown': false}); jqHintBtn = $(''); codeq.tr.translateDom(jqHintBtn); jqHintBtn.on('click', function (e) { jqHintBtn.off().remove(); appendHints(hints, jqHintBox); }); // hide the hint button if code changes fn = function (instance, changeObj) { jqHintBtn.off().remove(); editor.off('change', fn); }; editor.on('change', fn); jqHintBox.append(jqHintBtn); } } 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; } }; }; })();