commit
960f9b01b3
|
@ -2371,11 +2371,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@ionic/angular": {
|
"@ionic/angular": {
|
||||||
"version": "5.5.2",
|
"version": "5.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/@ionic/angular/-/angular-5.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@ionic/angular/-/angular-5.6.3.tgz",
|
||||||
"integrity": "sha512-yXIydPTIMAX4RobidAByaQ/y+yMS6FYgwEs08GTN/GyvQ4XeWVbojwTm62ILLN2qYS/80ok2uupFwlcyKSMztw==",
|
"integrity": "sha512-6MUQV+K0xrrdTHZle+HXIOEk5TIAsFt5r6hbhfzknfZT1IMNtoEgh1xpvoEjOpjvPa84mQo7oe6Hy4kM7TQmIQ==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@ionic/core": "5.5.2",
|
"@ionic/core": "5.6.3",
|
||||||
"tslib": "^1.9.3"
|
"tslib": "^1.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -2737,18 +2737,19 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@ionic/core": {
|
"@ionic/core": {
|
||||||
"version": "5.5.2",
|
"version": "5.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-5.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-5.6.3.tgz",
|
||||||
"integrity": "sha512-rOfPj8D5NRWdOYYulNTdKtMAMURfmutDQ3ciA3L7daCooG3MWt2/0siiL6rcZFMxfG7KDxHctuwVwYoC1mPuhg==",
|
"integrity": "sha512-RPugxDcCwB5rgEh6yR2QDTzblT8BRBktsW6y+VBt62yHRzgEAENEfVyvkADz+CkGAsmZuPmC8OQC2jJrx/fJFA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"ionicons": "^5.1.2",
|
"@stencil/core": "^2.4.0",
|
||||||
"tslib": "^1.10.0"
|
"ionicons": "^5.5.1",
|
||||||
|
"tslib": "^2.1.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": {
|
"tslib": {
|
||||||
"version": "1.14.1",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz",
|
||||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
|
"integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -3688,6 +3689,11 @@
|
||||||
"@sinonjs/commons": "^1.7.0"
|
"@sinonjs/commons": "^1.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@stencil/core": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-SHVX/XaMYEzZJr7ttFSU9a1GmZRMUS9l7f/hbWnKYRn4S9zl1CqGf2iR/ofJ7B+vKaHLjapCAVrzCrkciVIpXA=="
|
||||||
|
},
|
||||||
"@szmarczak/http-timer": {
|
"@szmarczak/http-timer": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz",
|
||||||
|
@ -12493,9 +12499,12 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"ionicons": {
|
"ionicons": {
|
||||||
"version": "5.2.3",
|
"version": "5.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/ionicons/-/ionicons-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/ionicons/-/ionicons-5.5.1.tgz",
|
||||||
"integrity": "sha512-87qtgBkieKVFagwYA9Cf91B3PCahQbEOMwMt8bSvlQSgflZ4eE5qI4MGj2ZlIyadeX0dgo+0CzZsy3ow0CsBAg=="
|
"integrity": "sha512-1auVisfaXmkmxINer8Q3kJGHP1vSxk86hf7By95eJ+Av9+oBcNuAEBfSe3jaMaGRVxVw8U/2j23MFq7R3c0HPg==",
|
||||||
|
"requires": {
|
||||||
|
"@stencil/core": "^2.5.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"ios-sim": {
|
"ios-sim": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
|
|
|
@ -65,7 +65,7 @@
|
||||||
"@ionic-native/status-bar": "^5.0.0",
|
"@ionic-native/status-bar": "^5.0.0",
|
||||||
"@ionic-native/web-intent": "^5.28.0",
|
"@ionic-native/web-intent": "^5.28.0",
|
||||||
"@ionic-native/zip": "^5.28.0",
|
"@ionic-native/zip": "^5.28.0",
|
||||||
"@ionic/angular": "^5.5.2",
|
"@ionic/angular": "^5.6.3",
|
||||||
"@ngx-translate/core": "^13.0.0",
|
"@ngx-translate/core": "^13.0.0",
|
||||||
"@ngx-translate/http-loader": "^6.0.0",
|
"@ngx-translate/http-loader": "^6.0.0",
|
||||||
"@types/cordova": "0.0.34",
|
"@types/cordova": "0.0.34",
|
||||||
|
|
|
@ -208,7 +208,7 @@ export class AddonModForumSyncProvider extends CoreCourseActivitySyncBaseProvide
|
||||||
if (CoreSync.isBlocked(AddonModForumProvider.COMPONENT, syncId, siteId)) {
|
if (CoreSync.isBlocked(AddonModForumProvider.COMPONENT, syncId, siteId)) {
|
||||||
this.logger.debug('Cannot sync forum ' + forumId + ' because it is blocked.');
|
this.logger.debug('Cannot sync forum ' + forumId + ' because it is blocked.');
|
||||||
|
|
||||||
return Promise.reject(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate }));
|
throw new Error(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate }));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug('Try to sync forum ' + forumId + ' for user ' + userId);
|
this.logger.debug('Try to sync forum ' + forumId + ' for user ' + userId);
|
||||||
|
|
|
@ -28,6 +28,7 @@ import { AddonModUrlModule } from './url/url.module';
|
||||||
import { AddonModLtiModule } from './lti/lti.module';
|
import { AddonModLtiModule } from './lti/lti.module';
|
||||||
import { AddonModH5PActivityModule } from './h5pactivity/h5pactivity.module';
|
import { AddonModH5PActivityModule } from './h5pactivity/h5pactivity.module';
|
||||||
import { AddonModSurveyModule } from './survey/survey.module';
|
import { AddonModSurveyModule } from './survey/survey.module';
|
||||||
|
import { AddonModScormModule } from './scorm/scorm.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
|
@ -46,6 +47,7 @@ import { AddonModSurveyModule } from './survey/survey.module';
|
||||||
AddonModLtiModule,
|
AddonModLtiModule,
|
||||||
AddonModH5PActivityModule,
|
AddonModH5PActivityModule,
|
||||||
AddonModSurveyModule,
|
AddonModSurveyModule,
|
||||||
|
AddonModScormModule,
|
||||||
],
|
],
|
||||||
providers: [],
|
providers: [],
|
||||||
exports: [],
|
exports: [],
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,37 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { AddonModScormIndexComponent } from './index/index';
|
||||||
|
import { CoreSharedModule } from '@/core/shared.module';
|
||||||
|
import { CoreCourseComponentsModule } from '@features/course/components/components.module';
|
||||||
|
import { AddonModScormTocComponent } from './toc/toc';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
AddonModScormIndexComponent,
|
||||||
|
AddonModScormTocComponent,
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
CoreSharedModule,
|
||||||
|
CoreCourseComponentsModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
AddonModScormIndexComponent,
|
||||||
|
AddonModScormTocComponent,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonModScormComponentsModule {}
|
|
@ -0,0 +1,246 @@
|
||||||
|
<!-- Buttons to add to the header. -->
|
||||||
|
<core-navbar-buttons slot="end">
|
||||||
|
<core-context-menu>
|
||||||
|
<core-context-menu-item *ngIf="externalUrl" [priority]="900" [content]="'core.openinbrowser' | translate"
|
||||||
|
[href]="externalUrl" iconAction="fas-external-link-alt">
|
||||||
|
</core-context-menu-item>
|
||||||
|
<core-context-menu-item *ngIf="description" [priority]="800" [content]="'core.moduleintro' | translate"
|
||||||
|
(action)="expandDescription()" iconAction="fas-arrow-right">
|
||||||
|
</core-context-menu-item>
|
||||||
|
<core-context-menu-item *ngIf="blog" [priority]="750" content="{{'addon.blog.blog' | translate}}"
|
||||||
|
iconAction="far-newspaper" (action)="gotoBlog()">
|
||||||
|
</core-context-menu-item>
|
||||||
|
<core-context-menu-item *ngIf="loaded && !hasOffline && isOnline" [priority]="700" [content]="'core.refresh' | translate"
|
||||||
|
(action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false">
|
||||||
|
</core-context-menu-item>
|
||||||
|
<core-context-menu-item *ngIf="loaded && hasOffline && isOnline" [priority]="600" (action)="doRefresh(null, $event, true)"
|
||||||
|
[content]="'core.settings.synchronizenow' | translate" [iconAction]="syncIcon" [closeOnClick]="false">
|
||||||
|
</core-context-menu-item>
|
||||||
|
<core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="500" [content]="prefetchText" (action)="prefetch($event)"
|
||||||
|
[iconAction]="prefetchStatusIcon" [closeOnClick]="false">
|
||||||
|
</core-context-menu-item>
|
||||||
|
<core-context-menu-item *ngIf="size" [priority]="400" [content]="'core.clearstoreddata' | translate:{$a: size}"
|
||||||
|
iconDescription="fas-archive" (action)="removeFiles($event)" iconAction="fas-trash" [closeOnClick]="false">
|
||||||
|
</core-context-menu-item>
|
||||||
|
</core-context-menu>
|
||||||
|
</core-navbar-buttons>
|
||||||
|
|
||||||
|
<!-- Content. -->
|
||||||
|
<core-loading [hideUntil]="loaded" class="core-loading-center">
|
||||||
|
|
||||||
|
<core-course-module-description [description]="description" [component]="component" [componentId]="componentId"
|
||||||
|
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
|
||||||
|
</core-course-module-description>
|
||||||
|
|
||||||
|
<!-- Warning message. -->
|
||||||
|
<ion-card class="core-info-card" *ngIf="scorm && scorm.warningMessage">
|
||||||
|
<ion-item>
|
||||||
|
<ion-icon name="fas-info-circle" slot="start"></ion-icon>
|
||||||
|
<ion-label>{{ scorm.warningMessage }}</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
</ion-card>
|
||||||
|
|
||||||
|
<ng-container *ngIf="scorm && loaded && !scorm.warningMessage">
|
||||||
|
<!-- Attempts status. -->
|
||||||
|
<ion-card *ngIf="(scorm.displayattemptstatus || offlineAttempts.length) && !skip">
|
||||||
|
<ion-card-header class="ion-text-wrap">
|
||||||
|
<ion-card-title>{{ 'addon.mod_scorm.attempts' | translate }}</ion-card-title>
|
||||||
|
</ion-card-header>
|
||||||
|
<ion-list class="addon-mod_scorm-attempt-summary">
|
||||||
|
<ng-container *ngIf="scorm.displayattemptstatus">
|
||||||
|
<ion-item class="ion-text-wrap" *ngIf="scorm.maxattempt! >= 0">
|
||||||
|
<ion-label>
|
||||||
|
<h3>{{ 'addon.mod_scorm.noattemptsallowed' | translate }}</h3>
|
||||||
|
</ion-label>
|
||||||
|
<p slot="end">
|
||||||
|
<span *ngIf="scorm.maxattempt == 0">{{ 'core.unlimited' | translate }}</span>
|
||||||
|
<span *ngIf="scorm.maxattempt! > 0">{{ scorm.maxattempt }}</span>
|
||||||
|
</p>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item class="ion-text-wrap" *ngIf="numAttempts >= 0">
|
||||||
|
<ion-label>
|
||||||
|
<h3>{{ 'addon.mod_scorm.noattemptsmade' | translate }}</h3>
|
||||||
|
</ion-label>
|
||||||
|
<p slot="end">{{ numAttempts }}</p>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item class="ion-text-wrap" *ngFor="let attempt of onlineAttempts">
|
||||||
|
<ion-label>
|
||||||
|
<h3>{{ 'addon.mod_scorm.gradeforattempt' | translate }} {{attempt.num}}</h3>
|
||||||
|
</ion-label>
|
||||||
|
<p slot="end">
|
||||||
|
<span *ngIf="attempt.grade != -1">{{ attempt.gradeFormatted }}</span>
|
||||||
|
<span *ngIf="attempt.grade == -1">{{ 'addon.mod_scorm.cannotcalculategrade' | translate }}</span>
|
||||||
|
</p>
|
||||||
|
</ion-item>
|
||||||
|
</ng-container>
|
||||||
|
<ion-item class="ion-text-wrap" *ngFor="let attempt of offlineAttempts">
|
||||||
|
<ion-label>
|
||||||
|
<h3>{{ 'addon.mod_scorm.gradeforattempt' | translate }} {{attempt.num}}</h3>
|
||||||
|
<p *ngIf="!scorm.maxattempt || attempt.num <= scorm.maxattempt">
|
||||||
|
{{ 'addon.mod_scorm.offlineattemptnote' | translate }}
|
||||||
|
</p>
|
||||||
|
<p *ngIf="scorm.maxattempt && attempt.num > scorm.maxattempt">
|
||||||
|
{{ 'addon.mod_scorm.offlineattemptovermax' | translate }}
|
||||||
|
</p>
|
||||||
|
</ion-label>
|
||||||
|
<p slot="end">
|
||||||
|
<span *ngIf="attempt.grade != -1">{{ attempt.gradeFormatted }}</span>
|
||||||
|
<span *ngIf="attempt.grade == -1">{{ 'addon.mod_scorm.cannotcalculategrade' | translate }}</span>
|
||||||
|
</p>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item class="ion-text-wrap" *ngIf="scorm.displayattemptstatus && gradeMethodReadable">
|
||||||
|
<ion-label>
|
||||||
|
<h3>{{ 'addon.mod_scorm.grademethod' | translate }}</h3>
|
||||||
|
</ion-label>
|
||||||
|
<p slot="end">{{ gradeMethodReadable }}</p>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item class="ion-text-wrap" *ngIf="scorm.displayattemptstatus && gradeFormatted">
|
||||||
|
<ion-label>
|
||||||
|
<h3>{{ 'addon.mod_scorm.gradereported' | translate }}</h3>
|
||||||
|
</ion-label>
|
||||||
|
<p slot="end">
|
||||||
|
<span *ngIf="grade != -1">{{ gradeFormatted }}</span>
|
||||||
|
<span *ngIf="grade == -1">{{ 'addon.mod_scorm.cannotcalculategrade' | translate }}</span>
|
||||||
|
</p>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item class="ion-text-wrap" *ngIf="syncTime">
|
||||||
|
<ion-label>
|
||||||
|
<h3>{{ 'core.lastsync' | translate }}</h3>
|
||||||
|
<p>{{ syncTime }}</p>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
</ion-list>
|
||||||
|
</ion-card>
|
||||||
|
|
||||||
|
<!-- Synchronization warning. -->
|
||||||
|
<ion-card class="core-warning-card" *ngIf="!errorMessage && hasOffline">
|
||||||
|
<ion-item>
|
||||||
|
<ion-icon name="fas-exclamation-triangle" slot="start"></ion-icon>
|
||||||
|
<ion-label>{{ 'core.hasdatatosync' | translate: {$a: moduleName} }}</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
</ion-card>
|
||||||
|
|
||||||
|
<!-- TOC. -->
|
||||||
|
<ion-card *ngIf="scorm && organizations && !skip &&
|
||||||
|
((scorm.displaycoursestructure && organizations.length) || organizations.length > 1)" class="addon-mod_scorm-toc">
|
||||||
|
<ion-card-header class="ion-text-wrap">
|
||||||
|
<ion-card-title>{{ 'addon.mod_scorm.contents' | translate }}</ion-card-title>
|
||||||
|
</ion-card-header>
|
||||||
|
<ion-list>
|
||||||
|
<ion-item class="ion-text-wrap" *ngIf="organizations.length > 1">
|
||||||
|
<ion-label>{{ 'addon.mod_scorm.organizations' | translate }}</ion-label>
|
||||||
|
<ion-select [(ngModel)]="currentOrganization.identifier" (ionChange)="loadOrganization()"
|
||||||
|
interface="action-sheet">
|
||||||
|
<ion-select-option *ngFor="let org of organizations" [value]="org.identifier">
|
||||||
|
{{ org.title }}
|
||||||
|
</ion-select-option>
|
||||||
|
</ion-select>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item class="ion-text-center" *ngIf="scorm.displaycoursestructure && loadingToc">
|
||||||
|
<ion-label>
|
||||||
|
<ion-spinner></ion-spinner>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item class="ion-text-wrap" *ngIf="scorm.displaycoursestructure && !loadingToc">
|
||||||
|
<!-- If data shown doesn't belong to last attempt, show a warning. -->
|
||||||
|
<ion-label>
|
||||||
|
<p *ngIf="attemptToContinue">
|
||||||
|
{{ 'addon.mod_scorm.dataattemptshown' | translate:{number: attemptToContinue} }}
|
||||||
|
</p>
|
||||||
|
<p>{{ currentOrganization.title }}</p>
|
||||||
|
<div *ngFor="let sco of toc" class="core-padding-{{sco.level}} addon-mod_scorm-type-{{sco.scormtype}}">
|
||||||
|
<p *ngIf="sco.isvisible">
|
||||||
|
<ion-icon *ngIf="sco.icon" [name]="sco.icon.icon" [attr.aria-label]="sco.icon.description"
|
||||||
|
slot="start">
|
||||||
|
</ion-icon>
|
||||||
|
<a *ngIf="sco.prereq && sco.launch" (click)="open($event, false, sco.id)" tappable="true">
|
||||||
|
<core-format-text [text]="sco.title" contextLevel="module" [contextInstanceId]="module.id"
|
||||||
|
[courseId]="courseId">
|
||||||
|
</core-format-text>
|
||||||
|
</a>
|
||||||
|
<span *ngIf="!sco.prereq || !sco.launch">
|
||||||
|
<core-format-text [text]="sco.title" contextLevel="module" [contextInstanceId]="module.id"
|
||||||
|
[courseId]="courseId">
|
||||||
|
</core-format-text>
|
||||||
|
</span>
|
||||||
|
<span *ngIf="accessInfo && accessInfo.canviewscores && sco.scoreraw">
|
||||||
|
({{ 'addon.mod_scorm.score' | translate }}: {{sco.scoreraw}})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
</ion-list>
|
||||||
|
</ion-card>
|
||||||
|
|
||||||
|
<!-- Open in browser button. -->
|
||||||
|
<ion-card *ngIf="errorMessage">
|
||||||
|
<ion-item class="ion-text-wrap">
|
||||||
|
<ion-label>
|
||||||
|
<p class="text-danger">{{ errorMessage | translate }}</p>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
<ion-button class="ion-margin ion-text-wrap" expand="block" [href]="externalUrl" core-link>
|
||||||
|
{{ 'core.openinbrowser' | translate }}
|
||||||
|
<ion-icon name="fas-external-link-alt" slot="end"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</ion-card>
|
||||||
|
|
||||||
|
<!-- Warning that user doesn't have any more attempts. -->
|
||||||
|
<ion-card *ngIf="!errorMessage && attemptsLeft == 0">
|
||||||
|
<ion-item class="ion-text-wrap">
|
||||||
|
<ion-label>
|
||||||
|
<p class="text-danger">{{ 'addon.mod_scorm.exceededmaxattempts' | translate }}</p>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
</ion-card>
|
||||||
|
|
||||||
|
<!-- Open SCORM in app form -->
|
||||||
|
<ion-card *ngIf="!errorMessage && scorm && (!scorm.lastattemptlock || attemptsLeft > 0)">
|
||||||
|
<ion-list>
|
||||||
|
<ng-container *ngIf="!downloading && !skip">
|
||||||
|
<!-- Create new attempt -->
|
||||||
|
<ion-item class="ion-text-wrap"
|
||||||
|
*ngIf="!scorm.forcenewattempt && numAttempts > 0 && !incomplete && attemptsLeft > 0">
|
||||||
|
<ion-label>{{ 'addon.mod_scorm.newattempt' | translate }}</ion-label>
|
||||||
|
<ion-checkbox slot="end" name="newAttempt" [(ngModel)]="startNewAttempt">
|
||||||
|
</ion-checkbox>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<ion-item class="ion-text-wrap" *ngIf="statusMessage">
|
||||||
|
<ion-label>
|
||||||
|
<p>{{ statusMessage | translate }}</p>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<!-- Open mode (Preview or Normal) -->
|
||||||
|
<ion-grid>
|
||||||
|
<ion-row class="ion-align-items-center">
|
||||||
|
<ion-col *ngIf="!scorm.hidebrowse">
|
||||||
|
<ion-button expand="block" fill="outline" (click)="open($event, true)" class="ion-text-wrap">
|
||||||
|
{{ 'addon.mod_scorm.browse' | translate }}
|
||||||
|
<ion-icon name="fas-search" slot="end"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</ion-col>
|
||||||
|
<ion-col>
|
||||||
|
<ion-button expand="block" (click)="open($event)" class="ion-text-wrap">
|
||||||
|
{{ 'addon.mod_scorm.enter' | translate }}
|
||||||
|
<ion-icon name="fas-arrow-right" slot="end"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</ion-col>
|
||||||
|
</ion-row>
|
||||||
|
</ion-grid>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Download progress. -->
|
||||||
|
<ion-item class="ion-text-center" *ngIf="downloading">
|
||||||
|
<ion-label>
|
||||||
|
<ion-spinner></ion-spinner>
|
||||||
|
<h2 *ngIf="progressMessage">{{ progressMessage | translate }}</h2>
|
||||||
|
<core-progress-bar *ngIf="showPercentage" [progress]="percentage"></core-progress-bar>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
</ion-list>
|
||||||
|
</ion-card>
|
||||||
|
</ng-container>
|
||||||
|
</core-loading>
|
|
@ -0,0 +1,19 @@
|
||||||
|
@import "~theme/globals";
|
||||||
|
|
||||||
|
:host {
|
||||||
|
.addon-mod_scorm-attempt-summary ion-item > p {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addon-mod_scorm-toc {
|
||||||
|
// Hide all non sco icons using css to maintain padding.
|
||||||
|
ion-icon {
|
||||||
|
opacity: 0;
|
||||||
|
@include margin(5px, 8px, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addon-mod_scorm-type-sco ion-icon {
|
||||||
|
opacity: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,605 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { CoreConstants } from '@/core/constants';
|
||||||
|
import { Component, OnInit, Optional } from '@angular/core';
|
||||||
|
import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component';
|
||||||
|
import { CoreCourseContentsPage } from '@features/course/pages/contents/contents';
|
||||||
|
import { CoreCourse } from '@features/course/services/course';
|
||||||
|
import { IonContent } from '@ionic/angular';
|
||||||
|
import { CoreNavigator } from '@services/navigator';
|
||||||
|
import { CoreDomUtils } from '@services/utils/dom';
|
||||||
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
import { Translate } from '@singletons';
|
||||||
|
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||||
|
import { AddonModScormPrefetchHandler } from '../../services/handlers/prefetch';
|
||||||
|
import {
|
||||||
|
AddonModScorm,
|
||||||
|
AddonModScormAttemptCountResult,
|
||||||
|
AddonModScormGetScormAccessInformationWSResponse,
|
||||||
|
AddonModScormAttemptGrade,
|
||||||
|
AddonModScormOrganization,
|
||||||
|
AddonModScormProvider,
|
||||||
|
AddonModScormScorm,
|
||||||
|
} from '../../services/scorm';
|
||||||
|
import { AddonModScormHelper, AddonModScormTOCScoWithIcon } from '../../services/scorm-helper';
|
||||||
|
import {
|
||||||
|
AddonModScormAutoSyncEventData,
|
||||||
|
AddonModScormSync,
|
||||||
|
AddonModScormSyncProvider,
|
||||||
|
AddonModScormSyncResult,
|
||||||
|
} from '../../services/scorm-sync';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that displays a SCORM entry page.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'addon-mod-scorm-index',
|
||||||
|
templateUrl: 'addon-mod-scorm-index.html',
|
||||||
|
styleUrls: ['index.scss'],
|
||||||
|
})
|
||||||
|
export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit {
|
||||||
|
|
||||||
|
component = AddonModScormProvider.COMPONENT;
|
||||||
|
moduleName = 'scorm';
|
||||||
|
|
||||||
|
scorm?: AddonModScormScorm; // The SCORM object.
|
||||||
|
currentOrganization: Partial<AddonModScormOrganization> = {}; // Selected organization.
|
||||||
|
startNewAttempt = false;
|
||||||
|
errorMessage?: string; // Error message.
|
||||||
|
syncTime?: string; // Last sync time.
|
||||||
|
hasOffline = false; // Whether the SCORM has offline data.
|
||||||
|
attemptToContinue?: number; // The attempt to continue or review.
|
||||||
|
statusMessage?: string; // Message about the status.
|
||||||
|
downloading = false; // Whether the SCORM is being downloaded.
|
||||||
|
percentage?: string; // Download/unzip percentage.
|
||||||
|
showPercentage = false; // Whether to show the percentage.
|
||||||
|
progressMessage?: string; // Message about download/unzip.
|
||||||
|
organizations?: AddonModScormOrganization[]; // List of organizations.
|
||||||
|
loadingToc = false; // Whether the TOC is being loaded.
|
||||||
|
toc?: AddonModScormTOCScoWithIcon[]; // Table of contents (structure).
|
||||||
|
accessInfo?: AddonModScormGetScormAccessInformationWSResponse; // Access information.
|
||||||
|
skip?: boolean; // Launch immediately.
|
||||||
|
incomplete = false; // Whether last attempt is incomplete.
|
||||||
|
numAttempts = -1; // Number of attempts.
|
||||||
|
grade?: number; // Grade.
|
||||||
|
gradeFormatted?: string; // Grade formatted.
|
||||||
|
gradeMethodReadable?: string; // Grade method in a readable format.
|
||||||
|
attemptsLeft = -1; // Number of attempts left.
|
||||||
|
onlineAttempts: AttemptGrade[] = []; // Grades for online attempts.
|
||||||
|
offlineAttempts: AttemptGrade[] = []; // Grades for offline attempts.
|
||||||
|
|
||||||
|
protected fetchContentDefaultError = 'addon.mod_scorm.errorgetscorm'; // Default error to show when loading contents.
|
||||||
|
protected syncEventName = AddonModScormSyncProvider.AUTO_SYNCED;
|
||||||
|
protected attempts?: AddonModScormAttemptCountResult; // Data about online and offline attempts.
|
||||||
|
protected lastAttempt?: number; // Last attempt.
|
||||||
|
protected lastIsOffline = false; // Whether the last attempt is offline.
|
||||||
|
protected hasPlayed = false; // Whether the user has opened the player page.
|
||||||
|
protected dataSentObserver?: CoreEventObserver; // To detect data sent to server.
|
||||||
|
protected dataSent = false; // Whether some data was sent to server while playing the SCORM.
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected content?: IonContent,
|
||||||
|
@Optional() courseContentsPage?: CoreCourseContentsPage,
|
||||||
|
) {
|
||||||
|
super('AddonModScormIndexComponent', content, courseContentsPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component being initialized.
|
||||||
|
*/
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
super.ngOnInit();
|
||||||
|
|
||||||
|
await this.loadContent(false, true);
|
||||||
|
|
||||||
|
if (!this.scorm) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.skip) {
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await AddonModScorm.logView(this.scorm.id, this.scorm.name);
|
||||||
|
|
||||||
|
this.checkCompletion();
|
||||||
|
} catch {
|
||||||
|
// Ignore errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the completion.
|
||||||
|
*/
|
||||||
|
protected checkCompletion(): void {
|
||||||
|
CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a SCORM package or restores an ongoing download.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async downloadScormPackage(): Promise<void> {
|
||||||
|
this.downloading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await AddonModScormPrefetchHandler.download(this.module, this.courseId, undefined, (data) => {
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.percentage = undefined;
|
||||||
|
this.showPercentage = false;
|
||||||
|
|
||||||
|
if (data.downloading) {
|
||||||
|
// Downloading package.
|
||||||
|
if (this.scorm!.packagesize && data.progress) {
|
||||||
|
const percentageNumber = Number(data.progress.loaded / this.scorm!.packagesize) * 100;
|
||||||
|
this.percentage = percentageNumber.toFixed(1);
|
||||||
|
this.showPercentage = percentageNumber >= 0 && percentageNumber <= 100;
|
||||||
|
}
|
||||||
|
} else if (data.message) {
|
||||||
|
// Show a message.
|
||||||
|
this.progressMessage = data.message;
|
||||||
|
} else if (data.progress && data.progress.loaded && data.progress.total) {
|
||||||
|
// Unzipping package.
|
||||||
|
const percentageNumber = Number(data.progress.loaded / data.progress.total) * 100;
|
||||||
|
this.percentage = percentageNumber.toFixed(1);
|
||||||
|
this.showPercentage = percentageNumber >= 0 && percentageNumber <= 100;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
this.progressMessage = undefined;
|
||||||
|
this.percentage = undefined;
|
||||||
|
this.downloading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Get the SCORM instance.
|
||||||
|
this.scorm = await AddonModScorm.getScorm(this.courseId, this.module.id, { moduleUrl: this.module.url });
|
||||||
|
|
||||||
|
this.dataRetrieved.emit(this.scorm);
|
||||||
|
this.description = this.scorm.intro || this.description;
|
||||||
|
this.errorMessage = AddonModScorm.isScormUnsupported(this.scorm);
|
||||||
|
|
||||||
|
if (this.scorm.warningMessage) {
|
||||||
|
return; // SCORM is closed or not open yet, we can't get more data.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sync) {
|
||||||
|
// Try to synchronize the SCORM.
|
||||||
|
await CoreUtils.ignoreErrors(this.syncActivity(showErrors));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [syncTime, accessInfo] = await Promise.all([
|
||||||
|
AddonModScormSync.getReadableSyncTime(this.scorm.id),
|
||||||
|
AddonModScorm.getAccessInformation(this.scorm.id, { cmId: this.module.id }),
|
||||||
|
this.fetchAttemptData(this.scorm),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.syncTime = syncTime;
|
||||||
|
this.accessInfo = accessInfo;
|
||||||
|
|
||||||
|
// Check whether to launch the SCORM immediately.
|
||||||
|
if (typeof this.skip == 'undefined') {
|
||||||
|
this.skip = !this.hasOffline && !this.errorMessage &&
|
||||||
|
(!this.scorm.lastattemptlock || this.attemptsLeft > 0) &&
|
||||||
|
this.accessInfo.canskipview && !this.accessInfo.canviewreport &&
|
||||||
|
this.scorm.skipview! >= AddonModScormProvider.SKIPVIEW_FIRST &&
|
||||||
|
(this.scorm.skipview == AddonModScormProvider.SKIPVIEW_ALWAYS || this.lastAttempt == 0);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.fillContextMenu(refresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch attempt data.
|
||||||
|
*
|
||||||
|
* @param scorm Scorm.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async fetchAttemptData(scorm: AddonModScormScorm): Promise<void> {
|
||||||
|
// Get the number of attempts.
|
||||||
|
this.attempts = await AddonModScorm.getAttemptCount(scorm.id, { cmId: this.module.id });
|
||||||
|
this.hasOffline = !!this.attempts.offline.length;
|
||||||
|
|
||||||
|
// Determine the attempt that will be continued or reviewed.
|
||||||
|
const attempt = await AddonModScormHelper.determineAttemptToContinue(scorm, this.attempts);
|
||||||
|
|
||||||
|
this.lastAttempt = attempt.num;
|
||||||
|
this.lastIsOffline = attempt.offline;
|
||||||
|
|
||||||
|
if (this.lastAttempt != this.attempts.lastAttempt.num) {
|
||||||
|
this.attemptToContinue = this.lastAttempt;
|
||||||
|
} else {
|
||||||
|
this.attemptToContinue = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the last attempt is incomplete.
|
||||||
|
this.incomplete = await AddonModScorm.isAttemptIncomplete(scorm.id, this.lastAttempt, {
|
||||||
|
offline: this.lastIsOffline,
|
||||||
|
cmId: this.module.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.numAttempts = this.attempts.total;
|
||||||
|
this.gradeMethodReadable = AddonModScorm.getScormGradeMethod(scorm);
|
||||||
|
this.attemptsLeft = AddonModScorm.countAttemptsLeft(scorm, this.attempts.lastAttempt.num);
|
||||||
|
|
||||||
|
if (scorm.forcenewattempt == AddonModScormProvider.SCORM_FORCEATTEMPT_ALWAYS ||
|
||||||
|
(scorm.forcenewattempt && !this.incomplete)) {
|
||||||
|
this.startNewAttempt = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
this.getReportedGrades(scorm, this.attempts),
|
||||||
|
this.fetchStructure(scorm),
|
||||||
|
this.loadPackageSize(scorm),
|
||||||
|
this.setStatusListener(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load SCORM package size if needed.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async loadPackageSize(scorm: AddonModScormScorm): Promise<void> {
|
||||||
|
if (scorm.packagesize || this.errorMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SCORM is supported but we don't have package size. Try to calculate it.
|
||||||
|
scorm.packagesize = await AddonModScorm.calculateScormSize(scorm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the structure of the SCORM (TOC).
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async fetchStructure(scorm: AddonModScormScorm): Promise<void> {
|
||||||
|
this.organizations = await AddonModScorm.getOrganizations(scorm.id, { cmId: this.module.id });
|
||||||
|
|
||||||
|
if (!this.currentOrganization.identifier) {
|
||||||
|
// Load first organization (if any).
|
||||||
|
if (this.organizations.length) {
|
||||||
|
this.currentOrganization.identifier = this.organizations[0].identifier;
|
||||||
|
} else {
|
||||||
|
this.currentOrganization.identifier = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.loadOrganizationToc(scorm, this.currentOrganization.identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the grade of an attempt and add it to the scorm attempts list.
|
||||||
|
*
|
||||||
|
* @param attempt The attempt number.
|
||||||
|
* @param offline Whether it's an offline attempt.
|
||||||
|
* @param attempts Object where to add the attempt.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async getAttemptGrade(
|
||||||
|
attempt: number,
|
||||||
|
offline: boolean,
|
||||||
|
attempts: Record<number, AddonModScormAttemptGrade>,
|
||||||
|
): Promise<void> {
|
||||||
|
const grade = await AddonModScorm.getAttemptGrade(this.scorm!, attempt, offline);
|
||||||
|
|
||||||
|
attempts[attempt] = {
|
||||||
|
num: attempt,
|
||||||
|
grade: grade,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the grades of each attempt and the grade of the SCORM.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async getReportedGrades(scorm: AddonModScormScorm, attempts: AddonModScormAttemptCountResult): Promise<void> {
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
|
const onlineAttempts: Record<number, AttemptGrade> = {};
|
||||||
|
const offlineAttempts: Record<number, AttemptGrade> = {};
|
||||||
|
|
||||||
|
// Calculate the grade for each attempt.
|
||||||
|
attempts.online.forEach((attempt) => {
|
||||||
|
// Check that attempt isn't in offline to prevent showing the same attempt twice. Offline should be more recent.
|
||||||
|
if (attempts.offline.indexOf(attempt) == -1) {
|
||||||
|
promises.push(this.getAttemptGrade(attempt, false, onlineAttempts));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
attempts.offline.forEach((attempt) => {
|
||||||
|
promises.push(this.getAttemptGrade(attempt, true, offlineAttempts));
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
// Calculate the grade of the whole SCORM. We only use online attempts to calculate this data.
|
||||||
|
this.grade = AddonModScorm.calculateScormGrade(scorm, onlineAttempts);
|
||||||
|
|
||||||
|
// Add the attempts to the SCORM in array format in ASC order, and format the grades.
|
||||||
|
this.onlineAttempts = CoreUtils.objectToArray(onlineAttempts);
|
||||||
|
this.offlineAttempts = CoreUtils.objectToArray(offlineAttempts);
|
||||||
|
this.onlineAttempts.sort((a, b) => a.num - b.num);
|
||||||
|
this.offlineAttempts.sort((a, b) => a.num - b.num);
|
||||||
|
|
||||||
|
// Now format the grades.
|
||||||
|
this.onlineAttempts.forEach((attempt) => {
|
||||||
|
attempt.gradeFormatted = AddonModScorm.formatGrade(scorm, attempt.grade);
|
||||||
|
});
|
||||||
|
this.offlineAttempts.forEach((attempt) => {
|
||||||
|
attempt.gradeFormatted = AddonModScorm.formatGrade(scorm, attempt.grade);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.gradeFormatted = AddonModScorm.formatGrade(scorm, this.grade);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if sync has succeed from result sync data.
|
||||||
|
*
|
||||||
|
* @param result Data returned on the sync function.
|
||||||
|
* @return If suceed or not.
|
||||||
|
*/
|
||||||
|
protected hasSyncSucceed(result: AddonModScormSyncResult): boolean {
|
||||||
|
if (result.updated || this.dataSent) {
|
||||||
|
// Check completion status if something was sent.
|
||||||
|
this.checkCompletion();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataSent = false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User entered the page that contains the component.
|
||||||
|
*/
|
||||||
|
ionViewDidEnter(): void {
|
||||||
|
super.ionViewDidEnter();
|
||||||
|
|
||||||
|
if (!this.hasPlayed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hasPlayed = false;
|
||||||
|
this.startNewAttempt = false; // Uncheck new attempt.
|
||||||
|
|
||||||
|
// Add a delay to make sure the player has started the last writing calls so we can detect conflicts.
|
||||||
|
setTimeout(() => {
|
||||||
|
this.dataSentObserver?.off(); // Stop listening for changes.
|
||||||
|
this.dataSentObserver = undefined;
|
||||||
|
|
||||||
|
// Refresh data.
|
||||||
|
this.showLoadingAndRefresh(true, false);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform the invalidate content function.
|
||||||
|
*
|
||||||
|
* @return Resolved when done.
|
||||||
|
*/
|
||||||
|
protected async invalidateContent(): Promise<void> {
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
promises.push(AddonModScorm.invalidateScormData(this.courseId));
|
||||||
|
|
||||||
|
if (this.scorm) {
|
||||||
|
promises.push(AddonModScorm.invalidateAllScormData(this.scorm.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares sync event data with current data to check if refresh content is needed.
|
||||||
|
*
|
||||||
|
* @param syncEventData Data receiven on sync observer.
|
||||||
|
* @return True if refresh is needed, false otherwise.
|
||||||
|
*/
|
||||||
|
protected isRefreshSyncNeeded(syncEventData: AddonModScormAutoSyncEventData): boolean {
|
||||||
|
if (syncEventData.updated && this.scorm && syncEventData.scormId == this.scorm.id) {
|
||||||
|
// Check completion status.
|
||||||
|
this.checkCompletion();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a organization's TOC.
|
||||||
|
*/
|
||||||
|
async loadOrganization(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.loadOrganizationToc(this.scorm!, this.currentOrganization.identifier!);
|
||||||
|
} catch (error) {
|
||||||
|
CoreDomUtils.showErrorModalDefault(error, this.fetchContentDefaultError, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the TOC of a certain organization.
|
||||||
|
*
|
||||||
|
* @param organizationId The organization id.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async loadOrganizationToc(scorm: AddonModScormScorm, organizationId: string): Promise<void> {
|
||||||
|
if (!scorm.displaycoursestructure) {
|
||||||
|
// TOC is not displayed, no need to load it.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadingToc = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.toc = await AddonModScormHelper.getToc(scorm.id, this.lastAttempt!, this.incomplete, {
|
||||||
|
organization: organizationId,
|
||||||
|
offline: this.lastIsOffline,
|
||||||
|
cmId: this.module.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search organization title.
|
||||||
|
this.organizations!.forEach((org) => {
|
||||||
|
if (org.identifier == organizationId) {
|
||||||
|
this.currentOrganization.title = org.title;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.loadingToc = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a SCORM. It will download the SCORM package if it's not downloaded or it has changed.
|
||||||
|
*
|
||||||
|
* @param event Event.
|
||||||
|
* @param scoId SCO that needs to be loaded when the SCORM is opened. If not defined, load first SCO.
|
||||||
|
*/
|
||||||
|
async open(event?: Event, preview: boolean = false, scoId?: number): Promise<void> {
|
||||||
|
if (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.downloading) {
|
||||||
|
// Scope is being downloaded, abort.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOutdated = this.currentStatus == CoreConstants.OUTDATED;
|
||||||
|
const scorm = this.scorm!;
|
||||||
|
|
||||||
|
if (!isOutdated && this.currentStatus != CoreConstants.NOT_DOWNLOADED) {
|
||||||
|
// Already downloaded, open it.
|
||||||
|
this.openScorm(scoId, preview);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SCORM needs to be downloaded.
|
||||||
|
await AddonModScormHelper.confirmDownload(scorm, isOutdated);
|
||||||
|
// Invalidate WS data if SCORM is outdated.
|
||||||
|
if (isOutdated) {
|
||||||
|
await CoreUtils.ignoreErrors(AddonModScorm.invalidateAllScormData(scorm.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.downloadScormPackage();
|
||||||
|
// Success downloading, open SCORM if user hasn't left the view.
|
||||||
|
if (!this.isDestroyed) {
|
||||||
|
this.openScorm(scoId, preview);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.isDestroyed) {
|
||||||
|
CoreDomUtils.showErrorModalDefault(
|
||||||
|
error,
|
||||||
|
Translate.instant('addon.mod_scorm.errordownloadscorm', { name: scorm.name }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a SCORM package.
|
||||||
|
*
|
||||||
|
* @param scoId SCO ID.
|
||||||
|
*/
|
||||||
|
protected openScorm(scoId?: number, preview: boolean = false): void {
|
||||||
|
// Display the full page when returning to the page.
|
||||||
|
this.skip = false;
|
||||||
|
this.hasPlayed = true;
|
||||||
|
|
||||||
|
// Detect if anything was sent to server.
|
||||||
|
this.dataSentObserver?.off();
|
||||||
|
|
||||||
|
this.dataSentObserver = CoreEvents.on(AddonModScormProvider.DATA_SENT_EVENT, (data) => {
|
||||||
|
if (data.scormId === this.scorm!.id) {
|
||||||
|
this.dataSent = true;
|
||||||
|
}
|
||||||
|
}, this.siteId);
|
||||||
|
|
||||||
|
CoreNavigator.navigate('player', {
|
||||||
|
params: {
|
||||||
|
mode: preview ? AddonModScormProvider.MODEBROWSE : AddonModScormProvider.MODENORMAL,
|
||||||
|
moduleUrl: this.module.url,
|
||||||
|
newAttempt: !!this.startNewAttempt,
|
||||||
|
organizationId: this.currentOrganization.identifier,
|
||||||
|
scoId: scoId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
protected async showStatus(status: string): Promise<void> {
|
||||||
|
|
||||||
|
if (status == CoreConstants.OUTDATED && this.scorm) {
|
||||||
|
// Only show the outdated message if the file should be downloaded.
|
||||||
|
const download = await AddonModScorm.shouldDownloadMainFile(this.scorm, true);
|
||||||
|
|
||||||
|
this.statusMessage = download ? 'addon.mod_scorm.scormstatusoutdated' : '';
|
||||||
|
} else if (status == CoreConstants.NOT_DOWNLOADED) {
|
||||||
|
this.statusMessage = 'addon.mod_scorm.scormstatusnotdownloaded';
|
||||||
|
} else if (status == CoreConstants.DOWNLOADING) {
|
||||||
|
if (!this.downloading) {
|
||||||
|
// It's being downloaded right now but the view isn't tracking it. "Restore" the download.
|
||||||
|
this.downloadScormPackage();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.statusMessage = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the sync of the activity.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async sync(): Promise<AddonModScormSyncResult> {
|
||||||
|
const result = await AddonModScormSync.syncScorm(this.scorm!);
|
||||||
|
|
||||||
|
if (!result.updated && this.dataSent) {
|
||||||
|
// The user sent data to server, but not in the sync process. Check if we need to fetch data.
|
||||||
|
await CoreUtils.ignoreErrors(
|
||||||
|
AddonModScormSync.prefetchAfterUpdate(AddonModScormPrefetchHandler.instance, this.module, this.courseId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grade for an online attempt.
|
||||||
|
*/
|
||||||
|
export type AttemptGrade = AddonModScormAttemptGrade & {
|
||||||
|
gradeFormatted?: string;
|
||||||
|
};
|
|
@ -0,0 +1,50 @@
|
||||||
|
<ion-header>
|
||||||
|
<ion-toolbar>
|
||||||
|
<ion-title>{{ 'addon.mod_scorm.toc' | translate }}</ion-title>
|
||||||
|
<ion-buttons slot="end">
|
||||||
|
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
|
||||||
|
<ion-icon slot="icon-only" name="fas-times"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</ion-buttons>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
<ion-content>
|
||||||
|
<nav>
|
||||||
|
<ion-list class="addon-mod_scorm-toc">
|
||||||
|
<ion-item class="ion-text-wrap" *ngIf="attemptToContinue">
|
||||||
|
<ion-label>
|
||||||
|
<p>{{ 'addon.mod_scorm.dataattemptshown' | translate:{number: attemptToContinue} }}</p>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item class="ion-text-center" *ngIf="isBrowse">
|
||||||
|
<ion-label>
|
||||||
|
<p>{{ 'addon.mod_scorm.browsemode' | translate }}</p>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item class="ion-text-center" *ngIf="isReview">
|
||||||
|
<ion-label>
|
||||||
|
<p>{{ 'addon.mod_scorm.reviewmode' | translate }}</p>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<!-- List of SCOs. -->
|
||||||
|
<ng-container *ngFor="let sco of toc">
|
||||||
|
<ion-item *ngIf="sco.isvisible" class="ion-text-wrap" [detail]="sco.prereq && sco.launch"
|
||||||
|
[ngClass]="'core-padding-' + sco.level + ' addon-mod_scorm-type-' + sco.scormtype"
|
||||||
|
[class.core-selected-item]="selected == sco.id" (click)="loadSco(sco)"
|
||||||
|
[disabled]="!sco.prereq || !sco.launch ? true : null" tappable>
|
||||||
|
<ion-icon *ngIf="sco.icon" [name]="sco.icon.icon" [attr.aria-label]="sco.icon.description" slot="start">
|
||||||
|
</ion-icon>
|
||||||
|
<ion-label>
|
||||||
|
<core-format-text [text]="sco.title" contextLevel="module" [contextInstanceId]="moduleId"
|
||||||
|
[courseId]="courseId">
|
||||||
|
</core-format-text>
|
||||||
|
<span *ngIf="accessInfo && accessInfo.canviewscores && sco.scoreraw">
|
||||||
|
({{ 'addon.mod_scorm.score' | translate }}: {{sco.scoreraw}})
|
||||||
|
</span>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
</ng-container>
|
||||||
|
</ion-list>
|
||||||
|
</nav>
|
||||||
|
</ion-content>
|
|
@ -0,0 +1,68 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Component, Input, OnInit } from '@angular/core';
|
||||||
|
import { ModalController } from '@singletons';
|
||||||
|
import { AddonModScormGetScormAccessInformationWSResponse, AddonModScormProvider } from '../../services/scorm';
|
||||||
|
import { AddonModScormTOCScoWithIcon } from '../../services/scorm-helper';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal to display the TOC of a SCORM.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'addon-mod-scorm-toc',
|
||||||
|
templateUrl: 'toc.html',
|
||||||
|
})
|
||||||
|
export class AddonModScormTocComponent implements OnInit {
|
||||||
|
|
||||||
|
@Input() toc: AddonModScormTOCScoWithIcon[] = [];
|
||||||
|
@Input() attemptToContinue?: number;
|
||||||
|
@Input() selected?: number;
|
||||||
|
@Input() moduleId!: number;
|
||||||
|
@Input() courseId!: number;
|
||||||
|
@Input() accessInfo!: AddonModScormGetScormAccessInformationWSResponse;
|
||||||
|
@Input() mode = '';
|
||||||
|
|
||||||
|
isBrowse = false;
|
||||||
|
isReview = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.isBrowse = this.mode === AddonModScormProvider.MODEBROWSE;
|
||||||
|
this.isReview = this.mode === AddonModScormProvider.MODEREVIEW;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function called when a SCO is clicked.
|
||||||
|
*
|
||||||
|
* @param sco Clicked SCO.
|
||||||
|
*/
|
||||||
|
loadSco(sco: AddonModScormTOCScoWithIcon): void {
|
||||||
|
if (!sco.prereq || !sco.isvisible || !sco.launch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalController.dismiss(sco);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close modal.
|
||||||
|
*/
|
||||||
|
closeModal(): void {
|
||||||
|
ModalController.dismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
{
|
||||||
|
"asset": "Asset",
|
||||||
|
"assetlaunched": "Asset - Viewed",
|
||||||
|
"attempts": "Attempts",
|
||||||
|
"averageattempt": "Average attempts",
|
||||||
|
"browse": "Preview",
|
||||||
|
"browsed": "Browsed",
|
||||||
|
"browsemode": "Preview mode",
|
||||||
|
"cannotcalculategrade": "Grade couldn't be calculated.",
|
||||||
|
"completed": "Completed",
|
||||||
|
"contents": "Contents",
|
||||||
|
"dataattemptshown": "This data belongs to the attempt number {{number}}.",
|
||||||
|
"enter": "Enter",
|
||||||
|
"errorcreateofflineattempt": "An error occurred while creating a new offline attempt. Please try again.",
|
||||||
|
"errordownloadscorm": "Error downloading SCORM: \"{{name}}\".",
|
||||||
|
"errorgetscorm": "Error getting SCORM data.",
|
||||||
|
"errorinvalidversion": "Sorry, the application only supports SCORM 1.2.",
|
||||||
|
"errornotdownloadable": "The download of SCORM packages is disabled. Please contact your site administrator.",
|
||||||
|
"errornovalidsco": "This SCORM package doesn't have a visible SCO to load.",
|
||||||
|
"errorpackagefile": "Sorry, the application only supports ZIP packages.",
|
||||||
|
"errorsyncscorm": "An error occurred while synchronising. Please try again.",
|
||||||
|
"exceededmaxattempts": "You have reached the maximum number of attempts.",
|
||||||
|
"failed": "Failed",
|
||||||
|
"firstattempt": "First attempt",
|
||||||
|
"gradeaverage": "Average grade",
|
||||||
|
"gradeforattempt": "Grade for attempt",
|
||||||
|
"gradehighest": "Highest grade",
|
||||||
|
"grademethod": "Grading method",
|
||||||
|
"gradereported": "Grade reported",
|
||||||
|
"gradescoes": "Learning objects",
|
||||||
|
"gradesum": "Sum grade",
|
||||||
|
"highestattempt": "Highest attempt",
|
||||||
|
"incomplete": "Incomplete",
|
||||||
|
"lastattempt": "Last completed attempt",
|
||||||
|
"modulenameplural": "SCORM packages",
|
||||||
|
"newattempt": "Start a new attempt",
|
||||||
|
"noattemptsallowed": "Number of attempts allowed",
|
||||||
|
"noattemptsmade": "Number of attempts you have made",
|
||||||
|
"notattempted": "Not attempted",
|
||||||
|
"offlineattemptnote": "This attempt has data that hasn't been synchronised.",
|
||||||
|
"offlineattemptovermax": "This attempt cannot be sent because you exceeded the maximum number of attempts.",
|
||||||
|
"organizations": "Organisations",
|
||||||
|
"passed": "Passed",
|
||||||
|
"reviewmode": "Review mode",
|
||||||
|
"score": "Score",
|
||||||
|
"scormstatusnotdownloaded": "This SCORM package is not downloaded. It will be automatically downloaded when you open it.",
|
||||||
|
"scormstatusoutdated": "This SCORM package has been modified since the last download. It will be automatically downloaded when you open it.",
|
||||||
|
"suspended": "Suspended",
|
||||||
|
"toc": "TOC",
|
||||||
|
"warningofflinedatadeleted": "Some offline data from attempt {{number}} has been discarded because it couldn't be counted as a new attempt.",
|
||||||
|
"warningsynconlineincomplete": "Some attempts couldn't be synchronised with the site because the last online attempt is not yet finished. Please finish the online attempt first."
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
<ion-header>
|
||||||
|
<ion-toolbar>
|
||||||
|
<ion-buttons slot="start">
|
||||||
|
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||||
|
</ion-buttons>
|
||||||
|
<ion-title>
|
||||||
|
<core-format-text [text]="title" contextLevel="module" [contextInstanceId]="module?.id" [courseId]="courseId">
|
||||||
|
</core-format-text>
|
||||||
|
</ion-title>
|
||||||
|
<ion-buttons slot="end">
|
||||||
|
<!-- The buttons defined by the component will be added in here. -->
|
||||||
|
</ion-buttons>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
<ion-content>
|
||||||
|
<ion-refresher slot="fixed" [disabled]="!activityComponent?.loaded" (ionRefresh)="activityComponent?.doRefresh($event.target)">
|
||||||
|
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
||||||
|
</ion-refresher>
|
||||||
|
|
||||||
|
<addon-mod-scorm-index [module]="module" [courseId]="courseId" (dataRetrieved)="updateData($event)">
|
||||||
|
</addon-mod-scorm-index>
|
||||||
|
</ion-content>
|
|
@ -0,0 +1,30 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Component, OnInit, ViewChild } from '@angular/core';
|
||||||
|
import { CoreCourseModuleMainActivityPage } from '@features/course/classes/main-activity-page';
|
||||||
|
import { AddonModScormIndexComponent } from '../../components/index/index';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page that displays the scorm entry page.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'page-addon-mod-scorm-index',
|
||||||
|
templateUrl: 'index.html',
|
||||||
|
})
|
||||||
|
export class AddonModScormIndexPage extends CoreCourseModuleMainActivityPage<AddonModScormIndexComponent> implements OnInit {
|
||||||
|
|
||||||
|
@ViewChild(AddonModScormIndexComponent) activityComponent?: AddonModScormIndexComponent;
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
<ion-header>
|
||||||
|
<ion-toolbar>
|
||||||
|
<ion-buttons slot="start">
|
||||||
|
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||||
|
</ion-buttons>
|
||||||
|
<ion-title>
|
||||||
|
<core-format-text [text]="title" contextLevel="module" [contextInstanceId]="cmId" [courseId]="courseId">
|
||||||
|
</core-format-text>
|
||||||
|
</ion-title>
|
||||||
|
<ion-buttons slot="end">
|
||||||
|
<ion-button *ngIf="showToc && !loadingToc && toc.length" (click)="openToc()"
|
||||||
|
[attr.aria-label]="'addon.mod_scorm.toc' | translate" aria-haspopup="true">
|
||||||
|
<ion-icon name="fas-bookmark" slot="icon-only"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-spinner *ngIf="showToc && loadingToc"></ion-spinner>
|
||||||
|
</ion-buttons>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
<ion-content>
|
||||||
|
<core-loading [hideUntil]="loaded">
|
||||||
|
<core-navigation-bar [previous]="previousSco" [next]="nextSco" (action)="loadSco($event)"></core-navigation-bar>
|
||||||
|
|
||||||
|
<core-iframe *ngIf="loaded && src" [src]="src" [iframeWidth]="scormWidth" [iframeHeight]="scormHeight"></core-iframe>
|
||||||
|
|
||||||
|
<p *ngIf="!src && errorMessage">{{ errorMessage | translate }}</p>
|
||||||
|
</core-loading>
|
||||||
|
</ion-content>
|
|
@ -0,0 +1,574 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CoreMainMenuPage } from '@features/mainmenu/pages/menu/menu';
|
||||||
|
import { CoreNavigator } from '@services/navigator';
|
||||||
|
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
|
||||||
|
import { CoreSync } from '@services/sync';
|
||||||
|
import { CoreDomUtils } from '@services/utils/dom';
|
||||||
|
import { CoreTimeUtils } from '@services/utils/time';
|
||||||
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
import { ModalController } from '@singletons';
|
||||||
|
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||||
|
import { AddonModScormDataModel12 } from '../../classes/data-model-12';
|
||||||
|
import { AddonModScormTocComponent } from '../../components/toc/toc';
|
||||||
|
import {
|
||||||
|
AddonModScorm,
|
||||||
|
AddonModScormAttemptCountResult,
|
||||||
|
AddonModScormGetScormAccessInformationWSResponse,
|
||||||
|
AddonModScormProvider,
|
||||||
|
AddonModScormScorm,
|
||||||
|
AddonModScormScoWithData,
|
||||||
|
AddonModScormUserDataMap,
|
||||||
|
} from '../../services/scorm';
|
||||||
|
import { AddonModScormHelper, AddonModScormTOCScoWithIcon } from '../../services/scorm-helper';
|
||||||
|
import { AddonModScormSync } from '../../services/scorm-sync';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page that allows playing a SCORM.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'page-addon-mod-scorm-player',
|
||||||
|
templateUrl: 'player.html',
|
||||||
|
})
|
||||||
|
export class AddonModScormPlayerPage implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
title?: string; // Title.
|
||||||
|
scorm!: AddonModScormScorm; // The SCORM object.
|
||||||
|
showToc = false; // Whether to show the table of contents (TOC).
|
||||||
|
loadingToc = true; // Whether the TOC is being loaded.
|
||||||
|
toc: AddonModScormTOCScoWithIcon[] = []; // List of SCOs.
|
||||||
|
loaded = false; // Whether the data has been loaded.
|
||||||
|
previousSco?: AddonModScormScoWithData; // Previous SCO.
|
||||||
|
nextSco?: AddonModScormScoWithData; // Next SCO.
|
||||||
|
src?: string; // Iframe src.
|
||||||
|
errorMessage?: string; // Error message.
|
||||||
|
accessInfo?: AddonModScormGetScormAccessInformationWSResponse; // Access information.
|
||||||
|
scormWidth?: number; // Width applied to scorm iframe.
|
||||||
|
scormHeight?: number; // Height applied to scorm iframe.
|
||||||
|
incomplete = false; // Whether last attempt is incomplete.
|
||||||
|
cmId!: number; // Course module ID.
|
||||||
|
courseId!: number; // Course ID.
|
||||||
|
|
||||||
|
protected siteId!: string;
|
||||||
|
protected mode!: string; // Mode to play the SCORM.
|
||||||
|
protected moduleUrl!: string; // Module URL.
|
||||||
|
protected newAttempt = false; // Whether to start a new attempt.
|
||||||
|
protected organizationId?: string; // Organization ID to load.
|
||||||
|
protected attempt?: number; // The attempt number.
|
||||||
|
protected offline = false; // Whether it's offline mode.
|
||||||
|
protected userData?: AddonModScormUserDataMap; // User data.
|
||||||
|
protected initialScoId?: number; // Initial SCO ID to load.
|
||||||
|
protected currentSco?: AddonModScormScoWithData; // Current SCO.
|
||||||
|
protected dataModel?: AddonModScormDataModel12; // Data Model.
|
||||||
|
protected attemptToContinue?: number; // Attempt to continue (for the popover).
|
||||||
|
|
||||||
|
// Observers.
|
||||||
|
protected tocObserver?: CoreEventObserver;
|
||||||
|
protected launchNextObserver?: CoreEventObserver;
|
||||||
|
protected launchPrevObserver?: CoreEventObserver;
|
||||||
|
protected goOfflineObserver?: CoreEventObserver;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected mainMenuPage: CoreMainMenuPage,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
this.cmId = CoreNavigator.getRouteNumberParam('cmId')!;
|
||||||
|
this.courseId = CoreNavigator.getRouteNumberParam('courseId')!;
|
||||||
|
this.mode = CoreNavigator.getRouteParam('mode') || AddonModScormProvider.MODENORMAL;
|
||||||
|
this.moduleUrl = CoreNavigator.getRouteParam('moduleUrl') || '';
|
||||||
|
this.newAttempt = !!CoreNavigator.getRouteBooleanParam('newAttempt');
|
||||||
|
this.organizationId = CoreNavigator.getRouteParam('organizationId');
|
||||||
|
this.initialScoId = CoreNavigator.getRouteNumberParam('scoId');
|
||||||
|
this.siteId = CoreSites.getCurrentSiteId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch the SCORM data.
|
||||||
|
await this.fetchData();
|
||||||
|
|
||||||
|
if (!this.currentSco) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set start time if it's a new attempt.
|
||||||
|
if (this.newAttempt) {
|
||||||
|
try {
|
||||||
|
await this.setStartTime(this.currentSco.id);
|
||||||
|
} catch (error) {
|
||||||
|
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load SCO.
|
||||||
|
this.loadSco(this.currentSco);
|
||||||
|
} finally {
|
||||||
|
this.loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async initialize(): Promise<void> {
|
||||||
|
// Get the SCORM instance.
|
||||||
|
this.scorm = await AddonModScorm.getScorm(this.courseId, this.cmId, {
|
||||||
|
moduleUrl: this.moduleUrl,
|
||||||
|
readingStrategy: CoreSitesReadingStrategy.PreferCache,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Block the SCORM so it cannot be synchronized.
|
||||||
|
CoreSync.blockOperation(AddonModScormProvider.COMPONENT, this.scorm.id, 'player');
|
||||||
|
|
||||||
|
// We use SCORM name at start, later we'll use the SCO title.
|
||||||
|
this.title = this.scorm.name;
|
||||||
|
this.showToc = AddonModScorm.displayTocInPlayer(this.scorm);
|
||||||
|
|
||||||
|
if (this.scorm.popup) {
|
||||||
|
this.mainMenuPage.changeVisibility(false);
|
||||||
|
|
||||||
|
// If we receive a value > 100 we assume it's a fixed pixel size.
|
||||||
|
if (this.scorm.width! > 100) {
|
||||||
|
this.scormWidth = this.scorm.width;
|
||||||
|
|
||||||
|
// Only get fixed size on height if width is also fixed.
|
||||||
|
if (this.scorm.height! > 100) {
|
||||||
|
this.scormHeight = this.scorm.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for events to update the TOC, navigate through SCOs and go offline.
|
||||||
|
this.tocObserver = CoreEvents.on(AddonModScormProvider.UPDATE_TOC_EVENT, (data) => {
|
||||||
|
if (data.scormId !== this.scorm.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.offline) {
|
||||||
|
// Wait a bit to make sure data is stored.
|
||||||
|
setTimeout(this.refreshToc.bind(this), 100);
|
||||||
|
} else {
|
||||||
|
this.refreshToc();
|
||||||
|
}
|
||||||
|
}, this.siteId);
|
||||||
|
|
||||||
|
this.launchNextObserver = CoreEvents.on(AddonModScormProvider.LAUNCH_NEXT_SCO_EVENT, (data) => {
|
||||||
|
if (data.scormId === this.scorm.id && this.nextSco) {
|
||||||
|
this.loadSco(this.nextSco);
|
||||||
|
}
|
||||||
|
}, this.siteId);
|
||||||
|
|
||||||
|
this.launchPrevObserver = CoreEvents.on(AddonModScormProvider.LAUNCH_PREV_SCO_EVENT, (data) => {
|
||||||
|
if (data.scormId === this.scorm.id && this.previousSco) {
|
||||||
|
this.loadSco(this.previousSco);
|
||||||
|
}
|
||||||
|
}, this.siteId);
|
||||||
|
|
||||||
|
this.goOfflineObserver = CoreEvents.on(AddonModScormProvider.GO_OFFLINE_EVENT, (data) => {
|
||||||
|
if (data.scormId !== this.scorm.id || this.offline) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.offline = true;
|
||||||
|
|
||||||
|
// Wait a bit to prevent collisions between this store and SCORM API's store.
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
AddonModScormHelper.convertAttemptToOffline(this.scorm, this.attempt!);
|
||||||
|
} catch (error) {
|
||||||
|
CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshToc();
|
||||||
|
}, 200);
|
||||||
|
}, this.siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the next and previous SCO.
|
||||||
|
*
|
||||||
|
* @param scoId Current SCO ID.
|
||||||
|
*/
|
||||||
|
protected calculateNextAndPreviousSco(scoId: number): void {
|
||||||
|
this.previousSco = AddonModScormHelper.getPreviousScoFromToc(this.toc, scoId);
|
||||||
|
this.nextSco = AddonModScormHelper.getNextScoFromToc(this.toc, scoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the attempt to use, the mode (normal/preview) and if it's offline or online.
|
||||||
|
*
|
||||||
|
* @param attemptsData Attempts count.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async determineAttemptAndMode(attemptsData: AddonModScormAttemptCountResult): Promise<void> {
|
||||||
|
const data = await AddonModScormHelper.determineAttemptToContinue(this.scorm, attemptsData);
|
||||||
|
|
||||||
|
let incomplete = false;
|
||||||
|
this.attempt = data.num;
|
||||||
|
this.offline = data.offline;
|
||||||
|
|
||||||
|
if (this.attempt != attemptsData.lastAttempt.num) {
|
||||||
|
this.attemptToContinue = this.attempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if current attempt is incomplete.
|
||||||
|
if (this.attempt > 0) {
|
||||||
|
incomplete = await AddonModScorm.isAttemptIncomplete(this.scorm.id, this.attempt, {
|
||||||
|
offline: this.offline,
|
||||||
|
cmId: this.cmId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine mode and attempt to use.
|
||||||
|
const result = AddonModScorm.determineAttemptAndMode(this.scorm, this.mode, this.attempt, this.newAttempt, incomplete);
|
||||||
|
|
||||||
|
if (result.attempt > this.attempt) {
|
||||||
|
// We're creating a new attempt.
|
||||||
|
if (this.offline) {
|
||||||
|
// Last attempt was offline, so we'll create a new offline attempt.
|
||||||
|
await AddonModScormHelper.createOfflineAttempt(this.scorm, result.attempt, attemptsData.online.length);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// Last attempt was online, verify that we can create a new online attempt. We ignore cache.
|
||||||
|
await AddonModScorm.getScormUserData(this.scorm.id, result.attempt, {
|
||||||
|
cmId: this.cmId,
|
||||||
|
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Cannot communicate with the server, create an offline attempt.
|
||||||
|
this.offline = true;
|
||||||
|
|
||||||
|
await AddonModScormHelper.createOfflineAttempt(this.scorm, result.attempt, attemptsData.online.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mode = result.mode;
|
||||||
|
this.newAttempt = result.newAttempt;
|
||||||
|
this.attempt = result.attempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch data needed to play the SCORM.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async fetchData(): Promise<void> {
|
||||||
|
if (!this.scorm) {
|
||||||
|
await this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for any ongoing sync to finish. We won't sync a SCORM while it's being played.
|
||||||
|
await AddonModScormSync.waitForSync(this.scorm.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get attempts data.
|
||||||
|
const attemptsData = await AddonModScorm.getAttemptCount(this.scorm.id, { cmId: this.cmId });
|
||||||
|
|
||||||
|
await this.determineAttemptAndMode(attemptsData);
|
||||||
|
|
||||||
|
const [data, accessInfo] = await Promise.all([
|
||||||
|
AddonModScorm.getScormUserData(this.scorm.id, this.attempt!, {
|
||||||
|
cmId: this.cmId,
|
||||||
|
offline: this.offline,
|
||||||
|
}),
|
||||||
|
AddonModScorm.getAccessInformation(this.scorm.id, {
|
||||||
|
cmId: this.cmId,
|
||||||
|
}),
|
||||||
|
this.fetchToc(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.userData = data;
|
||||||
|
this.accessInfo = accessInfo;
|
||||||
|
} catch (error) {
|
||||||
|
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the TOC.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async fetchToc(): Promise<void> {
|
||||||
|
this.loadingToc = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// We need to check incomplete again: attempt number or status might have changed.
|
||||||
|
this.incomplete = await AddonModScorm.isAttemptIncomplete(this.scorm.id, this.attempt!, {
|
||||||
|
offline: this.offline,
|
||||||
|
cmId: this.cmId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get TOC.
|
||||||
|
this.toc = await AddonModScormHelper.getToc(this.scorm.id, this.attempt!, this.incomplete, {
|
||||||
|
organization: this.organizationId,
|
||||||
|
offline: this.offline,
|
||||||
|
cmId: this.cmId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.currentSco) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.newAttempt) {
|
||||||
|
// Creating a new attempt, use the first SCO defined by the SCORM.
|
||||||
|
this.initialScoId = this.scorm.launch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine current SCO if we received an ID.
|
||||||
|
if (this.initialScoId && this.initialScoId > 0) {
|
||||||
|
// SCO set by parameter, get it from TOC.
|
||||||
|
this.currentSco = AddonModScormHelper.getScoFromToc(this.toc, this.initialScoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentSco) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No SCO defined. Get the first valid one.
|
||||||
|
const sco = await AddonModScormHelper.getFirstSco(this.scorm.id, this.attempt!, {
|
||||||
|
toc: this.toc,
|
||||||
|
organization: this.organizationId,
|
||||||
|
mode: this.mode,
|
||||||
|
offline: this.offline,
|
||||||
|
cmId: this.cmId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sco) {
|
||||||
|
this.currentSco = sco;
|
||||||
|
} else {
|
||||||
|
// We couldn't find a SCO to load: they're all inactive or without launch URL.
|
||||||
|
this.errorMessage = 'addon.mod_scorm.errornovalidsco';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.loadingToc = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page will leave.
|
||||||
|
*/
|
||||||
|
ionViewWillLeave(): void {
|
||||||
|
CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'scorm' });
|
||||||
|
|
||||||
|
// Empty src when leaving the state so unload event is triggered in the iframe.
|
||||||
|
this.src = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a SCO.
|
||||||
|
*
|
||||||
|
* @param sco The SCO to load.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
async loadSco(sco: AddonModScormScoWithData): Promise<void> {
|
||||||
|
if (!this.dataModel) {
|
||||||
|
// Create the model.
|
||||||
|
this.dataModel = new AddonModScormDataModel12(
|
||||||
|
this.siteId,
|
||||||
|
this.scorm,
|
||||||
|
sco.id,
|
||||||
|
this.attempt!,
|
||||||
|
this.userData!,
|
||||||
|
this.mode,
|
||||||
|
this.offline,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add the model to the window so the SCORM can access it.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(<any> window).API = this.dataModel;
|
||||||
|
} else {
|
||||||
|
// Load the SCO in the existing model.
|
||||||
|
this.dataModel.loadSco(sco.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentSco = sco;
|
||||||
|
this.title = sco.title || this.scorm.name; // Try to use SCO title.
|
||||||
|
|
||||||
|
this.calculateNextAndPreviousSco(sco.id);
|
||||||
|
|
||||||
|
// Load the SCO source.
|
||||||
|
this.loadScoSrc(sco);
|
||||||
|
|
||||||
|
if (sco.scormtype == 'asset') {
|
||||||
|
// Mark the asset as completed.
|
||||||
|
this.markCompleted(sco);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger SCO launch event.
|
||||||
|
CoreUtils.ignoreErrors(AddonModScorm.logLaunchSco(this.scorm.id, sco.id, this.scorm.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load SCO src.
|
||||||
|
*
|
||||||
|
* @param sco SCO to load.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async loadScoSrc(sco: AddonModScormScoWithData): Promise<void> {
|
||||||
|
const src = await AddonModScorm.getScoSrc(this.scorm, sco);
|
||||||
|
|
||||||
|
if (src == this.src) {
|
||||||
|
// Re-loading same page. Set it to empty and then re-set the src in the next digest so it detects it has changed.
|
||||||
|
this.src = '';
|
||||||
|
|
||||||
|
await CoreUtils.nextTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.src = src;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an SCO, mark it as completed.
|
||||||
|
*
|
||||||
|
* @param sco SCO to mark.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async markCompleted(sco: AddonModScormScoWithData): Promise<void> {
|
||||||
|
const tracks = [{
|
||||||
|
element: 'cmi.core.lesson_status',
|
||||||
|
value: 'completed',
|
||||||
|
}];
|
||||||
|
|
||||||
|
try {
|
||||||
|
AddonModScorm.saveTracks(sco.id, this.attempt!, tracks, this.scorm, this.offline);
|
||||||
|
} catch {
|
||||||
|
// Error saving data. Go offline if needed.
|
||||||
|
if (this.offline) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await AddonModScorm.getScormUserData(this.scorm.id, this.attempt!, {
|
||||||
|
cmId: this.cmId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data[sco.id] && data[sco.id].userdata['cmi.core.lesson_status'] == 'completed') {
|
||||||
|
// Already marked as completed.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Go offline.
|
||||||
|
await AddonModScormHelper.convertAttemptToOffline(this.scorm, this.attempt!);
|
||||||
|
|
||||||
|
this.offline = true;
|
||||||
|
this.dataModel?.setOffline(true);
|
||||||
|
|
||||||
|
await AddonModScorm.saveTracks(sco.id, this.attempt!, tracks, this.scorm, true);
|
||||||
|
} catch (error) {
|
||||||
|
CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Refresh TOC, some prerequisites might have changed.
|
||||||
|
this.refreshToc();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the TOC.
|
||||||
|
*/
|
||||||
|
async openToc(): Promise<void> {
|
||||||
|
const modal = await ModalController.create({
|
||||||
|
component: AddonModScormTocComponent,
|
||||||
|
componentProps: {
|
||||||
|
toc: this.toc,
|
||||||
|
attemptToContinue: this.attemptToContinue,
|
||||||
|
selected: this.currentSco && this.currentSco.id,
|
||||||
|
moduleId: this.cmId,
|
||||||
|
courseId: this.courseId,
|
||||||
|
accessInfo: this.accessInfo,
|
||||||
|
mode: this.mode,
|
||||||
|
},
|
||||||
|
cssClass: 'core-modal-lateral',
|
||||||
|
showBackdrop: true,
|
||||||
|
backdropDismiss: true,
|
||||||
|
// @todo enterAnimation: 'core-modal-lateral-transition',
|
||||||
|
// leaveAnimation: 'core-modal-lateral-transition'
|
||||||
|
});
|
||||||
|
|
||||||
|
await modal.present();
|
||||||
|
|
||||||
|
const result = await modal.onDidDismiss();
|
||||||
|
|
||||||
|
if (result.data) {
|
||||||
|
this.loadSco(result.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the TOC.
|
||||||
|
*
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async refreshToc(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await CoreUtils.ignoreErrors(AddonModScorm.invalidateAllScormData(this.scorm.id));
|
||||||
|
|
||||||
|
await this.fetchToc();
|
||||||
|
} catch (error) {
|
||||||
|
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set SCORM start time.
|
||||||
|
*
|
||||||
|
* @param scoId SCO ID.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async setStartTime(scoId: number): Promise<void> {
|
||||||
|
const tracks = [{
|
||||||
|
element: 'x.start.time',
|
||||||
|
value: String(CoreTimeUtils.timestamp()),
|
||||||
|
}];
|
||||||
|
|
||||||
|
await AddonModScorm.saveTracks(scoId, this.attempt!, tracks, this.scorm, this.offline);
|
||||||
|
|
||||||
|
if (this.offline) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New online attempt created, update cached data about online attempts.
|
||||||
|
await CoreUtils.ignoreErrors(AddonModScorm.getAttemptCount(this.scorm.id, {
|
||||||
|
cmId: this.cmId,
|
||||||
|
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component being destroyed.
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
// Stop listening for events.
|
||||||
|
this.tocObserver?.off();
|
||||||
|
this.launchNextObserver?.off();
|
||||||
|
this.launchPrevObserver?.off();
|
||||||
|
setTimeout(() => {
|
||||||
|
this.goOfflineObserver?.off();
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
this.mainMenuPage.changeVisibility(true);
|
||||||
|
|
||||||
|
// Unblock the SCORM so it can be synced.
|
||||||
|
CoreSync.unblockOperation(AddonModScormProvider.COMPONENT, this.scorm.id, 'player');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
|
||||||
|
import { CoreSharedModule } from '@/core/shared.module';
|
||||||
|
import { AddonModScormComponentsModule } from './components/components.module';
|
||||||
|
import { AddonModScormIndexPage } from './pages/index/index';
|
||||||
|
import { AddonModScormPlayerPage } from './pages/player/player';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: ':courseId/:cmId',
|
||||||
|
component: AddonModScormIndexPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':courseId/:cmId/player',
|
||||||
|
component: AddonModScormPlayerPage,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
RouterModule.forChild(routes),
|
||||||
|
CoreSharedModule,
|
||||||
|
AddonModScormComponentsModule,
|
||||||
|
],
|
||||||
|
declarations: [
|
||||||
|
AddonModScormIndexPage,
|
||||||
|
AddonModScormPlayerPage,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonModScormLazyModule {}
|
|
@ -0,0 +1,76 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { APP_INITIALIZER, NgModule, Type } from '@angular/core';
|
||||||
|
import { Routes } from '@angular/router';
|
||||||
|
import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate';
|
||||||
|
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
|
||||||
|
import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
|
||||||
|
import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module';
|
||||||
|
import { CoreCronDelegate } from '@services/cron';
|
||||||
|
import { CORE_SITE_SCHEMAS } from '@services/sites';
|
||||||
|
import { AddonModScormComponentsModule } from './components/components.module';
|
||||||
|
import { OFFLINE_SITE_SCHEMA } from './services/database/scorm';
|
||||||
|
import { AddonModScormGradeLinkHandler } from './services/handlers/grade-link';
|
||||||
|
import { AddonModScormIndexLinkHandler } from './services/handlers/index-link';
|
||||||
|
import { AddonModScormListLinkHandler } from './services/handlers/list-link';
|
||||||
|
import { AddonModScormModuleHandler, AddonModScormModuleHandlerService } from './services/handlers/module';
|
||||||
|
import { AddonModScormPrefetchHandler } from './services/handlers/prefetch';
|
||||||
|
import { AddonModScormSyncCronHandler } from './services/handlers/sync-cron';
|
||||||
|
import { AddonModScormProvider } from './services/scorm';
|
||||||
|
import { AddonModScormHelperProvider } from './services/scorm-helper';
|
||||||
|
import { AddonModScormOfflineProvider } from './services/scorm-offline';
|
||||||
|
import { AddonModScormSyncProvider } from './services/scorm-sync';
|
||||||
|
|
||||||
|
export const ADDON_MOD_SCORM_SERVICES: Type<unknown>[] = [
|
||||||
|
AddonModScormProvider,
|
||||||
|
AddonModScormOfflineProvider,
|
||||||
|
AddonModScormHelperProvider,
|
||||||
|
AddonModScormSyncProvider,
|
||||||
|
];
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: AddonModScormModuleHandlerService.PAGE_NAME,
|
||||||
|
loadChildren: () => import('./scorm-lazy.module').then(m => m.AddonModScormLazyModule),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
CoreMainMenuTabRoutingModule.forChild(routes),
|
||||||
|
AddonModScormComponentsModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: CORE_SITE_SCHEMAS,
|
||||||
|
useValue: [OFFLINE_SITE_SCHEMA],
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
multi: true,
|
||||||
|
deps: [],
|
||||||
|
useFactory: () => () => {
|
||||||
|
CoreCourseModuleDelegate.registerHandler(AddonModScormModuleHandler.instance);
|
||||||
|
CoreCourseModulePrefetchDelegate.registerHandler(AddonModScormPrefetchHandler.instance);
|
||||||
|
CoreCronDelegate.register(AddonModScormSyncCronHandler.instance);
|
||||||
|
CoreContentLinksDelegate.registerHandler(AddonModScormGradeLinkHandler.instance);
|
||||||
|
CoreContentLinksDelegate.registerHandler(AddonModScormIndexLinkHandler.instance);
|
||||||
|
CoreContentLinksDelegate.registerHandler(AddonModScormListLinkHandler.instance);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AddonModScormModule {}
|
|
@ -0,0 +1,137 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { CoreSiteSchema } from '@services/sites';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database variables for AddonModScormOfflineProvider.
|
||||||
|
*/
|
||||||
|
export const ATTEMPTS_TABLE_NAME = 'addon_mod_scorm_offline_attempts';
|
||||||
|
export const TRACKS_TABLE_NAME = 'addon_mod_scorm_offline_scos_tracks';
|
||||||
|
export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = {
|
||||||
|
name: 'AddonModScormOfflineProvider',
|
||||||
|
version: 1,
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
name: ATTEMPTS_TABLE_NAME,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'scormid',
|
||||||
|
type: 'INTEGER',
|
||||||
|
notNull: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'attempt', // Attempt number.
|
||||||
|
type: 'INTEGER',
|
||||||
|
notNull: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userid',
|
||||||
|
type: 'INTEGER',
|
||||||
|
notNull: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'courseid',
|
||||||
|
type: 'INTEGER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'timecreated',
|
||||||
|
type: 'INTEGER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'timemodified',
|
||||||
|
type: 'INTEGER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'snapshot',
|
||||||
|
type: 'TEXT',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
primaryKeys: ['scormid', 'userid', 'attempt'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: TRACKS_TABLE_NAME,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'scormid',
|
||||||
|
type: 'INTEGER',
|
||||||
|
notNull: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'attempt', // Attempt number.
|
||||||
|
type: 'INTEGER',
|
||||||
|
notNull: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userid',
|
||||||
|
type: 'INTEGER',
|
||||||
|
notNull: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'scoid',
|
||||||
|
type: 'INTEGER',
|
||||||
|
notNull: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'element',
|
||||||
|
type: 'TEXT',
|
||||||
|
notNull: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'value',
|
||||||
|
type: 'TEXT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'timemodified',
|
||||||
|
type: 'INTEGER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'synced',
|
||||||
|
type: 'INTEGER',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
primaryKeys: ['scormid', 'userid', 'attempt', 'scoid', 'element'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline common data.
|
||||||
|
*/
|
||||||
|
export type AddonModScormOfflineDBCommonData = {
|
||||||
|
scormid: number;
|
||||||
|
attempt: number;
|
||||||
|
userid: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCORM attempt data.
|
||||||
|
*/
|
||||||
|
export type AddonModScormAttemptDBRecord = AddonModScormOfflineDBCommonData & {
|
||||||
|
courseid: number;
|
||||||
|
timecreated: number;
|
||||||
|
timemodified: number;
|
||||||
|
snapshot?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCORM track data.
|
||||||
|
*/
|
||||||
|
export type AddonModScormTrackDBRecord = AddonModScormOfflineDBCommonData & {
|
||||||
|
scoid: number;
|
||||||
|
element: string;
|
||||||
|
value?: string | null;
|
||||||
|
timemodified: number;
|
||||||
|
synced: number;
|
||||||
|
};
|
|
@ -0,0 +1,34 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { CoreContentLinksModuleGradeHandler } from '@features/contentlinks/classes/module-grade-handler';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to treat links to SCORM grade.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModScormGradeLinkHandlerService extends CoreContentLinksModuleGradeHandler {
|
||||||
|
|
||||||
|
name = 'AddonModScormGradeLinkHandler';
|
||||||
|
canReview = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('AddonModScorm', 'scorm');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddonModScormGradeLinkHandler = makeSingleton(AddonModScormGradeLinkHandlerService);
|
|
@ -0,0 +1,33 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { CoreContentLinksModuleIndexHandler } from '@features/contentlinks/classes/module-index-handler';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to treat links to SCORM index.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModScormIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler {
|
||||||
|
|
||||||
|
name = 'AddonModScormIndexLinkHandler';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('AddonModScorm', 'scorm', 'a');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddonModScormIndexLinkHandler = makeSingleton(AddonModScormIndexLinkHandlerService);
|
|
@ -0,0 +1,33 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { CoreContentLinksModuleListHandler } from '@features/contentlinks/classes/module-list-handler';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to treat links to SCORM list page.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModScormListLinkHandlerService extends CoreContentLinksModuleListHandler {
|
||||||
|
|
||||||
|
name = 'AddonModScormListLinkHandler';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('AddonModScorm', 'scorm');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddonModScormListLinkHandler = makeSingleton(AddonModScormListLinkHandlerService);
|
|
@ -0,0 +1,83 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { CoreConstants } from '@/core/constants';
|
||||||
|
import { Injectable, Type } from '@angular/core';
|
||||||
|
import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course';
|
||||||
|
import { CoreCourseModule } from '@features/course/services/course-helper';
|
||||||
|
import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate';
|
||||||
|
import { CoreNavigationOptions, CoreNavigator } from '@services/navigator';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
import { AddonModScormIndexComponent } from '../../components/index';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to support SCORM modules.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModScormModuleHandlerService implements CoreCourseModuleHandler {
|
||||||
|
|
||||||
|
static readonly PAGE_NAME = 'mod_scorm';
|
||||||
|
|
||||||
|
name = 'AddonModScorm';
|
||||||
|
modName = 'scorm';
|
||||||
|
|
||||||
|
supportedFeatures = {
|
||||||
|
[CoreConstants.FEATURE_GROUPS]: true,
|
||||||
|
[CoreConstants.FEATURE_GROUPINGS]: true,
|
||||||
|
[CoreConstants.FEATURE_MOD_INTRO]: true,
|
||||||
|
[CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true,
|
||||||
|
[CoreConstants.FEATURE_COMPLETION_HAS_RULES]: true,
|
||||||
|
[CoreConstants.FEATURE_GRADE_HAS_GRADE]: true,
|
||||||
|
[CoreConstants.FEATURE_GRADE_OUTCOMES]: true,
|
||||||
|
[CoreConstants.FEATURE_BACKUP_MOODLE2]: true,
|
||||||
|
[CoreConstants.FEATURE_SHOW_DESCRIPTION]: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
getData(module: CoreCourseAnyModuleData): CoreCourseModuleHandlerData {
|
||||||
|
return {
|
||||||
|
icon: CoreCourse.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined),
|
||||||
|
title: module.name,
|
||||||
|
class: 'addon-mod_scorm-handler',
|
||||||
|
showDownloadButton: true,
|
||||||
|
action(event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions) {
|
||||||
|
options = options || {};
|
||||||
|
options.params = options.params || {};
|
||||||
|
Object.assign(options.params, { module });
|
||||||
|
const routeParams = '/' + courseId + '/' + module.id;
|
||||||
|
|
||||||
|
CoreNavigator.navigateToSitePath(AddonModScormModuleHandlerService.PAGE_NAME + routeParams, options);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
async getMainComponent(): Promise<Type<unknown>> {
|
||||||
|
return AddonModScormIndexComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddonModScormModuleHandler = makeSingleton(AddonModScormModuleHandlerService);
|
|
@ -0,0 +1,58 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { CorePluginFileHandler } from '@services/plugin-file-delegate';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to treat file URLs in SCORM.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModScormPluginFileHandlerService implements CorePluginFileHandler {
|
||||||
|
|
||||||
|
name = 'AddonModScormPluginFileHandler';
|
||||||
|
component = 'mod_scorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
getComponentRevisionRegExp(args: string[]): RegExp | undefined {
|
||||||
|
// Check filearea.
|
||||||
|
if (args[2] == 'content') {
|
||||||
|
// Component + Filearea + Revision
|
||||||
|
return new RegExp('/mod_scorm/content/([0-9]+)/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
getComponentRevisionReplace(): string {
|
||||||
|
// Component + Filearea + Revision
|
||||||
|
return '/mod_scorm/content/0/';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the handler is enabled on a site level.
|
||||||
|
*
|
||||||
|
* @return Whether or not the handler is enabled on a site level.
|
||||||
|
*/
|
||||||
|
async isEnabled(): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddonModScormPluginFileHandler = makeSingleton(AddonModScormPluginFileHandlerService);
|
|
@ -0,0 +1,439 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { CoreError } from '@classes/errors/error';
|
||||||
|
import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler';
|
||||||
|
import { CoreCourseAnyModuleData, CoreCourseCommonModWSOptions } from '@features/course/services/course';
|
||||||
|
import { CoreApp } from '@services/app';
|
||||||
|
import { CoreFile } from '@services/file';
|
||||||
|
import { CoreFilepool } from '@services/filepool';
|
||||||
|
import { CoreFileSizeSum } from '@services/plugin-file-delegate';
|
||||||
|
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
|
||||||
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
import { CoreWSExternalFile } from '@services/ws';
|
||||||
|
import { makeSingleton, Translate } from '@singletons';
|
||||||
|
import { AddonModScorm, AddonModScormProvider, AddonModScormScorm } from '../scorm';
|
||||||
|
import { AddonModScormSync } from '../scorm-sync';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler to prefetch SCORMs.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModScormPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase {
|
||||||
|
|
||||||
|
name = 'AddonModScorm';
|
||||||
|
modName = 'scorm';
|
||||||
|
component = AddonModScormProvider.COMPONENT;
|
||||||
|
updatesNames = /^configuration$|^.*files$|^tracks$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
download(
|
||||||
|
module: CoreCourseAnyModuleData,
|
||||||
|
courseId: number,
|
||||||
|
dirPath?: string,
|
||||||
|
onProgress?: AddonModScormProgressCallback,
|
||||||
|
): Promise<void> {
|
||||||
|
const siteId = CoreSites.getCurrentSiteId();
|
||||||
|
|
||||||
|
return this.prefetchPackage(
|
||||||
|
module,
|
||||||
|
courseId,
|
||||||
|
this.downloadOrPrefetchScorm.bind(this, module, courseId, true, siteId, false, onProgress),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download or prefetch a SCORM.
|
||||||
|
*
|
||||||
|
* @param module Module.
|
||||||
|
* @param courseId Course ID the module belongs to.
|
||||||
|
* @param single True if we're downloading a single module, false if we're downloading a whole section.
|
||||||
|
* @param siteId Site ID.
|
||||||
|
* @param prefetch True to prefetch, false to download right away.
|
||||||
|
* @param onProgress Function to call on progress.
|
||||||
|
* @return Promise resolved with the "extra" data to store: the hash of the file.
|
||||||
|
*/
|
||||||
|
protected async downloadOrPrefetchScorm(
|
||||||
|
module: CoreCourseAnyModuleData,
|
||||||
|
courseId: number,
|
||||||
|
single: boolean,
|
||||||
|
siteId: string,
|
||||||
|
prefetch: boolean,
|
||||||
|
onProgress?: AddonModScormProgressCallback,
|
||||||
|
): Promise<string> {
|
||||||
|
|
||||||
|
const scorm = await this.getScorm(module, courseId, siteId);
|
||||||
|
|
||||||
|
const files = this.getIntroFilesFromInstance(module, scorm);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
// Download the SCORM file.
|
||||||
|
this.downloadOrPrefetchMainFileIfNeeded(scorm, prefetch, onProgress, siteId),
|
||||||
|
// Download WS data. If it fails we don't want to fail the whole download, so we'll ignore the error for now.
|
||||||
|
// @todo Implement a warning system so the user knows which SCORMs have failed.
|
||||||
|
CoreUtils.ignoreErrors(this.fetchWSData(scorm, siteId)),
|
||||||
|
// Download intro files, ignoring errors.
|
||||||
|
CoreUtils.ignoreErrors(CoreFilepool.downloadOrPrefetchFiles(siteId, files, prefetch, false, this.component, module.id)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Success, return the hash.
|
||||||
|
return scorm.sha1hash!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads/Prefetches and unzips the SCORM package.
|
||||||
|
*
|
||||||
|
* @param scorm SCORM object.
|
||||||
|
* @param prefetch True if prefetch, false otherwise.
|
||||||
|
* @param onProgress Function to call on progress.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when the file is downloaded and unzipped.
|
||||||
|
*/
|
||||||
|
protected async downloadOrPrefetchMainFile(
|
||||||
|
scorm: AddonModScormScorm,
|
||||||
|
prefetch?: boolean,
|
||||||
|
onProgress?: AddonModScormProgressCallback,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
siteId = siteId || CoreSites.getCurrentSiteId();
|
||||||
|
|
||||||
|
const packageUrl = AddonModScorm.getPackageUrl(scorm);
|
||||||
|
|
||||||
|
// Get the folder where the unzipped files will be.
|
||||||
|
const dirPath = await AddonModScorm.getScormFolder(scorm.moduleurl!);
|
||||||
|
|
||||||
|
// Notify that the download is starting.
|
||||||
|
onProgress && onProgress({ message: 'core.downloading' });
|
||||||
|
|
||||||
|
// Download the ZIP file to the filepool.
|
||||||
|
if (prefetch) {
|
||||||
|
await CoreFilepool.addToQueueByUrl(
|
||||||
|
siteId,
|
||||||
|
packageUrl,
|
||||||
|
this.component,
|
||||||
|
scorm.coursemodule,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
this.downloadProgress.bind(this, true, onProgress),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await CoreFilepool.downloadUrl(
|
||||||
|
siteId,
|
||||||
|
packageUrl,
|
||||||
|
true,
|
||||||
|
this.component,
|
||||||
|
scorm.coursemodule,
|
||||||
|
undefined,
|
||||||
|
this.downloadProgress.bind(this, true, onProgress),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the ZIP file path.
|
||||||
|
const zipPath = await CoreFilepool.getFilePathByUrl(siteId, packageUrl);
|
||||||
|
|
||||||
|
// Notify that the unzip is starting.
|
||||||
|
onProgress && onProgress({ message: 'core.unzipping' });
|
||||||
|
|
||||||
|
// Unzip and delete the zip when finished.
|
||||||
|
await CoreFile.unzipFile(zipPath, dirPath, this.downloadProgress.bind(this, false, onProgress));
|
||||||
|
|
||||||
|
await CoreUtils.ignoreErrors(CoreFilepool.removeFileByUrl(siteId, packageUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads/Prefetches and unzips the SCORM package if it should be downloaded.
|
||||||
|
*
|
||||||
|
* @param scorm SCORM object.
|
||||||
|
* @param prefetch True if prefetch, false otherwise.
|
||||||
|
* @param onProgress Function to call on progress.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when the file is downloaded and unzipped.
|
||||||
|
*/
|
||||||
|
protected async downloadOrPrefetchMainFileIfNeeded(
|
||||||
|
scorm: AddonModScormScorm,
|
||||||
|
prefetch?: boolean,
|
||||||
|
onProgress?: AddonModScormProgressCallback,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
|
||||||
|
siteId = siteId || CoreSites.getCurrentSiteId();
|
||||||
|
|
||||||
|
const result = AddonModScorm.isScormUnsupported(scorm);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
throw new CoreError(Translate.instant(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// First verify that the file needs to be downloaded.
|
||||||
|
// It needs to be checked manually because the ZIP file is deleted after unzipped, so the filepool will always download it.
|
||||||
|
const download = await AddonModScorm.shouldDownloadMainFile(scorm, undefined, siteId);
|
||||||
|
|
||||||
|
if (download) {
|
||||||
|
await this.downloadOrPrefetchMainFile(scorm, prefetch, onProgress, siteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function that converts a regular ProgressEvent into a AddonModScormProgressEvent.
|
||||||
|
*
|
||||||
|
* @param downloading True when downloading, false when unzipping.
|
||||||
|
* @param onProgress Function to call on progress.
|
||||||
|
* @param progress Event returned by the download function.
|
||||||
|
*/
|
||||||
|
protected downloadProgress(downloading: boolean, onProgress?: AddonModScormProgressCallback, progress?: ProgressEvent): void {
|
||||||
|
if (onProgress && progress && progress.loaded) {
|
||||||
|
onProgress({ downloading, progress });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get WS data for SCORM.
|
||||||
|
*
|
||||||
|
* @param scorm SCORM object.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when the data is prefetched.
|
||||||
|
*/
|
||||||
|
async fetchWSData(scorm: AddonModScormScorm, siteId?: string): Promise<void> {
|
||||||
|
siteId = siteId || CoreSites.getCurrentSiteId();
|
||||||
|
|
||||||
|
const modOptions: CoreCourseCommonModWSOptions = {
|
||||||
|
cmId: scorm.coursemodule,
|
||||||
|
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
|
||||||
|
siteId,
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
// Prefetch number of attempts (including not completed).
|
||||||
|
this.fetchAttempts(scorm, modOptions),
|
||||||
|
// Prefetch SCOs.
|
||||||
|
AddonModScorm.getScos(scorm.id, modOptions),
|
||||||
|
// Prefetch access information.
|
||||||
|
AddonModScorm.getAccessInformation(scorm.id, modOptions),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch attempts WS data.
|
||||||
|
*
|
||||||
|
* @param scorm SCORM object.
|
||||||
|
* @param modOptions Options.
|
||||||
|
* @returns Promise resolved when done.
|
||||||
|
*/
|
||||||
|
async fetchAttempts(scorm: AddonModScormScorm, modOptions: CoreCourseCommonModWSOptions): Promise<void> {
|
||||||
|
// If it fails, assume we have no attempts.
|
||||||
|
const numAttempts = await CoreUtils.ignoreErrors(AddonModScorm.getAttemptCountOnline(scorm.id, modOptions), 0);
|
||||||
|
|
||||||
|
if (numAttempts <= 0) {
|
||||||
|
// No attempts. We'll still try to get user data to be able to identify SCOs not visible and so.
|
||||||
|
await AddonModScorm.getScormUserDataOnline(scorm.id, 0, modOptions);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user data for each attempt.
|
||||||
|
const promises: Promise<unknown>[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i <= numAttempts; i++) {
|
||||||
|
promises.push(AddonModScorm.getScormUserDataOnline(scorm.id, i, modOptions).catch((error) => {
|
||||||
|
// Ignore failures of all the attempts that aren't the last one.
|
||||||
|
if (i == numAttempts) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
async getDownloadSize(module: CoreCourseAnyModuleData, courseId: number): Promise<CoreFileSizeSum> {
|
||||||
|
const scorm = await this.getScorm(module, courseId);
|
||||||
|
|
||||||
|
if (AddonModScorm.isScormUnsupported(scorm)) {
|
||||||
|
return { size: -1, total: false };
|
||||||
|
} else if (!scorm.packagesize) {
|
||||||
|
// We don't have package size, try to calculate it.
|
||||||
|
const size = await AddonModScorm.calculateScormSize(scorm);
|
||||||
|
|
||||||
|
return { size: size, total: true };
|
||||||
|
} else {
|
||||||
|
return { size: scorm.packagesize, total: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
async getDownloadedSize(module: CoreCourseAnyModuleData, courseId: number): Promise<number> {
|
||||||
|
const scorm = await this.getScorm(module, courseId);
|
||||||
|
|
||||||
|
// Get the folder where SCORM should be unzipped.
|
||||||
|
const path = await AddonModScorm.getScormFolder(scorm.moduleurl!);
|
||||||
|
|
||||||
|
return CoreFile.getDirectorySize(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of files. If not defined, we'll assume they're in module.contents.
|
||||||
|
*
|
||||||
|
* @param module Module.
|
||||||
|
* @param courseId Course ID the module belongs to.
|
||||||
|
* @param single True if we're downloading a single module, false if we're downloading a whole section.
|
||||||
|
* @return Promise resolved with the list of files.
|
||||||
|
*/
|
||||||
|
async getFiles(module: CoreCourseAnyModuleData, courseId: number): Promise<CoreWSExternalFile[]> {
|
||||||
|
try {
|
||||||
|
const scorm = await this.getScorm(module, courseId);
|
||||||
|
|
||||||
|
return AddonModScorm.getScormFileList(scorm);
|
||||||
|
} catch {
|
||||||
|
// SCORM not found, return empty list.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the SCORM instance from a module instance.
|
||||||
|
*
|
||||||
|
* @param module Module.
|
||||||
|
* @param courseId Course ID.
|
||||||
|
* @param siteId Site ID.
|
||||||
|
* @returns Promise resolved with the SCORM.
|
||||||
|
*/
|
||||||
|
protected getScorm(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise<AddonModScormScorm> {
|
||||||
|
const moduleUrl = 'url' in module ? module.url : undefined;
|
||||||
|
|
||||||
|
return AddonModScorm.getScorm(courseId, module.id, { moduleUrl, siteId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
invalidateContent(moduleId: number, courseId: number): Promise<void> {
|
||||||
|
return AddonModScorm.invalidateContent(moduleId, courseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise<void> {
|
||||||
|
// Invalidate the calls required to check if a SCORM is downloadable.
|
||||||
|
return AddonModScorm.invalidateScormData(courseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
async isDownloadable(module: CoreCourseAnyModuleData, courseId: number): Promise<boolean> {
|
||||||
|
const scorm = await this.getScorm(module, courseId);
|
||||||
|
|
||||||
|
if (scorm.warningMessage) {
|
||||||
|
// SCORM closed or not opened yet.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AddonModScorm.isScormUnsupported(scorm)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
prefetch(
|
||||||
|
module: CoreCourseAnyModuleData,
|
||||||
|
courseId: number,
|
||||||
|
single?: boolean,
|
||||||
|
dirPath?: string,
|
||||||
|
onProgress?: AddonModScormProgressCallback,
|
||||||
|
): Promise<void> {
|
||||||
|
const siteId = CoreSites.getCurrentSiteId();
|
||||||
|
|
||||||
|
return this.prefetchPackage(
|
||||||
|
module,
|
||||||
|
courseId,
|
||||||
|
this.downloadOrPrefetchScorm.bind(this, module, courseId, single, siteId, true, onProgress),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove module downloaded files. If not defined, we'll use getFiles to remove them (slow).
|
||||||
|
*
|
||||||
|
* @param module Module.
|
||||||
|
* @param courseId Course ID the module belongs to.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
async removeFiles(module: CoreCourseAnyModuleData, courseId: number): Promise<void> {
|
||||||
|
const siteId = CoreSites.getCurrentSiteId();
|
||||||
|
|
||||||
|
const scorm = await this.getScorm(module, courseId, siteId);
|
||||||
|
|
||||||
|
// Get the folder where SCORM should be unzipped.
|
||||||
|
const path = await AddonModScorm.getScormFolder(scorm.moduleurl!);
|
||||||
|
|
||||||
|
const promises: Promise<unknown>[] = [];
|
||||||
|
|
||||||
|
// Remove the unzipped folder.
|
||||||
|
promises.push(CoreFile.removeDir(path).catch((error) => {
|
||||||
|
if (error && (error.code == 1 || !CoreApp.isMobile())) {
|
||||||
|
// Not found, ignore error.
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Delete other files.
|
||||||
|
promises.push(CoreFilepool.removeFilesByComponent(siteId, this.component, module.id));
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a module.
|
||||||
|
*
|
||||||
|
* @param module Module.
|
||||||
|
* @param courseId Course ID the module belongs to
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
async sync(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise<unknown> {
|
||||||
|
const scorm = await this.getScorm(module, courseId, siteId);
|
||||||
|
|
||||||
|
return AddonModScormSync.syncScorm(scorm, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddonModScormPrefetchHandler = makeSingleton(AddonModScormPrefetchHandlerService);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress event used when downloading a SCORM.
|
||||||
|
*/
|
||||||
|
export type AddonModScormProgressEvent = {
|
||||||
|
downloading?: boolean; // Whether the event is due to the download of a chunk of data.
|
||||||
|
progress?: ProgressEvent; // Progress event sent by the download.
|
||||||
|
message?: string; // A message related to the progress, used to notify that a certain step of the download has started.
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress callback when downloading a SCORM.
|
||||||
|
*/
|
||||||
|
export type AddonModScormProgressCallback = (event: AddonModScormProgressEvent) => void;
|
|
@ -0,0 +1,44 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { CoreCronHandler } from '@services/cron';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
import { AddonModScormSync } from '../scorm-sync';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronization cron handler.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModScormSyncCronHandlerService implements CoreCronHandler {
|
||||||
|
|
||||||
|
name = 'AddonModScormSyncCronHandler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
execute(siteId?: string, force?: boolean): Promise<void> {
|
||||||
|
return AddonModScormSync.syncAllScorms(siteId, force);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
getInterval(): number {
|
||||||
|
return AddonModScormSync.syncInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddonModScormSyncCronHandler = makeSingleton(AddonModScormSyncCronHandlerService);
|
|
@ -0,0 +1,411 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { CoreError } from '@classes/errors/error';
|
||||||
|
import { CoreCourseCommonModWSOptions } from '@features/course/services/course';
|
||||||
|
import { CoreSites } from '@services/sites';
|
||||||
|
import { CoreDomUtils } from '@services/utils/dom';
|
||||||
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
import { makeSingleton, Translate } from '@singletons';
|
||||||
|
import {
|
||||||
|
AddonModScorm,
|
||||||
|
AddonModScormAttempt,
|
||||||
|
AddonModScormAttemptCountResult,
|
||||||
|
AddonModScormDataValue,
|
||||||
|
AddonModScormGetScosWithDataOptions,
|
||||||
|
AddonModScormProvider,
|
||||||
|
AddonModScormScoIcon,
|
||||||
|
AddonModScormScorm,
|
||||||
|
AddonModScormScoWithData,
|
||||||
|
AddonModScormTOCListSco,
|
||||||
|
AddonModScormUserDataMap,
|
||||||
|
} from './scorm';
|
||||||
|
import { AddonModScormOffline } from './scorm-offline';
|
||||||
|
|
||||||
|
// List of elements we want to ignore when copying attempts (they're calculated).
|
||||||
|
const elementsToIgnore = [
|
||||||
|
'status', 'score_raw', 'total_time', 'session_time', 'student_id', 'student_name', 'credit', 'mode', 'entry',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper service that provides some features for SCORM.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModScormHelperProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a confirm dialog if needed. If SCORM doesn't have size, try to calculate it.
|
||||||
|
*
|
||||||
|
* @param scorm SCORM to download.
|
||||||
|
* @param isOutdated True if package outdated, false if not outdated, undefined to calculate it.
|
||||||
|
* @return Promise resolved if the user confirms or no confirmation needed.
|
||||||
|
*/
|
||||||
|
async confirmDownload(scorm: AddonModScormScorm, isOutdated?: boolean): Promise<void> {
|
||||||
|
// Check if file should be downloaded.
|
||||||
|
const download = await AddonModScorm.shouldDownloadMainFile(scorm, isOutdated);
|
||||||
|
|
||||||
|
if (!download) {
|
||||||
|
// No need to download main file, no need to confirm.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let size = scorm.packagesize;
|
||||||
|
|
||||||
|
if (!size) {
|
||||||
|
// We don't have package size, try to calculate it.
|
||||||
|
size = await AddonModScorm.calculateScormSize(scorm);
|
||||||
|
|
||||||
|
// Store it so we don't have to calculate it again when using the same object.
|
||||||
|
scorm.packagesize = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CoreDomUtils.confirmDownloadSize({ size: size, total: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new offline attempt based on an existing online attempt.
|
||||||
|
*
|
||||||
|
* @param scorm SCORM.
|
||||||
|
* @param attempt Number of the online attempt.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when the attempt is created.
|
||||||
|
*/
|
||||||
|
async convertAttemptToOffline(scorm: AddonModScormScorm, attempt: number, siteId?: string): Promise<void> {
|
||||||
|
siteId = siteId || CoreSites.getCurrentSiteId();
|
||||||
|
|
||||||
|
// Get data from the online attempt.
|
||||||
|
const onlineData = await CoreUtils.ignoreErrors(
|
||||||
|
AddonModScorm.getScormUserData(scorm.id, attempt, { cmId: scorm.coursemodule, siteId }),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!onlineData) {
|
||||||
|
// Shouldn't happen.
|
||||||
|
throw new CoreError(Translate.instant('addon.mod_scorm.errorcreateofflineattempt'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The SCORM API might have written some data to the offline attempt already.
|
||||||
|
// We don't want to override it with cached online data.
|
||||||
|
const offlineData = await CoreUtils.ignoreErrors(
|
||||||
|
AddonModScormOffline.getScormUserData(scorm.id, attempt, undefined, siteId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dataToStore = CoreUtils.clone(onlineData);
|
||||||
|
|
||||||
|
// Filter the data to copy.
|
||||||
|
for (const scoId in dataToStore) {
|
||||||
|
const sco = dataToStore[scoId];
|
||||||
|
|
||||||
|
// Delete calculated data.
|
||||||
|
elementsToIgnore.forEach((el) => {
|
||||||
|
delete sco.userdata[el];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't override offline data.
|
||||||
|
if (offlineData && offlineData[sco.scoid] && offlineData[sco.scoid].userdata) {
|
||||||
|
const scoUserData: Record<string, AddonModScormDataValue> = {};
|
||||||
|
|
||||||
|
for (const element in sco.userdata) {
|
||||||
|
if (!offlineData[sco.scoid].userdata[element]) {
|
||||||
|
// This element is not stored in offline, we can save it.
|
||||||
|
scoUserData[element] = sco.userdata[element];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sco.userdata = scoUserData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await AddonModScormOffline.createNewAttempt(scorm, attempt, dataToStore, onlineData, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new offline attempt.
|
||||||
|
*
|
||||||
|
* @param scorm SCORM.
|
||||||
|
* @param newAttempt Number of the new attempt.
|
||||||
|
* @param lastOnline Number of the last online attempt.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when the attempt is created.
|
||||||
|
*/
|
||||||
|
async createOfflineAttempt(scorm: AddonModScormScorm, newAttempt: number, lastOnline: number, siteId?: string): Promise<void> {
|
||||||
|
siteId = siteId || CoreSites.getCurrentSiteId();
|
||||||
|
|
||||||
|
// Try to get data from online attempts.
|
||||||
|
const userData = await CoreUtils.ignoreErrors(
|
||||||
|
this.searchOnlineAttemptUserData(scorm.id, lastOnline, { cmId: scorm.coursemodule, siteId }),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!userData) {
|
||||||
|
throw new CoreError(Translate.instant('addon.mod_scorm.errorcreateofflineattempt'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// We're creating a new attempt, remove all the user data that is not needed for a new attempt.
|
||||||
|
for (const scoId in userData) {
|
||||||
|
const sco = userData[scoId];
|
||||||
|
const filtered: Record<string, AddonModScormDataValue> = {};
|
||||||
|
|
||||||
|
for (const element in sco.userdata) {
|
||||||
|
if (element.indexOf('.') == -1 && elementsToIgnore.indexOf(element) == -1) {
|
||||||
|
// The element doesn't use a dot notation, probably SCO data.
|
||||||
|
filtered[element] = sco.userdata[element];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sco.userdata = filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AddonModScormOffline.createNewAttempt(scorm, newAttempt, userData, undefined, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the attempt to continue/review. It will be:
|
||||||
|
* - The last incomplete online attempt if it hasn't been continued in offline and all offline attempts are complete.
|
||||||
|
* - The attempt with highest number without surpassing max attempts otherwise.
|
||||||
|
*
|
||||||
|
* @param scorm SCORM object.
|
||||||
|
* @param attempts Attempts count.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved with the attempt data.
|
||||||
|
*/
|
||||||
|
async determineAttemptToContinue(
|
||||||
|
scorm: AddonModScormScorm,
|
||||||
|
attempts: AddonModScormAttemptCountResult,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<AddonModScormAttempt> {
|
||||||
|
|
||||||
|
let lastOnline: number | undefined;
|
||||||
|
|
||||||
|
// Get last online attempt.
|
||||||
|
if (attempts.online.length) {
|
||||||
|
lastOnline = Math.max.apply(Math, attempts.online);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastOnline) {
|
||||||
|
return this.getLastBeforeMax(scorm, attempts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if last online incomplete.
|
||||||
|
const hasOffline = attempts.offline.indexOf(lastOnline) > -1;
|
||||||
|
|
||||||
|
const incomplete = await AddonModScorm.isAttemptIncomplete(scorm.id, lastOnline, {
|
||||||
|
offline: hasOffline,
|
||||||
|
cmId: scorm.coursemodule,
|
||||||
|
siteId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (incomplete) {
|
||||||
|
return {
|
||||||
|
num: lastOnline,
|
||||||
|
offline: hasOffline,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return this.getLastBeforeMax(scorm, attempts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the first SCO to load in a SCORM: the first valid and incomplete SCO.
|
||||||
|
*
|
||||||
|
* @param scormId Scorm ID.
|
||||||
|
* @param attempt Attempt number.
|
||||||
|
* @param options Other options.
|
||||||
|
* @return Promise resolved with the first SCO.
|
||||||
|
*/
|
||||||
|
async getFirstSco(
|
||||||
|
scormId: number,
|
||||||
|
attempt: number,
|
||||||
|
options: AddonModScormGetFirstScoOptions = {},
|
||||||
|
): Promise<AddonModScormScoWithData | undefined> {
|
||||||
|
|
||||||
|
const mode = options.mode || AddonModScormProvider.MODENORMAL;
|
||||||
|
const isNormalMode = mode === AddonModScormProvider.MODENORMAL;
|
||||||
|
|
||||||
|
let scos = options.toc;
|
||||||
|
if (!scos || !scos.length) {
|
||||||
|
// SCORM doesn't have a TOC. Get all the scos.
|
||||||
|
scos = await AddonModScorm.getScosWithData(scormId, attempt, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search the first valid SCO.
|
||||||
|
// In browse/review mode return the first visible sco. In normal mode, first incomplete sco.
|
||||||
|
const sco = scos.find(sco => sco.isvisible && sco.launch && sco.prereq &&
|
||||||
|
(!isNormalMode || AddonModScorm.isStatusIncomplete(sco.status)));
|
||||||
|
|
||||||
|
// If no "valid" SCO, load the first one. In web it loads the first child because the toc contains the organization SCO.
|
||||||
|
return sco || scos[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last attempt (number and whether it's offline).
|
||||||
|
* It'll be the highest number as long as it doesn't surpass the max number of attempts.
|
||||||
|
*
|
||||||
|
* @param scorm SCORM object.
|
||||||
|
* @param attempts Attempts count.
|
||||||
|
* @return Last attempt data.
|
||||||
|
*/
|
||||||
|
protected getLastBeforeMax(
|
||||||
|
scorm: AddonModScormScorm,
|
||||||
|
attempts: AddonModScormAttemptCountResult,
|
||||||
|
): AddonModScormAttempt {
|
||||||
|
if (scorm.maxattempt && attempts.lastAttempt.num > scorm.maxattempt) {
|
||||||
|
return {
|
||||||
|
num: scorm.maxattempt,
|
||||||
|
offline: attempts.offline.indexOf(scorm.maxattempt) > -1,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
num: attempts.lastAttempt.num,
|
||||||
|
offline: attempts.lastAttempt.offline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a TOC in array format and a scoId, return the next available SCO.
|
||||||
|
*
|
||||||
|
* @param toc SCORM's TOC.
|
||||||
|
* @param scoId SCO ID.
|
||||||
|
* @return Next SCO.
|
||||||
|
*/
|
||||||
|
getNextScoFromToc(toc: AddonModScormScoWithData[], scoId: number): AddonModScormScoWithData | undefined {
|
||||||
|
for (let i = 0; i < toc.length; i++) {
|
||||||
|
if (toc[i].id != scoId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We found the current SCO. Now search the next visible SCO with fulfilled prerequisites.
|
||||||
|
for (let j = i + 1; j < toc.length; j++) {
|
||||||
|
if (toc[j].isvisible && toc[j].prereq && toc[j].launch) {
|
||||||
|
return toc[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a TOC in array format and a scoId, return the previous available SCO.
|
||||||
|
*
|
||||||
|
* @param toc SCORM's TOC.
|
||||||
|
* @param scoId SCO ID.
|
||||||
|
* @return Previous SCO.
|
||||||
|
*/
|
||||||
|
getPreviousScoFromToc(toc: AddonModScormScoWithData[], scoId: number): AddonModScormScoWithData | undefined {
|
||||||
|
for (let i = 0; i < toc.length; i++) {
|
||||||
|
if (toc[i].id != scoId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We found the current SCO. Now let's search the previous visible SCO with fulfilled prerequisites.
|
||||||
|
for (let j = i - 1; j >= 0; j--) {
|
||||||
|
if (toc[j].isvisible && toc[j].prereq && toc[j].launch) {
|
||||||
|
return toc[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a TOC in array format and a scoId, return the SCO.
|
||||||
|
*
|
||||||
|
* @param toc SCORM's TOC.
|
||||||
|
* @param scoId SCO ID.
|
||||||
|
* @return SCO.
|
||||||
|
*/
|
||||||
|
getScoFromToc(toc: AddonModScormScoWithData[], scoId: number): AddonModScormScoWithData | undefined {
|
||||||
|
return toc.find(sco => sco.id == scoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SCORM TOC, formatted.
|
||||||
|
*
|
||||||
|
* @param scormId Scorm ID.
|
||||||
|
* @param lastAttempt Last attempt number.
|
||||||
|
* @param incomplete Whether last attempt is incomplete.
|
||||||
|
* @param options Options.
|
||||||
|
* @return Promise resolved with the TOC.
|
||||||
|
*/
|
||||||
|
async getToc(
|
||||||
|
scormId: number,
|
||||||
|
lastAttempt: number,
|
||||||
|
incomplete: boolean,
|
||||||
|
options: AddonModScormGetScosWithDataOptions = {},
|
||||||
|
): Promise<AddonModScormTOCScoWithIcon[]> {
|
||||||
|
const toc = await AddonModScorm.getOrganizationToc(scormId, lastAttempt, options);
|
||||||
|
|
||||||
|
const tocArray = <AddonModScormTOCScoWithIcon[]> AddonModScorm.formatTocToArray(toc);
|
||||||
|
|
||||||
|
// Get images for each SCO.
|
||||||
|
tocArray.forEach((sco) => {
|
||||||
|
sco.icon = AddonModScorm.getScoStatusIcon(sco, incomplete);
|
||||||
|
});
|
||||||
|
|
||||||
|
return tocArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches user data for an online attempt. If the data can't be retrieved, re-try with the previous online attempt.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param attempt Online attempt to get the data.
|
||||||
|
* @param options Other options.
|
||||||
|
* @return Promise resolved with user data.
|
||||||
|
*/
|
||||||
|
async searchOnlineAttemptUserData(
|
||||||
|
scormId: number,
|
||||||
|
attempt: number,
|
||||||
|
options: CoreCourseCommonModWSOptions = {},
|
||||||
|
): Promise<AddonModScormUserDataMap> {
|
||||||
|
options.siteId = options.siteId || CoreSites.getCurrentSiteId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await AddonModScorm.getScormUserData(scormId, attempt, options);
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt <= 0) {
|
||||||
|
// No more attempts to try.
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// We couldn't retrieve the data. Try again with the previous online attempt.
|
||||||
|
return await this.searchOnlineAttemptUserData(scormId, attempt - 1, options);
|
||||||
|
} catch {
|
||||||
|
// Couldn't retrieve previous attempts data either.
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddonModScormHelper = makeSingleton(AddonModScormHelperProvider);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options to pass to getFirstSco.
|
||||||
|
*/
|
||||||
|
export type AddonModScormGetFirstScoOptions = CoreCourseCommonModWSOptions & {
|
||||||
|
toc?: AddonModScormScoWithData[]; // SCORM's TOC. If not provided, it will be calculated.
|
||||||
|
organization?: string; // Organization to use.
|
||||||
|
mode?: string; // Mode.
|
||||||
|
offline?: boolean; // Whether the attempt is offline.
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOC SCO with icon.
|
||||||
|
*/
|
||||||
|
export type AddonModScormTOCScoWithIcon = AddonModScormTOCListSco & {
|
||||||
|
icon?: AddonModScormScoIcon;
|
||||||
|
};
|
|
@ -0,0 +1,997 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { SQLiteDB } from '@classes/sqlitedb';
|
||||||
|
import { CoreUser } from '@features/user/services/user';
|
||||||
|
import { CoreSites } from '@services/sites';
|
||||||
|
import { CoreSync } from '@services/sync';
|
||||||
|
import { CoreTextUtils } from '@services/utils/text';
|
||||||
|
import { CoreTimeUtils } from '@services/utils/time';
|
||||||
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
import { makeSingleton } from '@singletons';
|
||||||
|
import { CoreLogger } from '@singletons/logger';
|
||||||
|
import {
|
||||||
|
AddonModScormAttemptDBRecord,
|
||||||
|
AddonModScormOfflineDBCommonData,
|
||||||
|
AddonModScormTrackDBRecord,
|
||||||
|
ATTEMPTS_TABLE_NAME,
|
||||||
|
TRACKS_TABLE_NAME,
|
||||||
|
} from './database/scorm';
|
||||||
|
import {
|
||||||
|
AddonModScormDataEntry,
|
||||||
|
AddonModScormDataValue,
|
||||||
|
AddonModScormProvider,
|
||||||
|
AddonModScormScorm,
|
||||||
|
AddonModScormScoUserData,
|
||||||
|
AddonModScormUserDataMap,
|
||||||
|
AddonModScormWSSco,
|
||||||
|
} from './scorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service to handle offline SCORM.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModScormOfflineProvider {
|
||||||
|
|
||||||
|
protected logger: CoreLogger;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.logger = CoreLogger.getInstance('AddonModScormOfflineProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes an attempt number in the data stored in offline.
|
||||||
|
* This function is used to convert attempts into new attempts, so the stored snapshot will be removed and
|
||||||
|
* entries will be marked as not synced.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param attempt Number of the attempt to change.
|
||||||
|
* @param newAttempt New attempt number.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param userId User ID. If not defined use site's current user.
|
||||||
|
* @return Promise resolved when the attempt number changes.
|
||||||
|
*/
|
||||||
|
async changeAttemptNumber(
|
||||||
|
scormId: number,
|
||||||
|
attempt: number,
|
||||||
|
newAttempt: number,
|
||||||
|
siteId?: string,
|
||||||
|
userId?: number,
|
||||||
|
): Promise<void> {
|
||||||
|
|
||||||
|
const site = await CoreSites.getSite(siteId);
|
||||||
|
userId = userId || site.getUserId();
|
||||||
|
|
||||||
|
this.logger.debug(`Change attempt number from ${attempt} to ${newAttempt} in SCORM ${scormId}`);
|
||||||
|
|
||||||
|
// Update the attempt number.
|
||||||
|
const db = site.getDb();
|
||||||
|
const currentAttemptConditions: AddonModScormOfflineDBCommonData = {
|
||||||
|
scormid: scormId,
|
||||||
|
userid: userId,
|
||||||
|
attempt,
|
||||||
|
};
|
||||||
|
const newAttemptConditions: AddonModScormOfflineDBCommonData = {
|
||||||
|
scormid: scormId,
|
||||||
|
userid: userId,
|
||||||
|
attempt: newAttempt,
|
||||||
|
};
|
||||||
|
const newAttemptData: Partial<AddonModScormAttemptDBRecord> = {
|
||||||
|
attempt: newAttempt,
|
||||||
|
timemodified: CoreTimeUtils.timestamp(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Block the SCORM so it can't be synced.
|
||||||
|
CoreSync.blockOperation(AddonModScormProvider.COMPONENT, scormId, 'changeAttemptNumber', site.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.updateRecords(ATTEMPTS_TABLE_NAME, newAttemptData, currentAttemptConditions);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Now update the attempt number of all the tracks and mark them as not synced.
|
||||||
|
const newTrackData: Partial<AddonModScormTrackDBRecord> = {
|
||||||
|
attempt: newAttempt,
|
||||||
|
synced: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.updateRecords(TRACKS_TABLE_NAME, newTrackData, currentAttemptConditions);
|
||||||
|
} catch (error) {
|
||||||
|
// Failed to update the tracks, restore the old attempt number.
|
||||||
|
await db.updateRecords(ATTEMPTS_TABLE_NAME, { attempt }, newAttemptConditions);
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Unblock the SCORM.
|
||||||
|
CoreSync.unblockOperation(AddonModScormProvider.COMPONENT, scormId, 'changeAttemptNumber', site.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new offline attempt. It can be created from scratch or as a copy of another attempt.
|
||||||
|
*
|
||||||
|
* @param scorm SCORM.
|
||||||
|
* @param attempt Number of the new attempt.
|
||||||
|
* @param userData User data to store in the attempt.
|
||||||
|
* @param snapshot Optional. Snapshot to store in the attempt.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param userId User ID. If not defined use site's current user.
|
||||||
|
* @return Promise resolved when the new attempt is created.
|
||||||
|
*/
|
||||||
|
async createNewAttempt(
|
||||||
|
scorm: AddonModScormScorm,
|
||||||
|
attempt: number,
|
||||||
|
userData: AddonModScormUserDataMap,
|
||||||
|
snapshot?: AddonModScormUserDataMap,
|
||||||
|
siteId?: string,
|
||||||
|
userId?: number,
|
||||||
|
): Promise<void> {
|
||||||
|
|
||||||
|
const site = await CoreSites.getSite(siteId);
|
||||||
|
userId = userId || site.getUserId();
|
||||||
|
|
||||||
|
this.logger.debug(`Creating new offline attempt ${attempt} in SCORM ${scorm.id}`);
|
||||||
|
|
||||||
|
// Block the SCORM so it can't be synced.
|
||||||
|
CoreSync.blockOperation(AddonModScormProvider.COMPONENT, scorm.id, 'createNewAttempt', site.id);
|
||||||
|
|
||||||
|
// Create attempt in DB.
|
||||||
|
const db = site.getDb();
|
||||||
|
const entry: AddonModScormAttemptDBRecord = {
|
||||||
|
scormid: scorm.id,
|
||||||
|
userid: userId,
|
||||||
|
attempt,
|
||||||
|
courseid: scorm.course,
|
||||||
|
timecreated: CoreTimeUtils.timestamp(),
|
||||||
|
timemodified: CoreTimeUtils.timestamp(),
|
||||||
|
snapshot: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (snapshot) {
|
||||||
|
// Save a snapshot of the data we had when we created the attempt.
|
||||||
|
// Remove the default data, we don't want to store it.
|
||||||
|
entry.snapshot = JSON.stringify(this.removeDefaultData(snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insertRecord(ATTEMPTS_TABLE_NAME, entry);
|
||||||
|
|
||||||
|
// Store all the data in userData.
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
for (const key in userData) {
|
||||||
|
const sco = userData[key];
|
||||||
|
const tracks: AddonModScormDataEntry[] = [];
|
||||||
|
|
||||||
|
for (const element in sco.userdata) {
|
||||||
|
tracks.push({ element, value: sco.userdata[element] });
|
||||||
|
}
|
||||||
|
|
||||||
|
promises.push(this.saveTracks(scorm, sco.scoid, attempt, tracks, userData, site.id, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
} finally {
|
||||||
|
// Unblock the SCORM.
|
||||||
|
CoreSync.unblockOperation(AddonModScormProvider.COMPONENT, scorm.id, 'createNewAttempt', site.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all the stored data from an attempt.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param attempt Attempt number.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param userId User ID. If not defined use site's current user.
|
||||||
|
* @return Promise resolved when all the data has been deleted.
|
||||||
|
*/
|
||||||
|
async deleteAttempt(scormId: number, attempt: number, siteId?: string, userId?: number): Promise<void> {
|
||||||
|
const site = await CoreSites.getSite(siteId);
|
||||||
|
userId = userId || site.getUserId();
|
||||||
|
|
||||||
|
this.logger.debug(`Delete offline attempt ${attempt} in SCORM ${scormId}`);
|
||||||
|
|
||||||
|
const db = site.getDb();
|
||||||
|
const conditions: AddonModScormOfflineDBCommonData = {
|
||||||
|
scormid: scormId,
|
||||||
|
userid: userId,
|
||||||
|
attempt,
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
db.deleteRecords(ATTEMPTS_TABLE_NAME, conditions),
|
||||||
|
db.deleteRecords(TRACKS_TABLE_NAME, conditions),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to return a formatted list of interactions for reports.
|
||||||
|
* This function is based in Moodle's scorm_format_interactions.
|
||||||
|
*
|
||||||
|
* @param scoUserData Userdata from a certain SCO.
|
||||||
|
* @return Formatted userdata.
|
||||||
|
*/
|
||||||
|
protected formatInteractions(scoUserData: Record<string, AddonModScormDataValue>): Record<string, AddonModScormDataValue> {
|
||||||
|
const formatted: Record<string, AddonModScormDataValue> = {};
|
||||||
|
|
||||||
|
// Defined in order to unify scorm1.2 and scorm2004.
|
||||||
|
formatted.score_raw = '';
|
||||||
|
formatted.status = '';
|
||||||
|
formatted.total_time = '00:00:00';
|
||||||
|
formatted.session_time = '00:00:00';
|
||||||
|
|
||||||
|
for (const element in scoUserData) {
|
||||||
|
let value = scoUserData[element];
|
||||||
|
|
||||||
|
// Ignore elements that are calculated.
|
||||||
|
if (element == 'score_raw' || element == 'status' || element == 'total_time' || element == 'session_time') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted[element] = value;
|
||||||
|
switch (element) {
|
||||||
|
case 'cmi.core.lesson_status':
|
||||||
|
case 'cmi.completion_status':
|
||||||
|
if (value == 'not attempted') {
|
||||||
|
value = 'notattempted';
|
||||||
|
}
|
||||||
|
formatted.status = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cmi.core.score.raw':
|
||||||
|
case 'cmi.score.raw':
|
||||||
|
formatted.score_raw = CoreTextUtils.roundToDecimals(Number(value), 2); // Round to 2 decimals max.
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cmi.core.session_time':
|
||||||
|
case 'cmi.session_time':
|
||||||
|
formatted.session_time = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cmi.core.total_time':
|
||||||
|
case 'cmi.total_time':
|
||||||
|
formatted.total_time = value;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Nothing to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all the offline attempts in a certain site.
|
||||||
|
*
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when the offline attempts are retrieved.
|
||||||
|
*/
|
||||||
|
async getAllAttempts(siteId?: string): Promise<AddonModScormOfflineAttempt[]> {
|
||||||
|
const db = await CoreSites.getSiteDb(siteId);
|
||||||
|
|
||||||
|
const attempts = await db.getAllRecords<AddonModScormAttemptDBRecord>(ATTEMPTS_TABLE_NAME);
|
||||||
|
|
||||||
|
return attempts.map((attempt) => this.parseAttempt(attempt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an offline attempt.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param attempt Attempt number.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param userId User ID. If not defined use site's current user.
|
||||||
|
* @return Promise resolved with the attempt.
|
||||||
|
*/
|
||||||
|
async getAttempt(scormId: number, attempt: number, siteId?: string, userId?: number): Promise<AddonModScormOfflineAttempt> {
|
||||||
|
const site = await CoreSites.getSite(siteId);
|
||||||
|
userId = userId || site.getUserId();
|
||||||
|
|
||||||
|
const attemptRecord = await site.getDb().getRecord<AddonModScormAttemptDBRecord>(ATTEMPTS_TABLE_NAME, {
|
||||||
|
scormid: scormId,
|
||||||
|
userid: userId,
|
||||||
|
attempt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.parseAttempt(attemptRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the creation time of an attempt.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param attempt Attempt number.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param userId User ID. If not defined use site's current user.
|
||||||
|
* @return Promise resolved with time the attempt was created, undefined if attempt not found.
|
||||||
|
*/
|
||||||
|
async getAttemptCreationTime(scormId: number, attempt: number, siteId?: string, userId?: number): Promise<number | undefined> {
|
||||||
|
try {
|
||||||
|
const attemptRecord = await this.getAttempt(scormId, attempt, siteId, userId);
|
||||||
|
|
||||||
|
return attemptRecord.timecreated;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the offline attempts done by a user in the given SCORM.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param userId User ID. If not defined use site's current user.
|
||||||
|
* @return Promise resolved when the offline attempts are retrieved.
|
||||||
|
*/
|
||||||
|
async getAttempts(scormId: number, siteId?: string, userId?: number): Promise<AddonModScormOfflineAttempt[]> {
|
||||||
|
const site = await CoreSites.getSite(siteId);
|
||||||
|
userId = userId || site.getUserId();
|
||||||
|
|
||||||
|
const attempts = await site.getDb().getRecords<AddonModScormAttemptDBRecord>(ATTEMPTS_TABLE_NAME, {
|
||||||
|
scormid: scormId,
|
||||||
|
userid: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return attempts.map((attempt) => this.parseAttempt(attempt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the snapshot of an attempt.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param attempt Attempt number.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param userId User ID. If not defined use site's current user.
|
||||||
|
* @return Promise resolved with the snapshot or undefined if no snapshot.
|
||||||
|
*/
|
||||||
|
async getAttemptSnapshot(
|
||||||
|
scormId: number,
|
||||||
|
attempt: number,
|
||||||
|
siteId?: string,
|
||||||
|
userId?: number,
|
||||||
|
): Promise<AddonModScormUserDataMap | undefined> {
|
||||||
|
try {
|
||||||
|
const attemptRecord = await this.getAttempt(scormId, attempt, siteId, userId);
|
||||||
|
|
||||||
|
return attemptRecord.snapshot || undefined;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get launch URLs from a list of SCOs, indexing them by SCO ID.
|
||||||
|
*
|
||||||
|
* @param scos List of SCOs.
|
||||||
|
* @return Launch URLs indexed by SCO ID.
|
||||||
|
*/
|
||||||
|
protected getLaunchUrlsFromScos(scos: AddonModScormWSSco[]): Record<number, string> {
|
||||||
|
scos = scos || [];
|
||||||
|
|
||||||
|
const response: Record<number, string> = {};
|
||||||
|
|
||||||
|
scos.forEach((sco) => {
|
||||||
|
response[sco.id] = sco.launch;
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get data stored in local DB for a certain scorm and attempt.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param attempt Attempt number.
|
||||||
|
* @param excludeSynced Whether it should only return not synced entries.
|
||||||
|
* @param excludeNotSynced Whether it should only return synced entries.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param userId User ID. If not defined use site's current user.
|
||||||
|
* @return Promise resolved with the entries.
|
||||||
|
*/
|
||||||
|
async getScormStoredData(
|
||||||
|
scormId: number,
|
||||||
|
attempt: number,
|
||||||
|
excludeSynced?: boolean,
|
||||||
|
excludeNotSynced?: boolean,
|
||||||
|
siteId?: string,
|
||||||
|
userId?: number,
|
||||||
|
): Promise<AddonModScormOfflineTrack[]> {
|
||||||
|
if (excludeSynced && excludeNotSynced) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const site = await CoreSites.getSite(siteId);
|
||||||
|
userId = userId || site.getUserId();
|
||||||
|
|
||||||
|
const conditions: Partial<AddonModScormTrackDBRecord> = {
|
||||||
|
scormid: scormId,
|
||||||
|
userid: userId,
|
||||||
|
attempt,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (excludeSynced) {
|
||||||
|
conditions.synced = 0;
|
||||||
|
} else if (excludeNotSynced) {
|
||||||
|
conditions.synced = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracks = await site.getDb().getRecords<AddonModScormTrackDBRecord>(TRACKS_TABLE_NAME, conditions);
|
||||||
|
|
||||||
|
return this.parseTracks(tracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user data for a certain SCORM and offline attempt.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param attempt Attempt number.
|
||||||
|
* @param scos SCOs returned by AddonModScormProvider.getScos. If not supplied, this function will only return the
|
||||||
|
* SCOs that have something stored and cmi.launch_data will be undefined.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param userId User ID. If not defined use site's current user.
|
||||||
|
* @return Promise resolved when the user data is retrieved.
|
||||||
|
*/
|
||||||
|
async getScormUserData(
|
||||||
|
scormId: number,
|
||||||
|
attempt: number,
|
||||||
|
scos?: AddonModScormWSSco[],
|
||||||
|
siteId?: string,
|
||||||
|
userId?: number,
|
||||||
|
): Promise<AddonModScormUserDataMap> {
|
||||||
|
scos = scos || [];
|
||||||
|
|
||||||
|
let fullName = '';
|
||||||
|
let userName = '';
|
||||||
|
|
||||||
|
const site = await CoreSites.getSite(siteId);
|
||||||
|
userId = userId || site.getUserId();
|
||||||
|
|
||||||
|
// Get username and fullname.
|
||||||
|
if (userId == site.getUserId()) {
|
||||||
|
fullName = site.getInfo()?.fullname || '';
|
||||||
|
userName = site.getInfo()?.username || '';
|
||||||
|
} else {
|
||||||
|
const profile = await CoreUtils.ignoreErrors(CoreUser.getProfile(userId));
|
||||||
|
|
||||||
|
fullName = profile?.fullname || '';
|
||||||
|
userName = profile?.username || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user data.
|
||||||
|
const entries = await this.getScormStoredData(scormId, attempt, false, false, siteId, userId);
|
||||||
|
const response: AddonModScormUserDataMap = {};
|
||||||
|
const launchUrls = this.getLaunchUrlsFromScos(scos);
|
||||||
|
|
||||||
|
// Gather user data retrieved from DB, grouping it by scoid.
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const scoId = entry.scoid;
|
||||||
|
|
||||||
|
if (!response[scoId]) {
|
||||||
|
// Initialize SCO.
|
||||||
|
response[scoId] = {
|
||||||
|
scoid: scoId,
|
||||||
|
userdata: {
|
||||||
|
userid: userId!,
|
||||||
|
scoid: scoId,
|
||||||
|
timemodified: 0,
|
||||||
|
},
|
||||||
|
defaultdata: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
response[scoId].userdata[entry.element] = entry.value!;
|
||||||
|
if (entry.timemodified > Number(response[scoId].userdata.timemodified)) {
|
||||||
|
response[scoId].userdata.timemodified = entry.timemodified;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format each user data retrieved.
|
||||||
|
for (const scoId in response) {
|
||||||
|
const sco = response[scoId];
|
||||||
|
sco.userdata = this.formatInteractions(sco.userdata);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create empty entries for the SCOs without user data stored.
|
||||||
|
scos.forEach((sco) => {
|
||||||
|
if (!response[sco.id]) {
|
||||||
|
response[sco.id] = {
|
||||||
|
scoid: sco.id,
|
||||||
|
userdata: {
|
||||||
|
status: '',
|
||||||
|
score_raw: '', // eslint-disable-line @typescript-eslint/naming-convention
|
||||||
|
},
|
||||||
|
defaultdata: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate defaultdata.
|
||||||
|
for (const scoId in response) {
|
||||||
|
const sco = response[scoId];
|
||||||
|
|
||||||
|
sco.defaultdata = {};
|
||||||
|
sco.defaultdata['cmi.core.student_id'] = userName;
|
||||||
|
sco.defaultdata['cmi.core.student_name'] = fullName;
|
||||||
|
sco.defaultdata['cmi.core.lesson_mode'] = 'normal'; // Overridden in player.
|
||||||
|
sco.defaultdata['cmi.core.credit'] = 'credit'; // Overridden in player.
|
||||||
|
|
||||||
|
if (sco.userdata.status === '') {
|
||||||
|
sco.defaultdata['cmi.core.entry'] = 'ab-initio';
|
||||||
|
} else if (sco.userdata['cmi.core.exit'] === 'suspend') {
|
||||||
|
sco.defaultdata['cmi.core.entry'] = 'resume';
|
||||||
|
} else {
|
||||||
|
sco.defaultdata['cmi.core.entry'] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
sco.defaultdata['cmi.student_data.mastery_score'] = this.scormIsset(sco.userdata, 'masteryscore');
|
||||||
|
sco.defaultdata['cmi.student_data.max_time_allowed'] = this.scormIsset(sco.userdata, 'max_time_allowed');
|
||||||
|
sco.defaultdata['cmi.student_data.time_limit_action'] = this.scormIsset(sco.userdata, 'time_limit_action');
|
||||||
|
sco.defaultdata['cmi.core.total_time'] = this.scormIsset(sco.userdata, 'cmi.core.total_time', '00:00:00');
|
||||||
|
sco.defaultdata['cmi.launch_data'] = launchUrls[sco.scoid];
|
||||||
|
|
||||||
|
// Now handle standard userdata items.
|
||||||
|
sco.defaultdata['cmi.core.lesson_location'] = this.scormIsset(sco.userdata, 'cmi.core.lesson_location');
|
||||||
|
sco.defaultdata['cmi.core.lesson_status'] = this.scormIsset(sco.userdata, 'cmi.core.lesson_status');
|
||||||
|
sco.defaultdata['cmi.core.score.raw'] = this.scormIsset(sco.userdata, 'cmi.core.score.raw');
|
||||||
|
sco.defaultdata['cmi.core.score.max'] = this.scormIsset(sco.userdata, 'cmi.core.score.max');
|
||||||
|
sco.defaultdata['cmi.core.score.min'] = this.scormIsset(sco.userdata, 'cmi.core.score.min');
|
||||||
|
sco.defaultdata['cmi.core.exit'] = this.scormIsset(sco.userdata, 'cmi.core.exit');
|
||||||
|
sco.defaultdata['cmi.suspend_data'] = this.scormIsset(sco.userdata, 'cmi.suspend_data');
|
||||||
|
sco.defaultdata['cmi.comments'] = this.scormIsset(sco.userdata, 'cmi.comments');
|
||||||
|
sco.defaultdata['cmi.student_preference.language'] = this.scormIsset(sco.userdata, 'cmi.student_preference.language');
|
||||||
|
sco.defaultdata['cmi.student_preference.audio'] = this.scormIsset(sco.userdata, 'cmi.student_preference.audio', '0');
|
||||||
|
sco.defaultdata['cmi.student_preference.speed'] = this.scormIsset(sco.userdata, 'cmi.student_preference.speed', '0');
|
||||||
|
sco.defaultdata['cmi.student_preference.text'] = this.scormIsset(sco.userdata, 'cmi.student_preference.text', '0');
|
||||||
|
|
||||||
|
// Some data needs to be both in default data and user data.
|
||||||
|
sco.userdata.student_id = userName;
|
||||||
|
sco.userdata.student_name = fullName;
|
||||||
|
sco.userdata.mode = sco.defaultdata['cmi.core.lesson_mode'];
|
||||||
|
sco.userdata.credit = sco.defaultdata['cmi.core.credit'];
|
||||||
|
sco.userdata.entry = sco.defaultdata['cmi.core.entry'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a track in the offline tracks store.
|
||||||
|
* This function is based on Moodle's scorm_insert_track.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param scoId SCO ID.
|
||||||
|
* @param attempt Attempt number.
|
||||||
|
* @param element Name of the element to insert.
|
||||||
|
* @param value Value to insert.
|
||||||
|
* @param forceCompleted True if SCORM forces completed.
|
||||||
|
* @param scoData User data for the given SCO.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param userId User ID. If not set use site's current user.
|
||||||
|
* @return Promise resolved when the insert is done.
|
||||||
|
*/
|
||||||
|
protected async insertTrack(
|
||||||
|
scormId: number,
|
||||||
|
scoId: number,
|
||||||
|
attempt: number,
|
||||||
|
element: string,
|
||||||
|
value?: string | number,
|
||||||
|
forceCompleted?: boolean,
|
||||||
|
scoData?: AddonModScormScoUserData,
|
||||||
|
siteId?: string,
|
||||||
|
userId?: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const site = await CoreSites.getSite(siteId);
|
||||||
|
|
||||||
|
userId = userId || site.getUserId();
|
||||||
|
|
||||||
|
const scoUserData = scoData?.userdata || {};
|
||||||
|
const db = site.getDb();
|
||||||
|
let lessonStatusInserted = false;
|
||||||
|
|
||||||
|
if (forceCompleted) {
|
||||||
|
if (element == 'cmi.core.lesson_status' && value == 'incomplete') {
|
||||||
|
if (scoUserData['cmi.core.score.raw']) {
|
||||||
|
value = 'completed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (element == 'cmi.core.score.raw') {
|
||||||
|
if (scoUserData['cmi.core.lesson_status'] == 'incomplete') {
|
||||||
|
lessonStatusInserted = true;
|
||||||
|
|
||||||
|
await this.insertTrackToDB(db, userId, scormId, scoId, attempt, 'cmi.core.lesson_status', 'completed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scoUserData[element] && element == 'x.start.time') {
|
||||||
|
// Don't update x.start.time, keep the original value.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.insertTrackToDB(db, userId, scormId, scoId, attempt, element, value);
|
||||||
|
} catch (error) {
|
||||||
|
if (lessonStatusInserted) {
|
||||||
|
// Rollback previous insert.
|
||||||
|
await this.insertTrackToDB(db, userId, scormId, scoId, attempt, 'cmi.core.lesson_status', 'incomplete');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a track in the DB.
|
||||||
|
*
|
||||||
|
* @param db Site's DB.
|
||||||
|
* @param userId User ID.
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param scoId SCO ID.
|
||||||
|
* @param attempt Attempt number.
|
||||||
|
* @param element Name of the element to insert.
|
||||||
|
* @param value Value of the element to insert.
|
||||||
|
* @param synchronous True if insert should NOT return a promise. Please use it only if synchronous is a must.
|
||||||
|
* @return Returns a promise if synchronous=false, otherwise returns a boolean.
|
||||||
|
*/
|
||||||
|
protected insertTrackToDB(
|
||||||
|
db: SQLiteDB,
|
||||||
|
userId: number,
|
||||||
|
scormId: number,
|
||||||
|
scoId: number,
|
||||||
|
attempt: number,
|
||||||
|
element: string,
|
||||||
|
value: AddonModScormDataValue | undefined,
|
||||||
|
synchronous: true,
|
||||||
|
): boolean;
|
||||||
|
protected insertTrackToDB(
|
||||||
|
db: SQLiteDB,
|
||||||
|
userId: number,
|
||||||
|
scormId: number,
|
||||||
|
scoId: number,
|
||||||
|
attempt: number,
|
||||||
|
element: string,
|
||||||
|
value?: AddonModScormDataValue,
|
||||||
|
synchronous?: false,
|
||||||
|
): Promise<number>;
|
||||||
|
protected insertTrackToDB(
|
||||||
|
db: SQLiteDB,
|
||||||
|
userId: number,
|
||||||
|
scormId: number,
|
||||||
|
scoId: number,
|
||||||
|
attempt: number,
|
||||||
|
element: string,
|
||||||
|
value?: AddonModScormDataValue,
|
||||||
|
synchronous?: boolean,
|
||||||
|
): boolean | Promise<number> {
|
||||||
|
const entry: AddonModScormTrackDBRecord = {
|
||||||
|
userid: userId,
|
||||||
|
scormid: scormId,
|
||||||
|
scoid: scoId,
|
||||||
|
attempt,
|
||||||
|
element: element,
|
||||||
|
value: typeof value == 'undefined' ? null : JSON.stringify(value),
|
||||||
|
timemodified: CoreTimeUtils.timestamp(),
|
||||||
|
synced: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (synchronous) {
|
||||||
|
// The insert operation is always asynchronous, always return true.
|
||||||
|
db.insertRecord(TRACKS_TABLE_NAME, entry);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return db.insertRecord(TRACKS_TABLE_NAME, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a track in the offline tracks store, returning a synchronous value.
|
||||||
|
* Please use this function only if synchronous is a must. It's recommended to use insertTrack.
|
||||||
|
* This function is based on Moodle's scorm_insert_track.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param scoId SCO ID.
|
||||||
|
* @param attempt Attempt number.
|
||||||
|
* @param element Name of the element to insert.
|
||||||
|
* @param value Value of the element to insert.
|
||||||
|
* @param forceCompleted True if SCORM forces completed.
|
||||||
|
* @param scoData User data for the given SCO.
|
||||||
|
* @param userId User ID. If not set use current user.
|
||||||
|
* @return Promise resolved when the insert is done.
|
||||||
|
*/
|
||||||
|
protected insertTrackSync(
|
||||||
|
scormId: number,
|
||||||
|
scoId: number,
|
||||||
|
attempt: number,
|
||||||
|
element: string,
|
||||||
|
value?: AddonModScormDataValue,
|
||||||
|
forceCompleted?: boolean,
|
||||||
|
scoData?: AddonModScormScoUserData,
|
||||||
|
userId?: number,
|
||||||
|
): boolean {
|
||||||
|
userId = userId || CoreSites.getCurrentSiteUserId();
|
||||||
|
|
||||||
|
if (!CoreSites.isLoggedIn()) {
|
||||||
|
// Not logged in, we can't get the site DB. User logged out or session expired while an operation was ongoing.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoUserData = scoData?.userdata || {};
|
||||||
|
const db = CoreSites.getCurrentSite()!.getDb();
|
||||||
|
let lessonStatusInserted = false;
|
||||||
|
|
||||||
|
if (forceCompleted) {
|
||||||
|
if (element == 'cmi.core.lesson_status' && value == 'incomplete') {
|
||||||
|
if (scoUserData['cmi.core.score.raw']) {
|
||||||
|
value = 'completed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (element == 'cmi.core.score.raw') {
|
||||||
|
if (scoUserData['cmi.core.lesson_status'] == 'incomplete') {
|
||||||
|
lessonStatusInserted = true;
|
||||||
|
|
||||||
|
if (!this.insertTrackToDB(db, userId, scormId, scoId, attempt, 'cmi.core.lesson_status', 'completed', true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scoUserData[element] && element == 'x.start.time') {
|
||||||
|
// Don't update x.start.time, keep the original value.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.insertTrackToDB(db, userId, scormId, scoId, attempt, element, value, true)) {
|
||||||
|
// Insert failed.
|
||||||
|
if (lessonStatusInserted) {
|
||||||
|
// Rollback previous insert.
|
||||||
|
this.insertTrackToDB(db, userId, scormId, scoId, attempt, 'cmi.core.lesson_status', 'incomplete', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all the entries from a SCO and attempt as synced.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param attempt Attempt number.
|
||||||
|
* @param scoId SCO ID.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param userId User ID. If not defined use site's current user.
|
||||||
|
* @return Promise resolved when marked.
|
||||||
|
*/
|
||||||
|
async markAsSynced(scormId: number, attempt: number, scoId: number, siteId?: string, userId?: number): Promise<void> {
|
||||||
|
const site = await CoreSites.getSite(siteId);
|
||||||
|
userId = userId || site.getUserId();
|
||||||
|
|
||||||
|
this.logger.debug(`Mark SCO ${scoId} as synced for attempt ${attempt} in SCORM ${scormId}`);
|
||||||
|
|
||||||
|
await site.getDb().updateRecords(TRACKS_TABLE_NAME, { synced: 1 }, <Partial<AddonModScormTrackDBRecord>> {
|
||||||
|
scormid: scormId,
|
||||||
|
userid: userId,
|
||||||
|
attempt,
|
||||||
|
scoid: scoId,
|
||||||
|
synced: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an attempt.
|
||||||
|
*
|
||||||
|
* @param attempt Attempt to parse.
|
||||||
|
* @returns Parsed attempt.
|
||||||
|
*/
|
||||||
|
protected parseAttempt(attempt: AddonModScormAttemptDBRecord): AddonModScormOfflineAttempt {
|
||||||
|
return {
|
||||||
|
...attempt,
|
||||||
|
snapshot: attempt.snapshot ? CoreTextUtils.parseJSON(attempt.snapshot) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse tracks.
|
||||||
|
*
|
||||||
|
* @param tracks Tracks to parse.
|
||||||
|
* @returns Parsed tracks.
|
||||||
|
*/
|
||||||
|
protected parseTracks(tracks: AddonModScormTrackDBRecord[]): AddonModScormOfflineTrack[] {
|
||||||
|
return tracks.map((track) => ({
|
||||||
|
...track,
|
||||||
|
value: track.value ? CoreTextUtils.parseJSON(track.value) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the default data form user data.
|
||||||
|
*
|
||||||
|
* @param userData User data.
|
||||||
|
* @return User data without default data.
|
||||||
|
*/
|
||||||
|
protected removeDefaultData(userData: AddonModScormUserDataMap): AddonModScormUserDataMap {
|
||||||
|
const result: AddonModScormUserDataMap = CoreUtils.clone(userData);
|
||||||
|
|
||||||
|
for (const key in result) {
|
||||||
|
result[key].defaultdata = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a SCORM tracking record in offline.
|
||||||
|
*
|
||||||
|
* @param scorm SCORM.
|
||||||
|
* @param scoId Sco ID.
|
||||||
|
* @param attempt Attempt number.
|
||||||
|
* @param tracks Tracking data to store.
|
||||||
|
* @param userData User data for this attempt and SCO.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param userId User ID. If not defined use site's current user.
|
||||||
|
* @return Promise resolved when data is saved.
|
||||||
|
*/
|
||||||
|
async saveTracks(
|
||||||
|
scorm: AddonModScormScorm,
|
||||||
|
scoId: number,
|
||||||
|
attempt: number,
|
||||||
|
tracks: AddonModScormDataEntry[],
|
||||||
|
userData: AddonModScormUserDataMap,
|
||||||
|
siteId?: string,
|
||||||
|
userId?: number,
|
||||||
|
): Promise<void> {
|
||||||
|
|
||||||
|
const site = await CoreSites.getSite(siteId);
|
||||||
|
userId = userId || site.getUserId();
|
||||||
|
|
||||||
|
// Block the SCORM so it can't be synced.
|
||||||
|
CoreSync.blockOperation(AddonModScormProvider.COMPONENT, scorm.id, 'saveTracksOffline', siteId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Insert all the tracks.
|
||||||
|
await Promise.all(tracks.map((track) => this.insertTrack(
|
||||||
|
scorm.id,
|
||||||
|
scoId,
|
||||||
|
attempt,
|
||||||
|
track.element,
|
||||||
|
track.value,
|
||||||
|
scorm.forcecompleted,
|
||||||
|
userData[scoId],
|
||||||
|
siteId,
|
||||||
|
userId,
|
||||||
|
)));
|
||||||
|
} finally {
|
||||||
|
// Unblock the SCORM operation.
|
||||||
|
CoreSync.unblockOperation(AddonModScormProvider.COMPONENT, scorm.id, 'saveTracksOffline', siteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a SCORM tracking record in offline returning a synchronous value.
|
||||||
|
* Please use this function only if synchronous is a must. It's recommended to use saveTracks.
|
||||||
|
*
|
||||||
|
* @param scorm SCORM.
|
||||||
|
* @param scoId Sco ID.
|
||||||
|
* @param attempt Attempt number.
|
||||||
|
* @param tracks Tracking data to store.
|
||||||
|
* @param userData User data for this attempt and SCO.
|
||||||
|
* @return True if data to insert is valid, false otherwise. Returning true doesn't mean that the data
|
||||||
|
* has been stored, this function can return true but the insertion can still fail somehow.
|
||||||
|
*/
|
||||||
|
saveTracksSync(
|
||||||
|
scorm: AddonModScormScorm,
|
||||||
|
scoId: number,
|
||||||
|
attempt: number,
|
||||||
|
tracks: AddonModScormDataEntry[],
|
||||||
|
userData: AddonModScormUserDataMap,
|
||||||
|
userId?: number,
|
||||||
|
): boolean {
|
||||||
|
userId = userId || CoreSites.getCurrentSiteUserId();
|
||||||
|
let success = true;
|
||||||
|
|
||||||
|
tracks.forEach((track) => {
|
||||||
|
const trackSuccess = this.insertTrackSync(
|
||||||
|
scorm.id,
|
||||||
|
scoId,
|
||||||
|
attempt,
|
||||||
|
track.element,
|
||||||
|
track.value,
|
||||||
|
scorm.forcecompleted,
|
||||||
|
userData[scoId],
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
success = success && trackSuccess;
|
||||||
|
});
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for a parameter in userData and return it if it's set or return 'ifempty' if it's empty.
|
||||||
|
* Based on Moodle's scorm_isset function.
|
||||||
|
*
|
||||||
|
* @param userData Contains user's data.
|
||||||
|
* @param param Name of parameter that should be checked.
|
||||||
|
* @param ifEmpty Value to be replaced with if param is not set.
|
||||||
|
* @return Value from userData[param] if set, ifEmpty otherwise.
|
||||||
|
*/
|
||||||
|
protected scormIsset(
|
||||||
|
userData: Record<string, AddonModScormDataValue>,
|
||||||
|
param: string,
|
||||||
|
ifEmpty: AddonModScormDataValue = '',
|
||||||
|
): AddonModScormDataValue {
|
||||||
|
if (typeof userData[param] != 'undefined') {
|
||||||
|
return userData[param];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ifEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an attempt's snapshot.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param attempt Attempt number.
|
||||||
|
* @param userData User data to store as snapshot.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @param userId User ID. If not defined use site's current user.
|
||||||
|
* @return Promise resolved when snapshot has been stored.
|
||||||
|
*/
|
||||||
|
async setAttemptSnapshot(
|
||||||
|
scormId: number,
|
||||||
|
attempt: number,
|
||||||
|
userData: AddonModScormUserDataMap,
|
||||||
|
siteId?: string,
|
||||||
|
userId?: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const site = await CoreSites.getSite(siteId);
|
||||||
|
userId = userId || site.getUserId();
|
||||||
|
|
||||||
|
this.logger.debug(`Set snapshot for attempt ${attempt} in SCORM ${scormId}`);
|
||||||
|
|
||||||
|
const newData: Partial<AddonModScormAttemptDBRecord> = {
|
||||||
|
timemodified: CoreTimeUtils.timestamp(),
|
||||||
|
snapshot: JSON.stringify(this.removeDefaultData(userData)),
|
||||||
|
};
|
||||||
|
|
||||||
|
await site.getDb().updateRecords(ATTEMPTS_TABLE_NAME, newData, <Partial<AddonModScormAttemptDBRecord>> {
|
||||||
|
scormid: scormId,
|
||||||
|
userid: userId,
|
||||||
|
attempt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddonModScormOffline = makeSingleton(AddonModScormOfflineProvider);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCORM offline attempt data.
|
||||||
|
*/
|
||||||
|
export type AddonModScormOfflineAttempt = Omit<AddonModScormAttemptDBRecord, 'snapshot'> & {
|
||||||
|
snapshot?: AddonModScormUserDataMap | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCORM offline track data.
|
||||||
|
*/
|
||||||
|
export type AddonModScormOfflineTrack = Omit<AddonModScormTrackDBRecord, 'value'> & {
|
||||||
|
value?: string | number | null;
|
||||||
|
};
|
|
@ -0,0 +1,857 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { CoreError } from '@classes/errors/error';
|
||||||
|
import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync';
|
||||||
|
import { CoreCourse } from '@features/course/services/course';
|
||||||
|
import { CoreCourseLogHelper } from '@features/course/services/log-helper';
|
||||||
|
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
|
||||||
|
import { CoreSync } from '@services/sync';
|
||||||
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
import { makeSingleton, Translate } from '@singletons';
|
||||||
|
import { CoreEvents } from '@singletons/events';
|
||||||
|
import { AddonModScormPrefetchHandler } from './handlers/prefetch';
|
||||||
|
import {
|
||||||
|
AddonModScorm,
|
||||||
|
AddonModScormAttemptCountResult,
|
||||||
|
AddonModScormDataEntry,
|
||||||
|
AddonModScormProvider,
|
||||||
|
AddonModScormScorm,
|
||||||
|
AddonModScormUserDataMap,
|
||||||
|
} from './scorm';
|
||||||
|
import { AddonModScormOffline } from './scorm-offline';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service to sync SCORMs.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AddonModScormSyncProvider extends CoreCourseActivitySyncBaseProvider<AddonModScormSyncResult> {
|
||||||
|
|
||||||
|
static readonly AUTO_SYNCED = 'addon_mod_scorm_autom_synced';
|
||||||
|
|
||||||
|
protected componentTranslatableString = 'scorm';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('AddonModScormSyncProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an offline attempt to the right of the new attempts array if possible.
|
||||||
|
* If the attempt cannot be created as a new attempt then it will be deleted.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param attempt The offline attempt to treat.
|
||||||
|
* @param lastOffline Last offline attempt number.
|
||||||
|
* @param newAttemptsSameOrder Attempts that'll be created as new attempts but keeping the current order.
|
||||||
|
* @param newAttemptsAtEnd Object with attempts that'll be created at the end of the list (should be max 1).
|
||||||
|
* @param lastOfflineCreated Time when the last offline attempt was created.
|
||||||
|
* @param lastOfflineIncomplete Whether the last offline attempt is incomplete.
|
||||||
|
* @param warnings Array where to add the warnings.
|
||||||
|
* @param siteId Site ID.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async addToNewOrDelete(
|
||||||
|
scormId: number,
|
||||||
|
attempt: number,
|
||||||
|
lastOffline: number,
|
||||||
|
newAttemptsSameOrder: number[],
|
||||||
|
newAttemptsAtEnd: Record<number, number>,
|
||||||
|
lastOfflineCreated: number,
|
||||||
|
lastOfflineIncomplete: boolean,
|
||||||
|
warnings: string[],
|
||||||
|
siteId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (attempt == lastOffline) {
|
||||||
|
newAttemptsSameOrder.push(attempt);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the attempt can be created.
|
||||||
|
const time = await AddonModScormOffline.getAttemptCreationTime(scormId, attempt, siteId);
|
||||||
|
|
||||||
|
if (!time || time <= lastOfflineCreated) {
|
||||||
|
newAttemptsSameOrder.push(attempt);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This attempt was created after the last offline attempt, we'll add it to the end of the list if possible.
|
||||||
|
if (lastOfflineIncomplete) {
|
||||||
|
// It can't be added because the last offline attempt is incomplete, delete it.
|
||||||
|
this.logger.debug(`Try to delete attempt ${attempt} because it cannot be created as a new attempt.`);
|
||||||
|
|
||||||
|
await CoreUtils.ignoreErrors(AddonModScormOffline.deleteAttempt(scormId, attempt, siteId));
|
||||||
|
|
||||||
|
// eslint-disable-next-line id-blacklist
|
||||||
|
warnings.push(Translate.instant('addon.mod_scorm.warningofflinedatadeleted', { number: attempt }));
|
||||||
|
} else {
|
||||||
|
// Add the attempt at the end.
|
||||||
|
newAttemptsAtEnd[time] = attempt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if can retry an attempt synchronization.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param attempt Attempt number.
|
||||||
|
* @param lastOnline Last online attempt number.
|
||||||
|
* @param cmId Module ID.
|
||||||
|
* @param siteId Site ID.
|
||||||
|
* @return Promise resolved if can retry the synchronization, rejected otherwise.
|
||||||
|
*/
|
||||||
|
protected async canRetrySync(
|
||||||
|
scormId: number,
|
||||||
|
attempt: number,
|
||||||
|
lastOnline: number,
|
||||||
|
cmId: number,
|
||||||
|
siteId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
// If it's the last attempt we don't need to ignore cache because we already did it.
|
||||||
|
const refresh = lastOnline != attempt;
|
||||||
|
|
||||||
|
const siteData = await AddonModScorm.getScormUserData(scormId, attempt, {
|
||||||
|
cmId,
|
||||||
|
readingStrategy: refresh ? CoreSitesReadingStrategy.OnlyNetwork : undefined,
|
||||||
|
siteId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get synchronization snapshot (if sync fails it should store a snapshot).
|
||||||
|
const snapshot = await AddonModScormOffline.getAttemptSnapshot(scormId, attempt, siteId);
|
||||||
|
|
||||||
|
if (!snapshot || !Object.keys(snapshot).length || !this.snapshotEquals(snapshot, siteData)) {
|
||||||
|
// No snapshot or it doesn't match, we can't retry the synchronization.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new attempts at the end of the offline attempts list.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param newAttempts Object with the attempts to create. The keys are the timecreated, the values are the attempt number.
|
||||||
|
* @param lastOffline Number of last offline attempt.
|
||||||
|
* @param siteId Site ID.
|
||||||
|
* @return Promise resolved when done.
|
||||||
|
*/
|
||||||
|
protected async createNewAttemptsAtEnd(
|
||||||
|
scormId: number,
|
||||||
|
newAttempts: Record<number, number>,
|
||||||
|
lastOffline: number,
|
||||||
|
siteId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const times = Object.keys(newAttempts).sort(); // Sort in ASC order.
|
||||||
|
|
||||||
|
if (!times.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CoreUtils.allPromises(times.map((time, index) => {
|
||||||
|
const attempt = newAttempts[time];
|
||||||
|
|
||||||
|
return AddonModScormOffline.changeAttemptNumber(scormId, attempt, lastOffline + index + 1, siteId);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finish a sync process: remove offline data if needed, prefetch SCORM data, set sync time and return the result.
|
||||||
|
*
|
||||||
|
* @param siteId Site ID.
|
||||||
|
* @param scorm SCORM.
|
||||||
|
* @param warnings List of warnings generated by the sync.
|
||||||
|
* @param lastOnline Last online attempt number before the sync.
|
||||||
|
* @param lastOnlineWasFinished Whether the last online attempt was finished before the sync.
|
||||||
|
* @param initialCount Attempt count before the sync.
|
||||||
|
* @param updated Whether some data was sent to the site.
|
||||||
|
* @return Promise resolved on success.
|
||||||
|
*/
|
||||||
|
protected async finishSync(
|
||||||
|
siteId: string,
|
||||||
|
scorm: AddonModScormScorm,
|
||||||
|
warnings: string[],
|
||||||
|
lastOnline?: number,
|
||||||
|
lastOnlineWasFinished?: boolean,
|
||||||
|
initialCount?: AddonModScormAttemptCountResult,
|
||||||
|
updated?: boolean,
|
||||||
|
): Promise<AddonModScormSyncResult> {
|
||||||
|
const result: AddonModScormSyncResult = {
|
||||||
|
warnings: warnings,
|
||||||
|
attemptFinished: false,
|
||||||
|
updated: !!updated,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
try {
|
||||||
|
// Update downloaded data.
|
||||||
|
const module = await CoreCourse.getModuleBasicInfoByInstance(scorm.id, 'scorm', siteId);
|
||||||
|
|
||||||
|
await this.prefetchAfterUpdate(AddonModScormPrefetchHandler.instance, module, scorm.course, undefined, siteId);
|
||||||
|
} catch {
|
||||||
|
// Ignore errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await CoreUtils.ignoreErrors(this.setSyncTime(scorm.id, siteId));
|
||||||
|
|
||||||
|
if (!initialCount) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if an attempt was finished in Moodle.
|
||||||
|
// Get attempt count again to check if an attempt was finished.
|
||||||
|
const attemptsData = await AddonModScorm.getAttemptCount(scorm.id, { cmId: scorm.coursemodule, siteId });
|
||||||
|
|
||||||
|
if (attemptsData.online.length > initialCount.online.length) {
|
||||||
|
result.attemptFinished = true;
|
||||||
|
} else if (!lastOnlineWasFinished && lastOnline) {
|
||||||
|
// Last online attempt wasn't finished, let's check if it is now.
|
||||||
|
const incomplete = await AddonModScorm.isAttemptIncomplete(scorm.id, lastOnline, {
|
||||||
|
cmId: scorm.coursemodule,
|
||||||
|
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
|
||||||
|
siteId,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.attemptFinished = !incomplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the creation time and the status (complete/incomplete) of an offline attempt.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param attempt Attempt number.
|
||||||
|
* @param cmId Module ID.
|
||||||
|
* @param siteId Site ID.
|
||||||
|
* @return Promise resolved with the data.
|
||||||
|
*/
|
||||||
|
protected async getOfflineAttemptData(
|
||||||
|
scormId: number,
|
||||||
|
attempt: number,
|
||||||
|
cmId: number,
|
||||||
|
siteId: string,
|
||||||
|
): Promise<{incomplete: boolean; timecreated?: number}> {
|
||||||
|
|
||||||
|
// Check if last offline attempt is incomplete.
|
||||||
|
const incomplete = await AddonModScorm.isAttemptIncomplete(scormId, attempt, {
|
||||||
|
offline: true,
|
||||||
|
cmId,
|
||||||
|
siteId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const timecreated = await AddonModScormOffline.getAttemptCreationTime(scormId, attempt, siteId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
incomplete,
|
||||||
|
timecreated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the number of some offline attempts. We need to move all offline attempts after the collisions
|
||||||
|
* too, otherwise we would overwrite data.
|
||||||
|
* Example: We have offline attempts 1, 2 and 3. #1 and #2 have collisions. #1 can be synced, but #2 needs
|
||||||
|
* to be a new attempt. #3 will now be #4, and #2 will now be #3.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param newAttempts Attempts that need to be converted into new attempts.
|
||||||
|
* @param lastOnline Last online attempt.
|
||||||
|
* @param lastCollision Last attempt with collision (exists in online and offline).
|
||||||
|
* @param offlineAttempts Numbers of offline attempts.
|
||||||
|
* @param siteId Site ID.
|
||||||
|
* @return Promise resolved when attempts have been moved.
|
||||||
|
*/
|
||||||
|
protected async moveNewAttempts(
|
||||||
|
scormId: number,
|
||||||
|
newAttempts: number[],
|
||||||
|
lastOnline: number,
|
||||||
|
lastCollision: number,
|
||||||
|
offlineAttempts: number[],
|
||||||
|
siteId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!newAttempts.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastSuccessful: number | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Sort offline attempts in DESC order.
|
||||||
|
offlineAttempts = offlineAttempts.sort((a, b) => Number(a) <= Number(b) ? 1 : -1);
|
||||||
|
|
||||||
|
// First move the offline attempts after the collisions. Move them 1 by 1 in order.
|
||||||
|
for (const i in offlineAttempts) {
|
||||||
|
const attempt = offlineAttempts[i];
|
||||||
|
|
||||||
|
if (attempt > lastCollision) {
|
||||||
|
const newNumber = attempt + newAttempts.length;
|
||||||
|
|
||||||
|
await AddonModScormOffline.changeAttemptNumber(scormId, attempt, newNumber, siteId);
|
||||||
|
|
||||||
|
lastSuccessful = attempt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const successful: number[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Sort newAttempts in ASC order.
|
||||||
|
newAttempts = newAttempts.sort((a, b) => Number(a) >= Number(b) ? 1 : -1);
|
||||||
|
|
||||||
|
// Now move the attempts in newAttempts.
|
||||||
|
await Promise.all(newAttempts.map(async (attempt, index) => {
|
||||||
|
// No need to use chain of promises.
|
||||||
|
const newNumber = lastOnline + index + 1;
|
||||||
|
|
||||||
|
await AddonModScormOffline.changeAttemptNumber(scormId, attempt, newNumber, siteId);
|
||||||
|
|
||||||
|
successful.push(attempt);
|
||||||
|
}));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Moving the new attempts failed (it shouldn't happen). Let's undo the new attempts move.
|
||||||
|
await CoreUtils.allPromises(successful.map((attempt) => {
|
||||||
|
const newNumber = lastOnline + newAttempts.indexOf(attempt) + 1;
|
||||||
|
|
||||||
|
return AddonModScormOffline.changeAttemptNumber(scormId, newNumber, attempt, siteId);
|
||||||
|
}));
|
||||||
|
|
||||||
|
throw error; // It will now enter the catch that moves offline attempts after collisions.
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Moving offline attempts after collisions failed (it shouldn't happen). Let's undo the changes.
|
||||||
|
if (!lastSuccessful) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = lastSuccessful; offlineAttempts.indexOf(attempt) != -1; attempt++) {
|
||||||
|
// Move it back.
|
||||||
|
await AddonModScormOffline.changeAttemptNumber(scormId, attempt + newAttempts.length, attempt, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a snapshot from a synchronization.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param attempt Attemot number.
|
||||||
|
* @param cmId Module ID.
|
||||||
|
* @param siteId Site ID.
|
||||||
|
* @return Promise resolved when the snapshot is stored.
|
||||||
|
*/
|
||||||
|
protected async saveSyncSnapshot(scormId: number, attempt: number, cmId: number, siteId: string): Promise<void> {
|
||||||
|
// Try to get current state from the site.
|
||||||
|
let userData: AddonModScormUserDataMap;
|
||||||
|
|
||||||
|
try {
|
||||||
|
userData = await AddonModScorm.getScormUserData(scormId, attempt, {
|
||||||
|
cmId,
|
||||||
|
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
|
||||||
|
siteId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Error getting user data from the site. We'll have to build it ourselves.
|
||||||
|
// Let's try to get cached data about the attempt.
|
||||||
|
userData = await CoreUtils.ignoreErrors(
|
||||||
|
AddonModScorm.getScormUserData(scormId, attempt, { cmId, siteId }),
|
||||||
|
<AddonModScormUserDataMap> {},
|
||||||
|
);
|
||||||
|
|
||||||
|
// We need to add the synced data to the snapshot.
|
||||||
|
const syncedData = await AddonModScormOffline.getScormStoredData(scormId, attempt, false, true, siteId);
|
||||||
|
|
||||||
|
syncedData.forEach((entry) => {
|
||||||
|
if (!userData[entry.scoid]) {
|
||||||
|
userData[entry.scoid] = {
|
||||||
|
scoid: entry.scoid,
|
||||||
|
userdata: {},
|
||||||
|
defaultdata: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
userData[entry.scoid].userdata[entry.element] = entry.value || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return AddonModScormOffline.setAttemptSnapshot(scormId, attempt, userData, siteId);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares an attempt's snapshot with the data retrieved from the site.
|
||||||
|
* It only compares elements with dot notation. This means that, if some SCO has been added to Moodle web
|
||||||
|
* but the user hasn't generated data for it, then the snapshot will be detected as equal.
|
||||||
|
*
|
||||||
|
* @param snapshot Attempt's snapshot.
|
||||||
|
* @param userData Data retrieved from the site.
|
||||||
|
* @return True if snapshot is equal to the user data, false otherwise.
|
||||||
|
*/
|
||||||
|
protected snapshotEquals(snapshot: AddonModScormUserDataMap, userData: AddonModScormUserDataMap): boolean {
|
||||||
|
// Check that snapshot contains the data from the site.
|
||||||
|
for (const scoId in userData) {
|
||||||
|
const siteSco = userData[scoId];
|
||||||
|
const snapshotSco = snapshot[scoId];
|
||||||
|
|
||||||
|
for (const element in siteSco.userdata) {
|
||||||
|
if (element.indexOf('.') > -1) {
|
||||||
|
if (!snapshotSco || siteSco.userdata[element] !== snapshotSco.userdata[element]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now check the opposite way: site userData contains the data from the snapshot.
|
||||||
|
for (const scoId in snapshot) {
|
||||||
|
const siteSco = userData[scoId];
|
||||||
|
const snapshotSco = snapshot[scoId];
|
||||||
|
|
||||||
|
for (const element in snapshotSco.userdata) {
|
||||||
|
if (element.indexOf('.') > -1) {
|
||||||
|
if (!siteSco || siteSco.userdata[element] !== snapshotSco.userdata[element]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to synchronize all the SCORMs in a certain site or in all sites.
|
||||||
|
*
|
||||||
|
* @param siteId Site ID to sync. If not defined, sync all sites.
|
||||||
|
* @param force Wether to force sync not depending on last execution.
|
||||||
|
* @return Promise resolved if sync is successful, rejected if sync fails.
|
||||||
|
*/
|
||||||
|
syncAllScorms(siteId?: string, force?: boolean): Promise<void> {
|
||||||
|
return this.syncOnSites('all SCORMs', this.syncAllScormsFunc.bind(this, !!force), siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync all SCORMs on a site.
|
||||||
|
*
|
||||||
|
* @param force Wether to force sync or not.
|
||||||
|
* @param siteId Site ID to sync.
|
||||||
|
* @param Promise resolved if sync is successful, rejected if sync fails.
|
||||||
|
*/
|
||||||
|
protected async syncAllScormsFunc(force: boolean, siteId: string): Promise<void> {
|
||||||
|
|
||||||
|
// Get all offline attempts.
|
||||||
|
const attempts = await AddonModScormOffline.getAllAttempts(siteId);
|
||||||
|
|
||||||
|
const treated: Record<number, boolean> = {}; // To prevent duplicates.
|
||||||
|
|
||||||
|
// Sync all SCORMs that haven't been synced for a while and that aren't attempted right now.
|
||||||
|
await Promise.all(attempts.map(async (attempt) => {
|
||||||
|
if (treated[attempt.scormid] || CoreSync.isBlocked(AddonModScormProvider.COMPONENT, attempt.scormid, siteId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
treated[attempt.scormid] = true;
|
||||||
|
|
||||||
|
const scorm = await AddonModScorm.getScormById(attempt.courseid, attempt.scormid, { siteId });
|
||||||
|
|
||||||
|
const data = force ?
|
||||||
|
await this.syncScorm(scorm, siteId) :
|
||||||
|
await this.syncScormIfNeeded(scorm, siteId);
|
||||||
|
|
||||||
|
if (typeof data != 'undefined') {
|
||||||
|
// We tried to sync. Send event.
|
||||||
|
CoreEvents.trigger(AddonModScormSyncProvider.AUTO_SYNCED, {
|
||||||
|
scormId: scorm.id,
|
||||||
|
attemptFinished: data.attemptFinished,
|
||||||
|
warnings: data.warnings,
|
||||||
|
updated: data.updated,
|
||||||
|
}, siteId);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send data from a SCORM offline attempt to the site.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param attempt Attempt number.
|
||||||
|
* @param cmId Module ID.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when the attempt is successfully synced.
|
||||||
|
*/
|
||||||
|
protected async syncAttempt(scormId: number, attempt: number, cmId: number, siteId?: string): Promise<void> {
|
||||||
|
siteId = siteId || CoreSites.getCurrentSiteId();
|
||||||
|
|
||||||
|
this.logger.debug(`Try to sync attempt ${attempt} in SCORM ${scormId} and site ${siteId}`);
|
||||||
|
|
||||||
|
// Get only not synced entries.
|
||||||
|
const tracks = await AddonModScormOffline.getScormStoredData(scormId, attempt, true, false, siteId);
|
||||||
|
|
||||||
|
const scos: Record<number, AddonModScormDataEntry[]> = {};
|
||||||
|
let somethingSynced = false;
|
||||||
|
|
||||||
|
// Get data to send (only elements with dots like cmi.core.exit, in Mobile we store more data to make offline work).
|
||||||
|
tracks.forEach((track) => {
|
||||||
|
if (track.element.indexOf('.') > -1) {
|
||||||
|
if (!scos[track.scoid]) {
|
||||||
|
scos[track.scoid] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
scos[track.scoid].push({
|
||||||
|
element: track.element,
|
||||||
|
value: track.value || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send the data in each SCO.
|
||||||
|
const promises = Object.entries(scos).map(async ([key, tracks]) => {
|
||||||
|
const scoId = Number(key);
|
||||||
|
|
||||||
|
await AddonModScorm.saveTracksOnline(scormId, scoId, attempt, tracks, siteId);
|
||||||
|
|
||||||
|
// Sco data successfully sent. Mark them as synced. This is needed because some SCOs sync might fail.
|
||||||
|
await CoreUtils.ignoreErrors(AddonModScormOffline.markAsSynced(scormId, attempt, scoId, siteId));
|
||||||
|
|
||||||
|
somethingSynced = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await CoreUtils.allPromises(promises);
|
||||||
|
} catch (error) {
|
||||||
|
if (somethingSynced) {
|
||||||
|
// Some SCOs have been synced and some not.
|
||||||
|
// Try to store a snapshot of the current state to be able to re-try the synchronization later.
|
||||||
|
this.logger.error(`Error synchronizing some SCOs for attempt ${attempt} in SCORM ${scormId}. Saving snapshot.`);
|
||||||
|
|
||||||
|
await this.saveSyncSnapshot(scormId, attempt, cmId, siteId);
|
||||||
|
} else {
|
||||||
|
this.logger.error(`Error synchronizing attempt ${attempt} in SCORM ${scormId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt has been sent. Let's delete it from local.
|
||||||
|
await CoreUtils.ignoreErrors(AddonModScormOffline.deleteAttempt(scormId, attempt, siteId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a SCORM only if a certain time has passed since the last time.
|
||||||
|
*
|
||||||
|
* @param scorm SCORM.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved when the SCORM is synced or if it doesn't need to be synced.
|
||||||
|
*/
|
||||||
|
async syncScormIfNeeded(scorm: AddonModScormScorm, siteId?: string): Promise<AddonModScormSyncResult | undefined> {
|
||||||
|
const needed = await this.isSyncNeeded(scorm.id, siteId);
|
||||||
|
|
||||||
|
if (needed) {
|
||||||
|
return this.syncScorm(scorm, siteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to synchronize a SCORM.
|
||||||
|
*
|
||||||
|
* @param scorm SCORM.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved in success.
|
||||||
|
*/
|
||||||
|
syncScorm(scorm: AddonModScormScorm, siteId?: string): Promise<AddonModScormSyncResult> {
|
||||||
|
siteId = siteId || CoreSites.getCurrentSiteId();
|
||||||
|
|
||||||
|
if (this.isSyncing(scorm.id, siteId)) {
|
||||||
|
// There's already a sync ongoing for this SCORM, return the promise.
|
||||||
|
return this.getOngoingSync(scorm.id, siteId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that SCORM isn't blocked.
|
||||||
|
if (CoreSync.isBlocked(AddonModScormProvider.COMPONENT, scorm.id, siteId)) {
|
||||||
|
this.logger.debug('Cannot sync SCORM ' + scorm.id + ' because it is blocked.');
|
||||||
|
|
||||||
|
throw new CoreError(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate }));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`Try to sync SCORM ${scorm.id} in site ${siteId}`);
|
||||||
|
|
||||||
|
const syncPromise = this.performSyncScorm(scorm, siteId);
|
||||||
|
|
||||||
|
return this.addOngoingSync(scorm.id, syncPromise, siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to synchronize a SCORM.
|
||||||
|
*
|
||||||
|
* @param scorm SCORM.
|
||||||
|
* @param siteId Site ID. If not defined, current site.
|
||||||
|
* @return Promise resolved in success.
|
||||||
|
*/
|
||||||
|
protected async performSyncScorm(scorm: AddonModScormScorm, siteId: string): Promise<AddonModScormSyncResult> {
|
||||||
|
let warnings: string[] = [];
|
||||||
|
let lastOnline = 0;
|
||||||
|
let lastOnlineWasFinished = false;
|
||||||
|
|
||||||
|
// Sync offline logs.
|
||||||
|
await CoreUtils.ignoreErrors(CoreCourseLogHelper.syncIfNeeded(AddonModScormProvider.COMPONENT, scorm.id, siteId));
|
||||||
|
|
||||||
|
// Get attempts data. We ignore cache for online attempts, so this call will fail if offline or server down.
|
||||||
|
const attemptsData = await AddonModScorm.getAttemptCount(scorm.id, {
|
||||||
|
cmId: scorm.coursemodule,
|
||||||
|
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
|
||||||
|
siteId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attemptsData.offline || !attemptsData.offline.length) {
|
||||||
|
// Nothing to sync.
|
||||||
|
return this.finishSync(siteId, scorm, warnings, lastOnline, lastOnlineWasFinished);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialCount = attemptsData;
|
||||||
|
const collisions: number[] = [];
|
||||||
|
|
||||||
|
// Check if there are collisions between offline and online attempts (same number).
|
||||||
|
attemptsData.online.forEach((attempt) => {
|
||||||
|
lastOnline = Math.max(lastOnline, attempt);
|
||||||
|
if (attemptsData.offline.indexOf(attempt) > -1) {
|
||||||
|
collisions.push(attempt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if last online attempt is finished. Ignore cache.
|
||||||
|
let incomplete = lastOnline <= 0 ?
|
||||||
|
false :
|
||||||
|
await AddonModScorm.isAttemptIncomplete(scorm.id, lastOnline, {
|
||||||
|
cmId: scorm.coursemodule,
|
||||||
|
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
|
||||||
|
siteId,
|
||||||
|
});
|
||||||
|
|
||||||
|
lastOnlineWasFinished = !incomplete;
|
||||||
|
|
||||||
|
if (!collisions.length) {
|
||||||
|
if (incomplete) {
|
||||||
|
// No collisions, but last online attempt is incomplete so we can't send offline attempts.
|
||||||
|
warnings.push(Translate.instant('addon.mod_scorm.warningsynconlineincomplete'));
|
||||||
|
|
||||||
|
return this.finishSync(siteId, scorm, warnings, lastOnline, lastOnlineWasFinished, initialCount, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No collisions and last attempt is complete. Send offline attempts to Moodle.
|
||||||
|
await Promise.all(attemptsData.offline.map(async (attempt) => {
|
||||||
|
if (!scorm.maxattempt || attempt <= scorm.maxattempt) {
|
||||||
|
await this.syncAttempt(scorm.id, attempt, scorm.coursemodule, siteId);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// All data synced, finish.
|
||||||
|
return this.finishSync(siteId, scorm, warnings, lastOnline, lastOnlineWasFinished, initialCount, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have collisions, treat them.
|
||||||
|
warnings = await this.treatCollisions(scorm.id, collisions, lastOnline, attemptsData.offline, scorm.coursemodule, siteId);
|
||||||
|
|
||||||
|
// The offline attempts might have changed since some collisions can be converted to new attempts.
|
||||||
|
const entries = await AddonModScormOffline.getAttempts(scorm.id, siteId);
|
||||||
|
|
||||||
|
let cannotSyncSome = false;
|
||||||
|
|
||||||
|
// Get only the attempt number.
|
||||||
|
const attempts = entries.map((entry) => entry.attempt);
|
||||||
|
|
||||||
|
if (incomplete && attempts.indexOf(lastOnline) > -1) {
|
||||||
|
// Last online was incomplete, but it was continued in offline.
|
||||||
|
incomplete = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(attempts.map(async (attempt) => {
|
||||||
|
// We'll always sync attempts previous to lastOnline (failed sync or continued in offline).
|
||||||
|
// We'll only sync new attemps if last online attempt is completed.
|
||||||
|
if (!incomplete || attempt <= lastOnline) {
|
||||||
|
if (!scorm.maxattempt || attempt <= scorm.maxattempt) {
|
||||||
|
await this.syncAttempt(scorm.id, attempt, scorm.coursemodule, siteId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cannotSyncSome = true;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (cannotSyncSome) {
|
||||||
|
warnings.push(Translate.instant('addon.mod_scorm.warningsynconlineincomplete'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.finishSync(siteId, scorm, warnings, lastOnline, lastOnlineWasFinished, initialCount, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Treat collisions found in a SCORM synchronization process.
|
||||||
|
*
|
||||||
|
* @param scormId SCORM ID.
|
||||||
|
* @param collisions Numbers of attempts that exist both in online and offline.
|
||||||
|
* @param lastOnline Last online attempt.
|
||||||
|
* @param offlineAttempts Numbers of offline attempts.
|
||||||
|
* @param cmId Module ID.
|
||||||
|
* @param siteId Site ID.
|
||||||
|
* @return Promise resolved when the collisions have been treated. It returns warnings array.
|
||||||
|
* @description
|
||||||
|
*
|
||||||
|
* Treat collisions found in a SCORM synchronization process. A collision is when an attempt exists both in offline
|
||||||
|
* and online. A collision can be:
|
||||||
|
*
|
||||||
|
* - Two different attempts.
|
||||||
|
* - An online attempt continued in offline.
|
||||||
|
* - A failure in a previous sync.
|
||||||
|
*
|
||||||
|
* This function will move into new attempts the collisions that can't be merged. It will usually keep the order of the
|
||||||
|
* offline attempts EXCEPT if the offline attempt was created after the last offline attempt (edge case).
|
||||||
|
*
|
||||||
|
* Edge case: A user creates offline attempts and when he syncs we retrieve an incomplete online attempt, so the offline
|
||||||
|
* attempts cannot be synced. Then the user continues that online attempt and goes offline, so a collision is created.
|
||||||
|
* When we perform the next sync we detect that this collision cannot be merged, so this offline attempt needs to be
|
||||||
|
* created as a new attempt. Since this attempt was created after the last offline attempt, it will be added ot the end
|
||||||
|
* of the list if the last attempt is completed. If the last attempt is not completed then the offline data will de deleted
|
||||||
|
* because we can't create a new attempt.
|
||||||
|
*/
|
||||||
|
protected async treatCollisions(
|
||||||
|
scormId: number,
|
||||||
|
collisions: number[],
|
||||||
|
lastOnline: number,
|
||||||
|
offlineAttempts: number[],
|
||||||
|
cmId: number,
|
||||||
|
siteId: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const newAttemptsSameOrder: number[] = []; // Attempts that will be created as new attempts but keeping the current order.
|
||||||
|
const newAttemptsAtEnd: Record<number, number> = {}; // Attempts that'll be created at the end of list (should be max 1).
|
||||||
|
const lastCollision = Math.max.apply(Math, collisions);
|
||||||
|
let lastOffline = Math.max.apply(Math, offlineAttempts);
|
||||||
|
|
||||||
|
// Get needed data from the last offline attempt.
|
||||||
|
const lastOfflineData = await this.getOfflineAttemptData(scormId, lastOffline, cmId, siteId);
|
||||||
|
|
||||||
|
const promises = collisions.map(async (attempt) => {
|
||||||
|
// First get synced entries to detect if it was a failed synchronization.
|
||||||
|
const synced = await AddonModScormOffline.getScormStoredData(scormId, attempt, false, true, siteId);
|
||||||
|
|
||||||
|
if (synced.length) {
|
||||||
|
// The attempt has synced entries, it seems to be a failed synchronization.
|
||||||
|
// Let's get the entries that haven't been synced, maybe it just failed to delete the attempt.
|
||||||
|
const tracks = await AddonModScormOffline.getScormStoredData(scormId, attempt, true, false, siteId);
|
||||||
|
|
||||||
|
// Check if there are elements to sync.
|
||||||
|
const hasDataToSend = tracks.find(track => track.element.indexOf('.') > -1);
|
||||||
|
|
||||||
|
if (!hasDataToSend) {
|
||||||
|
// Nothing to sync, delete the attempt.
|
||||||
|
return CoreUtils.ignoreErrors(AddonModScormOffline.deleteAttempt(scormId, attempt, siteId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// There are elements to sync. We need to check if it's possible to sync them or not.
|
||||||
|
const canRetry = await this.canRetrySync(scormId, attempt, lastOnline, cmId, siteId);
|
||||||
|
|
||||||
|
if (!canRetry) {
|
||||||
|
// Cannot retry sync, we'll create a new offline attempt if possible.
|
||||||
|
return this.addToNewOrDelete(
|
||||||
|
scormId,
|
||||||
|
attempt,
|
||||||
|
lastOffline,
|
||||||
|
newAttemptsSameOrder,
|
||||||
|
newAttemptsAtEnd,
|
||||||
|
lastOfflineData.timecreated!,
|
||||||
|
lastOfflineData.incomplete,
|
||||||
|
warnings,
|
||||||
|
siteId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// It's not a failed synchronization. Check if it's an attempt continued in offline.
|
||||||
|
const snapshot = await AddonModScormOffline.getAttemptSnapshot(scormId, attempt, siteId);
|
||||||
|
|
||||||
|
if (!snapshot || !Object.keys(snapshot).length) {
|
||||||
|
// No snapshot, it's a different attempt.
|
||||||
|
newAttemptsSameOrder.push(attempt);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// It has a snapshot, it means it continued an online attempt. We need to check if they've diverged.
|
||||||
|
// If it's the last attempt we don't need to ignore cache because we already did it.
|
||||||
|
const refresh = lastOnline != attempt;
|
||||||
|
|
||||||
|
const userData = await AddonModScorm.getScormUserData(scormId, attempt, {
|
||||||
|
cmId,
|
||||||
|
readingStrategy: refresh ? CoreSitesReadingStrategy.OnlyNetwork : undefined,
|
||||||
|
siteId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this.snapshotEquals(snapshot, userData)) {
|
||||||
|
// Snapshot has diverged, it will be converted into a new attempt if possible.
|
||||||
|
return this.addToNewOrDelete(
|
||||||
|
scormId,
|
||||||
|
attempt,
|
||||||
|
lastOffline,
|
||||||
|
newAttemptsSameOrder,
|
||||||
|
newAttemptsAtEnd,
|
||||||
|
lastOfflineData.timecreated!,
|
||||||
|
lastOfflineData.incomplete,
|
||||||
|
warnings,
|
||||||
|
siteId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
await this.moveNewAttempts(scormId, newAttemptsSameOrder, lastOnline, lastCollision, offlineAttempts, siteId);
|
||||||
|
|
||||||
|
// The new attempts that need to keep the order have been created.
|
||||||
|
// Now create the new attempts at the end of the list of offline attempts. It should only be 1 attempt max.
|
||||||
|
lastOffline = lastOffline + newAttemptsSameOrder.length;
|
||||||
|
|
||||||
|
await this.createNewAttemptsAtEnd(scormId, newAttemptsAtEnd, lastOffline, siteId);
|
||||||
|
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddonModScormSync = makeSingleton(AddonModScormSyncProvider);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data returned by a SCORM sync.
|
||||||
|
*/
|
||||||
|
export type AddonModScormSyncResult = {
|
||||||
|
warnings: string[]; // List of warnings.
|
||||||
|
attemptFinished: boolean; // Whether an attempt was finished in the site due to the sync,
|
||||||
|
updated: boolean; // Whether some data was sent to the site.
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto sync event data.
|
||||||
|
*/
|
||||||
|
export type AddonModScormAutoSyncEventData = {
|
||||||
|
scormId: number;
|
||||||
|
attemptFinished: boolean;
|
||||||
|
warnings: string[];
|
||||||
|
updated: boolean;
|
||||||
|
};
|
File diff suppressed because it is too large
Load Diff
|
@ -136,7 +136,7 @@ import { ADDON_MOD_LTI_SERVICES } from '@addons/mod/lti/lti.module';
|
||||||
import { ADDON_MOD_PAGE_SERVICES } from '@addons/mod/page/page.module';
|
import { ADDON_MOD_PAGE_SERVICES } from '@addons/mod/page/page.module';
|
||||||
import { ADDON_MOD_QUIZ_SERVICES } from '@addons/mod/quiz/quiz.module';
|
import { ADDON_MOD_QUIZ_SERVICES } from '@addons/mod/quiz/quiz.module';
|
||||||
import { ADDON_MOD_RESOURCE_SERVICES } from '@addons/mod/resource/resource.module';
|
import { ADDON_MOD_RESOURCE_SERVICES } from '@addons/mod/resource/resource.module';
|
||||||
// @todo import { ADDON_MOD_SCORM_SERVICES } from '@addons/mod/scorm/scorm.module';
|
import { ADDON_MOD_SCORM_SERVICES } from '@addons/mod/scorm/scorm.module';
|
||||||
import { ADDON_MOD_SURVEY_SERVICES } from '@addons/mod/survey/survey.module';
|
import { ADDON_MOD_SURVEY_SERVICES } from '@addons/mod/survey/survey.module';
|
||||||
import { ADDON_MOD_URL_SERVICES } from '@addons/mod/url/url.module';
|
import { ADDON_MOD_URL_SERVICES } from '@addons/mod/url/url.module';
|
||||||
// @todo import { ADDON_MOD_WIKI_SERVICES } from '@addons/mod/wiki/wiki.module';
|
// @todo import { ADDON_MOD_WIKI_SERVICES } from '@addons/mod/wiki/wiki.module';
|
||||||
|
@ -301,7 +301,7 @@ export class CoreCompileProvider {
|
||||||
...ADDON_MOD_PAGE_SERVICES,
|
...ADDON_MOD_PAGE_SERVICES,
|
||||||
...ADDON_MOD_QUIZ_SERVICES,
|
...ADDON_MOD_QUIZ_SERVICES,
|
||||||
...ADDON_MOD_RESOURCE_SERVICES,
|
...ADDON_MOD_RESOURCE_SERVICES,
|
||||||
// @todo ...ADDON_MOD_SCORM_SERVICES,
|
...ADDON_MOD_SCORM_SERVICES,
|
||||||
...ADDON_MOD_SURVEY_SERVICES,
|
...ADDON_MOD_SURVEY_SERVICES,
|
||||||
...ADDON_MOD_URL_SERVICES,
|
...ADDON_MOD_URL_SERVICES,
|
||||||
// @todo ...ADDON_MOD_WIKI_SERVICES,
|
// @todo ...ADDON_MOD_WIKI_SERVICES,
|
||||||
|
|
|
@ -37,6 +37,11 @@ export class CoreCourseLogCronHandlerService implements CoreCronHandler {
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
async execute(siteId?: string, force?: boolean): Promise<void> {
|
async execute(siteId?: string, force?: boolean): Promise<void> {
|
||||||
|
if (!siteId && !CoreSites.isLoggedIn()) {
|
||||||
|
// No current site, stop.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const site = await CoreSites.getSite(siteId);
|
const site = await CoreSites.getSite(siteId);
|
||||||
|
|
||||||
return CoreCourse.logView(site.getSiteHomeId(), undefined, site.getId(), site.getInfo()?.sitename);
|
return CoreCourse.logView(site.getSiteHomeId(), undefined, site.getId(), site.getInfo()?.sitename);
|
||||||
|
|
|
@ -480,3 +480,10 @@ ion-button.core-button-select {
|
||||||
.core-browser-copy-area {
|
.core-browser-copy-area {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Different levels of padding.
|
||||||
|
@for $i from 0 through 15 {
|
||||||
|
.core-padding-#{$i} {
|
||||||
|
@include padding(null, null, null, 15px * $i + 16px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue