/* 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 . */
(function () {
window.siteDefinition = {logLevel: 'debug'}; // for debug purposes
// ================================================================================
// JSON-ization (stringification) function
// ================================================================================
var jsonize = JSON && JSON.stringify || function (obj) {
var t, buffer, i, isFirst;
if (null === obj) return 'null';
t = typeof obj;
if (t === 'string') {
return '"' + obj.replace(regexpBackslash, '\\\\').replace(regexpQuote, '\\"') + '"';
}
if (t === 'number') {
if (isFinite(obj)) return obj.toString();
throw new Error('Cannot jsonize a non-finite number: ' + obj.toString());
}
if (t === 'boolean') {
if (obj) return 'true';
return 'false';
}
if (t === 'object') {
if (obj instanceof String) return jsonize(obj.valueOf());
if (obj instanceof Number) return jsonize(obj.valueOf());
if (obj instanceof Boolean) return jsonize(obj.valueOf());
if (obj instanceof Array) {
buffer = [ '[' ];
isFirst = true;
for (i = 0; i < obj.length; i++) {
if (isFirst) isFirst = false;
else buffer.push(',');
buffer.push(jsonize(obj[i]));
}
buffer.push(']');
return buffer.join('');
}
buffer = [ '{' ];
isFirst = true;
for (i in obj) {
if (!obj.hasOwnProperty(i)) continue;
if (isFirst) isFirst = false;
else buffer.push(',');
buffer.push(jsonize(i), ':', jsonize(obj[i]));
}
buffer.push('}');
return buffer.join('');
}
throw new Error('Cannot jsonize ' + t);
};
// ================================================================================
// The log module: contains methods for logging, sending logs to the server
// ================================================================================
var log = (function () {
var logServiceUrl = window.siteDefinition && (typeof siteDefinition.logService === 'string') && siteDefinition.logService,
assembleOutput = function (stuff, e) {
var lines = [ stuff ];
if (e && e.stack) lines.push(e.stack);
return lines.join('\n');
},
console = window.console || {},
levelPrefix = {'debug': 'DEBUG: ', 'info': 'INFO: ', 'warn': 'WARN: ', 'error': 'ERROR: '},
levelSeverity = {'debug': 1, 'info': 2, 'warn': 3, 'error': 4, 'off': 5},
severityThreshold = window.siteDefinition && levelSeverity[siteDefinition.logLevel] || levelSeverity['off'],
logToConsole = function (level) {
var prefix = levelPrefix[level];
if (levelSeverity[level] < severityThreshold) return function () {}; // level is below threshold, ignore
if (typeof console[level] === 'function') return function (output) {console[level](output);};
if (typeof console.log === 'function') return function (output) {console.log(prefix + output);};
if (typeof dump === 'function') return function (output) {dump(prefix + output);};
return function () {}; // no way to log in browser
},
consoleLogger = {
'debug': logToConsole('debug'),
'info': logToConsole('info'),
'warn': logToConsole('warn'),
'error': logToConsole('error')
},
logger = {},
level;
if (logServiceUrl) {
// log to the service and to the console
var logs = []; // buffered logs
for (level in levelPrefix) {
if (!levelPrefix.hasOwnProperty(level)) continue;
logger[level] = (function (levelLogger, level) {
if (levelSeverity[level] < severityThreshold) return function () {}; // level is below threshold, ignore
return function (stuff, e) {
var output = assembleOutput(stuff, e);
levelLogger(output);
logs.push({
't': Date.now(),
'l': level,
'm': output
});
};
})(consoleLogger[level], level);
}
setInterval(function () {
// each second send any logs to the server
var copyOfLogs;
if (logs.length < 1) return;
copyOfLogs = jsonize({'logs': logs});
logs = [];
$.ajax({
contentType: 'application/json',
dataType: 'application/json',
type: 'POST',
url: logServiceUrl,
data: copyOfLogs,
error: function (jqXHR, textStatus, errorThrown) {
consoleLogger.error(assembleOutput('Posting of logs to ' + logServiceUrl + ' failed: ' + textStatus, errorThrown));
},
success: function (data, textStatus, jqXHR) {}
});
}, 1000);
}
else {
// log only to the console
for (level in levelPrefix) {
if (!levelPrefix.hasOwnProperty(level)) continue;
logger[level] = (function (levelLogger) {
if (levelSeverity[level] < severityThreshold) return function () {}; // level is below threshold, ignore
return function (stuff, e) { levelLogger(assembleOutput(stuff, e)); };
})(consoleLogger[level]);
}
}
return logger;
})();
// ================================================================================
// The core CodeQ module
// ================================================================================
var jqDisabled = $('#disabled'), // used in codeq.wait()
waitCssEnter = {'cursor': 'wait', 'display': ''};
// regular expressions for the templating function, etc.
var regexpQuote = new RegExp('"', 'g'),
regexpBackslash = new RegExp('\\\\', 'g'),
regexpWhiteSpaceStart = new RegExp('^[ \r\n\t]+'),
regexpWhiteSpaceEnd = new RegExp('[ \r\n\t]+$'),
regexpWhiteSpaceTrim = new RegExp('^[ \\t\\r\\n]*(.*[^ \\t\\r\\n])[ \\t\\r\\n]*$', 'm'),
regexpAmp = new RegExp('&', 'g'),
regexpLt = new RegExp('<', 'g'),
regexpGt = new RegExp('>', 'g'),
regexpCR = new RegExp('\\r', 'g'),
regexpLF = new RegExp('\\n', 'g'),
regexpTab = new RegExp('\\t', 'g');
// convert a string into its definition (javascript literal)
var stringToDef = function (str) {
return str.replace(regexpBackslash, '\\\\').replace(regexpQuote, '\\"').replace(regexpCR, '\\r').replace(regexpLF, '\\n').replace(regexpTab, '\\t');
};
var resources = {}; // resource tree, loaded from data/resources.json in the boot sequence
// event dispatch
var eventListeners = {}, // keyed by event name, value is an array of listeners
asyncTimer = null,
queuedEvents = [],
fireEvents = function () {
if (asyncTimer !== null) return;
asyncTimer = setTimeout(function () {
var N = queuedEvents.length,
i, event, args, listeners, j;
asyncTimer = null;
for (i = 0; i < N; i++) {
event = queuedEvents[i];
listeners = eventListeners[event.name];
if (!listeners) continue;
args = event.args;
listeners = listeners.slice(); // make a copy of the list, so we're unaffected of any changes
for (j = 0; j < listeners.length; j++) {
try {
listeners[j](args);
}
catch (e) {
codeq.log.error('Error while invoking an event handler for ' + event.name + ': ' + e, e);
}
}
}
queuedEvents.splice(0, N);
if (queuedEvents.length > 0) fireEvents();
}, 0);
};
var tokenize = function (line) {
var result = [],
N = line.length,
i = 0, // character index into line
storedAtom = null, // the previous atom, to decide whether to store a string or an object {key:'', value:''}
atom = [], // currently parsed content
isKeyValue = false,
storeAtom = function () {
if (atom.length == 0) return;
if (storedAtom === null) storedAtom = atom.join('');
else if (isKeyValue) {
result.push({'key': storedAtom, 'value': atom.join('')});
storedAtom = null;
isKeyValue = false;
}
else {
result.push(storedAtom);
storedAtom = atom.join('');
}
atom.length = 0;
},
c, q;
while (i < N) {
c = line[i++];
switch (c) {
// end-of-atom characters
case ' ':
case '\r':
case '\n':
case '\t':
storeAtom();
break;
// escape character
case '\\':
if (i < N) atom.push(line[i++]);
break;
// key-value delimiter character
case '=':
if (storedAtom) {
if (isKeyValue) atom.push(c);
else if (atom.length == 0) isKeyValue = true; // must have been a whitespace before "="
else {
storeAtom();
isKeyValue = true;
}
}
else atom.push(c); // "=" is delimiter only before the value, not before the key
break;
// quoted string
case '"':
case "'":
stringConsume:
while (i < N) {
q = line[i++];
switch (q) {
case '\\':
if (i < N) atom.push(line[i++]);
break;
case c:
break stringConsume;
default:
atom.push(q);
break;
}
}
break;
default:
atom.push(c);
break;
}
}
// purge anything in cache
storeAtom();
if (storedAtom !== null) result.push(storedAtom);
return result;
};
var resolveResource = function (resourceName, resourceBranches) {
var traversedPath = ['data'], // top-level directory
branch = resources,
candidate = null,
i, fragment;
if (!resourceName) {
codeq.log.error('No resource name provided; path: "' + resourceBranches.join('/') + '"');
return null;
}
if (branch[resourceName]) candidate = 'data/' + resourceName; // top-level match
for (i = 0; i < resourceBranches.length; i++) {
fragment = resourceBranches[i];
branch = branch[fragment];
if (!branch) {
codeq.log.error('Resource sub-branch ' + fragment + ' does not exist; resource: "' + resourceName + '", path: "' + resourceBranches.join('/') + '"');
break;
}
traversedPath.push(fragment);
if (branch[resourceName]) candidate = traversedPath.join('/') + '/' + resourceName;
}
if (candidate) return codeq.ajaxPrefix + candidate;
codeq.log.error('Resource ' + resourceName + ' was not found; path: "' + resourceBranches.join('/') + '"');
return null;
};
var directiveHandlers = {
'resource': function (code, tokens, templatePath) {
code.push('_result.push("', resolveResource(tokens[1], templatePath) || 'data/broken.png', '");\n');
},
'img': function (code, tokens, templatePath) {
var N = tokens.length,
attrs = [],
token, i;
for (i = 1; i < N; i++) {
token = tokens[i];
if (!token || typeof token !== 'object') {
codeq.log.error('Invalid token at position ' + i + ' in @img');
continue;
}
switch (token.key) {
case 'src':
attrs.push('src="' + resolveResource(token.value, templatePath) + '"');
break;
case 'alt':
attrs.push('alt="' + codeq.escapeHtml(token.value) + '"');
break;
case 'class':
attrs.push('class="' + token.value + '"');
break;
}
}
code.push('_result.push("");\n');
}
};
var makeActivityHandler = function (editor, problem_id) {
var lastActivityMillis = Date.now(),
deltaActivityMillis = function deltaActivityMillisFunc () {
var now = Date.now(),
dt = now - lastActivityMillis;
lastActivityMillis = now;
return dt;
},
queue = [],
ts = null,
timer = function () {
var promise;
ts = null;
if (queue.length === 0) return Q(true);
promise = codeq.comms.sendActivity(queue, editor.getDoc().getValue(), problem_id);
queue.length = 0;
return promise;
},
flush = function () {
clearTimeout(ts);
return timer();
};
return {
'queueTrace': function (trace) {
trace['dt'] = deltaActivityMillis();
queue.push(trace);
if (ts === null) ts = setTimeout(timer, 10000); // flush every 10 seconds
return this;
},
'flush': flush,
'addAndPurge': function (trace) {
var accumulatedTrace = queue;
queue = [];
trace['dt'] = deltaActivityMillis();
accumulatedTrace.push(trace);
if (ts !== null) {
clearTimeout(ts);
ts = null;
}
return accumulatedTrace;
}
};
};
window.codeq = {
'jsonize': jsonize,
'log': log,
'samlLogin' : false,//falg if we logged in via saml
'settings': {
'gui_lang': 'en', // boot sequence overrides this if the browser uses a supported language
'gui_layout': 'line-layout'
},
'availableLangs': [], // filled at boot from 'supportedLangs'
'supportedLangs': {
'en': 'English',
'sl': 'Slovenščina'
},
'supportedLayouts':[
'line-layout',
'square-layout'
],
'runningOnPhone': false, // default value, will be changed if detected otherwise
'browserType': 'unknown',
'platformType': 'unknown',
'isWebApp': false, // this is a PhoneGap/Cordova build, will be overridden in cordova.js for webapp
'setLayout': function(newLayout){
codeq.settings['gui_layout'] = newLayout;
codeq.fire('layoutchange');
},
'getLayout': function(){
return codeq.settings['gui_layout'];
},
'setLang': function (newLang) {
codeq.settings['gui_lang'] = newLang;
codeq.fire('langchange', {'lang': newLang});
},
'getLang': function(){
return codeq.settings['gui_lang'];
},
'setResources': function (newResources) {
resources = newResources;
},
'escapeHtml': function (s) {
return ('' + s).replace(regexpAmp, '&').replace(regexpLt, '<').replace(regexpGt, '>');
},
/**
* Returns the number of Unicode code points in the given string.
*
* @param s {string}
* @returns {number}
*/
'codePointCount': function (s) {
var n = 0, i, code;
if (typeof s !== 'string') {
code = 'codePointCount(): argument not a string: type = ' + typeof s + ', is null = ' + (s === null);
if ((typeof s === 'object') && (s !== null) && s.constructor) code += ', constructor = ' + s.constructor.name;
codeq.log.error(code);
return 0;
}
for (i = 0; i < s.length; i++) {
try {
code = s.charCodeAt(i);
}
catch (e) {
codeq.log.error('Invocation of charCodeAt() failed at iteration #' + i + ': ' + e, e);
return 0;
}
if ((code >= 0xd800) && (code < 0xe000)) i++;
n++;
}
return n;
},
'makeActivityHandler': makeActivityHandler,
'wait': function (promise) {
jqDisabled.css(waitCssEnter);
return promise.fin(function () {
jqDisabled.css('display', 'none');
});
},
'templator': function (str, templatePath, templateName) {
var f, parts, i, subparts, subpart,
src = [ 'var _result = [], echo = function (s) { _result.push(s); };\n' ],
atoms, j, atom, debugPrefix, s, r, tokens, handler;
if (!templateName) templateName = 'unknown template';
debugPrefix = '[' + templateName + ']';
if (!templatePath) templatePath = [];
// remove comments
parts = str.split('[%--'); // break on start-of-comment
atoms = [ parts[0] ]; // first part is not a comment
for (i = 1; i < parts.length; i++) { // iterate over start-of-comments
atom = parts[i].split('--%]'); // break on end-of-comment
if (atom.length > 1) { // if end-of-comment was encountered
atoms.push(atom[1]); // add whatever is trailing it
for (j = 2; j < atom.length; j++) { // re-add even dangling end-of-comments
atoms.push('--%]');
atoms.push(atom[j]);
}
}
}
// start processing
parts = atoms.join('').split('[%');
if (parts[0].length > 0) src.push('_result.push("', stringToDef(parts[0]), '");\n'); // the first part that doesn't begin with '[%'
for (i = 1; i < parts.length; i++) { // for every part that begins with '[%'
if (parts[i].slice(0, 2) === '--') { // a comment start
subparts = parts[i].split('--%]'); // split at comment end
}
else { // a start of a statement or of a value reference
subparts = parts[i].split('%]'); // there should be only one terminating '%]', find it
subpart = subparts[0].replace(regexpWhiteSpaceStart, '').replace(regexpWhiteSpaceEnd, ''); // trim the white space
if (subpart.length > 0) {
if (subpart[0] === '=') { // a value reference
s = subpart.slice(1);
r = regexpWhiteSpaceTrim.exec(s);
r = r && r[1] || s;
src.push('_result.push(typeof this.', r, ' === \'undefined\' ? \'', r, ' missing\' : this.', r, ');\n');
}
else if (subpart[0] === '@') { // a directive
tokens = tokenize(subpart.slice(1));
if (tokens.length === 0) {
codeq.log.error('An empty directive in ' + templateName);
}
else {
handler = directiveHandlers[tokens[0]];
if (!handler) {
codeq.log.error('An unknown directive in ' + templateName + ': ' + tokens[0]);
}
else {
handler(src, tokens, templatePath);
}
}
}
else { // javascript statement(s)
src.push(subpart, '\n');
}
}
}
if ((subparts.length > 1) && (subparts[1].length > 0)) { // there's a trailing text
src.push('_result.push("', stringToDef(subparts[1]), '");\n');
}
}
src.push('return _result.join("");');
// if (_internalDoLog_) codeq.log.debug('createTemplate(): ' + debugPrefix + ' creating templating function:\nfunction(_components_) {\n' + src.join('') + '\n}\n=== Created from template: ===\n' + str);
try {
f = new Function(src.join('')); // create a function based on the given template
}
catch (e) {
codeq.log.error('createTemplate(): ' + debugPrefix + ' Failed to instantiate template function: ' + e + '\nfunction() {\n' + src.join('') + '\n}\n=== Created from template: ===\n' + str, e);
throw e;
}
return function (args) {
var esc = codeq.escapeHtml,
escArgs = {},
key;
if ((typeof args === 'object') && (args !== null)) {
for (key in args) {
if (!args.hasOwnProperty(key)) continue;
escArgs[key] = esc(args[key]);
}
}
try {
return f.apply(escArgs);
}
catch (e) {
codeq.log.error('Error evaluating template ' + templateName + ' function: ' + e + '\nfunction() {\n' + src.join('') + '\nCreated from template:\n' + str, e);
throw e;
}
};
},
// codeq event handling
'fire': function (eventName, args) {
queuedEvents.push({'name': eventName, 'args': args});
fireEvents();
},
'on': function (eventName, callback) {
var listeners = eventListeners[eventName],
i;
if (listeners) {
for (i = listeners.length - 1; i >= 0; i--) {
if (listeners[i] === callback) return; // already registered
}
}
else {
listeners = [];
eventListeners[eventName] = listeners;
}
listeners.push(callback);
},
'off': function (eventName, callback) {
var listeners = eventListeners[eventName],
i;
if (listeners) {
for (i = listeners.length - 1; i >= 0; i--) {
if (listeners[i] === callback) listeners.splice(i, 1);
}
}
},
// reset the app
'reset': function (reason) {
codeq.log.info('App reset: ' + (reason || 'no reason given'));
codeq.globalStateMachine.transition('login');
codeq.wait(
codeq.comms.getSid() == null ? Q() : codeq.comms.logout()
.finally(
codeq.comms.disconnect
)
.fail(function (e) {
codeq.log.debug(e)
}) // ignore errors
)
.then(function () {
if (reason) {
alert(reason);
}
})
.done();
}
};
})();