diff --git a/h5p-development.class.php b/h5p-development.class.php index 491c456..1eb793f 100644 --- a/h5p-development.class.php +++ b/h5p-development.class.php @@ -9,7 +9,7 @@ class H5PDevelopment { const MODE_CONTENT = 1; const MODE_LIBRARY = 2; - private $h5pF, $libraries, $language; + private $h5pF, $libraries, $language, $filesPath; /** * Constructor. @@ -23,6 +23,7 @@ class H5PDevelopment { public function __construct($H5PFramework, $filesPath, $language, $libraries = NULL) { $this->h5pF = $H5PFramework; $this->language = $language; + $this->filesPath = $filesPath; if ($libraries !== NULL) { $this->libraries = $libraries; } @@ -86,7 +87,7 @@ class H5PDevelopment { $library['libraryId'] = $this->h5pF->getLibraryId($library['machineName'], $library['majorVersion'], $library['minorVersion']); $this->h5pF->saveLibraryData($library, $library['libraryId'] === FALSE); - $library['path'] = $path . '/' . $contents[$i]; + $library['path'] = 'development/' . $contents[$i]; $this->libraries[H5PDevelopment::libraryToString($library['machineName'], $library['majorVersion'], $library['minorVersion'])] = $library; } @@ -142,8 +143,7 @@ class H5PDevelopment { if (isset($this->libraries[$library]) === FALSE) { return NULL; } - - return $this->getFileContents($this->libraries[$library]['path'] . '/semantics.json'); + return $this->getFileContents($this->filesPath . $this->libraries[$library]['path'] . '/semantics.json'); } /** @@ -161,7 +161,7 @@ class H5PDevelopment { return NULL; } - return $this->getFileContents($this->libraries[$library]['path'] . '/language/' . $language . '.json'); + return $this->getFileContents($this->filesPath . $this->libraries[$library]['path'] . '/language/' . $language . '.json'); } /** diff --git a/h5p.classes.php b/h5p.classes.php index 1bef34d..9a70cc2 100644 --- a/h5p.classes.php +++ b/h5p.classes.php @@ -247,6 +247,13 @@ interface H5PFrameworkInterface { */ public function updateContent($content, $contentMainId = NULL); + /** + * Resets marked user data for the given content. + * + * @param int $contentId + */ + public function resetContentUserData($contentId); + /** * Save what libraries a library is dependending on * @@ -1599,7 +1606,7 @@ class H5PCore { public static $coreApi = array( 'majorVersion' => 1, - 'minorVersion' => 4 + 'minorVersion' => 5 ); public static $styles = array( 'styles/h5p.css', @@ -1644,7 +1651,7 @@ class H5PCore { $this->development_mode = $development_mode; if ($development_mode & H5PDevelopment::MODE_LIBRARY) { - $this->h5pD = new H5PDevelopment($this->h5pF, $path, $language); + $this->h5pD = new H5PDevelopment($this->h5pF, $path . '/', $language); } } @@ -1662,6 +1669,9 @@ class H5PCore { $content['id'] = $this->h5pF->insertContent($content, $contentMainId); } + // Some user data for content has to be reset when the content changes. + $this->h5pF->resetContentUserData($contentMainId ? $contentMainId : $content['id']); + return $content['id']; } @@ -1787,7 +1797,7 @@ class H5PCore { } foreach ($dependency[$type] as $file) { $assets[] = (object) array( - 'path' => /*$prefix .*/ $dependency['path'] . '/' . trim(is_array($file) ? $file['path'] : $file), + 'path' => $prefix . '/' . $dependency['path'] . '/' . trim(is_array($file) ? $file['path'] : $file), 'version' => $dependency['version'] ); } @@ -1835,7 +1845,7 @@ class H5PCore { ); foreach ($dependencies as $dependency) { if (isset($dependency['path']) === FALSE) { - $dependency['path'] = '/libraries/' . H5PCore::libraryToString($dependency, TRUE); + $dependency['path'] = 'libraries/' . H5PCore::libraryToString($dependency, TRUE); $dependency['preloadedJs'] = explode(',', $dependency['preloadedJs']); $dependency['preloadedCss'] = explode(',', $dependency['preloadedCss']); } @@ -2739,13 +2749,13 @@ class H5PContentValidator { 'type' => 'group', 'fields' => $library['semantics'], ), FALSE); - $validkeys = array('library', 'params', 'uuid'); + $validkeys = array('library', 'params', 'subContentId'); if (isset($semantics->extraAttributes)) { $validkeys = array_merge($validkeys, $semantics->extraAttributes); } $this->filterParams($value, $validkeys); - if (isset($value->uuid) && ! preg_match('/^\{?[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\}?$/', $value->uuid)) { - unset($value->uuid); + if (isset($value->subContentId) && ! preg_match('/^\{?[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\}?$/', $value->subContentId)) { + unset($value->subContentId); } // Find all dependencies for this library diff --git a/js/h5p-event-dispatcher.js b/js/h5p-event-dispatcher.js index d67ee10..1d52ef7 100644 --- a/js/h5p-event-dispatcher.js +++ b/js/h5p-event-dispatcher.js @@ -9,18 +9,55 @@ H5P.Event = function(type, data, extras) { this.type = type; this.data = data; var bubbles = false; + + // Is this an external event? + var external = false; + + // Is this event scheduled to be sent externally? + var scheduledForExternal = false; + if (extras === undefined) { extras = {}; } if (extras.bubbles === true) { bubbles = true; } + if (extras.external === true) { + external = true; + } + + /** + * Prevent this event from bubbling up to parent + * + * @returns {undefined} + */ this.preventBubbling = function() { bubbles = false; }; + + /** + * Get bubbling status + * + * @returns {Boolean} - true if bubbling false otherwise + */ this.getBubbles = function() { return bubbles; }; + + /** + * Try to schedule an event for externalDispatcher + * + * @returns {Boolean} + * - true if external and not already scheduled + * - false otherwise + */ + this.scheduleForExternal = function() { + if (external && !scheduledForExternal) { + scheduledForExternal = true; + return true; + } + return false; + }; }; H5P.EventDispatcher = (function () { @@ -144,27 +181,42 @@ H5P.EventDispatcher = (function () { * Custom event data(used when event type as string is used as first * argument */ - this.trigger = function (event, eventData) { + this.trigger = function (event, eventData, extras) { if (event === undefined) { return; } if (typeof event === 'string') { - event = new H5P.Event(event, eventData); + event = new H5P.Event(event, eventData, extras); } else if (eventData !== undefined) { event.data = eventData; } - if (triggers[event.type] === undefined) { - return; + + // Check to see if this event should go externally after all triggering and bubbling is done + var scheduledForExternal = event.scheduleForExternal(); + + if (triggers[event.type] !== undefined) { + // Call all listeners + for (var i = 0; i < triggers[event.type].length; i++) { + triggers[event.type][i].listener.call(triggers[event.type][i].thisArg, event); + } } - // Call all listeners - for (var i = 0; i < triggers[event.type].length; i++) { - triggers[event.type][i].listener.call(triggers[event.type][i].thisArg, event); + + if (triggers['*'] !== undefined) { + // Call all * listeners + for (var i = 0; i < triggers['*'].length; i++) { + triggers['*'][i].listener.call(triggers['*'][i].thisArg, event); + } } + // Bubble if (event.getBubbles() && self.parent instanceof H5P.EventDispatcher && typeof self.parent.trigger === 'function') { self.parent.trigger(event); - } + } + + if (scheduledForExternal) { + H5P.externalDispatcher.trigger(event); + } }; } diff --git a/js/h5p-x-api-event.js b/js/h5p-x-api-event.js index 9b30675..a8513a4 100644 --- a/js/h5p-x-api-event.js +++ b/js/h5p-x-api-event.js @@ -6,7 +6,7 @@ var H5P = H5P || {}; * @class */ H5P.XAPIEvent = function() { - H5P.Event.call(this, 'xAPI', {'statement': {}}, {bubbles: true}); + H5P.Event.call(this, 'xAPI', {'statement': {}}, {bubbles: true, external: true}); }; H5P.XAPIEvent.prototype = Object.create(H5P.Event.prototype); @@ -87,8 +87,8 @@ H5P.XAPIEvent.prototype.setObject = function(instance) { } } }; - if (instance.uuid) { - this.data.statement.object.definition.extensions['http://h5p.org/x-api/h5p-uuid'] = instance.uuid; + if (instance.subContentId) { + this.data.statement.object.definition.extensions['http://h5p.org/x-api/h5p-subContentId'] = instance.subContentId; // Don't set titles on main content, title should come from publishing platform if (typeof instance.getH5PTitle === 'function') { this.data.statement.object.definition.name = { @@ -112,8 +112,8 @@ H5P.XAPIEvent.prototype.setObject = function(instance) { * @param {object} instance - the H5P instance */ H5P.XAPIEvent.prototype.setContext = function(instance) { - if (instance.parent && (instance.parent.contentId || instance.parent.uuid)) { - var parentId = instance.parent.uuid === undefined ? instance.parent.contentId : instance.parent.uuid; + if (instance.parent && (instance.parent.contentId || instance.parent.subContentId)) { + var parentId = instance.parent.subContentId === undefined ? instance.parent.contentId : instance.parent.subContentId; this.data.statement.context = { "contextActivities": { "parent": [ @@ -179,8 +179,8 @@ H5P.XAPIEvent.prototype.getContentXAPIId = function (instance) { var xAPIId; if (instance.contentId && H5PIntegration && H5PIntegration.contents) { xAPIId = H5PIntegration.contents['cid-' + instance.contentId].url; - if (instance.uuid) { - xAPIId += '?uuid=' + instance.uuid; + if (instance.subContentId) { + xAPIId += '?subContentId=' + instance.subContentId; } } return xAPIId; diff --git a/js/h5p-x-api.js b/js/h5p-x-api.js index 9d0e7cd..fa5e51a 100644 --- a/js/h5p-x-api.js +++ b/js/h5p-x-api.js @@ -4,7 +4,7 @@ var H5P = H5P || {}; H5P.externalDispatcher = new H5P.EventDispatcher(); if (H5P.isFramed && H5P.externalEmbed !== true) { - H5P.externalDispatcher.on('xAPI', window.top.H5P.externalDispatcher.trigger); + H5P.externalDispatcher.on('*', window.top.H5P.externalDispatcher.trigger); } // EventDispatcher extensions diff --git a/js/h5p.js b/js/h5p.js index 3425a03..c359c1b 100644 --- a/js/h5p.js +++ b/js/h5p.js @@ -62,6 +62,29 @@ H5P.init = function (target) { params: JSON.parse(contentData.jsonContent) }; + H5P.getUserData(contentId, 'state', function (err, previousState) { + if (previousState) { + library.userDatas = { + state: previousState + }; + } + else if (previousState === null) { + // Content has been reset. Display dialog. + delete contentData.contentUserData; + var dialog = new H5P.Dialog('content-user-data-reset', 'Data Reset', '

' + H5P.t('contentChanged') + '

' + H5P.t('startingOver') + '

OK
', $container); + H5P.jQuery(dialog).on('dialog-opened', function (event, $dialog) { + $dialog.find('.h5p-dialog-ok-button').click(function () { + dialog.close(); + }).keypress(function (event) { + if (event.which === 32) { + dialog.close(); + } + }); + }); + dialog.open(); + } + }); + // Create new instance. var instance = H5P.newRunnable(library, contentId, $container, true); @@ -111,7 +134,37 @@ H5P.init = function (target) { // Listen for xAPI events. H5P.on(instance, 'xAPI', H5P.xAPICompletedListener); - H5P.on(instance, 'xAPI', H5P.externalDispatcher.trigger); + + // Auto save current state if supported + if (H5PIntegration.saveFreq !== false && ( + instance.getCurrentState instanceof Function || + typeof instance.getCurrentState === 'function')) { + + var saveTimer, save = function () { + var state = instance.getCurrentState(); + if (state !== undefined) { + H5P.setUserData(contentId, 'state', state, undefined, true, true); + } + if (H5PIntegration.saveFreq) { + // Continue autosave + saveTimer = setTimeout(save, H5PIntegration.saveFreq * 1000); + } + }; + + if (H5PIntegration.saveFreq) { + // Start autosave + saveTimer = setTimeout(save, H5PIntegration.saveFreq * 1000); + } + + // xAPI events will schedule a save in three seconds. + H5P.on(instance, 'xAPI', function (event) { + var verb = event.getVerb(); + if (verb === 'completed' || verb === 'progressed') { + clearTimeout(saveTimer); + saveTimer = setTimeout(save, 3000); + } + }); + } if (H5P.isFramed) { var resizeDelay; @@ -548,9 +601,10 @@ H5P.classFromName = function (name) { * @return {Object} Instance. */ H5P.newRunnable = function (library, contentId, $attachTo, skipResize, extras) { - var nameSplit, versionSplit; + var nameSplit, versionSplit, machineName; try { nameSplit = library.library.split(' ', 2); + machineName = nameSplit[0]; versionSplit = nameSplit[1].split('.', 2); } catch (err) { @@ -581,16 +635,23 @@ H5P.newRunnable = function (library, contentId, $attachTo, skipResize, extras) { if (extras === undefined) { extras = {}; } - if (library.uuid) { - extras.uuid = library.uuid; + if (library.subContentId) { + extras.subContentId = library.subContentId; } - - // Some old library versions have their own custom third parameter. Make sure we don't send them the extras. They'll interpret it as something else + + if (library.userDatas && library.userDatas.state) { + extras.previousState = library.userDatas.state; + } + + var instance; + // Some old library versions have their own custom third parameter. + // Make sure we don't send them the extras. + // (they will interpret it as something else) if (H5P.jQuery.inArray(library.library, ['H5P.CoursePresentation 1.0', 'H5P.CoursePresentation 1.1', 'H5P.CoursePresentation 1.2', 'H5P.CoursePresentation 1.3']) > -1) { - var instance = new constructor(library.params, contentId); + instance = new constructor(library.params, contentId); } else { - var instance = new constructor(library.params, contentId, extras); + instance = new constructor(library.params, contentId, extras); } if (instance.$ === undefined) { @@ -600,8 +661,8 @@ H5P.newRunnable = function (library, contentId, $attachTo, skipResize, extras) { if (instance.contentId === undefined) { instance.contentId = contentId; } - if (instance.uuid === undefined && library.uuid) { - instance.uuid = library.uuid; + if (instance.subContentId === undefined && library.subContentId) { + instance.subContentId = library.subContentId; } if (instance.parent === undefined && extras && extras.parent) { instance.parent = extras.parent; @@ -609,6 +670,11 @@ H5P.newRunnable = function (library, contentId, $attachTo, skipResize, extras) { if ($attachTo !== undefined) { instance.attach($attachTo); + H5P.trigger(instance, 'domChanged', { + '$target': $attachTo, + 'library': machineName, + 'key': 'newLibrary' + }, {'bubbles': true, 'external': true}); if (skipResize === undefined || !skipResize) { // Resize content. @@ -1378,10 +1444,10 @@ if (String.prototype.trim === undefined) { * @param {string} eventType * The event type */ -H5P.trigger = function(instance, eventType) { +H5P.trigger = function(instance, eventType, data, extras) { // Try new event system first if (instance.trigger !== undefined) { - instance.trigger(eventType); + instance.trigger(eventType, data, extras); } // Try deprecated event system else if (instance.$ !== undefined && instance.$.trigger !== undefined) { @@ -1415,7 +1481,7 @@ H5P.on = function(instance, eventType, handler) { /** * Create UUID - * + * * @returns {String} UUID */ H5P.createUUID = function() { @@ -1441,9 +1507,209 @@ H5P.createH5PTitle = function(rawTitle, maxLength) { return title; }; -H5P.jQuery(document).ready(function () { - if (!H5P.preventInit) { - // Start script need to be an external resource to load in correct order for IE9. - H5P.init(document.body); +// Wrap in privates +(function ($) { + + /** + * Creates ajax requests for inserting, updateing and deleteing + * content user data. + * + * @private + * @param {number} contentId What content to store the data for. + * @param {string} dataType Identifies the set of data for this content. + * @param {string} subContentId Identifies sub content + * @param {function} [done] Callback when ajax is done. + * @param {object} [data] To be stored for future use. + * @param {boolean} [preload=false] Data is loaded when content is loaded. + * @param {boolean} [invalidate=false] Data is invalidated when content changes. + * @param {boolean} [async=true] + */ + function contentUserDataAjax(contentId, dataType, subContentId, done, data, preload, invalidate, async) { + var options = { + url: H5PIntegration.ajaxPath + 'content-user-data/' + contentId + '/' + dataType + '/' + (subContentId ? subContentId : 0), + dataType: 'json', + async: async === undefined ? true : async + }; + if (data !== undefined) { + options.type = 'POST'; + options.data = { + data: (data === null ? 0 : data), + preload: (preload ? 1 : 0), + invalidate: (invalidate ? 1 : 0) + }; + } + else { + options.type = 'GET'; + } + if (done !== undefined) { + options.error = function (xhr, error) { + done(error); + }; + options.success = function (response) { + if (!response.success) { + done(response.error); + return; + } + + if (response.data === false || response.data === undefined) { + done(); + return; + } + + done(undefined, response.data); + }; + } + + $.ajax(options); } -}); + + /** + * Get user data for given content. + * + * @public + * @param {number} contentId What content to get data for. + * @param {string} dataId Identifies the set of data for this content. + * @param {function} done Callback with error and data parameters. + * @param {string} [subContentId] Identifies which data belongs to sub content. + */ + H5P.getUserData = function (contentId, dataId, done, subContentId) { + if (!subContentId) { + subContentId = 0; // Default + } + + var content = H5PIntegration.contents['cid-' + contentId]; + var preloadedData = content.contentUserData; + if (preloadedData && preloadedData[subContentId] && preloadedData[subContentId][dataId]) { + if (preloadedData[subContentId][dataId] === 'RESET') { + done(undefined, null); + return; + } + try { + done(undefined, JSON.parse(preloadedData[subContentId][dataId])); + } + catch (err) { + done(err); + } + } + else { + contentUserDataAjax(contentId, dataId, subContentId, function (err, data) { + if (err || data === undefined) { + done(err, data); + return; // Error or no data + } + + // Cache in preloaded + if (content.contentUserData === undefined) { + content.contentUserData = preloaded = {}; + } + if (preloadedData[subContentId] === undefined) { + preloadedData[subContentId] = {}; + } + preloadedData[subContentId][dataId] = data; + + // Done. Try to decode JSON + try { + done(undefined, JSON.parse(data)); + } + catch (e) { + done(e); + } + }); + } + }; + + /** + * Set user data for given content. + * + * @public + * @param {number} contentId What content to get data for. + * @param {string} dataId Identifies the set of data for this content. + * @param {object} data The data that is to be stored. + * @param {string} [subContentId] Identifies which data belongs to sub content. + * @param {boolean} [preloaded=false] If the data should be loaded when content is loaded. + * @param {boolean} [deleteOnChange=false] If the data should be invalidated when the content changes. + * @param {function} [errorCallback] Callback with error as parameters. + */ + H5P.setUserData = function (contentId, dataId, data, subContentId, preloaded, deleteOnChange, errorCallback, async) { + if (!subContentId) { + subContentId = 0; // Default + } + + try { + data = JSON.stringify(data); + } + catch (err) { + errorCallback(err); + return; // Failed to serialize. + } + + var content = H5PIntegration.contents['cid-' + contentId]; + if (!content.contentUserData) { + content.contentUserData = {}; + } + var preloadedData = content.contentUserData; + if (preloadedData[subContentId] === undefined) { + preloadedData[subContentId] = {}; + } + if (data === preloadedData[subContentId][dataId]) { + return; // No need to save this twice. + } + + preloadedData[subContentId][dataId] = data; + contentUserDataAjax(contentId, dataId, subContentId, function (error, data) { + if (errorCallback && error) { + errorCallback(error); + } + }, data, preloaded, deleteOnChange, async); + }; + + /** + * Delete user data for given content. + * + * @public + * @param {number} contentId What content to remove data for. + * @param {string} dataId Identifies the set of data for this content. + * @param {string} [subContentId] Identifies which data belongs to sub content. + */ + H5P.deleteUserData = function (contentId, dataId, subContentId) { + if (!subContentId) { + subContentId = 0; // Default + } + + // Remove from preloaded/cache + var preloadedData = H5PIntegration.contents['cid-' + contentId].contentUserData; + if (preloadedData && preloadedData[subContentId] && preloadedData[subContentId][dataId]) { + delete preloadedData[subContentId][dataId]; + } + + contentUserDataAjax(contentId, dataId, subContentId, undefined, null); + }; + + // Init H5P when page is fully loadded + $(document).ready(function () { + if (!H5P.preventInit) { + // Note that this start script has to be an external resource for it to + // load in correct order in IE9. + H5P.init(document.body); + } + + if (H5PIntegration.saveFreq !== false) { + // Store the current state of the H5P when leaving the page. + H5P.$window.on('beforeunload', function () { + for (var i = 0; i < H5P.instances.length; i++) { + var instance = H5P.instances[i]; + if (instance.getCurrentState instanceof Function || + typeof instance.getCurrentState === 'function') { + var state = instance.getCurrentState(); + if (state !== undefined) { + // Async is not used to prevent the request from being cancelled. + H5P.setUserData(instance.contentId, 'state', state, undefined, true, true, undefined, false); + + } + } + } + }); + } + }); + +})(H5P.jQuery);