Skip to content

Commit

Permalink
MDL-67789 h5p: Save current state using xAPI State
Browse files Browse the repository at this point in the history
Co-author: Andrew Lyons <andrew@moodle.com>
  • Loading branch information
sarjona committed Mar 15, 2023
1 parent 03a4abd commit bd0a6e6
Show file tree
Hide file tree
Showing 25 changed files with 1,292 additions and 182 deletions.
10 changes: 10 additions & 0 deletions h5p/amd/build/repository.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions h5p/amd/build/repository.min.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

99 changes: 99 additions & 0 deletions h5p/amd/src/repository.js
@@ -0,0 +1,99 @@
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.

/**
* Module to handle AJAX interactions.
*
* @module core_h5p/repository
* @copyright 2023 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {call as fetchMany} from 'core/ajax';
import * as config from 'core/config';

/**
* Send a xAPI statement to LMS.
*
* @param {string} component
* @param {Object} statements
* @returns {Promise}
*/
export const postStatement = (component, statements) => fetchMany([{
methodname: 'core_xapi_statement_post',
args: {
component,
requestjson: JSON.stringify(statements),
}
}])[0];

/**
* Send a xAPI state to LMS.
*
* @param {string} component
* @param {string} activityId
* @param {Object} agent
* @param {string} stateId
* @param {string} stateData
*/
export const postState = (
component,
activityId,
agent,
stateId,
stateData,
) => {
// Please note that we must use a Beacon send here.
// The XHR is not guaranteed because it will be aborted on page transition.
// https://developer.mozilla.org/en-US/docs/Web/API/Beacon_API
// Note: Moodle does not currently have a sendBeacon API endpoint.
const requestUrl = new URL(`${config.wwwroot}/lib/ajax/service.php`);
requestUrl.searchParams.set('sesskey', config.sesskey);

navigator.sendBeacon(requestUrl, JSON.stringify([{
index: 0,
methodname: 'core_xapi_post_state',
args: {
component,
activityId,
agent: JSON.stringify(agent),
stateId,
stateData,
}
}]));
};

/**
* Delete a xAPI state from LMS.
*
* @param {string} component
* @param {string} activityId
* @param {Object} agent
* @param {string} stateId
* @returns {Promise}
*/
export const deleteState = (
component,
activityId,
agent,
stateId,
) => fetchMany([{
methodname: 'core_xapi_delete_state',
args: {
component,
activityId,
agent: JSON.stringify(agent),
stateId,
},
}])[0];
51 changes: 39 additions & 12 deletions h5p/classes/framework.php
Expand Up @@ -16,6 +16,8 @@

namespace core_h5p;

use core_xapi\handler;
use core_xapi\xapi_exception;
use Moodle\H5PFrameworkInterface;
use Moodle\H5PCore;

