forked from CIT/Vmeda.Online
		
	MOBILE-3653 scorm: Implement services
This commit is contained in:
		
							parent
							
								
									6134704fb2
								
							
						
					
					
						commit
						aebc359083
					
				@ -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: [],
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										52
									
								
								src/addons/mod/scorm/lang.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/addons/mod/scorm/lang.json
									
									
									
									
									
										Normal file
									
								
							@ -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."
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										57
									
								
								src/addons/mod/scorm/scorm.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/addons/mod/scorm/scorm.module.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,57 @@
 | 
				
			|||||||
 | 
					// (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 { 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 { CoreCronDelegate } from '@services/cron';
 | 
				
			||||||
 | 
					import { CORE_SITE_SCHEMAS } from '@services/sites';
 | 
				
			||||||
 | 
					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 } from './services/handlers/module';
 | 
				
			||||||
 | 
					import { AddonModScormPrefetchHandler } from './services/handlers/prefetch';
 | 
				
			||||||
 | 
					import { AddonModScormSyncCronHandler } from './services/handlers/sync-cron';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const ADDON_MOD_SCORM_SERVICES: Type<unknown>[] = [
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@NgModule({
 | 
				
			||||||
 | 
					    imports: [
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    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 {}
 | 
				
			||||||
							
								
								
									
										137
									
								
								src/addons/mod/scorm/services/database/scorm.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								src/addons/mod/scorm/services/database/scorm.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										34
									
								
								src/addons/mod/scorm/services/handlers/grade-link.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/addons/mod/scorm/services/handlers/grade-link.ts
									
									
									
									
									
										Normal file
									
								
							@ -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);
 | 
				
			||||||
							
								
								
									
										33
									
								
								src/addons/mod/scorm/services/handlers/index-link.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/addons/mod/scorm/services/handlers/index-link.ts
									
									
									
									
									
										Normal file
									
								
							@ -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);
 | 
				
			||||||
							
								
								
									
										33
									
								
								src/addons/mod/scorm/services/handlers/list-link.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/addons/mod/scorm/services/handlers/list-link.ts
									
									
									
									
									
										Normal file
									
								
							@ -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);
 | 
				
			||||||
							
								
								
									
										83
									
								
								src/addons/mod/scorm/services/handlers/module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								src/addons/mod/scorm/services/handlers/module.ts
									
									
									
									
									
										Normal file
									
								
							@ -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);
 | 
				
			||||||
							
								
								
									
										58
									
								
								src/addons/mod/scorm/services/handlers/pluginfile.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/addons/mod/scorm/services/handlers/pluginfile.ts
									
									
									
									
									
										Normal file
									
								
							@ -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);
 | 
				
			||||||
							
								
								
									
										439
									
								
								src/addons/mod/scorm/services/handlers/prefetch.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										439
									
								
								src/addons/mod/scorm/services/handlers/prefetch.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
				
			||||||
							
								
								
									
										44
									
								
								src/addons/mod/scorm/services/handlers/sync-cron.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/addons/mod/scorm/services/handlers/sync-cron.ts
									
									
									
									
									
										Normal file
									
								
							@ -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);
 | 
				
			||||||
							
								
								
									
										411
									
								
								src/addons/mod/scorm/services/scorm-helper.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										411
									
								
								src/addons/mod/scorm/services/scorm-helper.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										997
									
								
								src/addons/mod/scorm/services/scorm-offline.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										997
									
								
								src/addons/mod/scorm/services/scorm-offline.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										857
									
								
								src/addons/mod/scorm/services/scorm-sync.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										857
									
								
								src/addons/mod/scorm/services/scorm-sync.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										2080
									
								
								src/addons/mod/scorm/services/scorm.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2080
									
								
								src/addons/mod/scorm/services/scorm.ts
									
									
									
									
									
										Normal file
									
								
							
										
											
												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,
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user