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)) { | ||||
|             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); | ||||
|  | ||||
| @ -28,6 +28,7 @@ import { AddonModUrlModule } from './url/url.module'; | ||||
| import { AddonModLtiModule } from './lti/lti.module'; | ||||
| import { AddonModH5PActivityModule } from './h5pactivity/h5pactivity.module'; | ||||
| import { AddonModSurveyModule } from './survey/survey.module'; | ||||
| import { AddonModScormModule } from './scorm/scorm.module'; | ||||
| 
 | ||||
| @NgModule({ | ||||
|     declarations: [], | ||||
| @ -46,6 +47,7 @@ import { AddonModSurveyModule } from './survey/survey.module'; | ||||
|         AddonModLtiModule, | ||||
|         AddonModH5PActivityModule, | ||||
|         AddonModSurveyModule, | ||||
|         AddonModScormModule, | ||||
|     ], | ||||
|     providers: [], | ||||
|     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_QUIZ_SERVICES } from '@addons/mod/quiz/quiz.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_URL_SERVICES } from '@addons/mod/url/url.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_QUIZ_SERVICES, | ||||
|             ...ADDON_MOD_RESOURCE_SERVICES, | ||||
|             // @todo ...ADDON_MOD_SCORM_SERVICES,
 | ||||
|             ...ADDON_MOD_SCORM_SERVICES, | ||||
|             ...ADDON_MOD_SURVEY_SERVICES, | ||||
|             ...ADDON_MOD_URL_SERVICES, | ||||
|             // @todo ...ADDON_MOD_WIKI_SERVICES,
 | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user