diff --git a/public/plugin/xapi/.htaccess b/public/plugin/xapi/.htaccess
new file mode 100644
index 00000000000..a3851a941ea
--- /dev/null
+++ b/public/plugin/xapi/.htaccess
@@ -0,0 +1 @@
+AcceptPathInfo On
\ No newline at end of file
diff --git a/public/plugin/xapi/README.md b/public/plugin/xapi/README.md
new file mode 100644
index 00000000000..64a7c88c2f8
--- /dev/null
+++ b/public/plugin/xapi/README.md
@@ -0,0 +1,88 @@
+# Experience API (xAPI)
+
+Allows you to connect to an external Learning Record Store and use activities with the xAPI standard.
+
+> You can import and use TinCan packages.
+> Import CMI5 packages is to be considered a Beta state and still in development.
+
+**Configuration**
+
+Set LRS endpoint, username and password to integrate an external LRS in Chamilo LMS.
+
+The fields "Learning path item viewed", "Learning path ended", "Quiz question answered" and "Quiz ended" allow enabling
+hooks when the user views an item in learning path, completes a learning path, answers a quiz question and ends the exam.
+
+The statements generated with these hooks are logged in Chamilo database, waiting to be sent to the LRS by a cron job.
+The cron job to configure on your server is located in `CHAMILO_PATH/plugin/xapi/cron/send_statements.php`.
+
+**Use the Statement API from Chamilo LMS**
+
+You can use xAPI's "Statement API" to save some statements from another service.
+You need to create credentials (username/password) to do this. First you need to enable the "menu_administrator" region
+in the plugin configuration. You will then be able to create the credentials with the new page "Experience API (xAPI)"
+inside de Plugins block in the Administration panel.
+The endpoint for the statements API is "https://CHAMILO_DOMAIN/plugin/xapi/lrs.php/";
+
+```mysql
+CREATE TABLE xapi_attachment (identifier INT AUTO_INCREMENT NOT NULL, statement_id VARCHAR(255) DEFAULT NULL, usageType VARCHAR(255) NOT NULL, contentType VARCHAR(255) NOT NULL, length INT NOT NULL, sha2 VARCHAR(255) NOT NULL, display LONGTEXT NOT NULL COMMENT '(DC2Type:json)', hasDescription TINYINT(1) NOT NULL, description LONGTEXT DEFAULT NULL COMMENT '(DC2Type:json)', fileUrl VARCHAR(255) DEFAULT NULL, content LONGTEXT DEFAULT NULL, INDEX IDX_7148C9A1849CB65B (statement_id), PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
+CREATE TABLE xapi_object (identifier INT AUTO_INCREMENT NOT NULL, group_id INT DEFAULT NULL, actor_id INT DEFAULT NULL, verb_id INT DEFAULT NULL, object_id INT DEFAULT NULL, type VARCHAR(255) DEFAULT NULL, activityId VARCHAR(255) DEFAULT NULL, hasActivityDefinition TINYINT(1) DEFAULT NULL, hasActivityName TINYINT(1) DEFAULT NULL, activityName LONGTEXT DEFAULT NULL COMMENT '(DC2Type:json)', hasActivityDescription TINYINT(1) DEFAULT NULL, activityDescription LONGTEXT DEFAULT NULL COMMENT '(DC2Type:json)', activityType VARCHAR(255) DEFAULT NULL, activityMoreInfo VARCHAR(255) DEFAULT NULL, mbox VARCHAR(255) DEFAULT NULL, mboxSha1Sum VARCHAR(255) DEFAULT NULL, openId VARCHAR(255) DEFAULT NULL, accountName VARCHAR(255) DEFAULT NULL, accountHomePage VARCHAR(255) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, referenced_statement_id VARCHAR(255) DEFAULT NULL, activityExtensions_id INT DEFAULT NULL, parentContext_id INT DEFAULT NULL, groupingContext_id INT DEFAULT NULL, categoryContext_id INT DEFAULT NULL, otherContext_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_E2B68640303C7F1D (activityExtensions_id), INDEX IDX_E2B68640FE54D947 (group_id), UNIQUE INDEX UNIQ_E2B6864010DAF24A (actor_id), UNIQUE INDEX UNIQ_E2B68640C1D03483 (verb_id), UNIQUE INDEX UNIQ_E2B68640232D562B (object_id), INDEX IDX_E2B68640988A4CEC (parentContext_id), INDEX IDX_E2B686404F542860 (groupingContext_id), INDEX IDX_E2B68640AEA1B132 (categoryContext_id), INDEX IDX_E2B68640B73EEAB7 (otherContext_id), PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
+CREATE TABLE xapi_result (identifier INT AUTO_INCREMENT NOT NULL, extensions_id INT DEFAULT NULL, hasScore TINYINT(1) NOT NULL, scaled DOUBLE PRECISION DEFAULT NULL, raw DOUBLE PRECISION DEFAULT NULL, min DOUBLE PRECISION DEFAULT NULL, max DOUBLE PRECISION DEFAULT NULL, success TINYINT(1) DEFAULT NULL, completion TINYINT(1) DEFAULT NULL, response VARCHAR(255) DEFAULT NULL, duration VARCHAR(255) DEFAULT NULL, UNIQUE INDEX UNIQ_5971ECBFD0A19400 (extensions_id), PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
+CREATE TABLE xapi_verb (identifier INT AUTO_INCREMENT NOT NULL, id VARCHAR(255) NOT NULL, display LONGTEXT NOT NULL COMMENT '(DC2Type:json)', PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
+CREATE TABLE xapi_extensions (identifier INT AUTO_INCREMENT NOT NULL, extensions LONGTEXT NOT NULL COMMENT '(DC2Type:json)', PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
+CREATE TABLE xapi_context (identifier INT AUTO_INCREMENT NOT NULL, instructor_id INT DEFAULT NULL, team_id INT DEFAULT NULL, extensions_id INT DEFAULT NULL, registration VARCHAR(255) DEFAULT NULL, hasContextActivities TINYINT(1) DEFAULT NULL, revision VARCHAR(255) DEFAULT NULL, platform VARCHAR(255) DEFAULT NULL, language VARCHAR(255) DEFAULT NULL, statement VARCHAR(255) DEFAULT NULL, UNIQUE INDEX UNIQ_3D7771908C4FC193 (instructor_id), UNIQUE INDEX UNIQ_3D777190296CD8AE (team_id), UNIQUE INDEX UNIQ_3D777190D0A19400 (extensions_id), PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
+CREATE TABLE xapi_actor (identifier INT AUTO_INCREMENT NOT NULL, type VARCHAR(255) DEFAULT NULL, mbox VARCHAR(255) DEFAULT NULL, mboxSha1Sum VARCHAR(255) DEFAULT NULL, openId VARCHAR(255) DEFAULT NULL, accountName VARCHAR(255) DEFAULT NULL, accountHomePage VARCHAR(255) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, members VARCHAR(255) NOT NULL, PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
+CREATE TABLE xapi_statement (id VARCHAR(255) NOT NULL, actor_id INT DEFAULT NULL, verb_id INT DEFAULT NULL, object_id INT DEFAULT NULL, result_id INT DEFAULT NULL, authority_id INT DEFAULT NULL, context_id INT DEFAULT NULL, created BIGINT DEFAULT NULL, `stored` BIGINT DEFAULT NULL, hasAttachments TINYINT(1) DEFAULT NULL, UNIQUE INDEX UNIQ_BAF6663B10DAF24A (actor_id), UNIQUE INDEX UNIQ_BAF6663BC1D03483 (verb_id), UNIQUE INDEX UNIQ_BAF6663B232D562B (object_id), UNIQUE INDEX UNIQ_BAF6663B7A7B643 (result_id), UNIQUE INDEX UNIQ_BAF6663B81EC865B (authority_id), UNIQUE INDEX UNIQ_BAF6663B6B00C1CF (context_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
+ALTER TABLE xapi_attachment ADD CONSTRAINT FK_7148C9A1849CB65B FOREIGN KEY (statement_id) REFERENCES xapi_statement (id);
+ALTER TABLE xapi_object ADD CONSTRAINT FK_E2B68640303C7F1D FOREIGN KEY (activityExtensions_id) REFERENCES xapi_extensions (identifier);
+ALTER TABLE xapi_object ADD CONSTRAINT FK_E2B68640FE54D947 FOREIGN KEY (group_id) REFERENCES xapi_object (identifier);
+ALTER TABLE xapi_object ADD CONSTRAINT FK_E2B6864010DAF24A FOREIGN KEY (actor_id) REFERENCES xapi_object (identifier);
+ALTER TABLE xapi_object ADD CONSTRAINT FK_E2B68640C1D03483 FOREIGN KEY (verb_id) REFERENCES xapi_verb (identifier);
+ALTER TABLE xapi_object ADD CONSTRAINT FK_E2B68640232D562B FOREIGN KEY (object_id) REFERENCES xapi_object (identifier);
+ALTER TABLE xapi_object ADD CONSTRAINT FK_E2B68640988A4CEC FOREIGN KEY (parentContext_id) REFERENCES xapi_context (identifier);
+ALTER TABLE xapi_object ADD CONSTRAINT FK_E2B686404F542860 FOREIGN KEY (groupingContext_id) REFERENCES xapi_context (identifier);
+ALTER TABLE xapi_object ADD CONSTRAINT FK_E2B68640AEA1B132 FOREIGN KEY (categoryContext_id) REFERENCES xapi_context (identifier);
+ALTER TABLE xapi_object ADD CONSTRAINT FK_E2B68640B73EEAB7 FOREIGN KEY (otherContext_id) REFERENCES xapi_context (identifier);
+ALTER TABLE xapi_result ADD CONSTRAINT FK_5971ECBFD0A19400 FOREIGN KEY (extensions_id) REFERENCES xapi_extensions (identifier);
+ALTER TABLE xapi_context ADD CONSTRAINT FK_3D7771908C4FC193 FOREIGN KEY (instructor_id) REFERENCES xapi_object (identifier);
+ALTER TABLE xapi_context ADD CONSTRAINT FK_3D777190296CD8AE FOREIGN KEY (team_id) REFERENCES xapi_object (identifier);
+ALTER TABLE xapi_context ADD CONSTRAINT FK_3D777190D0A19400 FOREIGN KEY (extensions_id) REFERENCES xapi_extensions (identifier);
+ALTER TABLE xapi_statement ADD CONSTRAINT FK_BAF6663B10DAF24A FOREIGN KEY (actor_id) REFERENCES xapi_object (identifier);
+ALTER TABLE xapi_statement ADD CONSTRAINT FK_BAF6663BC1D03483 FOREIGN KEY (verb_id) REFERENCES xapi_verb (identifier);
+ALTER TABLE xapi_statement ADD CONSTRAINT FK_BAF6663B232D562B FOREIGN KEY (object_id) REFERENCES xapi_object (identifier);
+ALTER TABLE xapi_statement ADD CONSTRAINT FK_BAF6663B7A7B643 FOREIGN KEY (result_id) REFERENCES xapi_result (identifier);
+ALTER TABLE xapi_statement ADD CONSTRAINT FK_BAF6663B81EC865B FOREIGN KEY (authority_id) REFERENCES xapi_object (identifier);
+ALTER TABLE xapi_statement ADD CONSTRAINT FK_BAF6663B6B00C1CF FOREIGN KEY (context_id) REFERENCES xapi_context (identifier);
+
+CREATE TABLE xapi_shared_statement (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(255) DEFAULT NULL, statement LONGTEXT NOT NULL COMMENT '(DC2Type:array)', sent TINYINT(1) DEFAULT '0' NOT NULL, INDEX idx_uuid (uuid), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
+
+CREATE TABLE xapi_lrs_auth (id INT AUTO_INCREMENT NOT NULL, username VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL, enabled TINYINT(1) NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
+
+CREATE TABLE xapi_tool_launch (id INT AUTO_INCREMENT NOT NULL, c_id INT NOT NULL, session_id INT DEFAULT NULL, title VARCHAR(255) NOT NULL, description LONGTEXT DEFAULT NULL, launch_url VARCHAR(255) NOT NULL, activity_id VARCHAR(255) DEFAULT NULL, activity_type VARCHAR(255) DEFAULT NULL, allow_multiple_attempts TINYINT(1) DEFAULT '1' NOT NULL, lrs_url VARCHAR(255) DEFAULT NULL, lrs_auth_username VARCHAR(255) DEFAULT NULL, lrs_auth_password VARCHAR(255) DEFAULT NULL, INDEX IDX_E18CB58391D79BD3 (c_id), INDEX IDX_E18CB583613FECDF (session_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
+ALTER TABLE xapi_tool_launch ADD CONSTRAINT FK_E18CB58391D79BD3 FOREIGN KEY (c_id) REFERENCES course (id);
+ALTER TABLE xapi_tool_launch ADD CONSTRAINT FK_E18CB583613FECDF FOREIGN KEY (session_id) REFERENCES session (id);
+
+CREATE TABLE xapi_cmi5_item (id INT AUTO_INCREMENT NOT NULL, tree_root INT DEFAULT NULL, parent_id INT DEFAULT NULL, identifier VARCHAR(255) NOT NULL, type VARCHAR(255) NOT NULL, title LONGTEXT NOT NULL COMMENT '(DC2Type:json)', description LONGTEXT NOT NULL COMMENT '(DC2Type:json)', url VARCHAR(255) DEFAULT NULL, activity_type VARCHAR(255) DEFAULT NULL, launch_method VARCHAR(255) DEFAULT NULL, move_on VARCHAR(255) DEFAULT NULL, mastery_score DOUBLE PRECISION DEFAULT NULL, launch_parameters VARCHAR(255) DEFAULT NULL, entitlement_key VARCHAR(255) DEFAULT NULL, status VARCHAR(255) DEFAULT NULL, lft INT NOT NULL, lvl INT NOT NULL, rgt INT NOT NULL, tool_id INT DEFAULT NULL, INDEX IDX_7CA116D88F7B22CC (tool_id), INDEX IDX_7CA116D8A977936C (tree_root), INDEX IDX_7CA116D8727ACA70 (parent_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
+ALTER TABLE xapi_cmi5_item ADD CONSTRAINT FK_7CA116D8A977936C FOREIGN KEY (tree_root) REFERENCES xapi_cmi5_item (id) ON DELETE CASCADE;
+ALTER TABLE xapi_cmi5_item ADD CONSTRAINT FK_7CA116D8727ACA70 FOREIGN KEY (parent_id) REFERENCES xapi_cmi5_item (id) ON DELETE CASCADE;
+
+CREATE TABLE xapi_activity_state (id INT AUTO_INCREMENT NOT NULL, state_id VARCHAR(255) NOT NULL, activity_id VARCHAR(255) NOT NULL, agent LONGTEXT NOT NULL COMMENT '(DC2Type:json)', document_data LONGTEXT NOT NULL COMMENT '(DC2Type:json)', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
+CREATE TABLE xapi_activity_profile (id INT AUTO_INCREMENT NOT NULL, profile_id VARCHAR(255) NOT NULL, activity_id VARCHAR(255) NOT NULL, document_data LONGTEXT NOT NULL COMMENT '(DC2Type:json)', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
+```
+
+**From 0.2 (beta) [2021-10-15]**
+- With the LRS an internal log is registered based on the actor mbox's email or the actor account's name coming from the statement
+To update, execute this queries:
+
+```sql
+CREATE TABLE xapi_internal_log (id INT AUTO_INCREMENT NOT NULL, user_id INT DEFAULT NULL, statement_id VARCHAR(255) NOT NULL, verb VARCHAR(255) NOT NULL, object_id VARCHAR(255) NOT NULL, activity_name VARCHAR(255) DEFAULT NULL, activity_description VARCHAR(255) DEFAULT NULL, score_scaled DOUBLE PRECISION DEFAULT NULL, score_raw DOUBLE PRECISION DEFAULT NULL, score_min DOUBLE PRECISION DEFAULT NULL, score_max DOUBLE PRECISION DEFAULT NULL, created_at DATETIME DEFAULT NULL, INDEX IDX_C1C667ACA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB;
+ALTER TABLE xapi_internal_log ADD CONSTRAINT FK_C1C667ACA76ED395 FOREIGN KEY (user_id) REFERENCES user (id);
+```
+
+**From 0.3 (beta) [2021-11-11]**
+
+- Fix: Add foreign keys with course/session in tool_launch table and foreign key with user in internal_log table.
+```sql
+ALTER TABLE xapi_internal_log ADD CONSTRAINT FK_C1C667ACA76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE;
+ALTER TABLE xapi_tool_launch ADD CONSTRAINT FK_E18CB58391D79BD3 FOREIGN KEY (c_id) REFERENCES course (id) ON DELETE CASCADE;
+ALTER TABLE xapi_tool_launch ADD CONSTRAINT FK_E18CB583613FECDF FOREIGN KEY (session_id) REFERENCES session (id) ON DELETE CASCADE;
+```
diff --git a/public/plugin/xapi/admin.php b/public/plugin/xapi/admin.php
new file mode 100644
index 00000000000..f83f55b4fd9
--- /dev/null
+++ b/public/plugin/xapi/admin.php
@@ -0,0 +1,210 @@
+getId()}";
+ }
+
+ $form = new FormValidator('frm_xapi_auth', 'post', $action);
+ $form->addText('username', get_lang('Username'), true);
+ $form->addText('password', get_lang('Password'), true);
+ $form->addCheckBox('enabled', get_lang('Enabled'), get_lang('Yes'));
+
+ $form->addButtonSave(get_lang('Save'));
+
+ if (null != $auth) {
+ $form->setDefaults(
+ [
+ 'username' => $auth->getUsername(),
+ 'password' => $auth->getPassword(),
+ 'enabled' => $auth->isEnabled(),
+ ]
+ );
+ }
+
+ return $form;
+}
+
+switch ($request->query->getAlpha('action')) {
+ case 'add':
+ $form = createForm();
+
+ if ($form->validate()) {
+ $values = $form->exportValues();
+
+ $auth = new XApiLrsAuth();
+ $auth
+ ->setUsername($values['username'])
+ ->setPassword($values['password'])
+ ->setEnabled(isset($values['enabled']))
+ ->setCreatedAt(
+ api_get_utc_datetime(null, false, true)
+ )
+ ;
+
+ $em->persist($auth);
+ $em->flush();
+
+ Display::addFlash(
+ Display::return_message(get_lang('ItemAdded'), 'success')
+ );
+
+ header('Location: '.$pageBaseUrl);
+
+ exit;
+ }
+
+ $pageActions = Display::url(
+ Display::return_icon('back.png', get_lang('Back'), [], ICON_SIZE_MEDIUM),
+ $pageBaseUrl
+ );
+ $pageContent = $form->returnForm();
+
+ break;
+
+ case 'edit':
+ $auth = $em->find(XApiLrsAuth::class, $request->query->getInt('id'));
+
+ if (null == $auth) {
+ api_not_allowed(true);
+ }
+
+ $form = createForm($auth);
+
+ if ($form->validate()) {
+ $values = $form->exportValues();
+
+ $auth
+ ->setUsername($values['username'])
+ ->setPassword($values['password'])
+ ->setEnabled(isset($values['enabled']))
+ ->setCreatedAt(
+ api_get_utc_datetime(null, false, true)
+ )
+ ;
+
+ $em->persist($auth);
+ $em->flush();
+
+ Display::addFlash(
+ Display::return_message(get_lang('ItemUpdated'), 'success')
+ );
+
+ header('Location: '.$pageBaseUrl);
+
+ exit;
+ }
+
+ $pageActions = Display::url(
+ Display::return_icon('back.png', get_lang('Back'), [], ICON_SIZE_MEDIUM),
+ $pageBaseUrl
+ );
+ $pageContent = $form->returnForm();
+
+ break;
+
+ case 'delete':
+ $auth = $em->find(XApiLrsAuth::class, $request->query->getInt('id'));
+
+ if (null == $auth) {
+ api_not_allowed(true);
+ }
+
+ $em->remove($auth);
+ $em->flush();
+
+ Display::addFlash(
+ Display::return_message(get_lang('ItemDeleted'), 'success')
+ );
+
+ header('Location: '.$pageBaseUrl);
+
+ exit;
+
+ case 'list':
+ default:
+ $pageActions = Display::url(
+ Display::return_icon('add.png', get_lang('Add'), [], ICON_SIZE_MEDIUM),
+ $pageBaseUrl.'?action=add'
+ );
+ $pageContent = Display::return_message(get_lang('NoData'), 'warning');
+
+ $auths = $em->getRepository(XApiLrsAuth::class)->findAll();
+
+ if (count($auths) > 0) {
+ $row = 0;
+
+ $table = new HTML_Table(['class' => 'table table-striped table-hover']);
+ $table->setHeaderContents($row, 0, get_lang('Username'));
+ $table->setHeaderContents($row, 1, get_lang('Password'));
+ $table->setHeaderContents($row, 2, get_lang('Enabled'));
+ $table->setHeaderContents($row, 3, get_lang('CreatedAt'));
+ $table->setHeaderContents($row, 4, get_lang('Actions'));
+
+ foreach ($auths as $auth) {
+ $row++;
+
+ $actions = [
+ Display::url(
+ Display::return_icon('edit.png', get_lang('Edit')),
+ $pageBaseUrl.'?action=edit&id='.$auth->getId()
+ ),
+ Display::url(
+ Display::return_icon('delete.png', get_lang('Edit')),
+ $pageBaseUrl.'?action=delete&id='.$auth->getId()
+ ),
+ ];
+
+ $table->setCellContents($row, 0, $auth->getUsername());
+ $table->setCellContents($row, 1, $auth->getPassword());
+ $table->setCellContents($row, 2, $auth->isEnabled() ? get_lang('Yes') : get_lang('No'));
+ $table->setCellContents($row, 3, api_convert_and_format_date($auth->getCreatedAt()));
+ $table->setCellContents($row, 4, implode(\PHP_EOL, $actions));
+ }
+
+ $pageContent = $table->toHtml();
+ }
+
+ break;
+}
+
+$interbreadcrumb[] = [
+ 'name' => get_lang('Administration'),
+ 'url' => api_get_path(WEB_CODE_PATH).'admin/index.php',
+];
+
+$view = new Template($plugin->get_title());
+$view->assign('actions', Display::toolbarAction('xapi_actions', [$pageActions]));
+$view->assign('content', $pageContent);
+$view->display_one_col_template();
diff --git a/public/plugin/xapi/assets/css/cmi5_launch.css b/public/plugin/xapi/assets/css/cmi5_launch.css
new file mode 100644
index 00000000000..eaa82c2da1d
--- /dev/null
+++ b/public/plugin/xapi/assets/css/cmi5_launch.css
@@ -0,0 +1,40 @@
+.section-global {
+ margin: 0;
+}
+
+#pnl-left ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+#pnl-left ul li a {
+ padding: 5px 0;
+ display: block;
+}
+#pnl-left ul ul li {
+ padding: 0 0 0 15px ;
+}
+
+@media (min-width: 992px) {
+ #pnl-left {
+ overflow: auto;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ }
+ #pnl-right {
+ position: absolute;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ }
+ #ifr-content {
+ min-width: 100%;
+ min-height: 100%;
+ padding: 0;
+ margin: 0;
+ position: absolute;
+ right: 0;
+ }
+}
\ No newline at end of file
diff --git a/public/plugin/xapi/assets/js/cmi5_launch.js b/public/plugin/xapi/assets/js/cmi5_launch.js
new file mode 100644
index 00000000000..22d2d963e07
--- /dev/null
+++ b/public/plugin/xapi/assets/js/cmi5_launch.js
@@ -0,0 +1,2 @@
+$(function () {
+});
\ No newline at end of file
diff --git a/public/plugin/xapi/cmi5/launch.php b/public/plugin/xapi/cmi5/launch.php
new file mode 100644
index 00000000000..0291a15be9e
--- /dev/null
+++ b/public/plugin/xapi/cmi5/launch.php
@@ -0,0 +1,192 @@
+find(XApiCmi5Item::class, $request->query->getInt('id'));
+$toolLaunch = $item->getTool();
+
+if ($toolLaunch->getId() !== $request->query->getInt('tool')) {
+ api_not_allowed(
+ false,
+ Display::return_message(get_lang('NotAllwed'), 'error')
+ );
+}
+
+$plugin = XApiPlugin::create();
+$user = api_get_user_entity(api_get_user_id());
+$nowDate = api_get_utc_datetime(null, false, true)->format('c');
+
+$registration = (string) Uuid::uuid4();
+$actor = new Agent(
+ InverseFunctionalIdentifier::withAccount(
+ new Account(
+ $user->getCompleteName(),
+ IRL::fromString(api_get_path(WEB_PATH))
+ )
+ ),
+ $user->getCompleteName()
+);
+$verb = new Verb(
+ IRI::fromString('http://adlnet.gov/expapi/verbs/launched'),
+ LanguageMap::create($plugin->getLangMap('Launched'))
+);
+$customActivityId = $plugin->generateIri($item->getId(), 'cmi5_item');
+
+$activity = new Activity(
+ $customActivityId,
+ new Definition(
+ LanguageMap::create($item->getTitle()),
+ LanguageMap::create($item->getDescription()),
+ IRI::fromString($item->getIdentifier())
+ )
+);
+
+$context = (new Context())
+ ->withPlatform(
+ api_get_setting('Institution').' - '.api_get_setting('siteName')
+ )
+ ->withLanguage(api_get_language_isocode())
+ ->withRegistration($registration)
+;
+
+$statementUuid = Uuid::uuid5(
+ $plugin->get(XApiPlugin::SETTING_UUID_NAMESPACE),
+ "cmi5_item/{$item->getId()}"
+);
+
+$statement = new Statement(
+ StatementId::fromUuid($statementUuid),
+ $actor,
+ $verb,
+ $activity,
+ null,
+ null,
+ api_get_utc_datetime(null, false, true),
+ null,
+ $context
+);
+
+$statementClient = XApiPlugin::create()->getXApiStatementClient();
+
+// try {
+// $statementClient->storeStatement($statement);
+// } catch (ConflictException $e) {
+// echo Display::return_message($e->getMessage(), 'error');
+//
+// exit;
+// } catch (XApiException $e) {
+// echo Display::return_message($e->getMessage(), 'error');
+//
+// exit;
+// }
+
+$viewSessionId = (string) Uuid::uuid4();
+
+$state = new State(
+ $activity,
+ $actor,
+ 'LMS.LaunchData',
+ (string) $registration
+);
+
+$documentDataData = [];
+$documentDataData['contentTemplate'] = [
+ 'extensions' => [
+ 'https://w3id.org/xapi/cmi5/context/extensions/sessionid' => $viewSessionId,
+ ],
+];
+$documentDataData['launchMode'] = 'Normal';
+$documentDataData['launchMethod'] = $item->getLaunchMethod();
+
+if ($item->getLaunchParameters()) {
+ $documentDataData['launchParameteres'] = $item->getLaunchParameters();
+}
+
+if ($item->getMasteryScore()) {
+ $documentDataData['masteryScore'] = $item->getMasteryScore();
+}
+
+if ($item->getEntitlementKey()) {
+ $documentDataData['entitlementKey'] = [
+ 'courseStructure' => $item->getEntitlementKey(),
+ ];
+}
+
+$documentData = new DocumentData($documentDataData);
+
+try {
+ $plugin
+ ->getXApiStateClient()
+ ->createOrReplaceDocument(
+ new StateDocument($state, $documentData)
+ )
+ ;
+} catch (Exception $exception) {
+ echo Display::return_message($exception->getMessage(), 'error');
+
+ exit;
+}
+
+$launchUrl = $plugin->generateLaunchUrl(
+ 'cmi5',
+ $item->getUrl(),
+ $customActivityId->getValue(),
+ $actor,
+ $registration,
+ $toolLaunch->getLrsUrl(),
+ $toolLaunch->getLrsAuthUsername(),
+ $toolLaunch->getLrsAuthPassword(),
+ $viewSessionId
+);
+
+if ('OwnWindow' === $item->getLaunchMethod()) {
+ Display::display_reduced_header();
+
+ echo '
';
+ echo Display::toolbarButton(
+ $plugin->get_lang('LaunchNewAttempt'),
+ $launchUrl,
+ 'external-link fa-fw',
+ 'success',
+ [
+ 'target' => '_blank',
+ ]
+ );
+ echo '';
+
+ Display::display_reduced_footer();
+
+ exit;
+}
+
+header("Location: $launchUrl");
diff --git a/public/plugin/xapi/cmi5/token.php b/public/plugin/xapi/cmi5/token.php
new file mode 100644
index 00000000000..d3228da2f09
--- /dev/null
+++ b/public/plugin/xapi/cmi5/token.php
@@ -0,0 +1,24 @@
+getMethod()) {
+ $token = base64_encode(uniqid());
+
+ $response->setStatusCode(Response::HTTP_OK);
+ $response->setData(['auth-token' => $token]);
+}
+
+$response->send();
diff --git a/public/plugin/xapi/cmi5/view.php b/public/plugin/xapi/cmi5/view.php
new file mode 100644
index 00000000000..61feb914e3d
--- /dev/null
+++ b/public/plugin/xapi/cmi5/view.php
@@ -0,0 +1,86 @@
+find(
+ XApiToolLaunch::class,
+ $request->query->getInt('id')
+);
+
+if (null === $toolLaunch
+ || 'cmi5' !== $toolLaunch->getActivityType()
+) {
+ header('Location: '.api_get_course_url());
+
+ exit;
+}
+
+$plugin = XApiPlugin::create();
+$course = api_get_course_entity();
+$session = api_get_session_entity();
+$cidReq = api_get_cidreq();
+$user = api_get_user_entity(api_get_user_id());
+$interfaceLanguage = api_get_interface_language();
+
+$itemsRepo = $em->getRepository(XApiCmi5Item::class);
+
+$query = $itemsRepo->createQueryBuilder('item');
+$query
+ ->where($query->expr()->eq('item.tool', ':tool'))
+ ->setParameter('tool', $toolLaunch->getId())
+;
+
+$tocHtml = $itemsRepo->buildTree(
+ $query->getQuery()->getArrayResult(),
+ [
+ 'decorate' => true,
+ 'rootOpen' => '
',
+ 'childOpen' => '',
+ 'childClose' => '',
+ 'nodeDecorator' => function ($node) use ($interfaceLanguage, $cidReq, $toolLaunch) {
+ $titleMap = LanguageMap::create($node['title']);
+ $title = XApiPlugin::extractVerbInLanguage($titleMap, $interfaceLanguage);
+
+ if ('block' === $node['type']) {
+ return Display::page_subheader($title, null, 'h4');
+ }
+
+ return Display::url(
+ $title,
+ "launch.php?tool={$toolLaunch->getId()}&id={$node['id']}&$cidReq",
+ [
+ 'target' => 'ifr_content',
+ 'class' => 'text-left btn-link',
+ ]
+ );
+ },
+ ]
+);
+
+$webPluginPath = api_get_path(WEB_PLUGIN_PATH);
+
+$htmlHeadXtra[] = api_get_css($webPluginPath.'xapi/assets/css/cmi5_launch.css');
+$htmlHeadXtra[] = api_get_js_simple($webPluginPath.'xapi/assets/js/cmi5_launch.js');
+
+$view = new Template('', false, false, true, true, false);
+$view->assign('tool', $toolLaunch);
+$view->assign('toc_html', $tocHtml);
+$view->assign('content', $view->fetch('xapi/views/cmi5_launch.twig'));
+$view->display_no_layout_template();
diff --git a/public/plugin/xapi/cron/send_statements.php b/public/plugin/xapi/cron/send_statements.php
new file mode 100644
index 00000000000..24feaae4b23
--- /dev/null
+++ b/public/plugin/xapi/cron/send_statements.php
@@ -0,0 +1,82 @@
+getRepository(XApiSharedStatement::class)
+ ->findBy(
+ ['uuid' => null, 'sent' => false],
+ null,
+ 100
+ )
+;
+$countNotSent = count($notSentSharedStatements);
+
+if ($countNotSent > 0) {
+ echo '['.time().'] Trying to send '.$countNotSent.' statements to LRS'.\PHP_EOL;
+
+ $client = XApiPlugin::create()->getXapiStatementCronClient();
+
+ foreach ($notSentSharedStatements as $notSentSharedStatement) {
+ $notSentStatement = $statementSerializer->deserializeStatement(
+ json_encode($notSentSharedStatement->getStatement())
+ );
+
+ if (null == $notSentStatement->getId()) {
+ $notSentStatement = $notSentStatement->withId(
+ StatementId::fromUuid(Uuid::uuid4())
+ );
+ }
+
+ try {
+ echo '['.time()."] Sending shared statement ({$notSentSharedStatement->getId()})";
+
+ $sentStatement = $client->storeStatement($notSentStatement);
+
+ echo "\t\tStatement ID received: \"{$sentStatement->getId()->getValue()}\"";
+ } catch (ConflictException $e) {
+ echo $e->getMessage().\PHP_EOL;
+
+ continue;
+ } catch (XApiException $e) {
+ echo $e->getMessage().\PHP_EOL;
+
+ continue;
+ }
+
+ $notSentSharedStatement
+ ->setUuid($sentStatement->getId()->getValue())
+ ->setSent(true)
+ ;
+
+ $em->persist($notSentSharedStatement);
+
+ echo "\t\tShared statement updated".\PHP_EOL;
+ }
+
+ $em->flush();
+} else {
+ echo 'No statements to process.'.\PHP_EOL;
+}
diff --git a/public/plugin/xapi/lang/english.php b/public/plugin/xapi/lang/english.php
new file mode 100644
index 00000000000..53ca13e3e0b
--- /dev/null
+++ b/public/plugin/xapi/lang/english.php
@@ -0,0 +1,58 @@
+This is generated automatically by Chamilo LMS. Don\'t replace it.';
+$strings['lrs_url'] = 'LRS endpoint';
+$strings['lrs_url_help'] = 'Base URL of the LRS';
+$strings['lrs_auth_username'] = 'LRS user';
+$strings['lrs_auth_username_help'] = 'Username for basic HTTP authentication';
+$strings['lrs_auth_password'] = 'LRS password';
+$strings['lrs_auth_password_help'] = 'Password for basic HTTP authentication';
+$strings['cron_lrs_url'] = 'Cron: LRS endpoint';
+$strings['cron_lrs_url_help'] = 'Alternative base URL of the LRS for the cron process';
+$strings['cron_lrs_auth_username'] = 'Cron: LRS user';
+$strings['cron_lrs_auth_username_help'] = 'Alternative username for basic HTTP authentication for the cron process';
+$strings['cron_lrs_auth_password'] = 'Cron: LRS password';
+$strings['cron_lrs_auth_password_help'] = 'Alternative password for basic HTTP authentication for the cron process';
+$strings['lrs_lp_item_viewed_active'] = 'Learning path item viewed';
+$strings['lrs_lp_end_active'] = 'Learning path ended';
+$strings['lrs_quiz_active'] = 'Quiz ended';
+$strings['lrs_quiz_question_active'] = 'Quiz question answered';
+$strings['lrs_portfolio_active'] = 'Portfolio events';
+
+$strings['NoActivities'] = 'No activities added yet';
+$strings['ActivityTitle'] = 'Activity';
+$strings['AddActivity'] = 'Add activity';
+$strings['TinCanPackage'] = 'TinCan package (zip)';
+$strings['Cmi5Package'] = 'Cmi5 package (zip)';
+$strings['OnlyZipAllowed'] = 'Only ZIP file allowed (.zip).';
+$strings['ActivityImported'] = 'Activity imported.';
+$strings['EditActivity'] = 'Edit activity';
+$strings['ActivityUpdated'] = 'Activity updated';
+$strings['ActivityLaunchUrl'] = 'Launch URL';
+$strings['ActivityId'] = 'Activity ID';
+$strings['ActivityType'] = 'Activity type';
+$strings['ActivityDeleted'] = 'Activity deleted';
+$strings['ActivityLaunch'] = 'Launch';
+$strings['ActivityFirstLaunch'] = 'First launch at';
+$strings['ActivityLastLaunch'] = 'Last launch at';
+$strings['LaunchNewAttempt'] = 'Launch new attempt';
+$strings['LrsConfiguration'] = 'LRS Configuration';
+$strings['Verb'] = 'Verb';
+$strings['Actor'] = 'Actor';
+$strings['ToolTinCan'] = 'Activities';
+$strings['Terminated'] = 'Terminated';
+$strings['Completed'] = 'Completed';
+$strings['Answered'] = 'Answered';
+$strings['Viewed'] = 'Viewed';
+$strings['ActivityAddedToLPCannotBeAccessed'] = 'This activity has been included in a learning path, so it cannot be accessed by students directly from here.';
+$strings['XApiPackage'] = 'XApi Package';
+$strings['TinCanAllowMultipleAttempts'] = 'Allow multiple attempts';
diff --git a/public/plugin/xapi/lang/french.php b/public/plugin/xapi/lang/french.php
new file mode 100644
index 00000000000..a563114df9b
--- /dev/null
+++ b/public/plugin/xapi/lang/french.php
@@ -0,0 +1,51 @@
+Cette valeur est générée automatiquement par Chamilo, ne la modifiez pas.';
+$strings['lrs_url'] = 'Point d\'entrée LRS';
+$strings['lrs_url_help'] = 'URL de base du LRS';
+$strings['lrs_auth_username'] = 'Utilisateur LRS';
+$strings['lrs_auth_username_help'] = 'Nom d\'utilisateur pour l\'authentification HTTP de base';
+$strings['lrs_auth_password'] = 'Mot de passe LRS';
+$strings['lrs_auth_password_help'] = 'Mot de passe pour l\'authentification HTTP de base';
+$strings['cron_lrs_url'] = 'Cron: LRS endpoint';
+$strings['cron_lrs_url_help'] = 'Alternative base URL of the LRS for the cron process';
+$strings['cron_lrs_auth_username'] = 'Cron: LRS user';
+$strings['cron_lrs_auth_username_help'] = 'Alternative username for basic HTTP authentication for the cron process';
+$strings['cron_lrs_auth_password'] = 'Cron: LRS password';
+$strings['cron_lrs_auth_password_help'] = 'Alternative password for basic HTTP authentication for the cron process';
+$strings['lrs_lp_item_viewed_active'] = 'Élément de parcours visionné';
+$strings['lrs_lp_end_active'] = 'Parcours terminé';
+$strings['lrs_quiz_active'] = 'Exercice terminé';
+$strings['lrs_quiz_question_active'] = 'Question d\'exercice répondue';
+$strings['lrs_portfolio_active'] = 'Événements de portfolio';
+
+$strings['NoActivities'] = 'Aucune activité ajoutée pour l\'instant';
+$strings['ActivityTitle'] = 'Activité';
+$strings['AddActivity'] = 'Ajouter activité';
+$strings['TinCanPackage'] = 'Paquet TinCan (zip)';
+$strings['OnlyZipAllowed'] = 'Seuls les fichiers ZIP sont autorisés (.zip).';
+$strings['ActivityImported'] = 'Activité importée.';
+$strings['EditActivity'] = 'Éditer activité';
+$strings['ActivityUpdated'] = 'Activité mise à jour';
+$strings['ActivityLaunchUrl'] = 'URL de lancement';
+$strings['ActivityId'] = 'ID d\'activité';
+$strings['ActivityType'] = 'Type d\'activité';
+$strings['ActivityDeleted'] = 'Activité supprimée';
+$strings['ActivityLaunch'] = 'Lancer';
+$strings['ActivityFirstLaunch'] = 'Premier lancement à';
+$strings['ActivityLastLaunch'] = 'Dernier lancement à';
+$strings['LaunchNewAttempt'] = 'Lancer nouvelle tentative';
+$strings['LrsConfiguration'] = 'Configuration LRS';
+$strings['Verb'] = 'Verbe';
+$strings['Actor'] = 'Acteur';
+$strings['ToolTinCan'] = 'Activités';
+$strings['ActivityAddedToLPCannotBeAccessed'] = 'Cet activité fait partie d\'un parcours d\'apprentissage, il n\'est donc pas accessible par les étudiants depuis cette page';
diff --git a/public/plugin/xapi/lang/spanish.php b/public/plugin/xapi/lang/spanish.php
new file mode 100644
index 00000000000..f724c1cb38c
--- /dev/null
+++ b/public/plugin/xapi/lang/spanish.php
@@ -0,0 +1,58 @@
+Esto es generado automáticamente por Chamilo LMS. No reemplazarlo.';
+$strings['lrs_url'] = 'LRS endpoint';
+$strings['lrs_url_help'] = 'Base de la URL del LRS';
+$strings['lrs_auth_username'] = 'Usuario del LRS';
+$strings['lrs_auth_username_help'] = 'Usuario para autenticación con HTTP básica';
+$strings['lrs_auth_password'] = 'Contraseña del LRS';
+$strings['lrs_auth_password_help'] = 'Contraseña para autenticación con HTTP básica';
+$strings['cron_lrs_url'] = 'Cron: LRS endpoint';
+$strings['cron_lrs_url_help'] = 'Opcional. Base de la URL alternativa del LRS del proceso cron.';
+$strings['cron_lrs_auth_username'] = 'Cron: Usuario del LRS';
+$strings['cron_lrs_auth_username_help'] = 'Opcional. Usuario alternativo para autenticación con HTTP básica del proceso cron';
+$strings['cron_lrs_auth_password'] = 'Cron: Contraseña del LRS';
+$strings['cron_lrs_auth_password_help'] = 'Opcional. Contraseña alternativa para autenticación con HTTP básica del proceso cron';
+$strings['lrs_lp_item_viewed_active'] = 'Visualización de contenido de lección';
+$strings['lrs_lp_end_active'] = 'Finalización de lección';
+$strings['lrs_quiz_active'] = 'Finalización de ejercicio';
+$strings['lrs_quiz_question_active'] = 'Resolución de pregunta en ejercicio';
+$strings['lrs_portfolio_active'] = 'Eventos en portafolio';
+
+$strings['NoActivities'] = 'No hay actividades aún';
+$strings['ActivityTitle'] = 'Actividad';
+$strings['AddActivity'] = 'Agregar actividad';
+$strings['TinCanPackage'] = 'Paquete TinCan (zip)';
+$strings['Cmi5Package'] = 'Paquete Cmi5(zip)';
+$strings['OnlyZipAllowed'] = 'Sólo archivos ZIP están permitidos (.zip).';
+$strings['ActivityImported'] = 'Actividad importada.';
+$strings['EditActivity'] = 'Editar actividad';
+$strings['ActivityUpdated'] = 'Actividad actualizada';
+$strings['ActivityLaunchUrl'] = 'URL de inicio';
+$strings['ActivityId'] = 'ID de actividad';
+$strings['ActivityType'] = 'Tipo de actividad';
+$strings['ActivityDeleted'] = 'Actividad eliminada';
+$strings['ActivityLaunch'] = 'Iniciar';
+$strings['ActivityFirstLaunch'] = 'Primer inicio';
+$strings['ActivityLastLaunch'] = 'Últimmo inicio';
+$strings['LaunchNewAttempt'] = 'Iniciar nuevo intento';
+$strings['LrsConfiguration'] = 'Configuración de LRS';
+$strings['Verb'] = 'Verbo';
+$strings['Actor'] = 'Actor';
+$strings['ToolTinCan'] = 'Actividades';
+$strings['Terminated'] = 'Terminó';
+$strings['Completed'] = 'Completó';
+$strings['Answered'] = 'Respondió';
+$strings['Viewed'] = 'Visualizó';
+$strings['ActivityAddedToLPCannotBeAccessed'] = 'Esta actividad ha sido incluida en una secuencia de aprendizaje, por lo cual no podrá ser accesible directamente por los estudiantes desde aquí.';
+$strings['XApiPackage'] = 'Paquete XApi';
+$strings['TinCanAllowMultipleAttempts'] = 'Permitir múltiples intentos';
diff --git a/public/plugin/xapi/lrs.php b/public/plugin/xapi/lrs.php
new file mode 100644
index 00000000000..3b86fb447a2
--- /dev/null
+++ b/public/plugin/xapi/lrs.php
@@ -0,0 +1,14 @@
+send();
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/LICENSE b/public/plugin/xapi/php-xapi/lrs-bundle/LICENSE
new file mode 100644
index 00000000000..de1d823c3d3
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2016-2017 Christian Flothmann
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished
+to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/README.md b/public/plugin/xapi/php-xapi/lrs-bundle/README.md
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/composer.json b/public/plugin/xapi/php-xapi/lrs-bundle/composer.json
new file mode 100644
index 00000000000..874027f0203
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/composer.json
@@ -0,0 +1,51 @@
+{
+ "name": "php-xapi/lrs-bundle",
+ "type": "symfony-bundle",
+ "description": "Experience API (xAPI) Learning Record Store (LRS) based on the Symfony Framework",
+ "keywords": ["xAPI", "Experience API", "Tin Can API", "LRS", "Learning Record Store", "Symfony", "bundle"],
+ "homepage": "https://github.com/php-xapi/lrs-bundle",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Christian Flothmann",
+ "homepage": "https://github.com/xabbuh"
+ },
+ {
+ "name": "Jérôme Parmentier",
+ "homepage": "https://github.com/Lctrs"
+ }
+ ],
+ "require": {
+ "php": "^7.1",
+ "php-xapi/exception": "^0.1 || ^0.2",
+ "php-xapi/model": "^1.1 || ^2.0 || ^3.0",
+ "php-xapi/repository-api": "^0.3@dev || ^0.4@dev",
+ "php-xapi/serializer": "^1.0 || ^2.0",
+ "php-xapi/symfony-serializer": "^1.0 || ^2.0",
+ "symfony/config": "^3.4 || ^4.3",
+ "symfony/dependency-injection": "^3.4 || ^4.3",
+ "symfony/http-foundation": "^3.4 || ^4.3",
+ "symfony/http-kernel": "^3.4 || ^4.3"
+ },
+ "require-dev": {
+ "phpspec/phpspec": "~2.3",
+ "php-xapi/json-test-fixtures": "^1.0 || ^2.0",
+ "php-xapi/test-fixtures": "^1.0.1",
+ "ramsey/uuid": "^2.9 || ^3.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "XApi\\LrsBundle\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "spec\\XApi\\LrsBundle\\": "spec/"
+ }
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-master": "0.1.x-dev"
+ }
+ }
+}
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementGetController.php b/public/plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementGetController.php
new file mode 100644
index 00000000000..8a98f3f8298
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementGetController.php
@@ -0,0 +1,211 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace XApi\LrsBundle\Controller;
+
+use DateTime;
+use Exception;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\ParameterBag;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Xabbuh\XApi\Common\Exception\NotFoundException;
+use Xabbuh\XApi\Model\IRL;
+use Xabbuh\XApi\Model\Statement;
+use Xabbuh\XApi\Model\StatementId;
+use Xabbuh\XApi\Model\StatementResult;
+use Xabbuh\XApi\Serializer\StatementResultSerializerInterface;
+use Xabbuh\XApi\Serializer\StatementSerializerInterface;
+use XApi\LrsBundle\Model\StatementsFilterFactory;
+use XApi\LrsBundle\Response\AttachmentResponse;
+use XApi\LrsBundle\Response\MultipartResponse;
+use XApi\Repository\Api\StatementRepositoryInterface;
+
+use const FILTER_VALIDATE_BOOLEAN;
+
+/**
+ * @author Jérôme Parmentier
+ */
+class StatementGetController
+{
+ protected static array $getParameters = [
+ 'statementId' => true,
+ 'voidedStatementId' => true,
+ 'agent' => true,
+ 'verb' => true,
+ 'activity' => true,
+ 'registration' => true,
+ 'related_activities' => true,
+ 'related_agents' => true,
+ 'since' => true,
+ 'until' => true,
+ 'limit' => true,
+ 'format' => true,
+ 'attachments' => true,
+ 'ascending' => true,
+ 'cursor' => true,
+ ];
+
+ public function __construct(
+ protected readonly StatementRepositoryInterface $repository,
+ protected readonly StatementSerializerInterface $statementSerializer,
+ protected readonly StatementResultSerializerInterface $statementResultSerializer,
+ protected readonly StatementsFilterFactory $statementsFilterFactory
+ ) {}
+
+ /**
+ * @return Response
+ *
+ * @throws BadRequestHttpException if the query parameters does not comply with xAPI specification
+ */
+ public function getStatement(Request $request)
+ {
+ $query = new ParameterBag(array_intersect_key($request->query->all(), self::$getParameters));
+
+ $this->validate($query);
+
+ $includeAttachments = $query->filter('attachments', false, FILTER_VALIDATE_BOOLEAN);
+
+ try {
+ if (($statementId = $query->get('statementId')) !== null) {
+ $statement = $this->repository->findStatementById(StatementId::fromString($statementId));
+
+ $response = $this->buildSingleStatementResponse($statement, $includeAttachments);
+ } elseif (($voidedStatementId = $query->get('voidedStatementId')) !== null) {
+ $statement = $this->repository->findVoidedStatementById(StatementId::fromString($voidedStatementId));
+
+ $response = $this->buildSingleStatementResponse($statement, $includeAttachments);
+ } else {
+ $statements = $this->repository->findStatementsBy($this->statementsFilterFactory->createFromParameterBag($query));
+
+ $response = $this->buildMultiStatementsResponse($statements, $query, $includeAttachments);
+ }
+ } catch (NotFoundException $e) {
+ $response = $this->buildMultiStatementsResponse([], $query)
+ ->setStatusCode(Response::HTTP_NOT_FOUND)
+ ->setContent('')
+ ;
+ } catch (Exception $exception) {
+ $response = Response::create('', Response::HTTP_BAD_REQUEST);
+ }
+
+ $now = new DateTime();
+ $response->headers->set('X-Experience-API-Consistent-Through', $now->format(DateTime::ATOM));
+ $response->headers->set('Content-Type', 'application/json');
+
+ return $response;
+ }
+
+ /**
+ * @param bool $includeAttachments true to include the attachments in the response, false otherwise
+ *
+ * @return JsonResponse|MultipartResponse
+ */
+ protected function buildSingleStatementResponse(Statement $statement, $includeAttachments = false)
+ {
+ $json = $this->statementSerializer->serializeStatement($statement);
+
+ $response = new Response($json, 200);
+
+ if ($includeAttachments) {
+ $response = $this->buildMultipartResponse($response, [$statement]);
+ }
+
+ $response->setLastModified($statement->getStored());
+
+ return $response;
+ }
+
+ /**
+ * @param Statement[] $statements
+ * @param bool $includeAttachments true to include the attachments in the response, false otherwise
+ *
+ * @return JsonResponse|MultipartResponse
+ */
+ protected function buildMultiStatementsResponse(array $statements, ParameterBag $query, $includeAttachments = false)
+ {
+ $moreUrlPath = $statements ? $this->generateMoreIrl($query) : null;
+
+ $json = $this->statementResultSerializer->serializeStatementResult(
+ new StatementResult($statements, $moreUrlPath)
+ );
+
+ $response = new Response($json, 200);
+
+ if ($includeAttachments) {
+ $response = $this->buildMultipartResponse($response, $statements);
+ }
+
+ return $response;
+ }
+
+ /**
+ * @param Statement[] $statements
+ *
+ * @return MultipartResponse
+ */
+ protected function buildMultipartResponse(JsonResponse $statementResponse, array $statements)
+ {
+ $attachmentsParts = [];
+
+ foreach ($statements as $statement) {
+ foreach ((array) $statement->getAttachments() as $attachment) {
+ $attachmentsParts[] = new AttachmentResponse($attachment);
+ }
+ }
+
+ return new MultipartResponse($statementResponse, $attachmentsParts);
+ }
+
+ /**
+ * Validate the parameters.
+ *
+ * @throws BadRequestHttpException if the parameters does not comply with the xAPI specification
+ */
+ protected function validate(ParameterBag $query): void
+ {
+ $hasStatementId = $query->has('statementId');
+ $hasVoidedStatementId = $query->has('voidedStatementId');
+
+ if ($hasStatementId && $hasVoidedStatementId) {
+ throw new BadRequestHttpException('Request must not have both statementId and voidedStatementId parameters at the same time.');
+ }
+
+ $hasAttachments = $query->has('attachments');
+ $hasFormat = $query->has('format');
+ $queryCount = $query->count();
+
+ if (($hasStatementId || $hasVoidedStatementId) && $hasAttachments && $hasFormat && $queryCount > 3) {
+ throw new BadRequestHttpException('Request must not contain statementId or voidedStatementId parameters, and also any other parameter besides "attachments" or "format".');
+ }
+
+ if (($hasStatementId || $hasVoidedStatementId) && ($hasAttachments || $hasFormat) && $queryCount > 2) {
+ throw new BadRequestHttpException('Request must not contain statementId or voidedStatementId parameters, and also any other parameter besides "attachments" or "format".');
+ }
+
+ if (($hasStatementId || $hasVoidedStatementId) && $queryCount > 1) {
+ throw new BadRequestHttpException('Request must not contain statementId or voidedStatementId parameters, and also any other parameter besides "attachments" or "format".');
+ }
+ }
+
+ protected function generateMoreIrl(ParameterBag $query): IRL
+ {
+ $params = $query->all();
+ $params['cursor'] = empty($params['cursor']) ? 1 : $params['cursor'] + 1;
+
+ return IRL::fromString(
+ '/plugin/xapi/lrs.php/statements?'.http_build_query($params)
+ );
+ }
+}
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementHeadController.php b/public/plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementHeadController.php
new file mode 100644
index 00000000000..b3b931bd8db
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementHeadController.php
@@ -0,0 +1,29 @@
+setContent('');
+ }
+}
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementPostController.php b/public/plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementPostController.php
new file mode 100644
index 00000000000..fe7f12656a4
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementPostController.php
@@ -0,0 +1,69 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace XApi\LrsBundle\Controller;
+
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
+use Xabbuh\XApi\Common\Exception\NotFoundException;
+use Xabbuh\XApi\Model\Statement;
+use XApi\Repository\Api\StatementRepositoryInterface;
+
+/**
+ * @author Jérôme Parmentier
+ */
+final class StatementPostController
+{
+ /**
+ * @var StatementRepositoryInterface
+ */
+ private $repository;
+
+ public function __construct(StatementRepositoryInterface $repository)
+ {
+ $this->repository = $repository;
+ }
+
+ public function postStatements(Request $request, array $statements): JsonResponse
+ {
+ $statementsToStore = [];
+
+ /** @var Statement $statement */
+ foreach ($statements as $statement) {
+ if (null === $statementId = $statement->getId()) {
+ $statementsToStore[] = $statement;
+
+ continue;
+ }
+
+ try {
+ $existingStatement = $this->repository->findStatementById($statement->getId());
+
+ if (!$existingStatement->equals($statement)) {
+ throw new ConflictHttpException('The new statement is not equal to an existing statement with the same id.');
+ }
+ } catch (NotFoundException $e) {
+ $statementsToStore[] = $statement;
+ }
+ }
+
+ $uuids = [];
+
+ foreach ($statementsToStore as $statement) {
+ $uuids[] = $this->repository->storeStatement($statement, true)->getValue();
+ }
+
+ return new JsonResponse($uuids);
+ }
+}
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementPutController.php b/public/plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementPutController.php
new file mode 100644
index 00000000000..6886eaa5df9
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementPutController.php
@@ -0,0 +1,68 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace XApi\LrsBundle\Controller;
+
+use InvalidArgumentException;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
+use Xabbuh\XApi\Common\Exception\NotFoundException;
+use Xabbuh\XApi\Model\Statement;
+use Xabbuh\XApi\Model\StatementId;
+use XApi\Repository\Api\StatementRepositoryInterface;
+
+/**
+ * @author Christian Flothmann
+ */
+final class StatementPutController
+{
+ private $repository;
+
+ public function __construct(StatementRepositoryInterface $repository)
+ {
+ $this->repository = $repository;
+ }
+
+ public function putStatement(Request $request, Statement $statement): Response
+ {
+ if (null === $statementId = $request->query->get('statementId')) {
+ throw new BadRequestHttpException('Required statementId parameter is missing.');
+ }
+
+ try {
+ $id = StatementId::fromString($statementId);
+ } catch (InvalidArgumentException $e) {
+ throw new BadRequestHttpException(sprintf('Parameter statementId ("%s") is not a valid UUID.', $statementId), $e);
+ }
+
+ if (null !== $statement->getId() && !$id->equals($statement->getId())) {
+ throw new ConflictHttpException(sprintf('Id parameter ("%s") and statement id ("%s") do not match.', $id->getValue(), $statement->getId()->getValue()));
+ }
+
+ try {
+ $existingStatement = $this->repository->findStatementById($id);
+
+ if (!$existingStatement->equals($statement)) {
+ throw new ConflictHttpException('The new statement is not equal to an existing statement with the same id.');
+ }
+ } catch (NotFoundException $e) {
+ $statement = $statement->withId($id);
+
+ $this->repository->storeStatement($statement, true);
+ }
+
+ return new Response('', 204);
+ }
+}
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/DependencyInjection/Configuration.php b/public/plugin/xapi/php-xapi/lrs-bundle/src/DependencyInjection/Configuration.php
new file mode 100644
index 00000000000..d19881540ab
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/DependencyInjection/Configuration.php
@@ -0,0 +1,39 @@
+
+ */
+final class Configuration implements ConfigurationInterface
+{
+ public function getConfigTreeBuilder()
+ {
+ $treeBuilder = new TreeBuilder();
+
+ $treeBuilder
+ ->root('xapi_lrs')
+ ->beforeNormalization()
+ ->ifTrue(function ($v) {
+ return isset($v['type']) && \in_array($v['type'], ['mongodb', 'orm']) && !isset($v['object_manager_service']);
+ })
+ ->thenInvalid('You need to configure the object manager service when the repository type is "mongodb" or orm".')
+ ->end()
+ ->children()
+ ->enumNode('type')
+ ->isRequired()
+ ->values(['in_memory', 'mongodb', 'orm'])
+ ->end()
+ ->scalarNode('object_manager_service')->end()
+ ->end()
+ ->end()
+ ;
+
+ return $treeBuilder;
+ }
+}
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/DependencyInjection/XApiLrsExtension.php b/public/plugin/xapi/php-xapi/lrs-bundle/src/DependencyInjection/XApiLrsExtension.php
new file mode 100644
index 00000000000..baa5b83bfbe
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/DependencyInjection/XApiLrsExtension.php
@@ -0,0 +1,66 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace XApi\LrsBundle\DependencyInjection;
+
+use Symfony\Component\Config\FileLocator;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Extension\Extension;
+use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
+
+/**
+ * @author Christian Flothmann
+ */
+final class XApiLrsExtension extends Extension
+{
+ public function load(array $configs, ContainerBuilder $container): void
+ {
+ $configuration = new Configuration();
+ $config = $this->processConfiguration($configuration, $configs);
+
+ $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
+
+ $loader->load('controller.xml');
+ $loader->load('event_listener.xml');
+ $loader->load('factory.xml');
+ $loader->load('serializer.xml');
+
+ switch ($config['type']) {
+ case 'in_memory':
+ break;
+
+ case 'mongodb':
+ $loader->load('doctrine.xml');
+ $loader->load('mongodb.xml');
+
+ $container->setAlias('xapi_lrs.doctrine.object_manager', $config['object_manager_service']);
+ $container->setAlias('xapi_lrs.repository.statement', 'xapi_lrs.repository.statement.doctrine');
+
+ break;
+
+ case 'orm':
+ $loader->load('doctrine.xml');
+ $loader->load('orm.xml');
+
+ $container->setAlias('xapi_lrs.doctrine.object_manager', $config['object_manager_service']);
+ $container->setAlias('xapi_lrs.repository.statement', 'xapi_lrs.repository.statement.doctrine');
+
+ break;
+ }
+ }
+
+ public function getAlias()
+ {
+ return 'xapi_lrs';
+ }
+}
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/EventListener/AlternateRequestSyntaxListener.php b/public/plugin/xapi/php-xapi/lrs-bundle/src/EventListener/AlternateRequestSyntaxListener.php
new file mode 100644
index 00000000000..22ecdfdb9de
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/EventListener/AlternateRequestSyntaxListener.php
@@ -0,0 +1,75 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace XApi\LrsBundle\EventListener;
+
+use Symfony\Component\HttpKernel\Event\GetResponseEvent;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * @author Jérôme Parmentier
+ */
+class AlternateRequestSyntaxListener
+{
+ public function onKernelRequest(GetResponseEvent $event): void
+ {
+ if (!$event->isMasterRequest()) {
+ return;
+ }
+
+ $request = $event->getRequest();
+
+ if (!$request->attributes->has('xapi_lrs.route')) {
+ return;
+ }
+
+ if ('POST' !== $request->getMethod()) {
+ return;
+ }
+
+ if (null === $method = $request->query->get('method')) {
+ return;
+ }
+
+ if ($request->query->count() > 1) {
+ throw new BadRequestHttpException('Including other query parameters than "method" is not allowed. You have to send them as POST parameters inside the request body.');
+ }
+
+ $request->setMethod($method);
+ $request->query->remove('method');
+
+ if (null !== $content = $request->request->get('content')) {
+ $request->request->remove('content');
+
+ $request->initialize(
+ $request->query->all(),
+ $request->request->all(),
+ $request->attributes->all(),
+ $request->cookies->all(),
+ $request->files->all(),
+ $request->server->all(),
+ $content
+ );
+ }
+
+ foreach ($request->request as $key => $value) {
+ if (\in_array($key, ['Authorization', 'X-Experience-API-Version', 'Content-Type', 'Content-Length', 'If-Match', 'If-None-Match'], true)) {
+ $request->headers->set($key, $value);
+ } else {
+ $request->query->set($key, $value);
+ }
+
+ $request->request->remove($key);
+ }
+ }
+}
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/EventListener/ExceptionListener.php b/public/plugin/xapi/php-xapi/lrs-bundle/src/EventListener/ExceptionListener.php
new file mode 100644
index 00000000000..34b013972b3
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/EventListener/ExceptionListener.php
@@ -0,0 +1,17 @@
+
+ */
+class ExceptionListener
+{
+ public function onKernelException(GetResponseForExceptionEvent $event): void {}
+}
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/EventListener/SerializerListener.php b/public/plugin/xapi/php-xapi/lrs-bundle/src/EventListener/SerializerListener.php
new file mode 100644
index 00000000000..d3698ed3a0d
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/EventListener/SerializerListener.php
@@ -0,0 +1,43 @@
+
+ */
+class SerializerListener
+{
+ private $statementSerializer;
+
+ public function __construct(StatementSerializerInterface $statementSerializer)
+ {
+ $this->statementSerializer = $statementSerializer;
+ }
+
+ public function onKernelRequest(GetResponseEvent $event): void
+ {
+ $request = $event->getRequest();
+
+ if (!$request->attributes->has('xapi_lrs.route')) {
+ return;
+ }
+
+ try {
+ switch ($request->attributes->get('xapi_serializer')) {
+ case 'statement':
+ $request->attributes->set('statement', $this->statementSerializer->deserializeStatement($request->getContent()));
+
+ break;
+ }
+ } catch (BaseSerializerException $e) {
+ throw new BadRequestHttpException(sprintf('The content of the request cannot be deserialized into a valid xAPI %s.', $request->attributes->get('xapi_serializer')), $e);
+ }
+ }
+}
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/EventListener/VersionListener.php b/public/plugin/xapi/php-xapi/lrs-bundle/src/EventListener/VersionListener.php
new file mode 100644
index 00000000000..7b2cbe29ba2
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/EventListener/VersionListener.php
@@ -0,0 +1,68 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace XApi\LrsBundle\EventListener;
+
+use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
+use Symfony\Component\HttpKernel\Event\GetResponseEvent;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * @author Jérôme Parmentier
+ */
+class VersionListener
+{
+ public function onKernelRequest(GetResponseEvent $event): void
+ {
+ if (!$event->isMasterRequest()) {
+ return;
+ }
+
+ $request = $event->getRequest();
+
+ if (!$request->attributes->has('xapi_lrs.route')) {
+ return;
+ }
+
+ if (null === $version = $request->headers->get('X-Experience-API-Version')) {
+ throw new BadRequestHttpException('Missing required "X-Experience-API-Version" header.');
+ }
+
+ if (preg_match('/^1\.0(?:\.\d+)?$/', $version)) {
+ if ('1.0' === $version) {
+ $request->headers->set('X-Experience-API-Version', '1.0.0');
+ }
+
+ return;
+ }
+
+ throw new BadRequestHttpException(sprintf('xAPI version "%s" is not supported.', $version));
+ }
+
+ public function onKernelResponse(FilterResponseEvent $event): void
+ {
+ if (!$event->isMasterRequest()) {
+ return;
+ }
+
+ if (!$event->getRequest()->attributes->has('xapi_lrs.route')) {
+ return;
+ }
+
+ $headers = $event->getResponse()->headers;
+
+ if (!$headers->has('X-Experience-API-Version')) {
+ $headers->set('X-Experience-API-Version', '1.0.3');
+ }
+ }
+}
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/Model/StatementsFilterFactory.php b/public/plugin/xapi/php-xapi/lrs-bundle/src/Model/StatementsFilterFactory.php
new file mode 100644
index 00000000000..7a5e35aef11
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/Model/StatementsFilterFactory.php
@@ -0,0 +1,92 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace XApi\LrsBundle\Model;
+
+use DateTime;
+use Symfony\Component\HttpFoundation\ParameterBag;
+use Xabbuh\XApi\Model\Activity;
+use Xabbuh\XApi\Model\IRI;
+use Xabbuh\XApi\Model\StatementsFilter;
+use Xabbuh\XApi\Model\Verb;
+use Xabbuh\XApi\Serializer\ActorSerializerInterface;
+
+use const FILTER_VALIDATE_BOOLEAN;
+
+/**
+ * @author Jérôme Parmentier
+ */
+class StatementsFilterFactory
+{
+ private $actorSerializer;
+
+ public function __construct(ActorSerializerInterface $actorSerializer)
+ {
+ $this->actorSerializer = $actorSerializer;
+ }
+
+ /**
+ * @return StatementsFilter
+ */
+ public function createFromParameterBag(ParameterBag $parameters)
+ {
+ $filter = new StatementsFilter();
+
+ if (($actor = $parameters->get('agent')) !== null) {
+ $filter->byActor($this->actorSerializer->deserializeActor($actor));
+ }
+
+ if (($verbId = $parameters->get('verb')) !== null) {
+ $filter->byVerb(new Verb(IRI::fromString($verbId)));
+ }
+
+ if (($activityId = $parameters->get('activity')) !== null) {
+ $filter->byActivity(new Activity(IRI::fromString($activityId)));
+ }
+
+ if (($registration = $parameters->get('registration')) !== null) {
+ $filter->byRegistration($registration);
+ }
+
+ if ($parameters->filter('related_activities', false, FILTER_VALIDATE_BOOLEAN)) {
+ $filter->enableRelatedActivityFilter();
+ } else {
+ $filter->disableRelatedActivityFilter();
+ }
+
+ if ($parameters->filter('related_agents', false, FILTER_VALIDATE_BOOLEAN)) {
+ $filter->enableRelatedAgentFilter();
+ } else {
+ $filter->disableRelatedAgentFilter();
+ }
+
+ if (($since = $parameters->get('since')) !== null) {
+ $filter->since(DateTime::createFromFormat(DateTime::ATOM, $since));
+ }
+
+ if (($until = $parameters->get('until')) !== null) {
+ $filter->until(DateTime::createFromFormat(DateTime::ATOM, $until));
+ }
+
+ if ($parameters->filter('ascending', false, FILTER_VALIDATE_BOOLEAN)) {
+ $filter->ascending();
+ } else {
+ $filter->descending();
+ }
+
+ $filter->cursor($parameters->getInt('cursor'));
+ $filter->limit($parameters->getInt('limit'));
+
+ return $filter;
+ }
+}
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/controller.xml b/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/controller.xml
new file mode 100644
index 00000000000..053e487dfed
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/controller.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/doctrine.xml b/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/doctrine.xml
new file mode 100644
index 00000000000..1eb2760b65f
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/doctrine.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/event_listener.xml b/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/event_listener.xml
new file mode 100644
index 00000000000..08ad38c13a5
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/event_listener.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/factory.xml b/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/factory.xml
new file mode 100644
index 00000000000..4c43d9db121
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/factory.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/orm.xml b/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/orm.xml
new file mode 100644
index 00000000000..a7c82047830
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/orm.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+ XApi\Repository\Api\Mapping\MappedStatement
+
+
+
+
+
+
+
+
+
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/routing.xml b/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/routing.xml
new file mode 100644
index 00000000000..f0e75024fb3
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/routing.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ xapi_lrs.controller.statement.put:putStatement
+ statement
+
+ true
+
+
+
+
+ xapi_lrs.controller.statement.post:postStatements
+ statement
+
+ true
+
+
+
+
+ xapi_lrs.controller.statement.get:getStatement
+
+ true
+
+
+
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/serializer.xml b/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/serializer.xml
new file mode 100644
index 00000000000..2ce345a97af
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/Resources/config/serializer.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/Response/AttachmentResponse.php b/public/plugin/xapi/php-xapi/lrs-bundle/src/Response/AttachmentResponse.php
new file mode 100644
index 00000000000..ea01fafbac3
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/Response/AttachmentResponse.php
@@ -0,0 +1,70 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace XApi\LrsBundle\Response;
+
+use LogicException;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Xabbuh\XApi\Model\Attachment;
+
+/**
+ * @author Jérôme Parmentier
+ */
+class AttachmentResponse extends Response
+{
+ protected $attachment;
+
+ public function __construct(Attachment $attachment)
+ {
+ parent::__construct(null);
+
+ $this->attachment = $attachment;
+ }
+
+ public function prepare(Request $request): void
+ {
+ if (!$this->headers->has('Content-Type')) {
+ $this->headers->set('Content-Type', $this->attachment->getContentType());
+ }
+
+ $this->headers->set('Content-Transfer-Encoding', 'binary');
+ $this->headers->set('X-Experience-API-Hash', $this->attachment->getSha2());
+ }
+
+ /**
+ * @throws LogicException
+ */
+ public function sendContent(): void
+ {
+ throw new LogicException('An AttachmentResponse is only meant to be part of a multipart Response.');
+ }
+
+ /**
+ * @throws LogicException when the content is not null
+ */
+ public function setContent($content): void
+ {
+ if (null !== $content) {
+ throw new LogicException('The content cannot be set on an AttachmentResponse instance.');
+ }
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getContent()
+ {
+ return $this->attachment->getContent();
+ }
+}
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/Response/MultipartResponse.php b/public/plugin/xapi/php-xapi/lrs-bundle/src/Response/MultipartResponse.php
new file mode 100644
index 00000000000..2509f0e4406
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/Response/MultipartResponse.php
@@ -0,0 +1,129 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace XApi\LrsBundle\Response;
+
+use LogicException;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * @author Jérôme Parmentier
+ */
+class MultipartResponse extends Response
+{
+ protected $subtype;
+ protected $boundary;
+ protected $statementPart;
+
+ /**
+ * @var Response[]
+ */
+ protected $parts;
+
+ /**
+ * @param AttachmentResponse[] $attachmentsParts
+ * @param int $status
+ * @param string|null $subtype
+ */
+ public function __construct(JsonResponse $statementPart, array $attachmentsParts = [], $status = 200, array $headers = [], $subtype = null)
+ {
+ parent::__construct(null, $status, $headers);
+
+ if (null === $subtype) {
+ $subtype = 'mixed';
+ }
+
+ $this->subtype = $subtype;
+ $this->boundary = uniqid('', true);
+ $this->statementPart = $statementPart;
+
+ $this->setAttachmentsParts($attachmentsParts);
+ }
+
+ /**
+ * @return $this
+ */
+ public function addAttachmentPart(AttachmentResponse $part)
+ {
+ if (null !== $part->getContent()) {
+ $this->parts[] = $part;
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param AttachmentResponse[] $attachmentsParts
+ *
+ * @return $this
+ */
+ public function setAttachmentsParts(array $attachmentsParts)
+ {
+ $this->parts = [$this->statementPart];
+
+ foreach ($attachmentsParts as $part) {
+ $this->addAttachmentPart($part);
+ }
+
+ return $this;
+ }
+
+ public function prepare(Request $request)
+ {
+ foreach ($this->parts as $part) {
+ $part->prepare($request);
+ }
+
+ $this->headers->set('Content-Type', sprintf('multipart/%s; boundary="%s"', $this->subtype, $this->boundary));
+ $this->headers->set('Transfer-Encoding', 'chunked');
+
+ return parent::prepare($request);
+ }
+
+ public function sendContent()
+ {
+ $content = '';
+ foreach ($this->parts as $part) {
+ $content .= sprintf('--%s', $this->boundary)."\r\n";
+ $content .= $part->headers."\r\n";
+ $content .= $part->getContent();
+ $content .= "\r\n";
+ }
+
+ $content .= sprintf('--%s--', $this->boundary)."\r\n";
+
+ echo $content;
+
+ return $this;
+ }
+
+ /**
+ * @throws LogicException when the content is not null
+ */
+ public function setContent($content): void
+ {
+ if (null !== $content) {
+ throw new LogicException('The content cannot be set on a MultipartResponse instance.');
+ }
+ }
+
+ /**
+ * @return false
+ */
+ public function getContent()
+ {
+ return false;
+ }
+}
diff --git a/public/plugin/xapi/php-xapi/lrs-bundle/src/XApiLrsBundle.php b/public/plugin/xapi/php-xapi/lrs-bundle/src/XApiLrsBundle.php
new file mode 100644
index 00000000000..ff6af63e1fb
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/lrs-bundle/src/XApiLrsBundle.php
@@ -0,0 +1,28 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace XApi\LrsBundle;
+
+use Symfony\Component\HttpKernel\Bundle\Bundle;
+use XApi\LrsBundle\DependencyInjection\XApiLrsExtension;
+
+/**
+ * @author Christian Flothmann
+ */
+class XApiLrsBundle extends Bundle
+{
+ public function getContainerExtension()
+ {
+ return new XApiLrsExtension();
+ }
+}
diff --git a/public/plugin/xapi/php-xapi/repository-doctrine-orm/LICENSE b/public/plugin/xapi/php-xapi/repository-doctrine-orm/LICENSE
new file mode 100644
index 00000000000..de1d823c3d3
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/repository-doctrine-orm/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2016-2017 Christian Flothmann
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished
+to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/public/plugin/xapi/php-xapi/repository-doctrine-orm/README.md b/public/plugin/xapi/php-xapi/repository-doctrine-orm/README.md
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/public/plugin/xapi/php-xapi/repository-doctrine-orm/composer.json b/public/plugin/xapi/php-xapi/repository-doctrine-orm/composer.json
new file mode 100644
index 00000000000..dace61cba6d
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/repository-doctrine-orm/composer.json
@@ -0,0 +1,41 @@
+{
+ "name": "php-xapi/repository-doctrine-orm",
+ "description": "Doctrine based ORM implementations of an Experience API (xAPI) repository",
+ "keywords": ["xAPI", "Tin Can API", "Experience API", "storage", "database", "repository", "entity", "Doctrine", "ORM"],
+ "homepage": "https://github.com/php-xapi/repository-orm/",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Christian Flothmann",
+ "homepage": "https://github.com/xabbuh"
+ }
+ ],
+ "require": {
+ "php": "^5.6 || ^7.0",
+ "doctrine/orm": "^2.3",
+ "php-xapi/repository-api": "^0.4",
+ "php-xapi/repository-doctrine": "^0.4"
+ },
+ "require-dev": {
+ "symfony/phpunit-bridge": "^3.4 || ^4.0"
+ },
+ "provide": {
+ "php-xapi/repository-implementation": "0.3"
+ },
+ "autoload": {
+ "psr-4": {
+ "XApi\\Repository\\ORM\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "XApi\\Repository\\ORM\\Tests\\": "tests/"
+ }
+ },
+ "minimum-stability": "dev",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "0.1.x-dev"
+ }
+ }
+}
diff --git a/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Actor.orm.xml b/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Actor.orm.xml
new file mode 100644
index 00000000000..040148a0946
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Actor.orm.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Attachment.orm.xml b/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Attachment.orm.xml
new file mode 100644
index 00000000000..4fb517de63e
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Attachment.orm.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Context.orm.xml b/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Context.orm.xml
new file mode 100644
index 00000000000..a33661e1cf6
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Context.orm.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Extensions.orm.xml b/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Extensions.orm.xml
new file mode 100644
index 00000000000..efec3463284
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Extensions.orm.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Result.orm.xml b/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Result.orm.xml
new file mode 100644
index 00000000000..d1ffe53054a
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Result.orm.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Statement.orm.xml b/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Statement.orm.xml
new file mode 100644
index 00000000000..321d5b86410
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Statement.orm.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/StatementObject.orm.xml b/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/StatementObject.orm.xml
new file mode 100644
index 00000000000..22464ce3faa
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/StatementObject.orm.xml
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Verb.orm.xml b/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Verb.orm.xml
new file mode 100644
index 00000000000..2e2277c6d7e
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/repository-doctrine-orm/metadata/Verb.orm.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/plugin/xapi/php-xapi/repository-doctrine-orm/src/StatementRepository.php b/public/plugin/xapi/php-xapi/repository-doctrine-orm/src/StatementRepository.php
new file mode 100644
index 00000000000..a32e2d47c4f
--- /dev/null
+++ b/public/plugin/xapi/php-xapi/repository-doctrine-orm/src/StatementRepository.php
@@ -0,0 +1,70 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace XApi\Repository\ORM;
+
+use Doctrine\ORM\EntityRepository;
+use XApi\Repository\Doctrine\Mapping\Context;
+use XApi\Repository\Doctrine\Mapping\Statement;
+use XApi\Repository\Doctrine\Repository\Mapping\StatementRepository as BaseStatementRepository;
+
+/**
+ * @author Christian Flothmann
+ */
+final class StatementRepository extends EntityRepository implements BaseStatementRepository
+{
+ public function findStatement(array $criteria)
+ {
+ return parent::findOneBy($criteria);
+ }
+
+ public function findStatements(array $criteria)
+ {
+ if (!empty($criteria['registration'])) {
+ $contexts = $this->_em->getRepository(Context::class)->findBy([
+ 'registration' => $criteria['registration'],
+ ]);
+
+ $criteria['context'] = $contexts;
+ }
+
+ if (!empty($criteria['verb'])) {
+ $verbs = $this->_em->getRepository(Verb::class)->findBy(['id' => $criteria['verb']]);
+
+ $criteria['verb'] = $verbs;
+ }
+
+ unset(
+ $criteria['registration'],
+ $criteria['related_activities'],
+ $criteria['related_agents'],
+ $criteria['ascending'],
+ );
+
+ return parent::findBy(
+ $criteria,
+ ['created' => 'ASC'],
+ $criteria['limit'] ?? null,
+ $criteria['cursor'] ?? null
+ );
+ }
+
+ public function storeStatement(Statement $mappedStatement, $flush = true): void
+ {
+ $this->_em->persist($mappedStatement);
+
+ if ($flush) {
+ $this->_em->flush();
+ }
+ }
+}
diff --git a/public/plugin/xapi/plugin.php b/public/plugin/xapi/plugin.php
new file mode 100644
index 00000000000..bc7d829d927
--- /dev/null
+++ b/public/plugin/xapi/plugin.php
@@ -0,0 +1,7 @@
+get_info();
diff --git a/public/plugin/xapi/src/Hook/XApiActivityHookObserver.php b/public/plugin/xapi/src/Hook/XApiActivityHookObserver.php
new file mode 100644
index 00000000000..15f2ba24b03
--- /dev/null
+++ b/public/plugin/xapi/src/Hook/XApiActivityHookObserver.php
@@ -0,0 +1,64 @@
+plugin = XApiPlugin::create();
+ }
+
+ /**
+ * @throws \Doctrine\ORM\ORMException
+ * @throws \Doctrine\ORM\OptimisticLockException
+ */
+ protected function saveSharedStatement(Statement $statement): XApiSharedStatement
+ {
+ $statementSerialized = $this->serializeStatement($statement);
+
+ $sharedStmt = new XApiSharedStatement(
+ json_decode($statementSerialized, true)
+ );
+
+ $em = Database::getManager();
+ $em->persist($sharedStmt);
+ $em->flush();
+
+ return $sharedStmt;
+ }
+
+ /**
+ * Serialize a statement to JSON.
+ *
+ * @return string
+ */
+ private function serializeStatement(Statement $statement)
+ {
+ $serializer = Serializer::createSerializer();
+ $statementSerializer = new StatementSerializer($serializer);
+
+ return $statementSerializer->serializeStatement($statement);
+ }
+}
diff --git a/public/plugin/xapi/src/Hook/XApiCreateCourseHookObserver.php b/public/plugin/xapi/src/Hook/XApiCreateCourseHookObserver.php
new file mode 100644
index 00000000000..ebdbd0241d3
--- /dev/null
+++ b/public/plugin/xapi/src/Hook/XApiCreateCourseHookObserver.php
@@ -0,0 +1,30 @@
+getEventData();
+
+ $type = $data['type'];
+ $courseInfo = $data['course_info'];
+
+ $plugin = XApiPlugin::create();
+
+ if (HOOK_EVENT_TYPE_POST == $type) {
+ $plugin->addCourseToolForTinCan($courseInfo['real_id']);
+ }
+ }
+}
diff --git a/public/plugin/xapi/src/Hook/XApiLearningPathEndHookObserver.php b/public/plugin/xapi/src/Hook/XApiLearningPathEndHookObserver.php
new file mode 100644
index 00000000000..358ca3c1b89
--- /dev/null
+++ b/public/plugin/xapi/src/Hook/XApiLearningPathEndHookObserver.php
@@ -0,0 +1,25 @@
+getEventData();
+ $em = Database::getManager();
+
+ $lpView = $em->find('ChamiloCourseBundle:CLpView', $data['lp_view_id']);
+ $lp = $em->find('ChamiloCourseBundle:CLp', $lpView->getLpId());
+
+ $learningPathEnded = new LearningPathCompleted($lpView, $lp);
+
+ $statement = $learningPathEnded->generate();
+
+ $this->saveSharedStatement($statement);
+ }
+}
diff --git a/public/plugin/xapi/src/Hook/XApiLearningPathItemViewedHookObserver.php b/public/plugin/xapi/src/Hook/XApiLearningPathItemViewedHookObserver.php
new file mode 100644
index 00000000000..b9eb6d296da
--- /dev/null
+++ b/public/plugin/xapi/src/Hook/XApiLearningPathItemViewedHookObserver.php
@@ -0,0 +1,31 @@
+getEventData();
+ $em = Database::getManager();
+
+ $lpItemView = $em->find('ChamiloCourseBundle:CLpItemView', $data['item_view_id']);
+ $lpItem = $em->find('ChamiloCourseBundle:CLpItem', $lpItemView->getLpItemId());
+
+ if ('quiz' == $lpItem->getItemType()) {
+ return null;
+ }
+
+ $lpView = $em->find('ChamiloCourseBundle:CLpView', $lpItemView->getLpViewId());
+
+ $lpItemViewed = new LearningPathItemViewed($lpItemView, $lpItem, $lpView);
+
+ $statement = $lpItemViewed->generate();
+
+ $this->saveSharedStatement($statement);
+ }
+}
diff --git a/public/plugin/xapi/src/Hook/XApiPortfolioCommentEditedHookObserver.php b/public/plugin/xapi/src/Hook/XApiPortfolioCommentEditedHookObserver.php
new file mode 100644
index 00000000000..8f296c23182
--- /dev/null
+++ b/public/plugin/xapi/src/Hook/XApiPortfolioCommentEditedHookObserver.php
@@ -0,0 +1,21 @@
+getEventData()['comment'];
+
+ $statement = (new PortfolioCommentEdited($comment))->generate();
+
+ $this->saveSharedStatement($statement);
+ }
+}
diff --git a/public/plugin/xapi/src/Hook/XApiPortfolioCommentScoredHookObserver.php b/public/plugin/xapi/src/Hook/XApiPortfolioCommentScoredHookObserver.php
new file mode 100644
index 00000000000..ef0e0ecdbec
--- /dev/null
+++ b/public/plugin/xapi/src/Hook/XApiPortfolioCommentScoredHookObserver.php
@@ -0,0 +1,21 @@
+getEventData()['comment'];
+
+ $statement = (new PortfolioCommentScored($comment))->generate();
+
+ $this->saveSharedStatement($statement);
+ }
+}
diff --git a/public/plugin/xapi/src/Hook/XApiPortfolioDownloadedHookObserver.php b/public/plugin/xapi/src/Hook/XApiPortfolioDownloadedHookObserver.php
new file mode 100644
index 00000000000..18e4cd7a36e
--- /dev/null
+++ b/public/plugin/xapi/src/Hook/XApiPortfolioDownloadedHookObserver.php
@@ -0,0 +1,19 @@
+getEventData()['owner'];
+
+ $statement = (new PortfolioDownloaded($owner))->generate();
+
+ $this->saveSharedStatement($statement);
+ }
+}
diff --git a/public/plugin/xapi/src/Hook/XApiPortfolioItemAddedHookObserver.php b/public/plugin/xapi/src/Hook/XApiPortfolioItemAddedHookObserver.php
new file mode 100644
index 00000000000..55d26ad650f
--- /dev/null
+++ b/public/plugin/xapi/src/Hook/XApiPortfolioItemAddedHookObserver.php
@@ -0,0 +1,21 @@
+getEventData()['portfolio'];
+
+ $portfolioItemShared = new PortfolioItemShared($item);
+
+ $statement = $portfolioItemShared->generate();
+
+ $this->saveSharedStatement($statement);
+ }
+}
diff --git a/public/plugin/xapi/src/Hook/XApiPortfolioItemCommentedHookObserver.php b/public/plugin/xapi/src/Hook/XApiPortfolioItemCommentedHookObserver.php
new file mode 100644
index 00000000000..3b5bbc974b9
--- /dev/null
+++ b/public/plugin/xapi/src/Hook/XApiPortfolioItemCommentedHookObserver.php
@@ -0,0 +1,21 @@
+getEventData()['comment'];
+
+ $portfolioItemCommented = new PortfolioItemCommented($comment);
+
+ $statement = $portfolioItemCommented->generate();
+
+ $this->saveSharedStatement($statement);
+ }
+}
diff --git a/public/plugin/xapi/src/Hook/XApiPortfolioItemEditedHookObserver.php b/public/plugin/xapi/src/Hook/XApiPortfolioItemEditedHookObserver.php
new file mode 100644
index 00000000000..91db05f08a5
--- /dev/null
+++ b/public/plugin/xapi/src/Hook/XApiPortfolioItemEditedHookObserver.php
@@ -0,0 +1,21 @@
+getEventData()['item'];
+
+ $statement = (new PortfolioItemEdited($item))->generate();
+
+ $this->saveSharedStatement($statement);
+ }
+}
diff --git a/public/plugin/xapi/src/Hook/XApiPortfolioItemHighlightedHookObserver.php b/public/plugin/xapi/src/Hook/XApiPortfolioItemHighlightedHookObserver.php
new file mode 100644
index 00000000000..df9f813cbdb
--- /dev/null
+++ b/public/plugin/xapi/src/Hook/XApiPortfolioItemHighlightedHookObserver.php
@@ -0,0 +1,24 @@
+getEventData()['item'];
+
+ $statement = (new PortfolioItemHighlighted($item))->generate();
+
+ $this->saveSharedStatement($statement);
+ }
+}
diff --git a/public/plugin/xapi/src/Hook/XApiPortfolioItemScoredHookObserver.php b/public/plugin/xapi/src/Hook/XApiPortfolioItemScoredHookObserver.php
new file mode 100644
index 00000000000..d70e1bf509a
--- /dev/null
+++ b/public/plugin/xapi/src/Hook/XApiPortfolioItemScoredHookObserver.php
@@ -0,0 +1,21 @@
+getEventData()['item'];
+
+ $statement = (new PortfolioItemScored($item))->generate();
+
+ $this->saveSharedStatement($statement);
+ }
+}
diff --git a/public/plugin/xapi/src/Hook/XApiPortfolioItemViewedHookObserver.php b/public/plugin/xapi/src/Hook/XApiPortfolioItemViewedHookObserver.php
new file mode 100644
index 00000000000..f0a50c7cb24
--- /dev/null
+++ b/public/plugin/xapi/src/Hook/XApiPortfolioItemViewedHookObserver.php
@@ -0,0 +1,27 @@
+getEventData()['portfolio'];
+
+ $statement = (new PortfolioItemViewed($item))->generate();
+
+ $this->saveSharedStatement($statement);
+ }
+}
diff --git a/public/plugin/xapi/src/Hook/XApiQuizEndHookObserver.php b/public/plugin/xapi/src/Hook/XApiQuizEndHookObserver.php
new file mode 100644
index 00000000000..c52e3c9f665
--- /dev/null
+++ b/public/plugin/xapi/src/Hook/XApiQuizEndHookObserver.php
@@ -0,0 +1,25 @@
+getEventData();
+ $em = Database::getManager();
+
+ $exe = $em->find('ChamiloCoreBundle:TrackEExercises', $data['exe_id']);
+ $quiz = $em->find('ChamiloCourseBundle:CQuiz', $exe->getExeExoId());
+
+ $quizCompleted = new QuizCompleted($exe, $quiz);
+
+ $statement = $quizCompleted->generate();
+
+ $this->saveSharedStatement($statement);
+ }
+}
diff --git a/public/plugin/xapi/src/Hook/XApiQuizQuestionAnsweredHookObserver.php b/public/plugin/xapi/src/Hook/XApiQuizQuestionAnsweredHookObserver.php
new file mode 100644
index 00000000000..217fb5e434e
--- /dev/null
+++ b/public/plugin/xapi/src/Hook/XApiQuizQuestionAnsweredHookObserver.php
@@ -0,0 +1,38 @@
+getEventData();
+
+ $em = Database::getManager();
+ $attemptRepo = $em->getRepository(TrackEAttempt::class);
+
+ $exe = $em->find(TrackEExercises::class, $data['exe_id']);
+ $question = $em->find(CQuizQuestion::class, $data['question']['id']);
+ $attempt = $attemptRepo->findOneBy(
+ [
+ 'exeId' => $exe->getExeId(),
+ 'questionId' => $question->getId(),
+ ]
+ );
+ $quiz = $em->find(CQuiz::class, $data['quiz']['id']);
+
+ $quizQuestionAnswered = new QuizQuestionAnswered($attempt, $question, $quiz);
+
+ $statement = $quizQuestionAnswered->generate();
+
+ $this->saveSharedStatement($statement);
+ }
+}
diff --git a/public/plugin/xapi/src/Importer/PackageImporter.php b/public/plugin/xapi/src/Importer/PackageImporter.php
new file mode 100644
index 00000000000..90ca238323c
--- /dev/null
+++ b/public/plugin/xapi/src/Importer/PackageImporter.php
@@ -0,0 +1,68 @@
+packageFileInfo = $fileInfo;
+ $this->course = $course;
+
+ $this->courseDirectoryPath = api_get_path(SYS_COURSE_PATH).$this->course->getDirectory();
+ }
+
+ /**
+ * @return \Chamilo\PluginBundle\XApi\Importer\XmlPackageImporter|\Chamilo\PluginBundle\XApi\Importer\ZipPackageImporter
+ */
+ public static function create(array $fileInfo, Course $course)
+ {
+ if ('text/xml' === $fileInfo['type']) {
+ return new XmlPackageImporter($fileInfo, $course);
+ }
+
+ return new ZipPackageImporter($fileInfo, $course);
+ }
+
+ /**
+ * @return mixed
+ *
+ * @throws Exception
+ */
+ abstract public function import(): string;
+
+ public function getPackageType(): string
+ {
+ return $this->packageType;
+ }
+}
diff --git a/public/plugin/xapi/src/Importer/XmlPackageImporter.php b/public/plugin/xapi/src/Importer/XmlPackageImporter.php
new file mode 100644
index 00000000000..c41384aba98
--- /dev/null
+++ b/public/plugin/xapi/src/Importer/XmlPackageImporter.php
@@ -0,0 +1,26 @@
+packageFileInfo['name'], ['tincan.xml', 'cmi5.xml'])) {
+ throw new Exception('Invalid package');
+ }
+
+ $this->packageType = explode('.', $this->packageFileInfo['name'], 2)[0];
+
+ return $this->packageFileInfo['tmp_name'];
+ }
+}
diff --git a/public/plugin/xapi/src/Importer/ZipPackageImporter.php b/public/plugin/xapi/src/Importer/ZipPackageImporter.php
new file mode 100644
index 00000000000..0ddef299a2e
--- /dev/null
+++ b/public/plugin/xapi/src/Importer/ZipPackageImporter.php
@@ -0,0 +1,85 @@
+packageFileInfo['tmp_name']);
+ $zipContent = $zipFile->listContent();
+
+ $packageSize = array_reduce(
+ $zipContent,
+ function ($accumulator, $zipEntry) {
+ if (preg_match('~.(php.*|phtml)$~i', $zipEntry['filename'])) {
+ throw new Exception("File \"{$zipEntry['filename']}\" contains a PHP script");
+ }
+
+ if (\in_array($zipEntry['filename'], ['tincan.xml', 'cmi5.xml'])) {
+ $this->packageType = explode('.', $zipEntry['filename'], 2)[0];
+ }
+
+ return $accumulator + $zipEntry['size'];
+ }
+ );
+
+ if (empty($this->packageType)) {
+ throw new Exception('Invalid package');
+ }
+
+ $this->validateEnoughSpace($packageSize);
+
+ $pathInfo = pathinfo($this->packageFileInfo['name']);
+
+ $packageDirectoryPath = $this->generatePackageDirectory($pathInfo['filename']);
+
+ $zipFile->extract($packageDirectoryPath);
+
+ return "$packageDirectoryPath/{$this->packageType}.xml";
+ }
+
+ /**
+ * @throws Exception
+ */
+ protected function validateEnoughSpace(int $packageSize): void
+ {
+ $courseSpaceQuota = DocumentManager::get_course_quota($this->course->getCode());
+
+ if (!enough_size($packageSize, $this->courseDirectoryPath, $courseSpaceQuota)) {
+ throw new Exception('Not enough space to storage package.');
+ }
+ }
+
+ private function generatePackageDirectory(string $name): string
+ {
+ $directoryPath = implode(
+ '/',
+ [
+ $this->courseDirectoryPath,
+ $this->packageType,
+ api_replace_dangerous_char($name),
+ ]
+ );
+
+ $fs = new Filesystem();
+ $fs->mkdir(
+ $directoryPath,
+ api_get_permissions_for_new_directories()
+ );
+
+ return $directoryPath;
+ }
+}
diff --git a/public/plugin/xapi/src/Lrs/AboutController.php b/public/plugin/xapi/src/Lrs/AboutController.php
new file mode 100644
index 00000000000..df05b336af0
--- /dev/null
+++ b/public/plugin/xapi/src/Lrs/AboutController.php
@@ -0,0 +1,30 @@
+ [
+ '1.0.3',
+ '1.0.2',
+ '1.0.1',
+ '1.0.0',
+ ],
+ ];
+
+ return JsonResponse::create($json);
+ }
+}
diff --git a/public/plugin/xapi/src/Lrs/ActivitiesProfileController.php b/public/plugin/xapi/src/Lrs/ActivitiesProfileController.php
new file mode 100644
index 00000000000..391750b15fb
--- /dev/null
+++ b/public/plugin/xapi/src/Lrs/ActivitiesProfileController.php
@@ -0,0 +1,80 @@
+httpRequest->query->get('profileId');
+ $activityId = $this->httpRequest->query->get('activityId');
+
+ $em = Database::getManager();
+ $profileRepo = $em->getRepository(XApiActivityProfile::class);
+
+ /** @var XApiActivityProfile $activityProfile */
+ $activityProfile = $profileRepo->findOneBy(
+ [
+ 'profileId' => $profileId,
+ 'activityId' => $activityId,
+ ]
+ );
+
+ if (empty($activityProfile)) {
+ return Response::create(null, Response::HTTP_NO_CONTENT);
+ }
+
+ return Response::create(
+ json_encode($activityProfile->getDocumentData())
+ );
+ }
+
+ public function head(): Response
+ {
+ return $this->get()->setContent('');
+ }
+
+ public function put(): Response
+ {
+ $profileId = $this->httpRequest->query->get('profileId');
+ $activityId = $this->httpRequest->query->get('activityId');
+ $documentData = $this->httpRequest->getContent();
+
+ $em = Database::getManager();
+ $profileRepo = $em->getRepository(XApiActivityProfile::class);
+
+ /** @var XApiActivityProfile $activityProfile */
+ $activityProfile = $profileRepo->findOneBy(
+ [
+ 'profileId' => $profileId,
+ 'activityId' => $activityId,
+ ]
+ );
+
+ if (empty($activityProfile)) {
+ $activityProfile = new XApiActivityProfile();
+ $activityProfile
+ ->setProfileId($profileId)
+ ->setActivityId($activityId)
+ ;
+ }
+
+ $activityProfile->setDocumentData(json_decode($documentData, true));
+
+ $em->persist($activityProfile);
+ $em->flush();
+
+ return Response::create(null, Response::HTTP_NO_CONTENT);
+ }
+}
diff --git a/public/plugin/xapi/src/Lrs/ActivitiesStateController.php b/public/plugin/xapi/src/Lrs/ActivitiesStateController.php
new file mode 100644
index 00000000000..b980fb1a71e
--- /dev/null
+++ b/public/plugin/xapi/src/Lrs/ActivitiesStateController.php
@@ -0,0 +1,121 @@
+httpRequest->query->get('agent');
+ $activityId = $this->httpRequest->query->get('activityId');
+ $stateId = $this->httpRequest->query->get('stateId');
+
+ $state = Database::select(
+ '*',
+ Database::get_main_table('xapi_activity_state'),
+ [
+ 'where' => [
+ 'state_id = ? AND activity_id = ? AND MD5(agent) = ?' => [
+ Database::escape_string($stateId),
+ Database::escape_string($activityId),
+ md5($requestedAgent),
+ ],
+ ],
+ ],
+ 'first'
+ );
+
+ if (empty($state)) {
+ return JsonResponse::create([], Response::HTTP_NOT_FOUND);
+ }
+
+ $requestedAgent = $serializer->deserialize(
+ $this->httpRequest->query->get('agent'),
+ Actor::class,
+ 'json'
+ );
+
+ /** @var Actor $stateAgent */
+ $stateAgent = $serializer->deserialize(
+ $state['agent'],
+ Actor::class,
+ 'json'
+ );
+
+ if (!$stateAgent->equals($requestedAgent)) {
+ return JsonResponse::create([], Response::HTTP_NOT_FOUND);
+ }
+
+ $documentData = json_decode($state['document_data'], true);
+
+ return JsonResponse::create($documentData);
+ }
+
+ public function head(): Response
+ {
+ return $this->get()->setContent('');
+ }
+
+ public function post(): Response
+ {
+ return $this->put();
+ }
+
+ public function put(): Response
+ {
+ $activityId = $this->httpRequest->query->get('activityId');
+ $agent = $this->httpRequest->query->get('agent');
+ $stateId = $this->httpRequest->query->get('stateId');
+ $documentData = $this->httpRequest->getContent();
+
+ $state = Database::select(
+ 'id',
+ Database::get_main_table('xapi_activity_state'),
+ [
+ 'where' => [
+ 'state_id = ? AND activity_id = ? AND MD5(agent) = ?' => [
+ Database::escape_string($stateId),
+ Database::escape_string($activityId),
+ md5($agent),
+ ],
+ ],
+ ],
+ 'first'
+ );
+
+ $em = Database::getManager();
+
+ if (empty($state)) {
+ $state = new XApiActivityState();
+ $state
+ ->setActivityId($activityId)
+ ->setAgent(json_decode($agent, true))
+ ->setStateId($stateId)
+ ;
+ } else {
+ $state = $em->find(XApiActivityState::class, $state['id']);
+ }
+
+ $state->setDocumentData(json_decode($documentData, true));
+
+ $em->persist($state);
+ $em->flush();
+
+ return Response::create('', Response::HTTP_NO_CONTENT);
+ }
+}
diff --git a/public/plugin/xapi/src/Lrs/BaseController.php b/public/plugin/xapi/src/Lrs/BaseController.php
new file mode 100644
index 00000000000..353cc4c68e5
--- /dev/null
+++ b/public/plugin/xapi/src/Lrs/BaseController.php
@@ -0,0 +1,25 @@
+httpRequest = $httpRequest;
+ }
+}
diff --git a/public/plugin/xapi/src/Lrs/LrsRequest.php b/public/plugin/xapi/src/Lrs/LrsRequest.php
new file mode 100644
index 00000000000..b4f4389def6
--- /dev/null
+++ b/public/plugin/xapi/src/Lrs/LrsRequest.php
@@ -0,0 +1,218 @@
+request = HttpRequest::createFromGlobals();
+ }
+
+ public function send(): void
+ {
+ try {
+ $this->alternateRequestSyntax();
+
+ $controllerName = $this->getControllerName();
+ $methodName = $this->getMethodName();
+
+ $response = $this->generateResponse($controllerName, $methodName);
+ } catch (XApiException $xApiException) {
+ $response = HttpResponse::create('', HttpResponse::HTTP_BAD_REQUEST);
+ } catch (HttpException $httpException) {
+ $response = HttpResponse::create(
+ $httpException->getMessage(),
+ $httpException->getStatusCode()
+ );
+ } catch (Exception $exception) {
+ $response = HttpResponse::create($exception->getMessage(), HttpResponse::HTTP_BAD_REQUEST);
+ }
+
+ $response->headers->set('X-Experience-API-Version', '1.0.3');
+
+ $response->send();
+ }
+
+ /**
+ * @throws AccessDeniedException
+ */
+ private function validateAuth(): bool
+ {
+ if (!$this->request->headers->has('Authorization')) {
+ throw new AccessDeniedException();
+ }
+
+ $authHeader = $this->request->headers->get('Authorization');
+
+ $parts = explode('Basic ', $authHeader, 2);
+
+ if (empty($parts[1])) {
+ throw new AccessDeniedException();
+ }
+
+ $authDecoded = base64_decode($parts[1]);
+
+ $parts = explode(':', $authDecoded, 2);
+
+ if (empty($parts) || 2 !== \count($parts)) {
+ throw new AccessDeniedException();
+ }
+
+ list($username, $password) = $parts;
+
+ $auth = Database::getManager()
+ ->getRepository(XApiLrsAuth::class)
+ ->findOneBy(
+ ['username' => $username, 'password' => $password, 'enabled' => true]
+ )
+ ;
+
+ if (null == $auth) {
+ throw new AccessDeniedException();
+ }
+
+ return true;
+ }
+
+ private function validateVersion(): void
+ {
+ $version = $this->request->headers->get('X-Experience-API-Version');
+
+ if (null === $version) {
+ throw new BadRequestHttpException('The "X-Experience-API-Version" header is required.');
+ }
+
+ if (preg_match('/^1\.0(?:\.\d+)?$/', $version)) {
+ if ('1.0' === $version) {
+ $this->request->headers->set('X-Experience-API-Version', '1.0.0');
+ }
+
+ return;
+ }
+
+ throw new BadRequestHttpException("The xAPI version \"$version\" is not supported.");
+ }
+
+ private function getControllerName(): ?string
+ {
+ $segments = explode('/', $this->request->getPathInfo());
+ $segments = array_filter($segments);
+ $segments = array_values($segments);
+
+ if (empty($segments)) {
+ throw new BadRequestHttpException('Bad request');
+ }
+
+ $segments = array_map('ucfirst', $segments);
+ $controllerName = implode('', $segments).'Controller';
+
+ return "Chamilo\\PluginBundle\\XApi\\Lrs\\$controllerName";
+ }
+
+ private function getMethodName(): string
+ {
+ $method = $this->request->getMethod();
+
+ return strtolower($method);
+ }
+
+ /**
+ * @throws AccessDeniedException
+ */
+ private function generateResponse(string $controllerName, string $methodName): HttpResponse
+ {
+ if (!class_exists($controllerName)
+ || !method_exists($controllerName, $methodName)
+ ) {
+ throw new NotFoundHttpException();
+ }
+
+ if (AboutController::class !== $controllerName) {
+ $this->validateAuth();
+ $this->validateVersion();
+ }
+
+ /** @var HttpResponse $response */
+ return \call_user_func(
+ [
+ new $controllerName($this->request),
+ $methodName,
+ ]
+ );
+ }
+
+ private function alternateRequestSyntax(): void
+ {
+ if ('POST' !== $this->request->getMethod()) {
+ return;
+ }
+
+ if (null === $method = $this->request->query->get('method')) {
+ return;
+ }
+
+ if ($this->request->query->count() > 1) {
+ throw new BadRequestHttpException('Including other query parameters than "method" is not allowed. You have to send them as POST parameters inside the request body.');
+ }
+
+ $this->request->setMethod($method);
+ $this->request->query->remove('method');
+
+ if (null !== $content = $this->request->request->get('content')) {
+ $this->request->request->remove('content');
+
+ $this->request->initialize(
+ $this->request->query->all(),
+ $this->request->request->all(),
+ $this->request->attributes->all(),
+ $this->request->cookies->all(),
+ $this->request->files->all(),
+ $this->request->server->all(),
+ $content
+ );
+ }
+
+ $headerNames = [
+ 'Authorization',
+ 'X-Experience-API-Version',
+ 'Content-Type',
+ 'Content-Length',
+ 'If-Match',
+ 'If-None-Match',
+ ];
+
+ foreach ($this->request->request as $key => $value) {
+ if (\in_array($key, $headerNames, true)) {
+ $this->request->headers->set($key, $value);
+ } else {
+ $this->request->query->set($key, $value);
+ }
+
+ $this->request->request->remove($key);
+ }
+ }
+}
diff --git a/public/plugin/xapi/src/Lrs/StatementsController.php b/public/plugin/xapi/src/Lrs/StatementsController.php
new file mode 100644
index 00000000000..f212dd174c4
--- /dev/null
+++ b/public/plugin/xapi/src/Lrs/StatementsController.php
@@ -0,0 +1,147 @@
+statementRepository = new StatementRepository(
+ $pluginEm->getRepository(StatementEntity::class)
+ );
+ $this->serializer = Serializer::createSerializer();
+ $this->serializerFactory = new SerializerFactory($this->serializer);
+ }
+
+ public function get(): Response
+ {
+ $getStatementController = new StatementGetController(
+ $this->statementRepository,
+ $this->serializerFactory->createStatementSerializer(),
+ $this->serializerFactory->createStatementResultSerializer(),
+ new StatementsFilterFactory(
+ new ActorSerializer($this->serializer)
+ )
+ );
+
+ return $getStatementController->getStatement($this->httpRequest);
+ }
+
+ public function head(): Response
+ {
+ $headStatementController = new StatementHeadController(
+ $this->statementRepository,
+ $this->serializerFactory->createStatementSerializer(),
+ $this->serializerFactory->createStatementResultSerializer(),
+ new StatementsFilterFactory(
+ new ActorSerializer($this->serializer)
+ )
+ );
+
+ return $headStatementController->getStatement($this->httpRequest);
+ }
+
+ public function put(): Response
+ {
+ $statement = $this->serializerFactory
+ ->createStatementSerializer()
+ ->deserializeStatement(
+ $this->httpRequest->getContent()
+ )
+ ;
+
+ $putStatementController = new StatementPutController($this->statementRepository);
+
+ $response = $putStatementController->putStatement($this->httpRequest, $statement);
+
+ $this->saveLog(
+ [$this->httpRequest->query->get('statementId')]
+ );
+
+ return $response;
+ }
+
+ public function post(): Response
+ {
+ $content = $this->httpRequest->getContent();
+
+ if ('[' !== substr($content, 0, 1)) {
+ $content = "[$content]";
+ }
+
+ $statements = $this->serializerFactory
+ ->createStatementSerializer()
+ ->deserializeStatements($content)
+ ;
+
+ $postStatementController = new StatementPostController($this->statementRepository);
+
+ $response = $postStatementController->postStatements($this->httpRequest, $statements);
+
+ $this->saveLog(
+ json_decode($response->getContent(), false)
+ );
+
+ return $response;
+ }
+
+ /**
+ * @param array $statementsId
+ */
+ private function saveLog(array $statementsId): void
+ {
+ foreach ($statementsId as $statementId) {
+ try {
+ $storedStatement = $this->statementRepository->findStatementById(
+ StatementId::fromString($statementId)
+ );
+
+ InternalLogUtil::saveStatementForInternalLog($storedStatement);
+ } catch (NotFoundException $e) {
+ }
+ }
+ }
+}
diff --git a/public/plugin/xapi/src/Lrs/Utils/InternalLogUtil.php b/public/plugin/xapi/src/Lrs/Utils/InternalLogUtil.php
new file mode 100644
index 00000000000..24b4fa364b0
--- /dev/null
+++ b/public/plugin/xapi/src/Lrs/Utils/InternalLogUtil.php
@@ -0,0 +1,119 @@
+getActor())) {
+ return;
+ }
+
+ $statementObject = $statement->getObject();
+
+ if (!$statementObject instanceof Activity) {
+ return;
+ }
+
+ $languageIso = api_get_language_isocode();
+ $statementVerbString = XApiPlugin::extractVerbInLanguage($statement->getVerb()->getDisplay(), $languageIso);
+
+ $internalLog = new XApiInternalLog();
+ $internalLog
+ ->setUser($user)
+ ->setVerb($statementVerbString)
+ ->setObjectId($statementObject->getId()->getValue())
+ ;
+
+ if (null !== $statementId = $statement->getId()) {
+ $internalLog->setStatementId($statementId->getValue());
+ }
+
+ if (null !== $definition = $statementObject->getDefinition()) {
+ if (null !== $nameInLanguages = $definition->getName()) {
+ $internalLog->setActivityName(
+ XApiPlugin::extractVerbInLanguage($nameInLanguages, $languageIso)
+ );
+ }
+
+ if (null !== $descriptionInLanguage = $definition->getDescription()) {
+ $internalLog->setActivityDescription(
+ XApiPlugin::extractVerbInLanguage($descriptionInLanguage, $languageIso)
+ );
+ }
+ }
+
+ if (null !== $statementResult = $statement->getResult()) {
+ if (null !== $score = $statementResult->getScore()) {
+ $internalLog
+ ->setScoreScaled(
+ $score->getScaled()
+ )
+ ->setScoreRaw(
+ $score->getRaw()
+ )
+ ->setScoreMin(
+ $score->getMin()
+ )
+ ->setScoreMax(
+ $score->getMax()
+ )
+ ;
+ }
+ }
+
+ if (null !== $created = $statement->getCreated()) {
+ $internalLog->setCreatedAt($created);
+ }
+
+ $em = Database::getManager();
+ $em->persist($internalLog);
+ $em->flush();
+ }
+
+ private static function getUserFromActor(Actor $actor): ?object
+ {
+ if (!$actor instanceof Agent) {
+ return null;
+ }
+
+ $actorIri = $actor->getInverseFunctionalIdentifier();
+
+ if (null === $actorIri) {
+ return null;
+ }
+
+ $userRepo = UserManager::getRepository();
+
+ $user = null;
+
+ if (null !== $mbox = $actorIri->getMbox()) {
+ if ((null !== $parts = explode(':', $mbox->getValue(), 2)) && !empty($parts[1])) {
+ $user = $userRepo->findOneBy(['email' => $parts[1]]);
+ }
+ } elseif (null !== $account = $actorIri->getAccount()) {
+ $chamiloIrl = IRL::fromString(api_get_path(WEB_PATH));
+
+ if ($account->getHomePage()->equals($chamiloIrl)) {
+ $user = $userRepo->findOneBy(['username' => $account->getName()]);
+ }
+ }
+
+ return $user;
+ }
+}
diff --git a/public/plugin/xapi/src/Parser/Cmi5Parser.php b/public/plugin/xapi/src/Parser/Cmi5Parser.php
new file mode 100644
index 00000000000..e939ff5bb15
--- /dev/null
+++ b/public/plugin/xapi/src/Parser/Cmi5Parser.php
@@ -0,0 +1,179 @@
+filePath);
+ $xml = new Crawler($content);
+
+ $courseNode = $xml->filterXPath('//courseStructure/course');
+
+ $toolLaunch = new XApiToolLaunch();
+ $toolLaunch
+ ->setTitle(
+ current(
+ $this->getLanguageStrings(
+ $courseNode->filterXPath('//title')
+ )
+ )
+ )
+ ->setDescription(
+ current(
+ $this->getLanguageStrings(
+ $courseNode->filterXPath('//description')
+ )
+ )
+ )
+ ->setLaunchUrl('')
+ ->setActivityId($courseNode->attr('id'))
+ ->setActivityType('cmi5')
+ ->setAllowMultipleAttempts(false)
+ ->setCreatedAt(api_get_utc_datetime(null, false, true))
+ ->setCourse($this->course)
+ ->setSession($this->session)
+ ;
+
+ $toc = $this->generateToC($xml);
+
+ foreach ($toc as $cmi5Item) {
+ $toolLaunch->addItem($cmi5Item);
+ }
+
+ return $toolLaunch;
+ }
+
+ /**
+ * @return array
+ */
+ private function getLanguageStrings(Crawler $node)
+ {
+ $map = [];
+
+ foreach ($node->children() as $child) {
+ $key = $child->attributes['lang']->value;
+ $value = trim($child->textContent);
+
+ $map[$key] = $value;
+ }
+
+ return $map;
+ }
+
+ /**
+ * @return array
+ */
+ private function generateToC(Crawler $xml): array
+ {
+ $blocksMap = [];
+
+ /** @var array|XApiCmi5Item[] $items */
+ $items = $xml
+ ->filterXPath('//*')
+ ->reduce(
+ function (Crawler $node, $i) {
+ return \in_array($node->nodeName(), ['au', 'block']);
+ }
+ )
+ ->each(
+ function (Crawler $node, $i) use (&$blocksMap) {
+ $attributes = ['id', 'activityType', 'launchMethod', 'moveOn', 'masteryScore'];
+
+ list($id, $activityType, $launchMethod, $moveOn, $masteryMode) = $node->extract($attributes)[0];
+
+ $item = new XApiCmi5Item();
+ $item
+ ->setIdentifier($id)
+ ->setType($node->nodeName())
+ ->setTitle(
+ $this->getLanguageStrings(
+ $node->filterXPath('//title')
+ )
+ )
+ ->setDescription(
+ $this->getLanguageStrings(
+ $node->filterXPath('//description')
+ )
+ )
+ ;
+
+ if ('au' === $node->nodeName()) {
+ $launchParametersNode = $node->filterXPath('//launchParameters');
+ $entitlementKeyNode = $node->filterXPath('//entitlementKey');
+ $url
+ = $item
+ ->setUrl(
+ $this->parseLaunchUrl(
+ trim($node->filterXPath('//url')->text())
+ )
+ )
+ ->setActivityType($activityType ?: null)
+ ->setLaunchMethod($launchMethod ?: null)
+ ->setMoveOn($moveOn ?: 'NotApplicable')
+ ->setMasteryScore((float) $masteryMode ?: null)
+ ->setLaunchParameters(
+ $launchParametersNode->count() > 0 ? trim($launchParametersNode->text()) : null
+ )
+ ->setEntitlementKey(
+ $entitlementKeyNode->count() > 0 ? trim($entitlementKeyNode->text()) : null
+ )
+ ;
+ }
+
+ $parentNode = $node->parents()->first();
+
+ if ('block' === $parentNode->nodeName()) {
+ $blocksMap[$i] = $parentNode->attr('id');
+ }
+
+ return $item;
+ }
+ )
+ ;
+
+ foreach ($blocksMap as $itemPos => $parentIdentifier) {
+ foreach ($items as $item) {
+ if ($parentIdentifier === $item->getIdentifier()) {
+ $items[$itemPos]->setParent($item);
+ }
+ }
+ }
+
+ return $items;
+ }
+
+ /**
+ * @param string $url
+ *
+ * @return string
+ */
+ private function parseLaunchUrl($url)
+ {
+ $urlInfo = parse_url($url);
+
+ if (empty($urlInfo['scheme'])) {
+ $baseUrl = str_replace(
+ api_get_path(SYS_COURSE_PATH),
+ api_get_path(WEB_COURSE_PATH),
+ \dirname($this->filePath)
+ );
+
+ return "$baseUrl/$url";
+ }
+
+ return $url;
+ }
+}
diff --git a/public/plugin/xapi/src/Parser/PackageParser.php b/public/plugin/xapi/src/Parser/PackageParser.php
new file mode 100644
index 00000000000..15a0f0d5457
--- /dev/null
+++ b/public/plugin/xapi/src/Parser/PackageParser.php
@@ -0,0 +1,64 @@
+filePath = $filePath;
+ $this->course = $course;
+ $this->session = $session;
+ }
+
+ /**
+ * @return mixed
+ *
+ * @throws Exception
+ */
+ public static function create(string $packageType, string $filePath, Course $course, ?Session $session = null)
+ {
+ switch ($packageType) {
+ case 'tincan':
+ return new TinCanParser($filePath, $course, $session);
+
+ case 'cmi5':
+ return new Cmi5Parser($filePath, $course, $session);
+
+ default:
+ throw new Exception('Invalid package.');
+ }
+ }
+
+ abstract public function parse(): XApiToolLaunch;
+}
diff --git a/public/plugin/xapi/src/Parser/TinCanParser.php b/public/plugin/xapi/src/Parser/TinCanParser.php
new file mode 100644
index 00000000000..aa3e876a1be
--- /dev/null
+++ b/public/plugin/xapi/src/Parser/TinCanParser.php
@@ -0,0 +1,67 @@
+filePath);
+
+ $xml = new Crawler($content);
+
+ $activityNode = $xml->filter('tincan activities activity')->first();
+ $nodeName = $activityNode->filter('name');
+ $nodeDescription = $activityNode->filter('description');
+ $nodeLaunch = $activityNode->filter('launch');
+
+ $toolLaunch = new XApiToolLaunch();
+ $toolLaunch
+ ->setCourse($this->course)
+ ->setSession($this->session)
+ ->setCreatedAt(api_get_utc_datetime(null, false, true))
+ ->setActivityId($activityNode->attr('id'))
+ ->setActivityType($activityNode->attr('type'))
+ ->setLaunchUrl($this->parseLaunchUrl($nodeLaunch))
+ ;
+
+ if ($nodeName) {
+ $toolLaunch->setTitle($nodeName->text());
+ }
+
+ if ($nodeDescription) {
+ $toolLaunch->setDescription($nodeDescription->text() ?: null);
+ }
+
+ return $toolLaunch;
+ }
+
+ private function parseLaunchUrl(Crawler $launchNode): string
+ {
+ $launchUrl = $launchNode->text();
+
+ $urlInfo = parse_url($launchUrl);
+
+ if (empty($urlInfo['scheme'])) {
+ $baseUrl = str_replace(
+ api_get_path(SYS_COURSE_PATH),
+ api_get_path(WEB_COURSE_PATH),
+ \dirname($this->filePath)
+ );
+
+ return "$baseUrl/$launchUrl";
+ }
+
+ return $launchUrl;
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Activity/BaseActivity.php b/public/plugin/xapi/src/ToolExperience/Activity/BaseActivity.php
new file mode 100644
index 00000000000..c169021714d
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Activity/BaseActivity.php
@@ -0,0 +1,49 @@
+getCourseLanguage());
+
+ $courseUrl = api_get_course_url(
+ $course->getCode(),
+ $session ? $session->getId() : 0
+ );
+
+ return new Activity(
+ IRI::fromString($courseUrl),
+ new Definition(
+ LanguageMap::create([$languageIso => $course->getTitle()]),
+ null,
+ IRI::fromString('http://id.tincanapi.com/activitytype/lms/course')
+ )
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Activity/LearningPath.php b/public/plugin/xapi/src/ToolExperience/Activity/LearningPath.php
new file mode 100644
index 00000000000..7c3c7bcddf9
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Activity/LearningPath.php
@@ -0,0 +1,53 @@
+lp = $lp;
+ }
+
+ public function generate(): Activity
+ {
+ $lanIso = api_get_language_isocode();
+
+ $iri = $this->generateIri(
+ WEB_CODE_PATH,
+ 'lp/lp_controller.php',
+ [
+ 'action' => 'view',
+ 'lp_id' => $this->lp->getId(),
+ 'isStudentView' => 'true',
+ ]
+ );
+
+ return new Activity(
+ IRI::fromString($iri),
+ new Definition(
+ LanguageMap::create([$lanIso => $this->lp->getName()]),
+ null,
+ IRI::fromString('http://adlnet.gov/expapi/activities/lesson')
+ )
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Activity/LearningPathItem.php b/public/plugin/xapi/src/ToolExperience/Activity/LearningPathItem.php
new file mode 100644
index 00000000000..74a44f55f87
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Activity/LearningPathItem.php
@@ -0,0 +1,54 @@
+lpItem = $lpItem;
+ }
+
+ public function generate(): Activity
+ {
+ $langIso = api_get_language_isocode();
+
+ $iri = $this->generateIri(
+ WEB_CODE_PATH,
+ 'lp/lp_controller.php',
+ [
+ 'action' => 'view',
+ 'lp_id' => $this->lpItem->getLpId(),
+ 'isStudentView' => 'true',
+ 'lp_item' => $this->lpItem->getId(),
+ ]
+ );
+
+ return new Activity(
+ IRI::fromString($iri),
+ new Definition(
+ LanguageMap::create([$langIso => $this->lpItem->getTitle()]),
+ null,
+ IRI::fromString('http://id.tincanapi.com/activitytype/resource')
+ )
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Activity/Portfolio.php b/public/plugin/xapi/src/ToolExperience/Activity/Portfolio.php
new file mode 100644
index 00000000000..158552a69c2
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Activity/Portfolio.php
@@ -0,0 +1,54 @@
+owner = $owner;
+ }
+
+ public function generate(): Activity
+ {
+ $langIso = api_get_language_isocode();
+
+ $iri = $this->generateIri(
+ WEB_CODE_PATH,
+ 'portfolio/index.php',
+ [
+ 'action' => 'list',
+ 'user' => $this->owner->getId(),
+ ]
+ );
+
+ return new Activity(
+ IRI::fromString($iri),
+ new Definition(
+ LanguageMap::create(
+ [
+ $langIso => sprintf(
+ get_lang('XUserPortfolioItems'),
+ $this->owner->getCompleteNameWithUsername()
+ ),
+ ]
+ ),
+ null,
+ IRI::fromString('http://id.tincanapi.com/activitytype/collection-simple')
+ )
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Activity/PortfolioCategory.php b/public/plugin/xapi/src/ToolExperience/Activity/PortfolioCategory.php
new file mode 100644
index 00000000000..ecda193e1cc
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Activity/PortfolioCategory.php
@@ -0,0 +1,58 @@
+category = $category;
+ }
+
+ public function generate(): Activity
+ {
+ $iri = $this->generateIri(
+ WEB_PATH,
+ 'xapi/portfolio/',
+ [
+ 'user' => $this->category->getUser()->getId(),
+ 'category' => $this->category->getId(),
+ ]
+ );
+
+ $langIso = api_get_language_isocode();
+
+ $categoryDescription = $this->category->getDescription();
+
+ $definitionDescription = $categoryDescription
+ ? LanguageMap::create([$langIso => $categoryDescription])
+ : null;
+
+ return new Activity(
+ IRI::fromString($iri),
+ new Definition(
+ LanguageMap::create([$langIso => $this->category->getTitle()]),
+ $definitionDescription,
+ IRI::fromString('http://id.tincanapi.com/activitytype/category')
+ )
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Activity/PortfolioComment.php b/public/plugin/xapi/src/ToolExperience/Activity/PortfolioComment.php
new file mode 100644
index 00000000000..21a020b80ea
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Activity/PortfolioComment.php
@@ -0,0 +1,50 @@
+comment = $comment;
+ }
+
+ public function generate(): Activity
+ {
+ $iri = $this->generateIri(
+ WEB_CODE_PATH,
+ 'portfolio/index.php',
+ [
+ 'action' => 'view',
+ 'id' => $this->comment->getItem()->getId(),
+ 'comment' => $this->comment->getId(),
+ ]
+ );
+
+ return new Activity(
+ IRI::fromString($iri),
+ new Definition(
+ null,
+ null,
+ IRI::fromString('http://activitystrea.ms/schema/1.0/comment')
+ )
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Activity/PortfolioItem.php b/public/plugin/xapi/src/ToolExperience/Activity/PortfolioItem.php
new file mode 100644
index 00000000000..5506b833dc2
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Activity/PortfolioItem.php
@@ -0,0 +1,49 @@
+item = $item;
+ }
+
+ public function generate(): Activity
+ {
+ $langIso = api_get_language_isocode();
+
+ $iri = $this->generateIri(
+ WEB_CODE_PATH,
+ 'portfolio/index.php',
+ ['action' => 'view', 'id' => $this->item->getId()]
+ );
+
+ return new Activity(
+ IRI::fromString($iri),
+ new Definition(
+ LanguageMap::create([$langIso => $this->item->getTitle()]),
+ null,
+ IRI::fromString('http://activitystrea.ms/schema/1.0/article')
+ )
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Activity/Quiz.php b/public/plugin/xapi/src/ToolExperience/Activity/Quiz.php
new file mode 100644
index 00000000000..778cc806648
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Activity/Quiz.php
@@ -0,0 +1,57 @@
+quiz = $quiz;
+ }
+
+ public function generate(): Activity
+ {
+ $langIso = api_get_language_isocode();
+
+ $iri = $this->generateIri(
+ WEB_CODE_PATH,
+ 'exercise/overview.php',
+ ['exerciseId' => $this->quiz->getId()]
+ );
+
+ $definitionDescription = null;
+
+ if ($this->quiz->getDescription()) {
+ $definitionDescription = LanguageMap::create(
+ [$langIso => $this->quiz->getDescription()]
+ );
+ }
+
+ return new Activity(
+ IRI::fromString($iri),
+ new Definition(
+ LanguageMap::create([$langIso => $this->quiz->getTitle()]),
+ $definitionDescription,
+ IRI::fromString('http://adlnet.gov/expapi/activities/assessment')
+ )
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Activity/QuizQuestion.php b/public/plugin/xapi/src/ToolExperience/Activity/QuizQuestion.php
new file mode 100644
index 00000000000..2a8f613fe1a
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Activity/QuizQuestion.php
@@ -0,0 +1,176 @@
+question = $question;
+ }
+
+ public function generate(): Activity
+ {
+ $iri = $this->generateIri(
+ WEB_CODE_PATH,
+ 'xapi/quiz/',
+ ['question' => $this->question->getId()]
+ );
+
+ return new Activity(
+ IRI::fromString($iri),
+ $this->generateActivityDefinitionFromQuestionType()
+ );
+ }
+
+ /**
+ * @return InteractionDefinition
+ */
+ private function generateActivityDefinitionFromQuestionType()
+ {
+ $languageIso = api_get_language_isocode();
+ $courseId = api_get_course_int_id();
+
+ $questionTitle = strip_tags($this->question->getQuestion());
+ $questionTitle = trim($questionTitle);
+ $questionDescription = strip_tags($this->question->getDescription());
+ $questionDescription = trim($questionDescription);
+
+ $titleMap = LanguageMap::create([$languageIso => $questionTitle]);
+ $descriptionMap = $questionDescription ? LanguageMap::create([$languageIso => $questionDescription]) : null;
+
+ $objAnswer = new Answer($this->question->getId(), $courseId);
+ $objAnswer->read();
+
+ $type = IRI::fromString('http://adlnet.gov/expapi/activities/question');
+
+ switch ($this->question->getType()) {
+ case MULTIPLE_ANSWER:
+ case UNIQUE_ANSWER:
+ case UNIQUE_ANSWER_IMAGE:
+ case READING_COMPREHENSION:
+ $choices = [];
+ $correctResponsesPattern = [];
+
+ for ($i = 1; $i <= $objAnswer->nbrAnswers; $i++) {
+ $choices[] = new InteractionComponent(
+ $objAnswer->iid[$i],
+ LanguageMap::create([$languageIso => $objAnswer->selectAnswer($i)])
+ );
+
+ if ($objAnswer->isCorrect($i)) {
+ $correctResponsesPattern[] = $objAnswer->iid[$i];
+ }
+ }
+
+ return new ChoiceInteractionDefinition(
+ $titleMap,
+ $descriptionMap,
+ $type,
+ null,
+ null,
+ [implode('[,]', $correctResponsesPattern)],
+ $choices
+ );
+
+ case DRAGGABLE:
+ $choices = [];
+
+ for ($i = 1; $i <= $objAnswer->nbrAnswers; $i++) {
+ if ((int) $objAnswer->correct[$i] > 0) {
+ $choices[] = new InteractionComponent(
+ $objAnswer->correct[$i],
+ LanguageMap::create([$languageIso => $objAnswer->answer[$i]])
+ );
+ }
+ }
+
+ $correctResponsesPattern = \array_slice($objAnswer->autoId, 0, $objAnswer->nbrAnswers / 2);
+
+ return new SequencingInteractionDefinition(
+ $titleMap,
+ $descriptionMap,
+ $type,
+ null,
+ null,
+ [implode('[,]', $correctResponsesPattern)],
+ $choices
+ );
+
+ case MATCHING:
+ case MATCHING_DRAGGABLE:
+ /** @var array|InteractionComponent[] $source */
+ $source = [];
+
+ /** @var array|InteractionComponent[] $source */
+ $target = [];
+ $correctResponsesPattern = [];
+
+ for ($i = 1; $i <= $objAnswer->nbrAnswers; $i++) {
+ $interactionComponent = new InteractionComponent(
+ $objAnswer->selectAutoId($i),
+ LanguageMap::create([$languageIso => $objAnswer->selectAnswer($i)])
+ );
+
+ if ((int) $objAnswer->correct[$i] > 0) {
+ $source[] = $interactionComponent;
+
+ $correctResponsesPattern[] = $objAnswer->selectAutoId($i).'[.]'.$objAnswer->correct[$i];
+ } else {
+ $target[] = $interactionComponent;
+ }
+ }
+
+ return new MatchingInteractionDefinition(
+ $titleMap,
+ $descriptionMap,
+ $type,
+ null,
+ null,
+ [implode('[,]', $correctResponsesPattern)],
+ $source,
+ $target
+ );
+
+ case FREE_ANSWER:
+ return new LongFillInInteractionDefinition($titleMap, $descriptionMap, $type);
+
+ case FILL_IN_BLANKS:
+ case HOT_SPOT:
+ case HOT_SPOT_DELINEATION:
+ case MULTIPLE_ANSWER_COMBINATION:
+ case UNIQUE_ANSWER_NO_OPTION:
+ case MULTIPLE_ANSWER_TRUE_FALSE:
+ case MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY:
+ case MULTIPLE_ANSWER_COMBINATION_TRUE_FALSE:
+ case GLOBAL_MULTIPLE_ANSWER:
+ case CALCULATED_ANSWER:
+ case ANNOTATION:
+ case ORAL_EXPRESSION:
+ default:
+ return new OtherInteractionDefinition($titleMap, $descriptionMap, $type);
+ }
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Activity/Site.php b/public/plugin/xapi/src/ToolExperience/Activity/Site.php
new file mode 100644
index 00000000000..c757b10e046
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Activity/Site.php
@@ -0,0 +1,33 @@
+ $platform])
+ )
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Actor/BaseActor.php b/public/plugin/xapi/src/ToolExperience/Actor/BaseActor.php
new file mode 100644
index 00000000000..9feab5205dd
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Actor/BaseActor.php
@@ -0,0 +1,17 @@
+user = $user;
+ }
+
+ public function generate(): Agent
+ {
+ return new Agent(
+ InverseFunctionalIdentifier::withMbox(
+ IRI::fromString('mailto:'.$this->user->getEmail())
+ ),
+ $this->user->getCompleteName()
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Statement/BaseStatement.php b/public/plugin/xapi/src/ToolExperience/Statement/BaseStatement.php
new file mode 100644
index 00000000000..8658e1cf510
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Statement/BaseStatement.php
@@ -0,0 +1,54 @@
+get(XApiPlugin::SETTING_UUID_NAMESPACE),
+ uniqid($type)
+ );
+
+ return StatementId::fromUuid($uuid);
+ }
+
+ protected function generateContext(): Context
+ {
+ $platform = api_get_setting('Institution').' - '.api_get_setting('siteName');
+
+ $groupingActivities = [];
+ $groupingActivities[] = (new SiteActivity())->generate();
+
+ if (api_get_course_id()) {
+ $groupingActivities[] = (new CourseActivity())->generate();
+ }
+
+ return (new Context())
+ ->withPlatform($platform)
+ ->withLanguage(api_get_language_isocode())
+ ->withContextActivities(
+ new ContextActivities(null, $groupingActivities)
+ )
+ ;
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Statement/LearningPathCompleted.php b/public/plugin/xapi/src/ToolExperience/Statement/LearningPathCompleted.php
new file mode 100644
index 00000000000..88f6ce4a1d8
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Statement/LearningPathCompleted.php
@@ -0,0 +1,64 @@
+lpView = $lpView;
+ $this->lp = $lp;
+ }
+
+ public function generate(): Statement
+ {
+ $user = api_get_user_entity($this->lpView->getUserId());
+ $userActor = new UserActor($user);
+ $completedVerb = new Completed();
+ $lpActivity = new LearningPathActivity($this->lp);
+
+ return new Statement(
+ $this->generateStatementId('learning-path'),
+ $userActor->generate(),
+ $completedVerb->generate(),
+ $lpActivity->generate(),
+ new Result(
+ new Score(1, 100, 0, 100),
+ null,
+ true
+ ),
+ null,
+ api_get_utc_datetime(null, false, true),
+ null,
+ $this->generateContext()
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Statement/LearningPathItemViewed.php b/public/plugin/xapi/src/ToolExperience/Statement/LearningPathItemViewed.php
new file mode 100644
index 00000000000..afec39eca0b
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Statement/LearningPathItemViewed.php
@@ -0,0 +1,82 @@
+lpItemView = $lpItemView;
+ $this->lpItem = $lpItem;
+ $this->lpView = $lpView;
+ }
+
+ public function generate(): Statement
+ {
+ $user = api_get_user_entity($this->lpView->getUserId());
+ $lp = Database::getManager()->find(CLp::class, $this->lpView->getLpId());
+
+ $userActor = new UserActor($user);
+ $viewedVerb = new ViewedVerb();
+ $lpItemActivity = new LearningPathItemActivity($this->lpItem);
+ $lpActivity = new LearningPath($lp);
+
+ $context = $this->generateContext();
+ $contextActivities = $context
+ ->getContextActivities()
+ ->withAddedGroupingActivity($lpActivity->generate())
+ ;
+
+ return new Statement(
+ $this->generateStatementId('learning-path-item'),
+ $userActor->generate(),
+ $viewedVerb->generate(),
+ $lpItemActivity->generate(),
+ new Result(
+ null,
+ null,
+ 'completed' === $this->lpItemView->getStatus(),
+ null,
+ 'PT'.$this->lpItemView->getTotalTime().'S'
+ ),
+ null,
+ api_get_utc_datetime(null, false, true),
+ null,
+ $context->withContextActivities($contextActivities)
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Statement/PortfolioAttachmentsTrait.php b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioAttachmentsTrait.php
new file mode 100644
index 00000000000..4b488d9dda5
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioAttachmentsTrait.php
@@ -0,0 +1,92 @@
+ $portfolioAttachments
+ *
+ * @return array
+ */
+ protected function generateAttachments(array $portfolioAttachments, User $user): array
+ {
+ if (empty($portfolioAttachments)) {
+ return [];
+ }
+
+ $attachments = [];
+
+ $userDirectory = UserManager::getUserPathById($user->getId(), 'system');
+ $attachmentsDirectory = $userDirectory.'portfolio_attachments/';
+
+ $langIso = api_get_language_isocode();
+
+ $cidreq = api_get_cidreq();
+ $baseUrl = api_get_path(WEB_CODE_PATH).'portfolio/index.php?'.($cidreq ? $cidreq.'&' : '');
+
+ foreach ($portfolioAttachments as $portfolioAttachment) {
+ $attachmentFilename = $attachmentsDirectory.$portfolioAttachment->getPath();
+
+ $display = LanguageMap::create(
+ ['und' => $portfolioAttachment->getFilename()]
+ );
+ $description = null;
+
+ if ($portfolioAttachment->getComment()) {
+ $description = LanguageMap::create(
+ [$langIso => $portfolioAttachment->getComment()]
+ );
+ }
+
+ $attachments[] = new Attachment(
+ IRI::fromString('http://id.tincanapi.com/attachment/supporting_media'),
+ mime_content_type($attachmentFilename),
+ $portfolioAttachment->getSize(),
+ hash_file('sha256', $attachmentFilename),
+ $display,
+ $description,
+ IRL::fromString(
+ $baseUrl.http_build_query(['action' => 'download', 'file' => $portfolioAttachment->getPath()])
+ )
+ );
+ }
+
+ return $attachments;
+ }
+
+ protected function generateAttachmentsForItem(Portfolio $item): array
+ {
+ $itemAttachments = Database::getManager()
+ ->getRepository(PortfolioAttachment::class)
+ ->findFromItem($item)
+ ;
+
+ return $this->generateAttachments($itemAttachments, $item->getUser());
+ }
+
+ protected function generateAttachmentsForComment(PortfolioCommentEntity $comment): array
+ {
+ $commentAttachments = Database::getManager()
+ ->getRepository(PortfolioAttachment::class)
+ ->findFromComment($this->comment)
+ ;
+
+ return $this->generateAttachments($commentAttachments, $comment->getAuthor());
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Statement/PortfolioComment.php b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioComment.php
new file mode 100644
index 00000000000..9b2057a9314
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioComment.php
@@ -0,0 +1,35 @@
+comment = $comment;
+ $this->item = $this->comment->getItem();
+ $this->parentComment = $this->comment->getParent();
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Statement/PortfolioCommentEdited.php b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioCommentEdited.php
new file mode 100644
index 00000000000..677216684c6
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioCommentEdited.php
@@ -0,0 +1,41 @@
+comment);
+ $context = $this->generateContext();
+ $attachements = $this->generateAttachmentsForComment($this->comment);
+
+ return new Statement(
+ $this->generateStatementId('portfolio-comment'),
+ $actor->generate(),
+ $verb->generate(),
+ $object->generate(),
+ null,
+ null,
+ api_get_utc_datetime(null, false, true),
+ null,
+ $context,
+ $attachements
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Statement/PortfolioCommentScored.php b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioCommentScored.php
new file mode 100644
index 00000000000..e916d0a27e3
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioCommentScored.php
@@ -0,0 +1,49 @@
+comment->getScore();
+ $scaled = $maxScore ? ($rawScore / $maxScore) : 0;
+
+ $actor = new User($user);
+ $verb = new Scored();
+ $object = new PortfolioCommentActivity($this->comment);
+ $context = $this->generateContext();
+ $attachments = $this->generateAttachmentsForComment($this->comment);
+ $score = new Score($scaled, $rawScore, 0, $maxScore);
+ $result = new Result($score);
+
+ return new Statement(
+ $this->generateStatementId('portfolio-comment'),
+ $actor->generate(),
+ $verb->generate(),
+ $object->generate(),
+ $result,
+ null,
+ api_get_utc_datetime(null, false, true),
+ null,
+ $context,
+ $attachments
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Statement/PortfolioDownloaded.php b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioDownloaded.php
new file mode 100644
index 00000000000..94c1e2fe9ed
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioDownloaded.php
@@ -0,0 +1,46 @@
+owner = $owner;
+ }
+
+ public function generate(): Statement
+ {
+ $user = api_get_user_entity(api_get_user_id());
+
+ $actor = new UserActor($user);
+ $verb = new Downloaded();
+ $object = new PortfolioActivity($this->owner);
+ $context = $this->generateContext();
+
+ return new Statement(
+ $this->generateStatementId('portfolio-item'),
+ $actor->generate(),
+ $verb->generate(),
+ $object->generate(),
+ null,
+ null,
+ api_get_utc_datetime(null, false, true),
+ null,
+ $context
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItem.php b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItem.php
new file mode 100644
index 00000000000..b9fa481e456
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItem.php
@@ -0,0 +1,43 @@
+item = $item;
+ }
+
+ protected function generateContext(): Context
+ {
+ $context = parent::generateContext();
+
+ $category = $this->item->getCategory();
+
+ if ($category) {
+ $categoryActivity = new PortfolioCategory($category);
+
+ $contextActivities = $context
+ ->getContextActivities()
+ ->withAddedCategoryActivity(
+ $categoryActivity->generate()
+ )
+ ;
+
+ $context = $context->withContextActivities($contextActivities);
+ }
+
+ return $context;
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItemCommented.php b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItemCommented.php
new file mode 100644
index 00000000000..cfd550ffe0c
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItemCommented.php
@@ -0,0 +1,78 @@
+generateStatementId('portfolio-comment');
+
+ $userActor = new UserActor($this->comment->getAuthor());
+ $statementResult = new Result(null, null, null, $this->comment->getContent());
+
+ $context = $this->generateContext();
+
+ $em = Database::getManager();
+ $commentAttachments = $em->getRepository(PortfolioAttachment::class)->findFromComment($this->comment);
+
+ $attachments = $this->generateAttachments(
+ $commentAttachments,
+ $this->comment->getAuthor()
+ );
+
+ if ($this->parentComment) {
+ $repliedVerb = new RepliedVerb();
+
+ $itemActivity = new PortfolioItemActivity($this->item);
+ $parentCommentActivity = new PortfolioCommentActivity($this->parentComment);
+
+ $contextActivities = $context
+ ->getContextActivities()
+ ->withAddedGroupingActivity($itemActivity->generate())
+ ;
+
+ return new Statement(
+ $statementId,
+ $userActor->generate(),
+ $repliedVerb->generate(),
+ $parentCommentActivity->generate(),
+ $statementResult,
+ null,
+ $this->comment->getDate(),
+ null,
+ $context->withContextActivities($contextActivities),
+ $attachments
+ );
+ }
+ $itemShared = new PortfolioItemShared($this->item);
+
+ $commentedVerb = new CommentedVerb();
+
+ return $itemShared->generate()
+ ->withId($statementId)
+ ->withActor($userActor->generate())
+ ->withVerb($commentedVerb->generate())
+ ->withStored($this->comment->getDate())
+ ->withResult($statementResult)
+ ->withContext($context)
+ ->withAttachments($attachments)
+ ;
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItemEdited.php b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItemEdited.php
new file mode 100644
index 00000000000..4cf0e4bf958
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItemEdited.php
@@ -0,0 +1,41 @@
+item);
+ $context = $this->generateContext();
+ $attachements = $this->generateAttachmentsForItem($this->item);
+
+ return new Statement(
+ $this->generateStatementId('portfolio-item'),
+ $actor->generate(),
+ $verb->generate(),
+ $object->generate(),
+ null,
+ null,
+ api_get_utc_datetime(null, false, true),
+ null,
+ $context,
+ $attachements
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItemHighlighted.php b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItemHighlighted.php
new file mode 100644
index 00000000000..84dbee9ef11
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItemHighlighted.php
@@ -0,0 +1,41 @@
+item);
+ $context = $this->generateContext();
+ $attachments = $this->generateAttachmentsForItem($this->item);
+
+ return new Statement(
+ $this->generateStatementId('portfolio-item'),
+ $actor->generate(),
+ $verb->generate(),
+ $object->generate(),
+ null,
+ null,
+ api_get_utc_datetime(null, false, true),
+ null,
+ $context,
+ $attachments
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItemScored.php b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItemScored.php
new file mode 100644
index 00000000000..467cab129bc
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItemScored.php
@@ -0,0 +1,49 @@
+item->getScore();
+ $scaled = $maxScore ? ($rawScore / $maxScore) : 0;
+
+ $actor = new User($user);
+ $verb = new Scored();
+ $object = new PortfolioItemActivity($this->item);
+ $context = $this->generateContext();
+ $attachments = $this->generateAttachmentsForItem($this->item);
+ $score = new Score($scaled, $rawScore, 0, $maxScore);
+ $result = new Result($score);
+
+ return new Statement(
+ $this->generateStatementId('portfolio-item'),
+ $actor->generate(),
+ $verb->generate(),
+ $object->generate(),
+ $result,
+ null,
+ api_get_utc_datetime(null, false, true),
+ null,
+ $context,
+ $attachments
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItemShared.php b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItemShared.php
new file mode 100644
index 00000000000..60b3f6c5829
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItemShared.php
@@ -0,0 +1,47 @@
+item->getUser();
+
+ $userActor = new User($itemAuthor);
+ $sharedVerb = new Shared();
+ $itemActivity = new PortfolioItem($this->item);
+
+ $context = $this->generateContext();
+
+ $attachments = $this->generateAttachmentsForItem($this->item);
+
+ return new Statement(
+ $this->generateStatementId('portfolio-item'),
+ $userActor->generate(),
+ $sharedVerb->generate(),
+ $itemActivity->generate(),
+ null,
+ null,
+ $this->item->getCreationDate(),
+ null,
+ $context,
+ $attachments
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItemViewed.php b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItemViewed.php
new file mode 100644
index 00000000000..19b90e65b0e
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Statement/PortfolioItemViewed.php
@@ -0,0 +1,42 @@
+item);
+ $context = $this->generateContext();
+ $attachments = $this->generateAttachmentsForItem($this->item);
+
+ return new Statement(
+ $this->generateStatementId('portfolio-item'),
+ $actor->generate(),
+ $verb->generate(),
+ $object->generate(),
+ null,
+ null,
+ $this->item->getCreationDate(),
+ null,
+ $context,
+ $attachments
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Statement/QuizCompleted.php b/public/plugin/xapi/src/ToolExperience/Statement/QuizCompleted.php
new file mode 100644
index 00000000000..34fd4d6bf99
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Statement/QuizCompleted.php
@@ -0,0 +1,71 @@
+exe = $exe;
+ $this->quiz = $quiz;
+ }
+
+ public function generate(): Statement
+ {
+ $user = api_get_user_entity($this->exe->getExeUserId());
+
+ $userActor = new UserActor($user);
+ $completedVerb = new CompletedVerb();
+ $quizActivity = new QuizActivity($this->quiz);
+
+ $rawResult = $this->exe->getExeResult();
+ $maxResult = $this->exe->getExeWeighting();
+ $scaledResult = $rawResult / $maxResult;
+
+ $duration = $this->exe->getExeDuration();
+
+ return new Statement(
+ $this->generateStatementId('exercise'),
+ $userActor->generate(),
+ $completedVerb->generate(),
+ $quizActivity->generate(),
+ new Result(
+ new Score($scaledResult, $rawResult, 0, $maxResult),
+ null,
+ true,
+ null,
+ $duration ? "PT{$duration}S" : null
+ ),
+ null,
+ $this->exe->getExeDate(),
+ null,
+ $this->generateContext()
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Statement/QuizQuestionAnswered.php b/public/plugin/xapi/src/ToolExperience/Statement/QuizQuestionAnswered.php
new file mode 100644
index 00000000000..f97dac6ec7b
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Statement/QuizQuestionAnswered.php
@@ -0,0 +1,82 @@
+attempt = $attempt;
+ $this->question = $question;
+ $this->quiz = $quiz;
+ }
+
+ public function generate(): Statement
+ {
+ $user = api_get_user_entity($this->attempt->getUserId());
+
+ $userActor = new UserActor($user);
+ $answeredVerb = new AnsweredVerb();
+ $questionActivity = new QuizQuestionActivity($this->question);
+ $quizActivity = new QuizActivity($this->quiz);
+
+ $rawResult = $this->attempt->getMarks();
+ $maxResult = $this->question->getPonderation();
+ $scaledResult = $maxResult ? ($rawResult / $maxResult) : 0;
+
+ $context = $this->generateContext();
+ $contextActivities = $context
+ ->getContextActivities()
+ ->withAddedGroupingActivity($quizActivity->generate())
+ ;
+
+ return new Statement(
+ $this->generateStatementId('exercise-question'),
+ $userActor->generate(),
+ $answeredVerb->generate(),
+ $questionActivity->generate(),
+ new Result(
+ new Score($scaledResult, $rawResult, null, $maxResult),
+ $rawResult > 0,
+ true
+ ),
+ null,
+ $this->attempt->getTms(),
+ null,
+ $context->withContextActivities($contextActivities)
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Verb/Answered.php b/public/plugin/xapi/src/ToolExperience/Verb/Answered.php
new file mode 100644
index 00000000000..c92b4fe205c
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Verb/Answered.php
@@ -0,0 +1,21 @@
+iri = $iri;
+ $this->display = $display;
+ }
+
+ public function generate(): Verb
+ {
+ $langIso = api_get_language_isocode();
+
+ return new Verb(
+ IRI::fromString($this->iri),
+ LanguageMap::create(
+ [
+ $langIso => get_lang($this->display),
+ ]
+ )
+ );
+ }
+}
diff --git a/public/plugin/xapi/src/ToolExperience/Verb/Commented.php b/public/plugin/xapi/src/ToolExperience/Verb/Commented.php
new file mode 100644
index 00000000000..4a5c3059b78
--- /dev/null
+++ b/public/plugin/xapi/src/ToolExperience/Verb/Commented.php
@@ -0,0 +1,21 @@
+',
+ ];
+ $settings = [
+ self::SETTING_UUID_NAMESPACE => 'text',
+
+ self::SETTING_LRS_URL => 'text',
+ self::SETTING_LRS_AUTH_USERNAME => 'text',
+ self::SETTING_LRS_AUTH_PASSWORD => 'text',
+
+ self::SETTING_CRON_LRS_URL => 'text',
+ self::SETTING_CRON_LRS_AUTH_USERNAME => 'text',
+ self::SETTING_CRON_LRS_AUTH_PASSWORD => 'text',
+
+ self::SETTING_LRS_LP_ITEM_ACTIVE => 'boolean',
+ self::SETTING_LRS_LP_ACTIVE => 'boolean',
+ self::SETTING_LRS_QUIZ_ACTIVE => 'boolean',
+ self::SETTING_LRS_QUIZ_QUESTION_ACTIVE => 'boolean',
+ self::SETTING_LRS_PORTFOLIO_ACTIVE => 'boolean',
+ ];
+
+ parent::__construct(
+ $version,
+ implode(', ', $author),
+ $settings
+ );
+ }
+
+ /**
+ * @return \XApiPlugin
+ */
+ public static function create()
+ {
+ static $result = null;
+
+ return $result ? $result : $result = new self();
+ }
+
+ /**
+ * Process to install plugin.
+ */
+ public function install()
+ {
+ $this->installInitialConfig();
+ $this->addCourseTools();
+ $this->installHook();
+ }
+
+ /**
+ * Process to uninstall plugin.
+ */
+ public function uninstall()
+ {
+ $this->uninstallHook();
+ $this->deleteCourseTools();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function uninstallHook()
+ {
+ $learningPathItemViewedHook = XApiLearningPathItemViewedHookObserver::create();
+ $learningPathEndHook = XApiLearningPathEndHookObserver::create();
+ $quizQuestionAnsweredHook = XApiQuizQuestionAnsweredHookObserver::create();
+ $quizEndHook = XApiQuizEndHookObserver::create();
+ $createCourseHook = XApiCreateCourseHookObserver::create();
+ $portfolioItemAddedHook = XApiPortfolioItemAddedHookObserver::create();
+ $portfolioItemCommentedHook = XApiPortfolioItemCommentedHookObserver::create();
+ $portfolioItemHighlightedHook = XApiPortfolioItemHighlightedHookObserver::create();
+ $portfolioDownloaded = XApiPortfolioDownloadedHookObserver::create();
+ $portfolioItemScoredHook = XApiPortfolioItemScoredHookObserver::create();
+ $portfolioCommentedScoredHook = XApiPortfolioCommentScoredHookObserver::create();
+ $portfolioItemEditedHook = XApiPortfolioItemEditedHookObserver::create();
+ $portfolioCommentEditedHook = XApiPortfolioCommentEditedHookObserver::create();
+
+ HookLearningPathItemViewed::create()->detach($learningPathItemViewedHook);
+ HookLearningPathEnd::create()->detach($learningPathEndHook);
+ HookQuizQuestionAnswered::create()->detach($quizQuestionAnsweredHook);
+ HookQuizEnd::create()->detach($quizEndHook);
+ HookCreateCourse::create()->detach($createCourseHook);
+ HookPortfolioItemAdded::create()->detach($portfolioItemAddedHook);
+ HookPortfolioItemCommented::create()->detach($portfolioItemCommentedHook);
+ HookPortfolioItemHighlighted::create()->detach($portfolioItemHighlightedHook);
+ HookPortfolioDownloaded::create()->detach($portfolioDownloaded);
+ HookPortfolioItemScored::create()->detach($portfolioItemScoredHook);
+ HookPortfolioCommentScored::create()->detach($portfolioCommentedScoredHook);
+ HookPortfolioItemEdited::create()->detach($portfolioItemEditedHook);
+ HookPortfolioCommentEdited::create()->detach($portfolioCommentEditedHook);
+
+ return 1;
+ }
+
+ /**
+ * @param string|null $lrsUrl
+ * @param string|null $lrsAuthUsername
+ * @param string|null $lrsAuthPassword
+ *
+ * @return \Xabbuh\XApi\Client\Api\StateApiClientInterface
+ */
+ public function getXApiStateClient($lrsUrl = null, $lrsAuthUsername = null, $lrsAuthPassword = null)
+ {
+ return $this
+ ->createXApiClient($lrsUrl, $lrsAuthUsername, $lrsAuthPassword)
+ ->getStateApiClient();
+ }
+
+ public function getXApiStatementClient(): StatementsApiClientInterface
+ {
+ return $this->createXApiClient()->getStatementsApiClient();
+ }
+
+ public function getXapiStatementCronClient(): StatementsApiClientInterface
+ {
+ $lrsUrl = $this->get(self::SETTING_CRON_LRS_URL);
+ $lrsUsername = $this->get(self::SETTING_CRON_LRS_AUTH_USERNAME);
+ $lrsPassword = $this->get(self::SETTING_CRON_LRS_AUTH_PASSWORD);
+
+ return $this
+ ->createXApiClient(
+ empty($lrsUrl) ? null : $lrsUrl,
+ empty($lrsUsername) ? null : $lrsUsername,
+ empty($lrsPassword) ? null : $lrsPassword
+ )
+ ->getStatementsApiClient();
+ }
+
+ /**
+ * Perform actions after save the plugin configuration.
+ *
+ * @return \XApiPlugin
+ */
+ public function performActionsAfterConfigure()
+ {
+ $learningPathItemViewedHook = XApiLearningPathItemViewedHookObserver::create();
+ $learningPathEndHook = XApiLearningPathEndHookObserver::create();
+ $quizQuestionAnsweredHook = XApiQuizQuestionAnsweredHookObserver::create();
+ $quizEndHook = XApiQuizEndHookObserver::create();
+ $portfolioItemAddedHook = XApiPortfolioItemAddedHookObserver::create();
+ $portfolioItemCommentedHook = XApiPortfolioItemCommentedHookObserver::create();
+ $portfolioItemViewedHook = XApiPortfolioItemViewedHookObserver::create();
+ $portfolioItemHighlightedHook = XApiPortfolioItemHighlightedHookObserver::create();
+ $portfolioDownloadedHook = XApiPortfolioDownloadedHookObserver::create();
+ $portfolioItemScoredHook = XApiPortfolioItemScoredHookObserver::create();
+ $portfolioCommentScoredHook = XApiPortfolioCommentScoredHookObserver::create();
+ $portfolioItemEditedHook = XApiPortfolioItemEditedHookObserver::create();
+ $portfolioCommentEditedHook = XApiPortfolioCommentEditedHookObserver::create();
+
+ $learningPathItemViewedEvent = HookLearningPathItemViewed::create();
+ $learningPathEndEvent = HookLearningPathEnd::create();
+ $quizQuestionAnsweredEvent = HookQuizQuestionAnswered::create();
+ $quizEndEvent = HookQuizEnd::create();
+ $portfolioItemAddedEvent = HookPortfolioItemAdded::create();
+ $portfolioItemCommentedEvent = HookPortfolioItemCommented::create();
+ $portfolioItemViewedEvent = HookPortfolioItemViewed::create();
+ $portfolioItemHighlightedEvent = HookPortfolioItemHighlighted::create();
+ $portfolioDownloadedEvent = HookPortfolioDownloaded::create();
+ $portfolioItemScoredEvent = HookPortfolioItemScored::create();
+ $portfolioCommentScoredEvent = HookPortfolioCommentScored::create();
+ $portfolioItemEditedEvent = HookPortfolioItemEdited::create();
+ $portfolioCommentEditedEvent = HookPortfolioCommentEdited::create();
+
+ if ('true' === $this->get(self::SETTING_LRS_LP_ITEM_ACTIVE)) {
+ $learningPathItemViewedEvent->attach($learningPathItemViewedHook);
+ } else {
+ $learningPathItemViewedEvent->detach($learningPathItemViewedHook);
+ }
+
+ if ('true' === $this->get(self::SETTING_LRS_LP_ACTIVE)) {
+ $learningPathEndEvent->attach($learningPathEndHook);
+ } else {
+ $learningPathEndEvent->detach($learningPathEndHook);
+ }
+
+ if ('true' === $this->get(self::SETTING_LRS_QUIZ_ACTIVE)) {
+ $quizQuestionAnsweredEvent->attach($quizQuestionAnsweredHook);
+ } else {
+ $quizQuestionAnsweredEvent->detach($quizQuestionAnsweredHook);
+ }
+
+ if ('true' === $this->get(self::SETTING_LRS_QUIZ_QUESTION_ACTIVE)) {
+ $quizEndEvent->attach($quizEndHook);
+ } else {
+ $quizEndEvent->detach($quizEndHook);
+ }
+
+ if ('true' === $this->get(self::SETTING_LRS_PORTFOLIO_ACTIVE)) {
+ $portfolioItemAddedEvent->attach($portfolioItemAddedHook);
+ $portfolioItemCommentedEvent->attach($portfolioItemCommentedHook);
+ $portfolioItemViewedEvent->attach($portfolioItemViewedHook);
+ $portfolioItemHighlightedEvent->attach($portfolioItemHighlightedHook);
+ $portfolioDownloadedEvent->attach($portfolioDownloadedHook);
+ $portfolioItemScoredEvent->attach($portfolioItemScoredHook);
+ $portfolioCommentScoredEvent->attach($portfolioCommentScoredHook);
+ $portfolioItemEditedEvent->attach($portfolioItemEditedHook);
+ $portfolioCommentEditedEvent->attach($portfolioCommentEditedHook);
+ } else {
+ $portfolioItemAddedEvent->detach($portfolioItemAddedHook);
+ $portfolioItemCommentedEvent->detach($portfolioItemCommentedHook);
+ $portfolioItemViewedEvent->detach($portfolioItemViewedHook);
+ $portfolioItemHighlightedEvent->detach($portfolioItemHighlightedHook);
+ $portfolioDownloadedEvent->detach($portfolioDownloadedHook);
+ $portfolioItemScoredEvent->detach($portfolioItemScoredHook);
+ $portfolioCommentScoredEvent->detach($portfolioCommentScoredHook);
+ $portfolioItemEditedEvent->detach($portfolioItemEditedHook);
+ $portfolioCommentEditedEvent->detach($portfolioCommentEditedHook);
+ }
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function installHook()
+ {
+ $createCourseHook = XApiCreateCourseHookObserver::create();
+
+ HookCreateCourse::create()->attach($createCourseHook);
+ }
+
+ /**
+ * @param string $variable
+ *
+ * @return array
+ */
+ public function getLangMap($variable)
+ {
+ $platformLanguage = api_get_setting('platformLanguage');
+ $platformLanguageIso = api_get_language_isocode($platformLanguage);
+
+ $map = [];
+ $map[$platformLanguageIso] = $this->getLangFromFile($variable, $platformLanguage);
+
+ try {
+ $interfaceLanguage = api_get_interface_language();
+ } catch (Exception $e) {
+ return $map;
+ }
+
+ if (!empty($interfaceLanguage) && $platformLanguage !== $interfaceLanguage) {
+ $interfaceLanguageIso = api_get_language_isocode($interfaceLanguage);
+
+ $map[$interfaceLanguageIso] = $this->getLangFromFile($variable, $interfaceLanguage);
+ }
+
+ return $map;
+ }
+
+ /**
+ * @param string $value
+ * @param string $type
+ *
+ * @return \Xabbuh\XApi\Model\IRI
+ */
+ public function generateIri($value, $type)
+ {
+ return IRI::fromString(
+ api_get_path(WEB_PATH)."xapi/$type/$value"
+ );
+ }
+
+ /**
+ * @param int $courseId
+ */
+ public function addCourseToolForTinCan($courseId)
+ {
+ // The $link param is set to "../plugin" as a hack to link correctly to the plugin URL in course tool.
+ // Otherwise, the link en the course tool will link to "/main/" URL.
+ $this->createLinkToCourseTool(
+ $this->get_lang('ToolTinCan'),
+ $courseId,
+ 'sessions_category.png',
+ '../plugin/xapi/start.php',
+ 0,
+ 'authoring'
+ );
+ }
+
+ /**
+ * @param string $language
+ *
+ * @return mixed|string
+ */
+ public static function extractVerbInLanguage(Xabbuh\XApi\Model\LanguageMap $languageMap, $language)
+ {
+ $iso = self::findLanguageIso($languageMap->languageTags(), $language);
+
+ $text = current($languageMap);
+
+ if (isset($languageMap[$iso])) {
+ $text = trim($languageMap[$iso]);
+ } elseif (isset($languageMap['und'])) {
+ $text = $languageMap['und'];
+ }
+
+ return $text;
+ }
+
+ /**
+ * @param string $needle
+ *
+ * @return string
+ */
+ public static function findLanguageIso(array $haystack, $needle)
+ {
+ if (in_array($needle, $haystack)) {
+ return $needle;
+ }
+
+ foreach ($haystack as $language) {
+ if (strpos($language, $needle) === 0) {
+ return $language;
+ }
+ }
+
+ return $haystack[0];
+ }
+
+ public function generateLaunchUrl(
+ $type,
+ $launchUrl,
+ $activityId,
+ Agent $actor,
+ $attemptId,
+ $customLrsUrl = null,
+ $customLrsUsername = null,
+ $customLrsPassword = null,
+ $viewSessionId = null
+ ) {
+ $lrsUrl = $customLrsUrl ?: $this->get(self::SETTING_LRS_URL);
+ $lrsAuthUsername = $customLrsUsername ?: $this->get(self::SETTING_LRS_AUTH_USERNAME);
+ $lrsAuthPassword = $customLrsPassword ?: $this->get(self::SETTING_LRS_AUTH_PASSWORD);
+
+ $queryData = [
+ 'endpoint' => trim($lrsUrl, "/ \t\n\r\0\x0B"),
+ 'actor' => Serializer::createSerializer()->serialize($actor, 'json'),
+ 'registration' => $attemptId,
+ ];
+
+ if ('tincan' === $type) {
+ $queryData['auth'] = 'Basic '.base64_encode(trim($lrsAuthUsername).':'.trim($lrsAuthPassword));
+ $queryData['activity_id'] = $activityId;
+ } elseif ('cmi5' === $type) {
+ $queryData['fetch'] = api_get_path(WEB_PLUGIN_PATH).'xapi/cmi5/token.php?session='.$viewSessionId;
+ $queryData['activityId'] = $activityId;
+ }
+
+ return $launchUrl.'?'.http_build_query($queryData, null, '&', PHP_QUERY_RFC3986);
+ }
+
+ /**
+ * @return \Doctrine\ORM\EntityManager|null
+ */
+ public static function getEntityManager()
+ {
+ $em = Database::getManager();
+
+ $prefixes = [
+ __DIR__.'/../php-xapi/repository-doctrine-orm/metadata' => 'XApi\Repository\Doctrine\Mapping',
+ ];
+
+ $driver = new SimplifiedXmlDriver($prefixes);
+ $driver->setGlobalBasename('global');
+
+ $config = Database::getDoctrineConfig(api_get_configuration_value('root_sys'));
+ $config->setMetadataDriverImpl($driver);
+
+ try {
+ return EntityManager::create($em->getConnection()->getParams(), $config);
+ } catch (ORMException $e) {
+ api_not_allowed(true, $e->getMessage());
+ }
+
+ return null;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAdminUrl()
+ {
+ $webPath = api_get_path(WEB_PLUGIN_PATH).$this->get_name();
+
+ return "$webPath/admin.php";
+ }
+
+ public function getLpResourceBlock(int $lpId)
+ {
+ $cidReq = api_get_cidreq(true, true, 'lp');
+ $webPath = api_get_path(WEB_PLUGIN_PATH).'xapi/';
+ $course = api_get_course_entity();
+ $session = api_get_session_entity();
+
+ $tools = Database::getManager()
+ ->getRepository(XApiToolLaunch::class)
+ ->findByCourseAndSession($course, $session);
+
+ $importIcon = Display::return_icon('import_scorm.png');
+ $moveIcon = Display::url(
+ Display::return_icon('move_everywhere.png', get_lang('Move'), [], ICON_SIZE_TINY),
+ '#',
+ ['class' => 'moved']
+ );
+
+ $return = '- '
+ .$importIcon
+ .Display::url(
+ get_lang('Import'),
+ $webPath."tool_import.php?$cidReq&".http_build_query(['lp_id' => $lpId])
+ )
+ .'
';
+
+ foreach ($tools as $tool) {
+ $toolAnchor = Display::url(
+ Security::remove_XSS($tool->getTitle()),
+ api_get_self()."?$cidReq&"
+ .http_build_query(
+ ['action' => 'add_item', 'type' => TOOL_XAPI, 'file' => $tool->getId(), 'lp_id' => $lpId]
+ ),
+ ['class' => 'moved']
+ );
+
+ $return .= Display::tag(
+ 'li',
+ $moveIcon.$importIcon.$toolAnchor,
+ [
+ 'class' => 'lp_resource_element',
+ 'data_id' => $tool->getId(),
+ 'data_type' => TOOL_XAPI,
+ 'title' => $tool->getTitle(),
+ ]
+ );
+ }
+
+ $return .= '
';
+
+ return $return;
+ }
+
+ /**
+ * @throws \Exception
+ */
+ private function installInitialConfig()
+ {
+ $uuidNamespace = Uuid::v1()->toRfc4122();
+
+ $pluginName = $this->get_name();
+ $urlId = api_get_current_access_url_id();
+
+ api_add_setting(
+ $uuidNamespace,
+ $pluginName.'_'.self::SETTING_UUID_NAMESPACE,
+ $pluginName,
+ 'setting',
+ 'Plugins',
+ $pluginName,
+ '',
+ '',
+ '',
+ $urlId,
+ 1
+ );
+
+ api_add_setting(
+ api_get_path(WEB_PATH).'plugin/xapi/lrs.php',
+ $pluginName.'_'.self::SETTING_LRS_URL,
+ $pluginName,
+ 'setting',
+ 'Plugins',
+ $pluginName,
+ '',
+ '',
+ '',
+ $urlId,
+ 1
+ );
+ }
+
+ /**
+ * @param string|null $lrsUrl
+ * @param string|null $lrsAuthUsername
+ * @param string|null $lrsAuthPassword
+ *
+ * @return \Xabbuh\XApi\Client\XApiClientInterface
+ */
+ private function createXApiClient($lrsUrl = null, $lrsAuthUsername = null, $lrsAuthPassword = null)
+ {
+ $baseUrl = $lrsUrl ?: $this->get(self::SETTING_LRS_URL);
+ $lrsAuthUsername = $lrsAuthUsername ?: $this->get(self::SETTING_LRS_AUTH_USERNAME);
+ $lrsAuthPassword = $lrsAuthPassword ?: $this->get(self::SETTING_LRS_AUTH_PASSWORD);
+
+ $clientBuilder = new XApiClientBuilder();
+ $clientBuilder
+ ->setHttpClient(Client::createWithConfig([RequestOptions::VERIFY => false]))
+ ->setRequestFactory(new GuzzleMessageFactory())
+ ->setBaseUrl(trim($baseUrl, "/ \t\n\r\0\x0B"))
+ ->setAuth(trim($lrsAuthUsername), trim($lrsAuthPassword));
+
+ return $clientBuilder->build();
+ }
+
+ private function addCourseTools()
+ {
+ $courses = Database::getManager()
+ ->createQuery('SELECT c.id FROM ChamiloCoreBundle:Course c')
+ ->getResult();
+
+ foreach ($courses as $course) {
+ $this->addCourseToolForTinCan($course['id']);
+ }
+ }
+
+ private function deleteCourseTools()
+ {
+ Database::getManager()
+ ->createQuery('DELETE FROM ChamiloCourseBundle:CTool t WHERE t.category = :category AND t.link LIKE :link')
+ ->execute(['category' => 'authoring', 'link' => '../plugin/xapi/start.php%']);
+ }
+}
diff --git a/public/plugin/xapi/start.php b/public/plugin/xapi/start.php
new file mode 100644
index 00000000000..d50db19a27b
--- /dev/null
+++ b/public/plugin/xapi/start.php
@@ -0,0 +1,153 @@
+getRepository(XApiToolLaunch::class);
+
+$course = api_get_course_entity();
+$session = api_get_session_entity();
+$userInfo = api_get_user_info();
+
+$cidReq = api_get_cidreq();
+
+$table = new SortableTable(
+ 'tbl_xapi',
+ function () use ($em, $course, $session, $isAllowedToEdit) {
+ return $em->getRepository(XApiToolLaunch::class)
+ ->countByCourseAndSession($course, $session, !$isAllowedToEdit)
+ ;
+ },
+ function ($start, $limit, $orderBy, $orderDir) use ($toolLaunchRepo, $course, $session, $isAllowedToEdit) {
+ $tools = $toolLaunchRepo->findByCourseAndSession($course, $session, ['title' => $orderDir], $limit, $start);
+
+ $data = [];
+
+ foreach ($tools as $toolLaunch) {
+ $wasAddedInLp = $toolLaunchRepo->wasAddedInLp($toolLaunch);
+
+ if ($wasAddedInLp && !$isAllowedToEdit) {
+ continue;
+ }
+
+ $datum = [];
+ $datum[] = [
+ $toolLaunch->getId(),
+ $toolLaunch->getTitle(),
+ $toolLaunch->getDescription(),
+ $toolLaunch->getActivityType(),
+ $wasAddedInLp,
+ ];
+
+ if ($isAllowedToEdit) {
+ $datum[] = $toolLaunch->getId();
+ }
+
+ $data[] = $datum;
+ }
+
+ return $data;
+ },
+ 0
+);
+$table->set_header(0, $plugin->get_lang('ActivityTitle'), true);
+$table->set_column_filter(
+ 0,
+ function (array $toolInfo) use ($cidReq, $session, $userInfo, $plugin) {
+ list($id, $title, $description, $activityType, $wasAddedInLp) = $toolInfo;
+
+ $sessionStar = api_get_session_image(
+ $session ? $session->getId() : 0,
+ $userInfo['status']
+ );
+
+ $data = Display::url(
+ $title.$sessionStar,
+ ('cmi5' === $activityType ? 'cmi5/view.php' : 'tincan/view.php')."?id=$id&$cidReq",
+ ['class' => 'show']
+ );
+
+ if ($description) {
+ $data .= \PHP_EOL.Display::tag('small', $description, ['class' => 'text-muted']);
+ }
+
+ if ($wasAddedInLp) {
+ $data .= Display::div(
+ $plugin->get_lang('ActivityAddedToLPCannotBeAccessed'),
+ ['class' => 'lp_content_type_label']
+ );
+ }
+
+ return $data;
+ }
+);
+
+if ($isAllowedToEdit) {
+ $thAttributes = ['class' => 'text-right', 'style' => 'width: 100px;'];
+
+ $table->set_header(1, get_lang('Actions'), false, $thAttributes, $thAttributes);
+ $table->set_column_filter(
+ 1,
+ function ($id) use ($cidReq, $isAllowedToEdit) {
+ $actions = [];
+
+ if ($isAllowedToEdit) {
+ $actions[] = Display::url(
+ Display::return_icon('statistics.png', get_lang('Reporting')),
+ "tincan/stats.php?$cidReq&id=$id"
+ );
+ $actions[] = Display::url(
+ Display::return_icon('edit.png', get_lang('Edit')),
+ "tool_edit.php?$cidReq&edit=$id"
+ );
+ $actions[] = Display::url(
+ Display::return_icon('delete.png', get_lang('Delete')),
+ "tool_delete.php?$cidReq&delete=$id"
+ );
+ }
+
+ return implode(\PHP_EOL, $actions);
+ }
+ );
+}
+
+$pageTitle = $plugin->get_lang('ToolTinCan');
+$pageContent = Display::return_message($plugin->get_lang('NoActivities'), 'info');
+
+if ($table->get_total_number_of_items() > 0) {
+ $pageContent = $table->return_table();
+}
+
+$view = new Template($pageTitle);
+$view->assign('header', $pageTitle);
+
+if ($isAllowedToEdit) {
+ $actions = Display::url(
+ Display::return_icon('import_scorm.png', get_lang('Import'), [], ICON_SIZE_MEDIUM),
+ "tool_import.php?$cidReq"
+ );
+
+ $view->assign(
+ 'actions',
+ Display::toolbarAction(
+ 'xapi_actions',
+ [$actions]
+ )
+ );
+}
+
+$view->assign('content', $pageContent);
+$view->display_one_col_template();
diff --git a/public/plugin/xapi/tincan/launch.php b/public/plugin/xapi/tincan/launch.php
new file mode 100644
index 00000000000..d1d0d3f7035
--- /dev/null
+++ b/public/plugin/xapi/tincan/launch.php
@@ -0,0 +1,148 @@
+request->get('attempt_id');
+$toolLaunch = $em->find(
+ XApiToolLaunch::class,
+ $request->request->getInt('id')
+);
+
+if (empty($attemptId)
+ || null === $toolLaunch
+ || $toolLaunch->getCourse()->getId() !== api_get_course_entity()->getId()
+) {
+ api_not_allowed(true);
+}
+
+$plugin = XApiPlugin::create();
+
+$activity = new Activity(
+ IRI::fromString($toolLaunch->getActivityId())
+);
+$actor = new Agent(
+ InverseFunctionalIdentifier::withMbox(
+ IRI::fromString('mailto:'.$user->getEmail())
+ ),
+ $user->getCompleteName()
+);
+$state = new State(
+ $activity,
+ $actor,
+ $plugin->generateIri('tool-'.$toolLaunch->getId(), 'state')->getValue()
+);
+
+$nowDate = api_get_utc_datetime(null, false, true)->format('c');
+
+try {
+ $stateDocument = $plugin
+ ->getXApiStateClient(
+ $toolLaunch->getLrsUrl(),
+ $toolLaunch->getLrsAuthUsername(),
+ $toolLaunch->getLrsAuthPassword()
+ )
+ ->getDocument($state)
+ ;
+
+ $data = $stateDocument->getData()->getData();
+
+ if ($stateDocument->offsetExists($attemptId)) {
+ $data[$attemptId][XApiPlugin::STATE_LAST_LAUNCH] = $nowDate;
+ } else {
+ $data[$attemptId] = [
+ XApiPlugin::STATE_FIRST_LAUNCH => $nowDate,
+ XApiPlugin::STATE_LAST_LAUNCH => $nowDate,
+ ];
+ }
+
+ uasort(
+ $data,
+ function ($attemptA, $attemptB) {
+ $timeA = strtotime($attemptA[XApiPlugin::STATE_LAST_LAUNCH]);
+ $timeB = strtotime($attemptB[XApiPlugin::STATE_LAST_LAUNCH]);
+
+ return $timeB - $timeA;
+ }
+ );
+
+ $documentData = new DocumentData($data);
+} catch (NotFoundException $notFoundException) {
+ $documentData = new DocumentData(
+ [
+ $attemptId => [
+ XApiPlugin::STATE_FIRST_LAUNCH => $nowDate,
+ XApiPlugin::STATE_LAST_LAUNCH => $nowDate,
+ ],
+ ]
+ );
+} catch (Exception $exception) {
+ Display::addFlash(
+ Display::return_message($exception->getMessage(), 'error')
+ );
+
+ header('Location: '.api_get_course_url());
+
+ exit;
+}
+
+try {
+ $plugin
+ ->getXApiStateClient()
+ ->createOrReplaceDocument(
+ new StateDocument($state, $documentData)
+ )
+ ;
+} catch (Exception $exception) {
+ Display::addFlash(
+ Display::return_message($exception->getMessage(), 'error')
+ );
+
+ header('Location: '.api_get_course_url());
+
+ exit;
+}
+
+$lrsUrl = $toolLaunch->getLrsUrl() ?: $plugin->get(XApiPlugin::SETTING_LRS_URL);
+$lrsAuthUsername = $toolLaunch->getLrsAuthUsername() ?: $plugin->get(XApiPlugin::SETTING_LRS_AUTH_USERNAME);
+$lrsAuthPassword = $toolLaunch->getLrsAuthPassword() ?: $plugin->get(XApiPlugin::SETTING_LRS_AUTH_PASSWORD);
+
+$activityLaunchUrl = $toolLaunch->getLaunchUrl().'?'
+ .http_build_query(
+ [
+ 'endpoint' => trim($lrsUrl, "/ \t\n\r\0\x0B"),
+ 'auth' => 'Basic '.base64_encode(trim($lrsAuthUsername).':'.trim($lrsAuthPassword)),
+ 'actor' => Serializer::createSerializer()->serialize($actor, 'json'),
+ 'registration' => $attemptId,
+ 'activity_id' => $toolLaunch->getActivityId(),
+ ],
+ null,
+ '&',
+ \PHP_QUERY_RFC3986
+ );
+
+header("Location: $activityLaunchUrl");
diff --git a/public/plugin/xapi/tincan/stats.php b/public/plugin/xapi/tincan/stats.php
new file mode 100644
index 00000000000..5abad6f01a0
--- /dev/null
+++ b/public/plugin/xapi/tincan/stats.php
@@ -0,0 +1,166 @@
+find(
+ XApiToolLaunch::class,
+ $request->query->getInt('id')
+);
+
+if (null === $toolLaunch) {
+ header('Location: '.api_get_course_url());
+
+ exit;
+}
+
+$course = api_get_course_entity();
+$session = api_get_session_entity();
+
+$cidReq = api_get_cidreq();
+
+$plugin = XApiPlugin::create();
+
+$length = 20;
+$page = $request->query->getInt('page', 1);
+$start = ($page - 1) * $length;
+$countStudentList = CourseManager::get_student_list_from_course_code(
+ $course->getCode(),
+ (bool) $session,
+ $session ? $session->getId() : 0,
+ null,
+ null,
+ true,
+ 0,
+ true
+);
+
+$statsUrl = api_get_self().'?'.api_get_cidreq().'&id='.$toolLaunch->getId();
+
+$paginator = new Paginator();
+$pagination = $paginator->paginate([]);
+$pagination->setTotalItemCount($countStudentList);
+$pagination->setItemNumberPerPage($length);
+$pagination->setCurrentPageNumber($page);
+$pagination->renderer = function ($data) use ($statsUrl) {
+ $render = '';
+ if ($data['pageCount'] > 1) {
+ $render = '';
+ }
+
+ return $render;
+};
+
+$students = CourseManager::get_student_list_from_course_code(
+ $course->getCode(),
+ (bool) $session,
+ $session ? $session->getId() : 0,
+ null,
+ null,
+ true,
+ 0,
+ false,
+ $start,
+ $length
+);
+
+$content = '';
+$content .= '';
+
+$loadingMessage = Display::returnFontAwesomeIcon('spinner', '', true, 'fa-pulse').' '.get_lang('Loading');
+
+foreach ($students as $studentInfo) {
+ $content .= Display::panelCollapse(
+ api_get_person_name($studentInfo['firstname'], $studentInfo['lastname']),
+ $loadingMessage,
+ "pnl-student-{$studentInfo['id']}",
+ [
+ 'class' => 'pnl-student',
+ 'data-student' => $studentInfo['id'],
+ 'data-tool' => $toolLaunch->getId(),
+ ],
+ "pnl-student-{$studentInfo['id']}-accordion",
+ "pnl-student-{$studentInfo['id']}-collapse",
+ false
+ );
+}
+
+$content .= '
';
+$content .= $pagination;
+
+// View
+$interbreadcrumb[] = [
+ 'name' => $plugin->get_title(),
+ 'url' => '../start.php',
+];
+
+$htmlHeadXtra[] = "";
+
+$actions = Display::url(
+ Display::return_icon('back.png', get_lang('Back'), [], ICON_SIZE_MEDIUM),
+ "../start.php?$cidReq"
+);
+
+$view = new Template($toolLaunch->getTitle());
+$view->assign(
+ 'actions',
+ Display::toolbarAction('xapi_actions', [$actions])
+);
+$view->assign('header', $toolLaunch->getTitle());
+$view->assign('content', $content);
+$view->display_one_col_template();
diff --git a/public/plugin/xapi/tincan/stats_attempts.ajax.php b/public/plugin/xapi/tincan/stats_attempts.ajax.php
new file mode 100644
index 00000000000..88b1ac6625f
--- /dev/null
+++ b/public/plugin/xapi/tincan/stats_attempts.ajax.php
@@ -0,0 +1,138 @@
+isXmlHttpRequest()
+ || !api_is_allowed_to_edit()
+ || !$course
+) {
+ echo Display::return_message(get_lang('NotAllowed'), 'error');
+
+ exit;
+}
+
+$plugin = XApiPlugin::create();
+$em = Database::getManager();
+
+$toolLaunch = $em->find(
+ XApiToolLaunch::class,
+ $request->request->getInt('tool')
+);
+
+$student = api_get_user_entity($request->request->getInt('student'));
+
+if (!$toolLaunch || !$student) {
+ echo Display::return_message(get_lang('NotAllowed'), 'error');
+
+ exit;
+}
+
+$userIsSubscribedToCourse = CourseManager::is_user_subscribed_in_course(
+ $student->getId(),
+ $course->getCode(),
+ (bool) $session,
+ $session ? $session->getId() : 0
+);
+
+if (!$userIsSubscribedToCourse) {
+ echo Display::return_message(get_lang('NotAllowed'), 'error');
+
+ exit;
+}
+
+$cidReq = api_get_cidreq();
+
+$xApiStateClient = $plugin->getXApiStateClient(
+ $toolLaunch->getLrsUrl(),
+ $toolLaunch->getLrsAuthUsername(),
+ $toolLaunch->getLrsAuthPassword()
+);
+
+$activity = new Activity(
+ IRI::fromString($toolLaunch->getActivityId())
+);
+
+$actor = new Agent(
+ InverseFunctionalIdentifier::withMbox(
+ IRI::fromString('mailto:'.$student->getEmail())
+ ),
+ $student->getCompleteName()
+);
+
+try {
+ $stateDocument = $xApiStateClient->getDocument(
+ new State(
+ $activity,
+ $actor,
+ $plugin->generateIri('tool-'.$toolLaunch->getId(), 'state')->getValue()
+ )
+ );
+} catch (NotFoundException $notFoundException) {
+ echo Display::return_message(get_lang('NoResults'), 'warning');
+
+ exit;
+} catch (XApiException $exception) {
+ echo Display::return_message($exception->getMessage(), 'error');
+
+ exit;
+}
+
+$content = '';
+
+if ($stateDocument) {
+ $i = 1;
+
+ foreach ($stateDocument->getData()->getData() as $attemptId => $attempt) {
+ $firstLaunch = api_convert_and_format_date(
+ $attempt[XApiPlugin::STATE_FIRST_LAUNCH],
+ DATE_TIME_FORMAT_LONG
+ );
+ $lastLaunch = api_convert_and_format_date(
+ $attempt[XApiPlugin::STATE_LAST_LAUNCH],
+ DATE_TIME_FORMAT_LONG
+ );
+
+ $content .= ''
+ .'- '.$plugin->get_lang('ActivityFirstLaunch').'
'
+ .'- '.$firstLaunch.'
'
+ .'- '.$plugin->get_lang('ActivityLastLaunch').'
'
+ .'- '.$lastLaunch.'
'
+ .'
'
+ .Display::toolbarButton(
+ get_lang('ShowAllAttempts'),
+ '#',
+ 'th-list',
+ 'default',
+ [
+ 'class' => 'btn_xapi_attempt_detail',
+ 'data-attempt' => $attemptId,
+ 'data-tool' => $toolLaunch->getId(),
+ 'style' => 'margin-bottom: 20px; margin-left: 180px;',
+ 'role' => 'button',
+ ]
+ );
+
+ $i++;
+ }
+}
+
+echo $content;
diff --git a/public/plugin/xapi/tincan/stats_statements.ajax.php b/public/plugin/xapi/tincan/stats_statements.ajax.php
new file mode 100644
index 00000000000..2185ce155fa
--- /dev/null
+++ b/public/plugin/xapi/tincan/stats_statements.ajax.php
@@ -0,0 +1,121 @@
+isXmlHttpRequest()
+ || !api_is_allowed_to_edit()
+ || !$course
+) {
+ echo Display::return_message(get_lang('NotAllowed'), 'error');
+
+ exit;
+}
+
+$plugin = XApiPlugin::create();
+$em = Database::getManager();
+
+$toolLaunch = $em->find(
+ XApiToolLaunch::class,
+ $request->request->getInt('tool')
+);
+
+$attempt = $request->request->get('attempt');
+
+if (!$toolLaunch || !$attempt) {
+ echo Display::return_message(get_lang('NoResults'), 'error');
+
+ exit;
+}
+
+$cidReq = api_get_cidreq();
+
+$xapiStatementClient = $plugin->getXApiStatementClient();
+
+$activity = new Activity(
+ IRI::fromString($toolLaunch->getActivityId())
+);
+
+$filter = new StatementsFilter();
+$filter
+ ->byRegistration($attempt)
+;
+
+try {
+ $result = $xapiStatementClient->getStatements($filter);
+} catch (XApiException $xApiException) {
+ echo Display::return_message($xApiException->getMessage(), 'error');
+
+ exit;
+} catch (Exception $exception) {
+ echo Display::return_message($exception->getMessage(), 'error');
+
+ exit;
+}
+
+$statements = $result->getStatements();
+
+if (count($statements) <= 0) {
+ echo Display::return_message(get_lang('NoResults'), 'warning');
+
+ exit;
+}
+
+$table = new HTML_Table(['class' => 'table table-condensed table-bordered table-striped table-hover']);
+$table->setHeaderContents(0, 0, get_lang('CreatedAt'));
+$table->setHeaderContents(0, 1, $plugin->get_lang('Actor'));
+$table->setHeaderContents(0, 2, $plugin->get_lang('Verb'));
+$table->setHeaderContents(0, 3, $plugin->get_lang('ActivityId'));
+
+$i = 1;
+
+$languageIso = api_get_language_isocode(api_get_interface_language());
+
+foreach ($statements as $statement) {
+ $timestampStored = $statement->getCreated() ? api_convert_and_format_date($statement->getCreated()) : '-';
+ $actor = $statement->getActor()->getName();
+ $verb = XApiPlugin::extractVerbInLanguage($statement->getVerb()->getDisplay(), $languageIso);
+ $activity = '';
+
+ $statementObject = $statement->getObject();
+
+ if ($statementObject instanceof Activity) {
+ if (null !== $statementObject->getDefinition()) {
+ $definition = $statementObject->getDefinition();
+
+ if (null !== $definition->getName()) {
+ $activity = XApiPlugin::extractVerbInLanguage($definition->getName(), $languageIso).'
';
+ }
+ }
+
+ $activity .= Display::tag(
+ 'small',
+ $statementObject->getId()->getValue(),
+ ['class' => 'text-muted']
+ );
+ }
+
+ $table->setCellContents($i, 0, $timestampStored);
+ $table->setCellContents($i, 1, $actor);
+ $table->setCellContents($i, 2, $verb);
+ $table->setCellContents($i, 3, $activity);
+
+ $i++;
+}
+
+$table->display();
diff --git a/public/plugin/xapi/tincan/view.php b/public/plugin/xapi/tincan/view.php
new file mode 100644
index 00000000000..2765161e1f0
--- /dev/null
+++ b/public/plugin/xapi/tincan/view.php
@@ -0,0 +1,196 @@
+find(
+ XApiToolLaunch::class,
+ $request->query->getInt('id')
+);
+
+if (null === $toolLaunch
+ || $toolLaunch->getCourse()->getId() !== api_get_course_entity()->getId()
+) {
+ api_not_allowed(true);
+}
+
+$plugin = XApiPlugin::create();
+
+$activity = new Activity(
+ IRI::fromString($toolLaunch->getActivityId())
+);
+$actor = new Agent(
+ InverseFunctionalIdentifier::withMbox(
+ IRI::fromString('mailto:'.$user->getEmail())
+ ),
+ $user->getCompleteName()
+);
+$state = new State(
+ $activity,
+ $actor,
+ $plugin->generateIri('tool-'.$toolLaunch->getId(), 'state')->getValue()
+);
+
+$cidReq = api_get_cidreq();
+
+try {
+ $stateDocument = $plugin
+ ->getXApiStateClient(
+ $toolLaunch->getLrsUrl(),
+ $toolLaunch->getLrsAuthUsername(),
+ $toolLaunch->getLrsAuthPassword()
+ )
+ ->getDocument($state)
+ ;
+} catch (NotFoundException $notFoundException) {
+ $stateDocument = null;
+} catch (Exception $exception) {
+ Display::addFlash(
+ Display::return_message($exception->getMessage(), 'error')
+ );
+
+ header('Location: '.api_get_course_url());
+
+ exit;
+}
+
+$formTarget = $originIsLearnpath ? '_self' : '_blank';
+
+$frmNewRegistration = new FormValidator(
+ 'launch_new',
+ 'post',
+ "launch.php?$cidReq",
+ '',
+ ['target' => $formTarget],
+ FormValidator::LAYOUT_INLINE
+);
+$frmNewRegistration->addHidden('attempt_id', Uuid::uuid4());
+$frmNewRegistration->addHidden('id', $toolLaunch->getId());
+$frmNewRegistration->addButton(
+ 'submit',
+ $plugin->get_lang('LaunchNewAttempt'),
+ 'external-link fa-fw',
+ 'success'
+);
+
+if ($stateDocument) {
+ $row = 0;
+
+ $table = new HTML_Table(['class' => 'table table-hover table-striped']);
+ $table->setHeaderContents($row, 0, $plugin->get_lang('ActivityFirstLaunch'));
+ $table->setHeaderContents($row, 1, $plugin->get_lang('ActivityLastLaunch'));
+ $table->setHeaderContents($row, 2, get_lang('Actions'));
+
+ $row++;
+
+ $langActivityLaunch = $plugin->get_lang('ActivityLaunch');
+
+ foreach ($stateDocument->getData()->getData() as $attemptId => $attempt) {
+ $firstLaunch = api_convert_and_format_date(
+ $attempt[XApiPlugin::STATE_FIRST_LAUNCH],
+ DATE_TIME_FORMAT_LONG
+ );
+ $lastLaunch = api_convert_and_format_date(
+ $attempt[XApiPlugin::STATE_LAST_LAUNCH],
+ DATE_TIME_FORMAT_LONG
+ );
+
+ $frmLaunch = new FormValidator(
+ "launch_$row",
+ 'post',
+ "launch.php?$cidReq",
+ '',
+ ['target' => $formTarget],
+ FormValidator::LAYOUT_INLINE
+ );
+ $frmLaunch->addHidden('attempt_id', $attemptId);
+ $frmLaunch->addHidden('id', $toolLaunch->getId());
+ $frmLaunch->addButton(
+ 'submit',
+ $langActivityLaunch,
+ 'external-link fa-fw',
+ 'default'
+ );
+
+ $table->setCellContents($row, 0, $firstLaunch);
+ $table->setCellContents($row, 1, $lastLaunch);
+ $table->setCellContents($row, 2, $frmLaunch->returnForm());
+
+ $row++;
+ }
+
+ $table->setColAttributes(0, ['class' => 'text-center']);
+ $table->setColAttributes(1, ['class' => 'text-center']);
+ $table->setColAttributes(2, ['class' => 'text-center']);
+}
+
+$interbreadcrumb[] = ['url' => '../start.php', 'name' => $plugin->get_lang('ToolTinCan')];
+
+$pageTitle = $toolLaunch->getTitle();
+$pageContent = '';
+
+if ($toolLaunch->getDescription()) {
+ $pageContent .= \PHP_EOL;
+ $pageContent .= "{$toolLaunch->getDescription()}
";
+}
+
+if ($toolLaunch->isAllowMultipleAttempts()
+ || empty($stateDocument)
+) {
+ $pageContent .= Display::div(
+ $frmNewRegistration->returnForm(),
+ ['class' => 'exercise_overview_options']
+ );
+}
+
+if ($stateDocument) {
+ $pageContent .= $table->toHtml();
+}
+
+$actions = '';
+
+if (!$originIsLearnpath) {
+ $actions = Display::url(
+ Display::return_icon('back.png', get_lang('Back'), [], ICON_SIZE_MEDIUM),
+ '../start.php?'.api_get_cidreq()
+ );
+}
+
+$view = new Template($pageTitle);
+$view->assign('header', $pageTitle);
+
+if ($actions) {
+ $view->assign(
+ 'actions',
+ Display::toolbarAction(
+ 'xapi_actions',
+ [$actions]
+ )
+ );
+}
+$view->assign('content', $pageContent);
+$view->display_one_col_template();
diff --git a/public/plugin/xapi/tool_delete.php b/public/plugin/xapi/tool_delete.php
new file mode 100644
index 00000000000..3776965fb48
--- /dev/null
+++ b/public/plugin/xapi/tool_delete.php
@@ -0,0 +1,42 @@
+find(
+ XApiToolLaunch::class,
+ $request->query->getInt('delete')
+);
+
+if (null === $toolLaunch
+ || $toolLaunch->getCourse()->getId() !== api_get_course_entity()->getId()
+) {
+ api_not_allowed(true);
+}
+
+$plugin = XApiPlugin::create();
+
+$em = Database::getManager();
+$em->remove($toolLaunch);
+$em->flush();
+
+Display::addFlash(
+ Display::return_message($plugin->get_lang('ActivityDeleted'), 'success')
+);
+
+header('Location: '.api_get_course_url());
+
+exit;
diff --git a/public/plugin/xapi/tool_edit.php b/public/plugin/xapi/tool_edit.php
new file mode 100644
index 00000000000..c357d184a48
--- /dev/null
+++ b/public/plugin/xapi/tool_edit.php
@@ -0,0 +1,151 @@
+find(
+ XApiToolLaunch::class,
+ $request->query->getInt('edit')
+);
+
+if (null === $toolLaunch) {
+ header('Location: '.api_get_course_url());
+
+ exit;
+}
+
+$course = api_get_course_entity();
+$session = api_get_session_entity();
+
+$cidReq = api_get_cidreq();
+
+$plugin = XApiPlugin::create();
+
+$toolIsCmi5 = 'cmi5' === $toolLaunch->getActivityType();
+$toolIsTinCan = !$toolIsCmi5;
+
+$langEditActivity = $plugin->get_lang('EditActivity');
+
+$frmActivity = new FormValidator('frm_activity', 'post', api_get_self()."?$cidReq&edit={$toolLaunch->getId()}");
+$frmActivity->addText('title', get_lang('Title'));
+$frmActivity->addTextarea('description', get_lang('Description'));
+
+if ($toolIsTinCan) {
+ $frmActivity->addButtonAdvancedSettings('advanced_params');
+ $frmActivity->addHtml('');
+ $frmActivity->addCheckBox('allow_multiple_attempts', '', get_lang('AllowMultipleAttempts'));
+ $frmActivity->addHtml('
');
+}
+
+$frmActivity->addButtonAdvancedSettings('lrs_params', $plugin->get_lang('LrsConfiguration'));
+$frmActivity->addHtml('');
+$frmActivity->addText(
+ 'lrs_url',
+ [
+ $plugin->get_lang('lrs_url'),
+ $plugin->get_lang('lrs_url_help'),
+ ],
+ false
+);
+$frmActivity->addText(
+ 'lrs_auth_username',
+ [
+ $plugin->get_lang('lrs_auth_username'),
+ $plugin->get_lang('lrs_auth_username_help'),
+ ],
+ false
+);
+$frmActivity->addText(
+ 'lrs_auth_password',
+ [
+ $plugin->get_lang('lrs_auth_password'),
+ $plugin->get_lang('lrs_auth_password_help'),
+ ],
+ false
+);
+$frmActivity->addHtml('
');
+$frmActivity->addButtonUpdate(get_lang('Update'));
+$frmActivity->applyFilter('title', 'trim');
+$frmActivity->applyFilter('description', 'trim');
+$frmActivity->applyFilter('lrs_url', 'trim');
+$frmActivity->applyFilter('lrs_auth', 'trim');
+
+if ($frmActivity->validate()) {
+ $values = $frmActivity->exportValues();
+
+ $toolLaunch
+ ->setTitle($values['title'])
+ ->setDescription(empty($values['description']) ? null : $values['description'])
+ ;
+
+ if ($toolIsTinCan && isset($values['allow_multiple_attempts'])) {
+ $toolLaunch->setAllowMultipleAttempts(true);
+ }
+
+ if (!empty($values['lrs_url'])
+ && !empty($values['lrs_auth_username'])
+ && !empty($values['lrs_auth_password'])
+ ) {
+ $toolLaunch
+ ->setLrsUrl($values['lrs_url'])
+ ->setLrsAuthUsername($values['lrs_auth_username'])
+ ->setLrsAuthPassword($values['lrs_auth_password'])
+ ;
+ }
+
+ $em->persist($toolLaunch);
+ $em->flush();
+
+ Display::addFlash(
+ Display::return_message($plugin->get_lang('ActivityUpdated'), 'success')
+ );
+
+ header('Location: '.api_get_course_url());
+
+ exit;
+}
+
+$frmActivity->setDefaults(
+ [
+ 'title' => $toolLaunch->getTitle(),
+ 'description' => $toolLaunch->getDescription(),
+ 'allow_multiple_attempts' => $toolLaunch->isAllowMultipleAttempts(),
+ 'lrs_url' => $toolLaunch->getLrsUrl(),
+ 'lrs_auth_username' => $toolLaunch->getLrsAuthUsername(),
+ 'lrs_auth_password' => $toolLaunch->getLrsAuthPassword(),
+ ]
+);
+
+$actions = Display::url(
+ Display::return_icon('back.png', get_lang('Back'), [], ICON_SIZE_MEDIUM),
+ 'start.php?'.api_get_cidreq()
+);
+
+$pageContent = $frmActivity->returnForm();
+
+$interbreadcrumb[] = ['url' => 'start.php', 'name' => $plugin->get_lang('ToolTinCan')];
+
+$view = new Template($langEditActivity);
+$view->assign('header', $langEditActivity);
+$view->assign(
+ 'actions',
+ Display::toolbarAction(
+ 'xapi_actions',
+ [$actions]
+ )
+);
+$view->assign('content', $pageContent);
+$view->display_one_col_template();
diff --git a/public/plugin/xapi/tool_import.php b/public/plugin/xapi/tool_import.php
new file mode 100644
index 00000000000..67dd6497526
--- /dev/null
+++ b/public/plugin/xapi/tool_import.php
@@ -0,0 +1,183 @@
+query->has('lp_id')) {
+ $lp = new learnpath('', $httpRequest->query->getInt('lp_id'), $userId);
+
+ if (!empty($lp->lp_id)) {
+ $pluginIndex = api_get_path(WEB_CODE_PATH)."lp/lp_controller.php?$cidReq&"
+ .http_build_query(['action' => 'add_item', 'type' => 'step', 'lp' => $lp->lp_id, 'lp_build_selected' => 8]);
+ }
+}
+
+$langAddActivity = $plugin->get_lang('AddActivity');
+
+$formAction = api_get_self()."?$cidReq&".($lp ? http_build_query(['lp_id' => $lp->lp_id]) : '');
+
+$frmActivity = new FormValidator('frm_activity', 'post', $formAction);
+$frmActivity->addFile('file', $plugin->get_lang('XApiPackage'));
+$frmActivity->addButtonAdvancedSettings('advanced_params');
+$frmActivity->addHtml('');
+$frmActivity->addText('title', get_lang('Title'), false);
+$frmActivity->addTextarea('description', get_lang('Description'));
+$frmActivity->addCheckBox('allow_multiple_attempts', '', $plugin->get_lang('TinCanAllowMultipleAttempts'));
+$frmActivity->addHtml('
');
+$frmActivity->addButtonAdvancedSettings('lrs_params', $plugin->get_lang('LrsConfiguration'));
+$frmActivity->addHtml('');
+$frmActivity->addText(
+ 'lrs_url',
+ [
+ $plugin->get_lang('lrs_url'),
+ $plugin->get_lang('lrs_url_help'),
+ ],
+ false
+);
+$frmActivity->addText(
+ 'lrs_auth_username',
+ [
+ $plugin->get_lang('lrs_auth_username'),
+ $plugin->get_lang('lrs_auth_username_help'),
+ ],
+ false
+);
+$frmActivity->addText(
+ 'lrs_auth_password',
+ [
+ $plugin->get_lang('lrs_auth_password'),
+ $plugin->get_lang('lrs_auth_password_help'),
+ ],
+ false
+);
+$frmActivity->addHtml('
');
+$frmActivity->addButtonImport(get_lang('Import'));
+$frmActivity->addRule('file', get_lang('ThisFileIsRequired'), 'required');
+$frmActivity->addRule(
+ 'file',
+ $plugin->get_lang('OnlyZipOrXmlAllowed'),
+ 'filetype',
+ ['zip', 'xml']
+);
+$frmActivity->applyFilter('title', 'trim');
+$frmActivity->applyFilter('description', 'trim');
+$frmActivity->applyFilter('lrs_url', 'trim');
+$frmActivity->applyFilter('lrs_auth', 'trim');
+
+if ($frmActivity->validate()) {
+ $values = $frmActivity->exportValues();
+ $zipFileInfo = $_FILES['file'];
+
+ try {
+ $importer = PackageImporter::create($zipFileInfo, $course);
+ $packageFile = $importer->import();
+
+ $parser = PackageParser::create(
+ $importer->getPackageType(),
+ $packageFile,
+ $course,
+ $session
+ );
+ $toolLaunch = $parser->parse();
+ } catch (Exception $e) {
+ Display::addFlash(
+ Display::return_message($e->getMessage(), 'error')
+ );
+
+ header("Location: $pluginIndex");
+
+ exit;
+ }
+
+ if ('tincan' === $importer->getPackageType() && isset($values['allow_multiple_attempts'])) {
+ $toolLaunch->setAllowMultipleAttempts(true);
+ }
+
+ if (!empty($values['title'])) {
+ $toolLaunch->setTitle($values['title']);
+ }
+
+ if (!empty($values['description'])) {
+ $toolLaunch->setDescription($values['description']);
+ }
+
+ if (!empty($values['lrs_url'])
+ && !empty($values['lrs_auth_username'])
+ && !empty($values['lrs_auth_password'])
+ ) {
+ $toolLaunch
+ ->setLrsUrl($values['lrs_url'])
+ ->setLrsAuthUsername($values['lrs_auth_username'])
+ ->setLrsAuthPassword($values['lrs_auth_password'])
+ ;
+ }
+
+ $em = Database::getManager();
+ $em->persist($toolLaunch);
+ $em->flush();
+
+ Display::addFlash(
+ Display::return_message($plugin->get_lang('ActivityImported'), 'success')
+ );
+
+ header("Location: $pluginIndex");
+
+ exit;
+}
+
+$frmActivity->setDefaults(['allow_multiple_attempts' => true]);
+
+$actions = Display::url(
+ Display::return_icon('back.png', get_lang('Back'), [], ICON_SIZE_MEDIUM),
+ $pluginIndex
+);
+
+$pageContent = $frmActivity->returnForm();
+
+if ($lp) {
+ $interbreadcrumb[] = [
+ 'url' => api_get_path(WEB_CODE_PATH).'lp/lp_controller.php?action=list&'.api_get_cidreq(),
+ 'name' => get_lang('LearningPaths'),
+ ];
+ $interbreadcrumb[] = [
+ 'url' => $pluginIndex,
+ 'name' => $lp->getNameNoTags(),
+ ];
+} else {
+ $interbreadcrumb[] = ['url' => $pluginIndex, 'name' => $plugin->get_lang('ToolTinCan')];
+}
+
+$view = new Template($langAddActivity);
+$view->assign('header', $langAddActivity);
+$view->assign(
+ 'actions',
+ Display::toolbarAction(
+ 'xapi_actions',
+ [$actions]
+ )
+);
+$view->assign('content', $pageContent);
+$view->display_one_col_template();
diff --git a/public/plugin/xapi/views/cmi5_launch.twig b/public/plugin/xapi/views/cmi5_launch.twig
new file mode 100644
index 00000000000..8aa5f8359fe
--- /dev/null
+++ b/public/plugin/xapi/views/cmi5_launch.twig
@@ -0,0 +1,15 @@
+
+
+
+
{{ tool.title }}
+
{{ tool.description }}
+
+
+
+ {{ toc_html }}
+
+
+
+
+
+
diff --git a/src/CoreBundle/Entity/XApiObject.php b/src/CoreBundle/Entity/XApiObject.php
index 719dc24f92e..16615605997 100644
--- a/src/CoreBundle/Entity/XApiObject.php
+++ b/src/CoreBundle/Entity/XApiObject.php
@@ -90,7 +90,7 @@ class XApiObject
/**
* @var Collection
*/
- #[ORM\OneToMany(mappedBy: 'group', targetEntity: self::class)]
+ #[ORM\OneToMany(mappedBy: 'group', targetEntity: self::class, cascade: ['persist', 'remove'])]
#[ORM\JoinColumn(referencedColumnName: 'identifier')]
private Collection $members;
diff --git a/src/CoreBundle/Entity/XApiSharedStatement.php b/src/CoreBundle/Entity/XApiSharedStatement.php
index 48fba8e14fd..8138c9a8a3d 100644
--- a/src/CoreBundle/Entity/XApiSharedStatement.php
+++ b/src/CoreBundle/Entity/XApiSharedStatement.php
@@ -28,6 +28,13 @@ class XApiSharedStatement
#[ORM\Column]
private ?bool $sent = null;
+ public function __construct(array $statement, string $uuid = null, bool $sent = false)
+ {
+ $this->statement = $statement;
+ $this->uuid = Uuid::fromString($uuid);
+ $this->sent = $sent;
+ }
+
public function getId(): ?int
{
return $this->id;
diff --git a/src/CoreBundle/Entity/XApiToolLaunch.php b/src/CoreBundle/Entity/XApiToolLaunch.php
index cbdd7761f83..f76032777d9 100644
--- a/src/CoreBundle/Entity/XApiToolLaunch.php
+++ b/src/CoreBundle/Entity/XApiToolLaunch.php
@@ -46,7 +46,7 @@ class XApiToolLaunch
private ?string $activityType = null;
#[ORM\Column]
- private ?bool $allowMultipleAttempts = null;
+ private ?bool $allowMultipleAttempts = true;
#[ORM\Column(length: 255, nullable: true)]
private ?string $lrsUrl = null;
diff --git a/src/CoreBundle/Entity/XApiVerb.php b/src/CoreBundle/Entity/XApiVerb.php
index 79094d26e33..a348571512b 100644
--- a/src/CoreBundle/Entity/XApiVerb.php
+++ b/src/CoreBundle/Entity/XApiVerb.php
@@ -20,8 +20,8 @@ class XApiVerb
#[ORM\Column(length: 255)]
private ?string $id = null;
- #[ORM\Column]
- private array $display = [];
+ #[ORM\Column(nullable: true)]
+ private ?array $display = null;
public function getIdentifier(): ?int
{
@@ -40,12 +40,12 @@ public function setId(string $id): static
return $this;
}
- public function getDisplay(): array
+ public function getDisplay(): ?array
{
return $this->display;
}
- public function setDisplay(array $display): static
+ public function setDisplay(?array $display): static
{
$this->display = $display;
diff --git a/src/CoreBundle/Migrations/Schema/V200/Version20240515094800.php b/src/CoreBundle/Migrations/Schema/V200/Version20240515094800.php
index 5f65b2a296d..dcede6cc807 100644
--- a/src/CoreBundle/Migrations/Schema/V200/Version20240515094800.php
+++ b/src/CoreBundle/Migrations/Schema/V200/Version20240515094800.php
@@ -52,9 +52,9 @@ public function up(Schema $schema): void
}
if ($hasTblVerb) {
- $this->addSql("ALTER TABLE xapi_verb CHANGE display display LONGTEXT NOT NULL COMMENT '(DC2Type:json)'");
+ $this->addSql("ALTER TABLE xapi_verb CHANGE display display LONGTEXT NULL COMMENT '(DC2Type:json)'");
} else {
- $this->addSql("CREATE TABLE xapi_verb (identifier INT AUTO_INCREMENT NOT NULL, id VARCHAR(255) NOT NULL, display LONGTEXT NOT NULL COMMENT '(DC2Type:json)', PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB ROW_FORMAT = DYNAMIC");
+ $this->addSql("CREATE TABLE xapi_verb (identifier INT AUTO_INCREMENT NOT NULL, id VARCHAR(255) NOT NULL, display LONGTEXT NULL COMMENT '(DC2Type:json)', PRIMARY KEY(identifier)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB ROW_FORMAT = DYNAMIC");
}
if ($hasTblObject) {