Expand Down Expand Up @@ -886,14 +888,6 @@ public function insertContent($content, $contentmainid = null) {
public function updateContent($content, $contentmainid = null) {
global $DB;

if (!isset($content['pathnamehash'])) {
$content['pathnamehash'] = '';
}

if (!isset($content['contenthash'])) {
$content['contenthash'] = '';
}

// If the libraryid declared in the package is empty, get the latest version.
if (empty($content['library']['libraryId'])) {
$mainlibrary = $this->get_latest_library_version($content['library']['machineName']);
Expand All @@ -919,11 +913,19 @@ public function updateContent($content, $contentmainid = null) {
'mainlibraryid' => $content['library']['libraryId'],
'timemodified' => time(),
'filtered' => null,
'pathnamehash' => $content['pathnamehash'],
'contenthash' => $content['contenthash']
];

if (isset($content['pathnamehash'])) {
$data['pathnamehash'] = $content['pathnamehash'];
}

if (isset($content['contenthash'])) {
$data['contenthash'] = $content['contenthash'];
}

if (!isset($content['id'])) {
$data['pathnamehash'] = $data['pathnamehash'] ?? '';
$data['contenthash'] = $data['contenthash'] ?? '';
$data['timecreated'] = $data['timemodified'];
$id = $DB->insert_record('h5p', $data);
} else {
Expand All @@ -941,7 +943,28 @@ public function updateContent($content, $contentmainid = null) {
* @param int $contentid The h5p content id
*/
public function resetContentUserData($contentid) {
// Currently, we do not store user data for a content.
global $DB;

// Get the component associated to the H5P content to reset.
$h5p = $DB->get_record('h5p', ['id' => $contentid]);
if (!$h5p) {
return;
}

$fs = get_file_storage();
$file = $fs->get_file_by_hash($h5p->pathnamehash);
if (!$file) {
return;
}

// Reset user data.
try {
$xapihandler = handler::create($file->get_component());
$xapihandler->reset_states($file->get_contextid());
} catch (xapi_exception $exception) {
// This component doesn't support xAPI State, so no content needs to be reset.
return;
}
}

/**
Expand Down Expand Up @@ -998,8 +1021,12 @@ public function copyLibraryUsage($contentid, $copyfromid, $contentmainid = null)
public function deleteContentData($contentid) {
global $DB;

// The user content should be reset (instead of removed), because this method is called when H5P content needs
// to be updated too (and the previous states must be kept, but reset).
$this->resetContentUserData($contentid);

// Remove content.
$DB->delete_records('h5p', array('id' => $contentid));
$DB->delete_records('h5p', ['id' => $contentid]);

// Remove content library dependencies.
$this->deleteLibraryUsage($contentid);
Expand Down
25 changes: 16 additions & 9 deletions h5p/classes/helper.php
Expand Up @@ -314,18 +314,20 @@ public static function get_cache_buster(): string {
/**
* Get the settings needed by the H5P library.
*
* @param string|null $component
* @return array The settings.
*/
public static function get_core_settings(): array {
public static function get_core_settings(?string $component = null): array {
global $CFG, $USER;

$basepath = $CFG->wwwroot . '/';
$systemcontext = context_system::instance();

// Generate AJAX paths.
$ajaxpaths = [];
$ajaxpaths['xAPIResult'] = '';
$ajaxpaths['contentUserData'] = '';
// H5P doesn't currently support xAPI State. It implements a mechanism in contentUserDataAjax() in h5p.js to update user
// data. However, in our case, we're overriding this method to call the xAPI State web services.
$ajaxpaths = [
'contentUserData' => '',
];

$factory = new factory();
$core = $factory->get_core();
Expand All @@ -336,13 +338,17 @@ public static function get_core_settings(): array {
$usersettings['name'] = $USER->username;
$usersettings['id'] = $USER->id;
}
$savefreq = false;
if ($component !== null && get_config($component, 'enablesavestate')) {
$savefreq = get_config($component, 'savestatefreq');
}
$settings = array(
'baseUrl' => $basepath,
'url' => "{$basepath}pluginfile.php/{$systemcontext->instanceid}/core_h5p",
'urlLibraries' => "{$basepath}pluginfile.php/{$systemcontext->id}/core_h5p/libraries",
'postUserStatistics' => false,
'ajax' => $ajaxpaths,
'saveFreq' => false,
'saveFreq' => $savefreq,
'siteUrl' => $CFG->wwwroot,
'l10n' => array('H5P' => $core->getLocalization()),
'user' => $usersettings,
Expand All @@ -360,13 +366,14 @@ public static function get_core_settings(): array {
/**
* Get the core H5P assets, including all core H5P JavaScript and CSS.
*
* @param string|null $component
* @return Array core H5P assets.
*/
public static function get_core_assets(): array {
global $CFG, $PAGE;
public static function get_core_assets(?string $component = null): array {
global $PAGE;

// Get core settings.
$settings = self::get_core_settings();
$settings = self::get_core_settings($component);
$settings['core'] = [
'styles' => [],
'scripts' => []
Expand Down
69 changes: 65 additions & 4 deletions h5p/classes/player.php
Expand Up @@ -27,7 +27,11 @@
defined('MOODLE_INTERNAL') || die();

use core_h5p\local\library\autoloader;
use core_xapi\handler;
use core_xapi\local\state;
use core_xapi\local\statement\item_activity;
use core_xapi\local\statement\item_agent;
use core_xapi\xapi_exception;

/**
* H5P player class, for displaying any local H5P content.
Expand Down Expand Up @@ -102,7 +106,7 @@ class player {
* Inits the H5P player for rendering the content.
*
* @param string $url Local URL of the H5P file to display.
* @param stdClass $config Configuration for H5P buttons.
* @param \stdClass $config Configuration for H5P buttons.
* @param bool $preventredirect Set to true in scripts that can not redirect (CLI, RSS feeds, etc.), throws exceptions
* @param string $component optional moodle component to sent xAPI tracking
* @param bool $skipcapcheck Whether capabilities should be checked or not to get the pluginfile URL because sometimes they
Expand Down Expand Up @@ -207,7 +211,7 @@ public function get_messages(): \stdClass {
* main H5P config variable.
*/
public function add_assets_to_page() {
global $PAGE;
global $PAGE, $USER;

$cid = $this->get_cid();
$systemcontext = \context_system::instance();
Expand All @@ -219,6 +223,7 @@ public function add_assets_to_page() {
\core_h5p\file_storage::CONTENT_FILEAREA, $this->h5pid, null, null);
$exporturl = $this->get_export_settings($displayoptions[ core::DISPLAY_OPTION_DOWNLOAD ]);
$xapiobject = item_activity::create_from_id($this->context->id);

$contentsettings = [
'library' => core::libraryToString($this->content['library']),
'fullScreen' => $this->content['library']['fullscreen'],
Expand All @@ -231,7 +236,7 @@ public function add_assets_to_page() {
'url' => $xapiobject->get_data()->id,
'contentUrl' => $contenturl->out(),
'metadata' => $this->content['metadata'],
'contentUserData' => [0 => ['state' => '{}']]
'contentUserData' => [0 => ['state' => $this->get_state_data($xapiobject)]],
];
// Get the core H5P assets, needed by the H5P classes to render the H5P content.
$settings = $this->get_assets();
Expand All @@ -241,6 +246,62 @@ public function add_assets_to_page() {
$PAGE->requires->data_for_js('H5PIntegration', $settings, true);
}

/**
* Get the stored xAPI state to use as user data.
*
* @param item_activity $xapiobject
* @return string The state data to pass to the player frontend
*/
private function get_state_data(item_activity $xapiobject): string {
global $USER;

// Initialize the H5P content with the saved state (if it's enabled and the user has some stored state).
$emptystatedata = '{}';
$savestate = (bool) get_config($this->component, 'enablesavestate');
if (!$savestate) {
return $emptystatedata;
}

$xapihandler = handler::create($this->component);
if (!$xapihandler) {
return $emptystatedata;
}

// The component implements the xAPI handler, so the state can be loaded.
$state = new state(
item_agent::create_from_user($USER),
$xapiobject,
'state',
null,
null
);
try {
$state = $xapihandler->load_state($state);
if (!$state) {
return $emptystatedata;
}

if (is_null($state->get_state_data())) {
// The state content should be reset because, for instance, the content has changed.
return 'RESET';
}

$statedata = $state->jsonSerialize();
if (is_null($statedata)) {
return $emptystatedata;
}

if (property_exists($statedata, 'h5p')) {
// As the H5P state doesn't always use JSON, we have added this h5p object to jsonize it.
return $statedata->h5p;
}
} catch (xapi_exception $exception) {
return $emptystatedata;
}

return $emptystatedata;
}

/**
* Outputs H5P wrapper HTML.
*
Expand Down Expand Up @@ -371,7 +432,7 @@ private function get_cid(): string {
*/
private function get_assets(): array {
// Get core assets.
$settings = helper::get_core_assets();
$settings = helper::get_core_assets($this->component);
// Added here because in the helper we don't have the h5p content id.
$settings['moodleLibraryPaths'] = $this->core->get_dependency_roots($this->h5pid);
// Add also the Moodle component where the results will be tracked.
Expand Down
5 changes: 5 additions & 0 deletions h5p/h5plib/v124/joubel/core/js/h5p.js
Expand Up @@ -2344,6 +2344,11 @@ H5P.createTitle = function (rawTitle, maxLength) {
done('Not signed in.');
return;
}
// Moodle patch to let override this method.
if (H5P.contentUserDataAjax !== undefined) {
return H5P.contentUserDataAjax(contentId, dataType, subContentId, done, data, preload, invalidate, async);
}
// End of Moodle patch.

var options = {
url: H5PIntegration.ajax.contentUserData.replace(':contentId', contentId).replace(':dataType', dataType).replace(':subContentId', subContentId ? subContentId : 0),
Expand Down

0 comments on commit bd0a6e6

Please sign in to comment.