From 17ae2556e076c81bba55c2cd75e2a2419d56f0b1 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 23 Apr 2018 13:05:49 +0200 Subject: [PATCH] MOBILE-2350 scorm: Implement SCORM provider and offline --- src/addon/mod/scorm/lang/en.json | 51 + .../mod/scorm/providers/scorm-offline.ts | 920 +++++++++ src/addon/mod/scorm/providers/scorm.ts | 1654 +++++++++++++++++ src/addon/mod/scorm/scorm.module.ts | 29 + src/assets/img/scorm/asset.gif | Bin 0 -> 178 bytes src/assets/img/scorm/browsed.gif | Bin 0 -> 105 bytes src/assets/img/scorm/completed.gif | Bin 0 -> 190 bytes src/assets/img/scorm/failed.gif | Bin 0 -> 190 bytes src/assets/img/scorm/incomplete.gif | Bin 0 -> 597 bytes src/assets/img/scorm/notattempted.gif | Bin 0 -> 79 bytes src/assets/img/scorm/passed.gif | Bin 0 -> 190 bytes src/assets/img/scorm/suspend.gif | Bin 0 -> 362 bytes 12 files changed, 2654 insertions(+) create mode 100644 src/addon/mod/scorm/lang/en.json create mode 100644 src/addon/mod/scorm/providers/scorm-offline.ts create mode 100644 src/addon/mod/scorm/providers/scorm.ts create mode 100644 src/addon/mod/scorm/scorm.module.ts create mode 100644 src/assets/img/scorm/asset.gif create mode 100644 src/assets/img/scorm/browsed.gif create mode 100644 src/assets/img/scorm/completed.gif create mode 100644 src/assets/img/scorm/failed.gif create mode 100644 src/assets/img/scorm/incomplete.gif create mode 100644 src/assets/img/scorm/notattempted.gif create mode 100644 src/assets/img/scorm/passed.gif create mode 100644 src/assets/img/scorm/suspend.gif diff --git a/src/addon/mod/scorm/lang/en.json b/src/addon/mod/scorm/lang/en.json new file mode 100644 index 000000000..038f4b33a --- /dev/null +++ b/src/addon/mod/scorm/lang/en.json @@ -0,0 +1,51 @@ +{ + "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", + "mode": "Mode", + "newattempt": "Start a new attempt", + "noattemptsallowed": "Number of attempts allowed", + "noattemptsmade": "Number of attempts you have made", + "normal": "Normal", + "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", + "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", + "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." +} \ No newline at end of file diff --git a/src/addon/mod/scorm/providers/scorm-offline.ts b/src/addon/mod/scorm/providers/scorm-offline.ts new file mode 100644 index 000000000..98842781b --- /dev/null +++ b/src/addon/mod/scorm/providers/scorm-offline.ts @@ -0,0 +1,920 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { AddonModScormProvider } from './scorm'; +import { SQLiteDB } from '@classes/sqlitedb'; + +/** + * Service to handle offline SCORM. + */ +@Injectable() +export class AddonModScormOfflineProvider { + + protected logger; + + // Variables for database. + protected ATTEMPTS_TABLE = 'addon_mod_scorm_offline_attempts'; + protected TRACKS_TABLE = 'addon_mod_scorm_offline_scos_tracks'; + protected tablesSchema = [ + { + name: this.ATTEMPTS_TABLE, + 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: this.TRACKS_TABLE, + 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'] + } + ]; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private timeUtils: CoreTimeUtilsProvider, + private syncProvider: CoreSyncProvider, private utils: CoreUtilsProvider, private textUtils: CoreTextUtilsProvider, + private userProvider: CoreUserProvider) { + this.logger = logger.getInstance('AddonModScormOfflineProvider'); + + this.sitesProvider.createTablesFromSchema(this.tablesSchema); + } + + /** + * 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 {number} scormId SCORM ID. + * @param {number} attempt Number of the attempt to change. + * @param {number} newAttempt New attempt number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined use site's current user. + * @return {Promise} Promise resolved when the attempt number changes. + */ + changeAttemptNumber(scormId: number, attempt: number, newAttempt: number, siteId?: string, userId?: number): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + 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(); + let newData: any = { + attempt: newAttempt, + timemodified: this.timeUtils.timestamp() + }; + + // Block the SCORM so it can't be synced. + this.syncProvider.blockOperation(AddonModScormProvider.COMPONENT, scormId, 'changeAttemptNumber', site.id); + + return db.updateRecords(this.ATTEMPTS_TABLE, newData, {scormId, userId, attempt}).then(() => { + + // Now update the attempt number of all the tracks and mark them as not synced. + newData = { + attempt: newAttempt, + synced: 0 + }; + + return db.updateRecords(this.TRACKS_TABLE, newData, {scormId, userId, attempt}).catch((error) => { + // Failed to update the tracks, restore the old attempt number. + return db.updateRecords(this.ATTEMPTS_TABLE, { attempt }, {scormId, userId, attempt: newAttempt}).then(() => { + return Promise.reject(error); + }); + }); + }).finally(() => { + // Unblock the SCORM. + this.syncProvider.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 {any} scorm SCORM. + * @param {number} attempt Number of the new attempt. + * @param {any} userData User data to store in the attempt. + * @param {any} [snapshot] Optional. Snapshot to store in the attempt. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined use site's current user. + * @return {Promise} Promise resolved when the new attempt is created. + */ + createNewAttempt(scorm: any, attempt: number, userData: any, snapshot?: any, siteId?: string, userId?: number): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + 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. + this.syncProvider.blockOperation(AddonModScormProvider.COMPONENT, scorm.id, 'createNewAttempt', site.id); + + // Create attempt in DB. + const db = site.getDb(), + entry: any = { + scormId: scorm.id, + userId: userId, + attempt: attempt, + courseId: scorm.course, + timecreated: this.timeUtils.timestamp(), + timemodified: this.timeUtils.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)); + } + + return db.insertRecord(this.ATTEMPTS_TABLE, entry).then(() => { + // Store all the data in userData. + const promises = []; + + for (const key in userData) { + const sco = userData[key], + tracks = []; + + for (const element in sco.userdata) { + tracks.push({element: element, value: sco.userdata[element]}); + } + + promises.push(this.saveTracks(scorm, sco.scoid, attempt, tracks, userData, site.id, userId)); + } + + return Promise.all(promises); + }).finally(() => { + // Unblock the SCORM. + this.syncProvider.unblockOperation(AddonModScormProvider.COMPONENT, scorm.id, 'createNewAttempt', site.id); + }); + }); + } + + /** + * Delete all the stored data from an attempt. + * + * @param {number} scormId SCORM ID. + * @param {number} attempt Attempt number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined use site's current user. + * @return {Promise} Promise resolved when all the data has been deleted. + */ + deleteAttempt(scormId: number, attempt: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + this.logger.debug('Delete offline attempt ' + attempt + ' in SCORM ' + scormId); + + const promises = [], + db = site.getDb(); + + // Delete the attempt. + promises.push(db.deleteRecords(this.ATTEMPTS_TABLE, {scormId, userId, attempt})); + + // Delete all the tracks. + promises.push(db.deleteRecords(this.TRACKS_TABLE, {scormId, userId, attempt})); + + return Promise.all(promises); + }); + } + + /** + * Helper function to return a formatted list of interactions for reports. + * This function is based in Moodle's scorm_format_interactions. + * + * @param {any} scoUserData Userdata from a certain SCO. + * @return {any} Formatted userdata. + */ + protected formatInteractions(scoUserData: any): any { + const formatted: any = {}; + + // 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') { + return; + } + + 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 = this.textUtils.roundToDecimals(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 {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the offline attempts are retrieved. + */ + getAllAttempts(siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.getAllRecords(this.ATTEMPTS_TABLE); + }).then((attempts) => { + attempts.forEach((attempt) => { + attempt.snapshot = this.textUtils.parseJSON(attempt.snapshot); + }); + + return attempts; + }); + } + + /** + * Get an offline attempt. + * + * @param {number} scormId SCORM ID. + * @param {number} attempt Attempt number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined use site's current user. + * @return {Promise} Promise resolved with the attempt. + */ + getAttempt(scormId: number, attempt: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.getDb().getRecord(this.ATTEMPTS_TABLE, {scormId, userId, attempt}).then((entry) => { + entry.snapshot = this.textUtils.parseJSON(entry.snapshot); + + return entry; + }); + }); + } + + /** + * Get the creation time of an attempt. + * + * @param {number} scormId SCORM ID. + * @param {number} attempt Attempt number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined use site's current user. + * @return {Promise} Promise resolved with time the attempt was created. + */ + getAttemptCreationTime(scormId: number, attempt: number, siteId?: string, userId?: number): Promise { + return this.getAttempt(scormId, attempt, siteId, userId).catch(() => { + return {}; // Attempt not found. + }).then((entry) => { + return entry.timecreated; + }); + } + + /** + * Get the offline attempts done by a user in the given SCORM. + * + * @param {number} scormId SCORM ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined use site's current user. + * @return {Promise} Promise resolved when the offline attempts are retrieved. + */ + getAttempts(scormId: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.getDb().getRecords(this.ATTEMPTS_TABLE, {scormId, userId}); + }).then((attempts) => { + attempts.forEach((attempt) => { + attempt.snapshot = this.textUtils.parseJSON(attempt.snapshot); + }); + + return attempts; + }); + } + + /** + * Get the snapshot of an attempt. + * + * @param {number} scormId SCORM ID. + * @param {number} attempt Attempt number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined use site's current user. + * @return {Promise} Promise resolved with the snapshot or undefined if no snapshot. + */ + getAttemptSnapshot(scormId: number, attempt: number, siteId?: string, userId?: number): Promise { + return this.getAttempt(scormId, attempt, siteId, userId).catch(() => { + return {}; // Attempt not found. + }).then((entry) => { + return entry.snapshot; + }); + } + + /** + * Get launch URLs from a list of SCOs, indexing them by SCO ID. + * + * @param {any[]} scos List of SCOs. Each SCO needs to have 'id' and 'launch' properties. + * @return {{[scoId: number]: string}} Launch URLs indexed by SCO ID. + */ + protected getLaunchUrlsFromScos(scos: any[]): {[scoId: number]: string} { + const response = {}; + + scos.forEach((sco) => { + response[sco.id] = sco.launch; + }); + + return response; + } + + /** + * Get data stored in local DB for a certain scorm and attempt. + * + * @param {number} scormId SCORM ID. + * @param {number} attempt Attempt number. + * @param {boolean} [excludeSynced] Whether it should only return not synced entries. + * @param {boolean} [excludeNotSynced] Whether it should only return synced entries. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined use site's current user. + * @return {Promise} Promise resolved with the entries. + */ + getScormStoredData(scormId: number, attempt: number, excludeSynced?: boolean, excludeNotSynced?: boolean, siteId?: string, + userId?: number): Promise { + + if (excludeSynced && excludeNotSynced) { + return Promise.resolve([]); + } + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + const conditions: any = { + scormId: scormId, + userId: userId, + attempt: attempt + }; + + if (excludeSynced) { + conditions.synced = 0; + } else if (excludeNotSynced) { + conditions.synced = 1; + } + + return site.getDb().getRecords(this.TRACKS_TABLE, conditions); + }).then((tracks) => { + tracks.forEach((track) => { + track.value = this.textUtils.parseJSON(track.value); + }); + + return tracks; + }); + } + + /** + * Get the user data for a certain SCORM and offline attempt. + * + * @param {number} scormId SCORM ID. + * @param {number} attempt Attempt number. + * @param {any[]} scos SCOs returned by AddonModScormProvider.getScos. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined use site's current user. + * @return {Promise} Promise resolved when the user data is retrieved. + */ + getScormUserData(scormId: number, attempt: number, scos: any[], siteId?: string, userId?: number): Promise { + let fullName = '', + userName = ''; + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + // Get username and fullname. + if (userId == site.getUserId()) { + fullName = site.getInfo().fullname; + userName = site.getInfo().username; + } else { + return this.userProvider.getProfile(userId).then((profile) => { + fullName = profile.fullname; + userName = profile.username || ''; + }).catch(() => { + // Ignore errors. + }); + } + }).then(() => { + + // Get user data. Ordering when using a compound index is complex, so we won't order by scoid. + return this.getScormStoredData(scormId, attempt, false, false, siteId, userId).then((entries) => { + const response = {}, + 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 + } + }; + } + + response[scoId].userdata[entry.element] = entry.value; + if (entry.timemodified > 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: '' + } + }; + } + }); + + // 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 {number} scormId SCORM ID. + * @param {number} scoId SCO ID. + * @param {number} attempt Attempt number. + * @param {string} element Name of the element to insert. + * @param {any} value Value to insert. + * @param {boolean} [forceCompleted] True if SCORM forces completed. + * @param {any} [scoData] User data for the given SCO. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not set use site's current user. + * @return {Promise} Promise resolved when the insert is done. + */ + protected insertTrack(scormId: number, scoId: number, attempt: number, element: string, value: any, forceCompleted?: boolean, + scoData?: any, siteId?: string, userId?: number): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + scoData = scoData || {}; + + const promises = [], // List of promises for actions previous to the real insert. + scoUserData = scoData.userdata || {}, + 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; + + promises.push(this.insertTrackToDB(db, userId, scormId, scoId, attempt, 'cmi.core.lesson_status', + 'completed')); + } + } + } + + return Promise.all(promises).then(() => { + // Don't update x.start.time, keep the original value. + if (!scoUserData[element] || element != 'x.start.time') { + let promise = > this.insertTrackToDB(db, userId, scormId, scoId, attempt, element, value); + + return promise.catch((error) => { + if (lessonStatusInserted) { + // Rollback previous insert. + promise = > this.insertTrackToDB(db, userId, scormId, scoId, attempt, + 'cmi.core.lesson_status', 'incomplete'); + + return promise.then(() => { + return Promise.reject(error); + }); + } + + return Promise.reject(null); + }); + } + }); + }); + } + + /** + * Insert a track in the DB. + * + * @param {SQLiteDB} db Site's DB. + * @param {number} userId User ID. + * @param {number} scormId SCORM ID. + * @param {number} scoId SCO ID. + * @param {number} attempt Attempt number. + * @param {string} element Name of the element to insert. + * @param {any} value Value of the element to insert. + * @param {boolean} synchronous True if insert should NOT return a promise. Please use it only if synchronous is a must. + * @return {boolean|Promise} 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: any, synchronous?: boolean): boolean | Promise { + + const entry = { + userId: userId, + scormId: scormId, + scoId: scoId, + attempt: attempt, + element: element, + value: typeof value == 'undefined' ? null : JSON.stringify(value), + timemodified: this.timeUtils.timestamp(), + synced: 0 + }; + + if (synchronous) { + // The insert operation is always asynchronous, always return true. + db.insertRecord(this.TRACKS_TABLE, entry); + + return true; + } else { + return db.insertRecord(this.TRACKS_TABLE, 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 {number} scormId SCORM ID. + * @param {number} scoId SCO ID. + * @param {number} attempt Attempt number. + * @param {string} element Name of the element to insert. + * @param {any} value Value of the element to insert. + * @param {boolean} [forceCompleted] True if SCORM forces completed. + * @param {any} [scoData] User data for the given SCO. + * @param {number} [userId] User ID. If not set use current user. + * @return {boolean} Promise resolved when the insert is done. + */ + protected insertTrackSync(scormId: number, scoId: number, attempt: number, element: string, value: any, + forceCompleted?: boolean, scoData?: any, userId?: number): boolean { + scoData = scoData || {}; + userId = userId || this.sitesProvider.getCurrentSiteUserId(); + + if (!this.sitesProvider.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 || {}, + db = this.sitesProvider.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; + } + } + } + } + + // Don't update x.start.time, keep the original value. + if (!scoUserData[element] || element != 'x.start.time') { + 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 {number} scormId SCORM ID. + * @param {number} attempt Attempt number. + * @param {number} scoId SCO ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined use site's current user. + * @return {Promise} Promise resolved when marked. + */ + markAsSynced(scormId: number, attempt: number, scoId: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + this.logger.debug('Mark SCO ' + scoId + ' as synced for attempt ' + attempt + ' in SCORM ' + scormId); + + return site.getDb().updateRecords(this.TRACKS_TABLE, {synced: 1}, { + scormId: scormId, + userId: userId, + attempt: attempt, + scoId: scoId, + synced: 0 + }); + }); + } + + /** + * Removes the default data form user data. + * + * @param {any} userData User data returned by AddonModScormProvider.getScormUserData. + * @return {any} User data without default data. + */ + protected removeDefaultData(userData: any): any { + const result = this.utils.clone(userData); + + for (const key in result) { + delete result[key].defaultdata; + } + + return result; + } + + /** + * Saves a SCORM tracking record in offline. + * + * @param {any} scorm SCORM. + * @param {number} scoId Sco ID. + * @param {number} attempt Attempt number. + * @param {any[]} tracks Tracking data to store. + * @param {any} userData User data for this attempt and SCO. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined use site's current user. + * @return {Promise} Promise resolved when data is saved. + */ + saveTracks(scorm: any, scoId: number, attempt: number, tracks: any[], userData: any, siteId?: string, userId?: number) + : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + // Block the SCORM so it can't be synced. + this.syncProvider.blockOperation(AddonModScormProvider.COMPONENT, scorm.id, 'saveTracksOffline', siteId); + + // Insert all the tracks. + const promises = []; + tracks.forEach((track) => { + promises.push(this.insertTrack(scorm.id, scoId, attempt, track.element, track.value, scorm.forcecompleted, + userData[scoId], siteId, userId)); + }); + + return Promise.all(promises).finally(() => { + // Unblock the SCORM operation. + this.syncProvider.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 {any} scorm SCORM. + * @param {number} scoId Sco ID. + * @param {number} attempt Attempt number. + * @param {Object[]} tracks Tracking data to store. + * @param {any} userData User data for this attempt and SCO. + * @return {boolean} 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: any, scoId: number, attempt: number, tracks: any[], userData: any, userId?: number): boolean { + userId = userId || this.sitesProvider.getCurrentSiteUserId(); + let success = true; + + tracks.forEach((track) => { + if (!this.insertTrackSync(scorm.id, scoId, attempt, track.element, track.value, scorm.forcecompleted, userData[scoId], + userId)) { + success = false; + } + }); + + 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 {any} userData Contains user's data. + * @param {string} param Name of parameter that should be checked. + * @param {any} [ifEmpty] Value to be replaced with if param is not set. + * @return {any} Value from userData[param] if set, ifEmpty otherwise. + */ + protected scormIsset(userData: any, param: string, ifEmpty: any = ''): any { + if (typeof userData[param] != 'undefined') { + return userData[param]; + } + + return ifEmpty; + } + + /** + * Set an attempt's snapshot. + * + * @param {number} scormId SCORM ID. + * @param {number} attempt Attempt number. + * @param {any} userData User data to store as snapshot. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined use site's current user. + * @return {Promise} Promise resolved when snapshot has been stored. + */ + setAttemptSnapshot(scormId: number, attempt: number, userData: any, siteId?: string, userId?: number): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + this.logger.debug('Set snapshot for attempt ' + attempt + ' in SCORM ' + scormId); + + const newData = { + timemodified: this.timeUtils.timestamp(), + snapshot: JSON.stringify(this.removeDefaultData(userData)) + }; + + return site.getDb().updateRecords(this.ATTEMPTS_TABLE, newData, { scormId, userId, attempt }); + }); + } +} diff --git a/src/addon/mod/scorm/providers/scorm.ts b/src/addon/mod/scorm/providers/scorm.ts new file mode 100644 index 000000000..3b6a41dbe --- /dev/null +++ b/src/addon/mod/scorm/providers/scorm.ts @@ -0,0 +1,1654 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { TranslateService } from '@ngx-translate/core'; +import { CoreFilepoolProvider } from '@providers/filepool'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreWSProvider } from '@providers/ws'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { AddonModScormOfflineProvider } from './scorm-offline'; +import { CoreSiteWSPreSets } from '@classes/site'; +import { CoreConstants } from '@core/constants'; + +/** + * Result of getAttemptCount. + */ +export interface AddonModScormAttemptCountResult { + /** + * List of online attempts numbers. + * @type {number[]} + */ + online?: number[]; + + /** + * List of offline attempts numbers. + * @type {number[]} + */ + offline?: number[]; + + /** + * Total of unique attempts. + * @type {number} + */ + total?: number; + + /** + * Last attempt in the SCORM: the number and whether it's offline. + * @type {{number: number, offline: boolean}} + */ + lastAttempt?: {number: number, offline: boolean}; +} + +/** + * Service that provides some features for SCORM. + */ +@Injectable() +export class AddonModScormProvider { + static COMPONENT = 'mmaModScorm'; + + // Public constants. + static GRADESCOES = 0; + static GRADEHIGHEST = 1; + static GRADEAVERAGE = 2; + static GRADESUM = 3; + + static HIGHESTATTEMPT = 0; + static AVERAGEATTEMPT = 1; + static FIRSTATTEMPT = 2; + static LASTATTEMPT = 3; + + static MODEBROWSE = 'browse'; + static MODENORMAL = 'normal'; + static MODEREVIEW = 'review'; + + // Protected constants. + protected VALID_STATUSES = ['notattempted', 'passed', 'completed', 'failed', 'incomplete', 'browsed', 'suspend']; + protected STATUSES = { + 'passed': 'passed', + 'completed': 'completed', + 'failed': 'failed', + 'incomplete': 'incomplete', + 'browsed': 'browsed', + 'not attempted': 'notattempted', + 'p': 'passed', + 'c': 'completed', + 'f': 'failed', + 'i': 'incomplete', + 'b': 'browsed', + 'n': 'notattempted' + }; + + protected ROOT_CACHE_KEY = 'mmaModScorm:'; + protected logger; + + constructor(logger: CoreLoggerProvider, private translate: TranslateService, private sitesProvider: CoreSitesProvider, + private wsProvider: CoreWSProvider, private textUtils: CoreTextUtilsProvider, private utils: CoreUtilsProvider, + private filepoolProvider: CoreFilepoolProvider, private scormOfflineProvider: AddonModScormOfflineProvider, + private timeUtils: CoreTimeUtilsProvider, private syncProvider: CoreSyncProvider) { + this.logger = logger.getInstance('AddonModScormProvider'); + } + + /** + * Calculates the SCORM grade based on the grading method and the list of attempts scores. + * We only treat online attempts to calculate a SCORM grade. + * + * @param {any} scorm SCORM. + * @param {any} onlineAttempts Object with the online attempts. Each attempt must have a property called "grade". + * @return {number} Grade. -1 if no grade. + */ + calculateScormGrade(scorm: any, onlineAttempts: any): number { + if (!onlineAttempts || !Object.keys(onlineAttempts).length) { + return -1; + } + + switch (scorm.whatgrade) { + case AddonModScormProvider.FIRSTATTEMPT: + return onlineAttempts[1] ? onlineAttempts[1].grade : -1; + + case AddonModScormProvider.LASTATTEMPT: + // Search the last attempt number. + let max = 0; + Object.keys(onlineAttempts).forEach((attemptNumber) => { + max = Math.max(Number(attemptNumber), max); + }); + + if (max > 0) { + return onlineAttempts[max].grade; + } + + return -1; + + case AddonModScormProvider.HIGHESTATTEMPT: + // Search the highest grade. + let grade = 0; + for (const attemptNumber in onlineAttempts) { + grade = Math.max(onlineAttempts[attemptNumber].grade, grade); + } + + return grade; + + case AddonModScormProvider.AVERAGEATTEMPT: + // Calculate the average. + let sumGrades = 0, + total = 0; + + for (const attemptNumber in onlineAttempts) { + sumGrades += onlineAttempts[attemptNumber].grade; + total++; + } + + return Math.round(sumGrades / total); + + default: + return -1; + } + } + + /** + * Calculates the size of a SCORM. + * + * @param {any} scorm SCORM. + * @return {Promise} Promise resolved with the SCORM size. + */ + calculateScormSize(scorm: any): Promise { + if (scorm.packagesize) { + return Promise.resolve(scorm.packagesize); + } + + return this.wsProvider.getRemoteFileSize(this.getPackageUrl(scorm)); + } + + /** + * Count the attempts left for the given scorm. + * + * @param {any} scorm SCORM. + * @param {number} attemptsCount Number of attempts performed. + * @return {number} Number of attempts left. + */ + countAttemptsLeft(scorm: any, attemptsCount: number): number { + if (scorm.maxattempt == 0) { + return Number.MAX_VALUE; // Unlimited attempts. + } + + attemptsCount = Number(attemptsCount); // Make sure it's a number. + if (isNaN(attemptsCount)) { + return -1; + } + + return scorm.maxattempt - attemptsCount; + } + + /** + * Returns the mode and attempt number to use based on mode selected and SCORM data. + * This function is based on Moodle's scorm_check_mode. + * + * @param {any} scorm SCORM. + * @param {string} mode Selected mode. + * @param {number} attempt Current attempt. + * @param {boolean} [newAttempt] Whether it should start a new attempt. + * @param {boolean} [incomplete] Whether current attempt is incomplete. + * @return {{mode: string, attempt: number, newAttempt: boolean}} Mode, attempt number and whether to start a new attempt. + */ + determineAttemptAndMode(scorm: any, mode: string, attempt: number, newAttempt?: boolean, incomplete?: boolean) + : {mode: string, attempt: number, newAttempt: boolean} { + + if (mode == AddonModScormProvider.MODEBROWSE) { + if (scorm.hidebrowse) { + // Prevent Browse mode if hidebrowse is set. + mode = AddonModScormProvider.MODENORMAL; + } else { + // We don't need to check attempts as browse mode is set. + if (attempt == 0) { + attempt = 1; + newAttempt = true; + } + + return { + mode: mode, + attempt: attempt, + newAttempt: newAttempt + }; + } + } + + // Validate user request to start a new attempt. + if (attempt == 0) { + newAttempt = true; + } else if (incomplete) { + // The option to start a new attempt should never have been presented. Force false. + newAttempt = false; + } else if (scorm.forcenewattempt) { + // A new attempt should be forced for already completed attempts. + newAttempt = true; + } + + if (newAttempt && (scorm.maxattempt == 0 || attempt < scorm.maxattempt)) { + // Create a new attempt. Force mode normal. + attempt++; + mode = AddonModScormProvider.MODENORMAL; + } else { + if (incomplete) { + // We can't review an incomplete attempt. + mode = AddonModScormProvider.MODENORMAL; + } else { + // We aren't starting a new attempt and the current one is complete, force review mode. + mode = AddonModScormProvider.MODEREVIEW; + } + } + + return { + mode: mode, + attempt: attempt, + newAttempt: newAttempt + }; + } + + /** + * Check if TOC should be displayed in the player. + * + * @param {any} scorm SCORM. + * @return {boolean} Whether it should display TOC. + */ + displayTocInPlayer(scorm: any): boolean { + return scorm.hidetoc !== 3; + } + + /** + * This is a little language parser for AICC_SCRIPT. + * Evaluates the expression and returns a boolean answer. + * See 2.3.2.5.1. Sequencing/Navigation Today - from the SCORM 1.2 spec (CAM). + * + * @param {string} prerequisites The AICC_SCRIPT prerequisites expression. + * @param {any} trackData The tracked user data of each SCO. + * @return {boolean} Whether the prerequisites are fulfilled. + */ + evalPrerequisites(prerequisites: string, trackData: any): boolean { + + const stack = []; // List of prerequisites. + + // Expand the amp entities. + prerequisites = prerequisites.replace(/&/gi, '&'); + // Find all my parsable tokens. + prerequisites = prerequisites.replace(/(&|\||\(|\)|\~)/gi, '\t$1\t'); + // Expand operators. + prerequisites = prerequisites.replace(/&/gi, '&&'); + prerequisites = prerequisites.replace(/\|/gi, '||'); + + // Now - grab all the tokens. + const elements = prerequisites.trim().split('\t'); + + // Process each token to build an expression to be evaluated. + elements.forEach((element) => { + element = element.trim(); + if (!element) { + return; + } + + if (!element.match(/^(&&|\|\||\(|\))$/gi)) { + // Create each individual expression. + // Search for ~ = <> X*{} . + + const re = /^(\d+)\*\{(.+)\}$/, // Sets like 3*{S34, S36, S37, S39}. + reOther = /^(.+)(\=|\<\>)(.+)$/; // Other symbols. + let matches; + + if (re.test(element)) { + matches = element.match(re); + + const repeat = matches[1], + set = matches[2].split(','); + let count = 0; + + set.forEach((setElement) => { + setElement = setElement.trim(); + + if (typeof trackData[setElement] != 'undefined' && + (trackData[setElement].status == 'completed' || trackData[setElement].status == 'passed')) { + count++; + } + }); + + if (count >= repeat) { + element = 'true'; + } else { + element = 'false'; + } + } else if (element == '~') { + // Not maps ~. + element = '!'; + } else if (reOther.test(element)) { + // Other symbols = | <> . + matches = element.match(reOther); + element = matches[1].trim(); + + if (typeof trackData[element] != 'undefined') { + let value = matches[3].trim().replace(/(\'|\")/gi), + oper; + + if (typeof this.STATUSES[value] != 'undefined') { + value = this.STATUSES[value]; + } + + if (matches[2] == '<>') { + oper = '!='; + } else { + oper = '=='; + } + + element = '(\'' + trackData[element].status + '\' ' + oper + ' \'' + value + '\')'; + } else { + element = 'false'; + } + } else { + // Everything else must be an element defined like S45 ... + if (typeof trackData[element] != 'undefined' && + (trackData[element].status == 'completed' || trackData[element].status == 'passed')) { + element = 'true'; + } else { + element = 'false'; + } + } + } + + // Add the element to the list of prerequisites. + stack.push(' ' + element + ' '); + }); + + // tslint:disable: no-eval + return eval(stack.join('') + ';'); + } + + /** + * Formats a grade to be displayed. + * + * @param {any} scorm SCORM. + * @param {number} grade Grade. + * @return {string} Grade to display. + */ + formatGrade(scorm: any, grade: number): string { + if (typeof grade == 'undefined' || grade == -1) { + return this.translate.instant('core.none'); + } + + if (scorm.grademethod !== AddonModScormProvider.GRADESCOES && scorm.maxgrade > 0) { + grade = (grade / scorm.maxgrade) * 100; + + return this.translate.instant('core.percentagenumber', {$a: this.textUtils.roundToDecimals(grade, 2)}); + } + + return String(grade); + } + + /** + * Formats a tree-like TOC into an array. + * + * @param {any[]} toc SCORM's TOC (tree format). + * @param {number} [level=0] The level of the TOC we're right now. 0 by default. + * @return {any[]} SCORM's TOC (array format). + */ + formatTocToArray(toc: any[], level: number = 0): any[] { + if (!toc || !toc.length) { + return []; + } + + let formatted = []; + + toc.forEach((node) => { + node.level = level; + formatted.push(node); + + formatted = formatted.concat(this.formatTocToArray(node.children, level + 1)); + }); + + return formatted; + } + + /** + * Get the number of attempts done by a user in the given SCORM. + * + * @param {number} scormId SCORM ID. + * @param {boolean} [ignoreMissing] Whether it should ignore attempts without grade/completion. Only for online attempts. + * @param {boolean} [ignoreCache] Whether it should ignore cached data for online attempts. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined use site's current user. + * @return {Promise} Promise resolved when done. + */ + getAttemptCount(scormId: number, ignoreMissing?: boolean, ignoreCache?: boolean, siteId?: string, userId?: number) + : Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + const result: AddonModScormAttemptCountResult = { + lastAttempt: { + number: 0, + offline: false + } + }, + promises = []; + + promises.push(this.getAttemptCountOnline(scormId, ignoreMissing, ignoreCache, siteId, userId).then((count) => { + + // Calculate numbers of online attempts. + result.online = []; + + for (let i = 1; i <= count; i++) { + result.online.push(i); + } + + // Calculate last attempt. + if (count > result.lastAttempt.number) { + result.lastAttempt.number = count; + result.lastAttempt.offline = false; + } + })); + + promises.push(this.scormOfflineProvider.getAttempts(scormId, siteId, userId).then((attempts) => { + // Get only attempt numbers. + result.offline = attempts.map((entry) => { + // Calculate last attempt. We use >= to prioritize offline events if an attempt is both online and offline. + if (entry.attempt >= result.lastAttempt.number) { + result.lastAttempt.number = entry.attempt; + result.lastAttempt.offline = true; + } + + return entry.attempt; + }); + })); + + return Promise.all(promises).then(() => { + let total = result.online.length; + + result.offline.forEach((attempt) => { + // Check if this attempt also exists in online, it might have been copied to local. + if (result.online.indexOf(attempt) == -1) { + total++; + } + }); + + result.total = total; + + return result; + }); + }); + } + + /** + * Get cache key for SCORM attempt count WS calls. + * + * @param {number} scormId SCORM ID. + * @param {number} [userId] User ID. If not defined, current user. + * @return {string} Cache key. + */ + protected getAttemptCountCacheKey(scormId: number, userId: number): string { + return this.ROOT_CACHE_KEY + 'attemptcount:' + scormId + ':' + userId; + } + + /** + * Get the number of attempts done by a user in the given SCORM in online. + * + * @param {number} scormId SCORM ID. + * @param {boolean} [ignoreMissing] Whether it should ignore attempts that haven't reported a grade/completion. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined use site's current user. + * @return {Promise} Promise resolved when the attempt count is retrieved. + */ + getAttemptCountOnline(scormId: number, ignoreMissing?: boolean, ignoreCache?: boolean, siteId?: string, userId?: number) + : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + const params = { + scormid: scormId, + userid: userId, + ignoremissingcompletion: ignoreMissing ? 1 : 0 + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getAttemptCountCacheKey(scormId, userId) + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_scorm_get_scorm_attempt_count', params, preSets).then((response) => { + if (response && typeof response.attemptscount != 'undefined') { + return response.attemptscount; + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get the grade for a certain SCORM and attempt. + * Based on Moodle's scorm_grade_user_attempt. + * + * @param {any} scorm SCORM. + * @param {number} attempt Attempt number. + * @param {boolean} [offline] Whether the attempt is offline. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the grade. If the attempt hasn't reported grade/completion, it will be -1. + */ + getAttemptGrade(scorm: any, attempt: number, offline?: boolean, siteId?: string): Promise { + const attemptScore = { + scos: 0, + values: 0, + max: 0, + sum: 0 + }; + + // Get the user data and use it to calculate the grade. + return this.getScormUserData(scorm.id, attempt, undefined, offline, false, siteId).then((data) => { + for (const scoId in data) { + const sco = data[scoId], + userData = sco.userdata; + + if (userData.status == 'completed' || userData.status == 'passed') { + attemptScore.scos++; + } + + if (userData.score_raw || (typeof scorm.scormtype != 'undefined' && + scorm.scormtype == 'sco' && typeof userData.score_raw != 'undefined')) { + + const scoreRaw = parseFloat(userData.score_raw); + attemptScore.values++; + attemptScore.sum += scoreRaw; + attemptScore.max = Math.max(scoreRaw, attemptScore.max); + } + } + + let score = 0; + + switch (scorm.grademethod) { + case AddonModScormProvider.GRADEHIGHEST: + score = attemptScore.max; + break; + + case AddonModScormProvider.GRADEAVERAGE: + if (attemptScore.values > 0) { + score = attemptScore.sum / attemptScore.values; + } else { + score = 0; + } + break; + + case AddonModScormProvider.GRADESUM: + score = attemptScore.sum; + break; + + case AddonModScormProvider.GRADESCOES: + score = attemptScore.scos; + break; + + default: + score = attemptScore.max; // Remote Learner GRADEHIGHEST is default. + } + + return score; + }); + } + + /** + * Get the list of a organizations defined in a SCORM package. + * + * @param {number} scormId SCORM ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the list of organizations. + */ + getOrganizations(scormId: number, siteId?: string): Promise { + return this.getScos(scormId, undefined, false, siteId).then((scos) => { + const organizations = []; + + scos.forEach((sco) => { + // Is an organization entry? + if (sco.organization == '' && sco.parent == '/' && sco.scormtype == '') { + organizations.push({ + identifier: sco.identifier, + title: sco.title, + sortorder: sco.sortorder + }); + } + }); + + return organizations; + }); + } + + /** + * Get the organization Toc any + * + * @param {number} scormId SCORM ID. + * @param {number} attempt The attempt number (to populate SCO track data). + * @param {string} [organization] Organization identifier. + * @param {boolean} [offline] Whether the attempt is offline. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the toc object. + */ + getOrganizationToc(scormId: number, attempt: number, organization?: string, offline?: boolean, siteId?: string) + : Promise { + + return this.getScosWithData(scormId, attempt, organization, offline, false, siteId).then((scos) => { + const map = {}, + rootScos = []; + + scos.forEach((sco, index) => { + sco.children = []; + map[sco.identifier] = index; + + if (sco.parent !== '/') { + if (sco.parent == organization) { + // It's a root SCO, add it to the root array. + rootScos.push(sco); + } else { + // Add this sco to the parent. + scos[map[sco.parent]].children.push(sco); + } + } + }); + + return rootScos; + }); + } + + /** + * Get the package URL of a given SCORM. + * + * @param {any} scorm SCORM. + * @return {string} Package URL. + */ + getPackageUrl(scorm: any): string { + if (scorm.packageurl) { + return scorm.packageurl; + } + if (scorm.reference) { + return scorm.reference; + } + + return ''; + } + + /** + * Get the user data for a certain SCORM and attempt. + * + * @param {number} scormId SCORM ID. + * @param {number} attempt Attempt number. + * @param {any[]} [scos] SCOs returned by getScos. Recommended if offline=true. + * @param {boolean} [offline] Whether the attempt is offline. + * @param {boolean} [ignoreCache] Whether it should ignore cached data for online attempts. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the user data is retrieved. + */ + getScormUserData(scormId: number, attempt: number, scos?: any[], offline?: boolean, ignoreCache?: boolean, siteId?: string) + : Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (offline) { + // Get SCOs if not provided. + const promise = scos ? Promise.resolve(scos) : this.getScos(scormId, undefined, undefined, siteId); + + return promise.then((scos) => { + return this.scormOfflineProvider.getScormUserData(scormId, attempt, scos, siteId); + }); + } else { + return this.getScormUserDataOnline(scormId, attempt, ignoreCache, siteId); + } + } + + /** + * Get cache key for SCORM user data WS calls. + * + * @param {number} scormId SCORM ID. + * @param {number} attempt Attempt number. + * @return {string} Cache key. + */ + protected getScormUserDataCacheKey(scormId: number, attempt: number): string { + return this.getScormUserDataCommonCacheKey(scormId) + ':' + attempt; + } + + /** + * Get common cache key for SCORM user data WS calls. + * + * @param {number} scormId SCORM ID. + * @return {string} Cache key. + */ + protected getScormUserDataCommonCacheKey(scormId: number): string { + return this.ROOT_CACHE_KEY + 'userdata:' + scormId; + } + + /** + * Get the user data for a certain SCORM and attempt in online. + * + * @param {number} scormId SCORM ID. + * @param {number} attempt Attempt number. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the user data is retrieved. + */ + getScormUserDataOnline(scormId: number, attempt: number, ignoreCache?: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + scormid: scormId, + attempt: attempt + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getScormUserDataCacheKey(scormId, attempt) + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_scorm_get_scorm_user_data', params, preSets).then((response) => { + if (response && response.data) { + // Format the response. + const data = {}; + + response.data.forEach((sco) => { + sco.defaultdata = this.utils.objectToKeyValueMap(sco.defaultdata, 'element', 'value'); + sco.userdata = this.utils.objectToKeyValueMap(sco.userdata, 'element', 'value'); + + data[sco.scoid] = sco; + }); + + return data; + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get cache key for get SCORM scos WS calls. + * + * @param {number} scormId SCORM ID. + * @return {string} Cache key. + */ + protected getScosCacheKey(scormId: number): string { + return this.ROOT_CACHE_KEY + 'scos:' + scormId; + } + + /** + * Retrieves the list of SCO objects for a given SCORM and organization. + * + * @param {number} scormId SCORM ID. + * @param {string} [organization] Organization. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail if offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.sitesProvider.getSite(siteId).then((site) => { + + // Don't send the organization to the WS, we'll filter them locally. + const params = { + scormid: scormId + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getScosCacheKey(scormId) + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_scorm_get_scorm_scoes', params, preSets).then((response) => { + + if (response && response.scoes) { + if (organization) { + // Filter SCOs by organization. + return response.scoes.filter((sco) => { + return sco.organization == organization; + }); + } else { + return response.scoes; + } + } + + return Promise.reject(null); + }); + }); + } + + /** + * Retrieves the list of SCO objects for a given SCORM and organization, including data about + * a certain attempt (status, isvisible, ...). + * + * @param {number} scormId SCORM ID. + * @param {number} attempt Attempt number. + * @param {string} [organization] Organization ID. + * @param {boolean} [offline] Whether the attempt is offline. + * @param {boolean} [ignoreCache] Whether it should ignore cached data for online attempts. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with a list of SCO objects. + */ + getScosWithData(scormId: number, attempt: number, organization?: string, offline?: boolean, ignoreCache?: boolean, + siteId?: string): Promise { + + // Get organization SCOs. + return this.getScos(scormId, organization, ignoreCache, siteId).then((scos) => { + // Get the track data for all the SCOs in the organization for the given attempt. + // We'll use this data to set SCO data like isvisible, status and so. + return this.getScormUserData(scormId, attempt, scos, offline, ignoreCache, siteId).then((data) => { + + const trackDataBySCO = {}; + + // First populate trackDataBySCO to index by SCO identifier. + // We want the full list first because it's needed by evalPrerequisites. + scos.forEach((sco) => { + trackDataBySCO[sco.identifier] = data[sco.id].userdata; + }); + + scos.forEach((sco) => { + // Add specific SCO information (related to tracked data). + const scoData = data[sco.id].userdata; + + if (!scoData) { + return; + } + + // Check isvisible attribute. + sco.isvisible = typeof scoData.isvisible == 'undefined' || (scoData.isvisible && scoData.isvisible !== 'false'); + // Check pre-requisites status. + sco.prereq = typeof scoData.prerequisites == 'undefined' || + this.evalPrerequisites(scoData.prerequisites, trackDataBySCO); + // Add status. + sco.status = (typeof scoData.status == 'undefined' || scoData.status === '') ? + 'notattempted' : scoData.status; + // Exit var. + sco.exitvar = typeof scoData.exitvar == 'undefined' ? 'cmi.core.exit' : scoData.exitvar; + sco.exitvalue = scoData[sco.exitvar]; + }); + + return scos; + }); + }); + } + + /** + * Given a SCORM and a SCO, returns the full launch URL for the SCO. + * + * @param {any} scorm SCORM. + * @param {any} sco SCO. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the URL. + */ + getScoSrc(scorm: any, sco: any, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Build the launch URL. Moodle web checks SCORM version, we don't need to, it's always SCORM 1.2. + let launchUrl = sco.launch, + parameters; + + if (sco.extradata && sco.extradata.length) { + for (let i = 0; i < sco.extradata.length; i++) { + const entry = sco.extradata[i]; + + if (entry.element == 'parameters') { + parameters = entry.value; + break; + } + } + } + + if (parameters) { + const connector = launchUrl.indexOf('?') > -1 ? '&' : '?'; + if (parameters.charAt(0) == '?') { + parameters = parameters.substr(1); + } + + launchUrl += connector + parameters; + } + + if (this.isExternalLink(launchUrl)) { + // It's an online URL. + return Promise.resolve(launchUrl); + } + + return this.filepoolProvider.getPackageDirUrlByUrl(siteId, scorm.moduleurl).then((dirPath) => { + return this.textUtils.concatenatePaths(dirPath, launchUrl); + }); + } + + /** + * Get the path to the folder where a SCORM is downloaded. + * + * @param {string} moduleUrl Module URL (returned by get_course_contents). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the folder path. + */ + getScormFolder(moduleUrl: string, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.filepoolProvider.getPackageDirPathByUrl(siteId, moduleUrl); + } + + /** + * Gets a list of files to downlaod for a SCORM, using a format similar to module.contents from get_course_contents. + * It will only return one file: the ZIP package. + * + * @param {any} scorm SCORM. + * @return {any[]} File list. + */ + getScormFileList(scorm: any): any[] { + const files = []; + + if (!this.isScormUnsupported(scorm) && !scorm.warningMessage) { + files.push({ + fileurl: this.getPackageUrl(scorm), + filepath: '/', + filename: scorm.reference, + filesize: scorm.packagesize, + type: 'file', + timemodified: 0 + }); + } + + return files; + } + + /** + * Get the URL and description of the status icon. + * + * @param {any} sco SCO. + * @param {boolean} [incomplete] Whether the SCORM is incomplete. + * @return {{url: string, description: string}} Image URL and description. + */ + getScoStatusIcon(sco: any, incomplete?: boolean): {url: string, description: string} { + let imageName = '', + descName = '', + status; + + if (sco.scormtype == 'sco') { + // Not an asset, calculate image using status. + status = sco.status; + if (this.VALID_STATUSES.indexOf(status) < 0) { + // Status empty or not valid, use 'notattempted'. + status = 'notattempted'; + } + + if (!incomplete) { + // Check if SCO is completed or not. If SCORM is incomplete there's no need to check SCO. + incomplete = this.isStatusIncomplete(status); + } + + if (incomplete && sco.exitvalue == 'suspend') { + imageName = 'suspend'; + descName = 'suspended'; + } else { + imageName = sco.status; + descName = sco.status; + } + } else { + imageName = 'asset'; + descName = (!sco.status || sco.status == 'notattempted') ? 'asset' : 'assetlaunched'; + } + + return { + url: 'assets/img/scorm/' + imageName + '.gif', + description: this.translate.instant('addon.mod_scorm.' + descName) + }; + } + + /** + * Get cache key for SCORM data WS calls. + * + * @param {number} courseId Course ID. + * @return {string} Cache key. + */ + protected getScormDataCacheKey(courseId: number): string { + return this.ROOT_CACHE_KEY + 'scorm:' + courseId; + } + + /** + * Get a SCORM with key=value. If more than one is found, only the first will be returned. + * + * @param {number} courseId Course ID. + * @param {string} key Name of the property to check. + * @param {any} value Value to search. + * @param {string} [moduleUrl] Module URL. + * @param {boolean} [forceCache] Whether it should always return cached data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the SCORM is retrieved. + */ + protected getScormByField(courseId: number, key: string, value: any, moduleUrl?: string, forceCache?: boolean, siteId?: string) + : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + courseids: [courseId] + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getScormDataCacheKey(courseId) + }; + + if (forceCache) { + preSets.omitExpires = true; + } + + return site.read('mod_scorm_get_scorms_by_courses', params, preSets).then((response) => { + if (response && response.scorms) { + const currentScorm = response.scorms.find((scorm) => { + return scorm[key] == value; + }); + + if (currentScorm) { + // If the SCORM isn't available the WS returns a warning and it doesn't return timeopen and timeclosed. + if (typeof currentScorm.timeopen == 'undefined') { + for (const i in response.warnings) { + const warning = response.warnings[i]; + if (warning.itemid === currentScorm.id) { + currentScorm.warningMessage = warning.message; + break; + } + } + } + + currentScorm.moduleurl = moduleUrl; + + return currentScorm; + } + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get a SCORM by module ID. + * + * @param {number} courseId Course ID. + * @param {number} cmId Course module ID. + * @param {string} [moduleUrl] Module URL. + * @param {boolean} [forceCache] Whether it should always return cached data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the SCORM is retrieved. + */ + getScorm(courseId: number, cmId: number, moduleUrl?: string, forceCache?: boolean, siteId?: string): Promise { + return this.getScormByField(courseId, 'coursemodule', cmId, moduleUrl, forceCache, siteId); + } + + /** + * Get a SCORM by SCORM ID. + * + * @param {number} courseId Course ID. + * @param {number} id SCORM ID. + * @param {string} [moduleUrl] Module URL. + * @param {boolean} [forceCache] Whether it should always return cached data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the SCORM is retrieved. + */ + getScormById(courseId: number, id: number, moduleUrl?: string, forceCache?: boolean, siteId?: string): Promise { + return this.getScormByField(courseId, 'id', id, moduleUrl, forceCache, siteId); + } + + /** + * Get a readable SCORM grade method. + * + * @param {any} scorm SCORM. + * @return {string} Grading method. + */ + getScormGradeMethod(scorm: any): string { + if (scorm.maxattempt == 1) { + switch (parseInt(scorm.grademethod, 10)) { + case AddonModScormProvider.GRADEHIGHEST: + return this.translate.instant('addon.mod_scorm.gradehighest'); + + case AddonModScormProvider.GRADEAVERAGE: + return this.translate.instant('addon.mod_scorm.gradeaverage'); + + case AddonModScormProvider.GRADESUM: + return this.translate.instant('addon.mod_scorm.gradesum'); + + case AddonModScormProvider.GRADESCOES: + return this.translate.instant('addon.mod_scorm.gradescoes'); + default: + return ''; + } + } else { + switch (parseInt(scorm.whatgrade, 10)) { + case AddonModScormProvider.HIGHESTATTEMPT: + return this.translate.instant('addon.mod_scorm.highestattempt'); + + case AddonModScormProvider.AVERAGEATTEMPT: + return this.translate.instant('addon.mod_scorm.averageattempt'); + + case AddonModScormProvider.FIRSTATTEMPT: + return this.translate.instant('addon.mod_scorm.firstattempt'); + + case AddonModScormProvider.LASTATTEMPT: + return this.translate.instant('addon.mod_scorm.lastattempt'); + default: + return ''; + } + } + } + + /** + * Invalidates all the data related to a certain SCORM. + * + * @param {number} scormId SCORM ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined use site's current user. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateAllScormData(scormId: number, siteId?: string, userId?: number): Promise { + const promises = []; + + promises.push(this.invalidateAttemptCount(scormId, siteId, userId)); + promises.push(this.invalidateScos(scormId, siteId)); + promises.push(this.invalidateScormUserData(scormId, siteId)); + + return Promise.all(promises); + } + + /** + * Invalidates attempt count. + * + * @param {number} scormId SCORM ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined use site's current user. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateAttemptCount(scormId: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.invalidateWsCacheForKey(this.getAttemptCountCacheKey(scormId, userId)); + }); + } + + /** + * Invalidate the prefetched content. + * + * @param {number} moduleId The module ID. + * @param {number} courseId Course ID of the module. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined use site's current user. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number, siteId?: string, userId?: number): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.getScorm(courseId, moduleId, undefined, false, siteId).then((scorm) => { + const promises = []; + + promises.push(this.invalidateAllScormData(scorm.id, siteId, userId)); + promises.push(this.filepoolProvider.invalidateFilesByComponent(siteId, AddonModScormProvider.COMPONENT, + moduleId, true)); + + return Promise.all(promises); + }); + } + + /** + * Invalidates SCORM data. + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateScormData(courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getScormDataCacheKey(courseId)); + }); + } + + /** + * Invalidates SCORM user data for all attempts. + * + * @param {number} scormId SCORM ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateScormUserData(scormId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getScormUserDataCommonCacheKey(scormId)); + }); + } + + /** + * Invalidates SCORM scos for all organizations. + * + * @param {number} scormId SCORM ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateScos(scormId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getScosCacheKey(scormId)); + }); + } + + /** + * Check if a SCORM's attempt is incomplete. + * + * @param {any} scormId SCORM ID. + * @param {number} attempt Attempt. + * @param {boolean} offline Whether the attempt is offline. + * @param {boolean} ignoreCache Whether it should ignore cached data for online attempts. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with a boolean: true if incomplete, false otherwise. + */ + isAttemptIncomplete(scormId: number, attempt: number, offline?: boolean, ignoreCache?: boolean, siteId?: string) + : Promise { + + return this.getScosWithData(scormId, attempt, undefined, offline, ignoreCache, siteId).then((scos) => { + + for (const i in scos) { + const sco = scos[i]; + + // Ignore SCOs not visible or without launch URL. + if (sco.isvisible && sco.launch) { + if (this.isStatusIncomplete(sco.status)) { + return true; + } + } + } + + return false; + }); + } + + /** + * Given a launch URL, check if it's a external link. + * Based on Moodle's scorm_external_link. + * + * @param {string} link Link to check. + * @return {boolean} Whether it's an external link. + */ + protected isExternalLink(link: string): boolean { + link = link.toLowerCase(); + + if (link.match(/https?:\/\//)) { + return true; + } else if (link.substr(0, 4) == 'www.') { + return true; + } + + return false; + } + + /** + * Check if the given SCORM is closed. + * + * @param {any} scorm SCORM to check. + * @return {boolean} Whether the SCORM is closed. + */ + isScormClosed(scorm: any): boolean { + const timeNow = this.timeUtils.timestamp(); + + if (scorm.timeclose > 0 && timeNow > scorm.timeclose) { + return true; + } + + return false; + } + + /** + * Check if the given SCORM is downloadable. + * + * @param {any} scorm SCORM to check. + * @return {boolean} Whether the SCORM is downloadable. + */ + isScormDownloadable(scorm: any): boolean { + return typeof scorm.protectpackagedownloads != 'undefined' && scorm.protectpackagedownloads === false; + } + + /** + * Check if the given SCORM is open. + * + * @param {any} scorm SCORM to check. + * @return {boolean} Whether the SCORM is open. + */ + isScormOpen(scorm: any): boolean { + const timeNow = this.timeUtils.timestamp(); + + if (scorm.timeopen > 0 && scorm.timeopen > timeNow) { + return false; + } + + return true; + } + + /** + * Check if a SCORM is unsupported in the app. If it's not, returns the error code to show. + * + * @param {any} scorm SCORM to check. + * @return {string} String with error code if unsupported, undefined if supported. + */ + isScormUnsupported(scorm: any): string { + if (!this.isScormValidVersion(scorm)) { + return 'addon.mod_scorm.errorinvalidversion'; + } else if (!this.isScormDownloadable(scorm)) { + return 'addon.mod_scorm.errornotdownloadable'; + } else if (!this.isValidPackageUrl(this.getPackageUrl(scorm))) { + return 'addon.mod_scorm.errorpackagefile'; + } + } + + /** + * Check if it's a valid SCORM 1.2. + * + * @param {any} scorm SCORM to check. + * @return {boolean} Whether the SCORM is valid. + */ + isScormValidVersion(scorm: any): boolean { + return scorm.version == 'SCORM_1.2'; + } + + /** + * Check if a SCO status is incomplete. + * + * @param {string} status SCO status. + * @return {boolean} Whether it's incomplete. + */ + isStatusIncomplete(status: any): boolean { + return !status || status == 'notattempted' || status == 'incomplete' || status == 'browsed'; + } + + /** + * Check if a package URL is valid. + * + * @param {string} packageUrl Package URL. + * @return {boolean} Whether it's valid. + */ + isValidPackageUrl(packageUrl: string): boolean { + if (!packageUrl) { + return false; + } + if (packageUrl.indexOf('imsmanifest.xml') > -1) { + return false; + } + + return true; + } + + /** + * Report a SCO as being launched. + * + * @param {number} scormId SCORM ID. + * @param {number} scoId SCO ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logLaunchSco(scormId: number, scoId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + scormid: scormId, + scoid: scoId + }; + + return site.write('mod_scorm_launch_sco', params).then((response) => { + if (!response || !response.status) { + return Promise.reject(null); + } + }); + }); + } + + /** + * Report a SCORM as being viewed. + * + * @param {string} id Module ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logView(id: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + scormid: id + }; + + return site.write('mod_scorm_view_scorm', params).then((response) => { + if (!response || !response.status) { + return Promise.reject(null); + } + }); + }); + } + + /** + * Saves a SCORM tracking record. + * + * @param {number} scoId Sco ID. + * @param {number} attempt Attempt number. + * @param {any[]} tracks Tracking data to store. + * @param {any} scorm SCORM. + * @param {boolean} offline Whether the attempt is offline. + * @param {any} [userData] User data for this attempt and SCO. If not defined, it will be retrieved from DB. Recommended. + * @return {Promise} Promise resolved when data is saved. + */ + saveTracks(scoId: number, attempt: number, tracks: any[], scorm: any, offline?: boolean, userData?: any, siteId?: string) + : Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (offline) { + const promise = userData ? Promise.resolve(userData) : this.getScormUserData(scorm.id, attempt, undefined, offline, + false, siteId); + + return promise.then((userData) => { + return this.scormOfflineProvider.saveTracks(scorm, scoId, attempt, tracks, userData, siteId); + }); + } else { + return this.saveTracksOnline(scorm.id, scoId, attempt, tracks, siteId).then(() => { + // Tracks have been saved, update cached user data. + this.updateUserDataAfterSave(scorm.id, attempt, tracks, siteId); + }); + } + } + + /** + * Saves a SCORM tracking record. + * + * @param {number} scormId SCORM ID. + * @param {number} scoId Sco ID. + * @param {number} attempt Attempt number. + * @param {any[]} tracks Tracking data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when data is saved. + */ + saveTracksOnline(scormId: number, scoId: number, attempt: number, tracks: any[], siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + if (!tracks || !tracks.length) { + return Promise.resolve(); // Nothing to save. + } + + const params = { + scoid: scoId, + attempt: attempt, + tracks: tracks + }; + + this.syncProvider.blockOperation(AddonModScormProvider.COMPONENT, scormId, 'saveTracksOnline', site.id); + + return site.write('mod_scorm_insert_scorm_tracks', params).then((response) => { + if (response && response.trackids) { + return response.trackids; + } + + return Promise.reject(null); + }).finally(() => { + this.syncProvider.unblockOperation(AddonModScormProvider.COMPONENT, scormId, 'saveTracksOnline', site.id); + }); + }); + } + + /** + * Saves a SCORM tracking record using a synchronous call. + * Please use this function only if synchronous is a must. It's recommended to use saveTracks. + * + * @param {number} scoId Sco ID. + * @param {number} attempt Attempt number. + * @param {any[]} tracks Tracking data to store. + * @param {any} scorm SCORM. + * @param {boolean} [offline] Whether the attempt is offline. + * @param {any} [userData] User data for this attempt and SCO. Required if offline=true. + * @return {boolean} In online returns true if data is inserted, false otherwise. + * In offline returns true if data to insert is valid, false otherwise. True doesn't mean that the + * data has been stored, this function can return true but the insertion can still fail somehow. + */ + saveTracksSync(scoId: number, attempt: number, tracks: any[], scorm: any, offline?: boolean, userData?: any): boolean { + if (offline) { + return this.scormOfflineProvider.saveTracksSync(scorm, scoId, attempt, tracks, userData); + } else { + const success = this.saveTracksSyncOnline(scoId, attempt, tracks); + + if (success) { + // Tracks have been saved, update cached user data. + this.updateUserDataAfterSave(scorm.id, attempt, tracks); + } + + return success; + } + } + + /** + * Saves a SCORM tracking record using a synchronous call. + * Please use this function only if synchronous is a must. It's recommended to use saveTracksOnline. + * + * @param {number} scoId Sco ID. + * @param {number} attempt Attempt number. + * @param {any[]} tracks Tracking data. + * @return {boolean} True if success, false otherwise. + */ + saveTracksSyncOnline(scoId: number, attempt: number, tracks: any[]): boolean { + const params = { + scoid: scoId, + attempt: attempt, + tracks: tracks + }, + currentSite = this.sitesProvider.getCurrentSite(), + preSets = { + siteUrl: currentSite.getURL(), + wsToken: currentSite.getToken() + }; + let wsFunction = 'mod_scorm_insert_scorm_tracks', + response; + + if (!tracks || !tracks.length) { + return true; // Nothing to save. + } + + // Check if the method is available, use a prefixed version if possible. + if (!currentSite.wsAvailable(wsFunction, false)) { + if (currentSite.wsAvailable(CoreConstants.WS_PREFIX + wsFunction, false)) { + wsFunction = CoreConstants.WS_PREFIX + wsFunction; + } else { + this.logger.error('WS function "' + wsFunction + '" is not available, even in compatibility mode.'); + + return false; + } + } + + response = this.wsProvider.syncCall(wsFunction, params, preSets); + if (response && !response.error && response.trackids) { + return true; + } + + return false; + } + + /** + * Check if the SCORM main file should be downloaded. + * This function should only be called if the SCORM can be downloaded (not downloaded or outdated). + * + * @param {any} scorm SCORM to check. + * @param {boolean} [isOutdated] True if package outdated, false if not downloaded, undefined to calculate it. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with true if it should be downloaded, false otherwise. + */ + shouldDownloadMainFile(scorm: any, isOutdated?: boolean, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const component = AddonModScormProvider.COMPONENT; + + if (typeof isOutdated == 'undefined') { + + // Calculate if it's outdated. + return this.filepoolProvider.getPackageData(siteId, component, scorm.coursemodule).then((data) => { + const isOutdated = data.status == CoreConstants.OUTDATED || + (data.status == CoreConstants.DOWNLOADING && data.previous == CoreConstants.OUTDATED); + + // Package needs to be downloaded if it's not outdated (not downloaded) or if the hash has changed. + return !isOutdated || data.extra != scorm.sha1hash; + }).catch(() => { + // Package not found, not downloaded. + return true; + }); + } else if (isOutdated) { + + // The package is outdated, but maybe the file hasn't changed. + return this.filepoolProvider.getPackageExtra(siteId, component, scorm.coursemodule).then((extra) => { + return scorm.sha1hash != extra; + }).catch(() => { + // Package not found, not downloaded. + return true; + }); + } else { + // Package is not outdated and not downloaded, download the main file. + return Promise.resolve(true); + } + } + + /** + * If needed, updates cached user data after saving tracks in online. + * + * @param {number} scormId SCORM ID. + * @param {number} attempt Attempt number. + * @param {any[]} tracks Tracking data saved. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when updated. + */ + protected updateUserDataAfterSave(scormId: number, attempt: number, tracks: any[], siteId?: string): Promise { + if (!tracks || !tracks.length) { + return Promise.resolve(); + } + + // Check if we need to update. We only update if we sent some track with a dot notation. + let needsUpdate = false; + for (let i = 0, len = tracks.length; i < len; i++) { + const track = tracks[i]; + if (track.element && track.element.indexOf('.') > -1) { + needsUpdate = true; + break; + } + } + + if (needsUpdate) { + return this.getScormUserDataOnline(scormId, attempt, true, siteId); + } + + return Promise.resolve(); + } +} diff --git a/src/addon/mod/scorm/scorm.module.ts b/src/addon/mod/scorm/scorm.module.ts new file mode 100644 index 000000000..252e60e9a --- /dev/null +++ b/src/addon/mod/scorm/scorm.module.ts @@ -0,0 +1,29 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { AddonModScormProvider } from './providers/scorm'; +import { AddonModScormOfflineProvider } from './providers/scorm-offline'; + +@NgModule({ + declarations: [ + ], + imports: [ + ], + providers: [ + AddonModScormProvider, + AddonModScormOfflineProvider + ] +}) +export class AddonModScormModule { } diff --git a/src/assets/img/scorm/asset.gif b/src/assets/img/scorm/asset.gif new file mode 100644 index 0000000000000000000000000000000000000000..09fa56516f57b56f918c07d2829942c768c53044 GIT binary patch literal 178 zcmZ?wbhEHb6krfw_{_`j|Ns9pKy>E+%rpPffN17_V<1ZV&j3WmGZ~E27#NHhkbvS( z7FH1kUIrbI7|09;miP@PHN7QGf{$z!oAWT0wY-OSanYPtK|BT1 z6MD>1ad2S^qtgisPYK4r9fdkhGu}T~EILI~>a75S{yOH_hSq@!d%)GAo-mZtkKFS6Bat*>a`m#LG+{Rt9ST DK2s#` literal 0 HcmV?d00001 diff --git a/src/assets/img/scorm/completed.gif b/src/assets/img/scorm/completed.gif new file mode 100644 index 0000000000000000000000000000000000000000..536191528d7c913387d5799feb1c6486981f6aa0 GIT binary patch literal 190 zcmZ?wbhEHb6krfw_{_`j|Ns9p|IYxy%>OfIo|&0;CM|7dn(-N9mH+CVb6!EyXq(T9$wQqPf zzp*){x?n*@04t+IMWjfc+GC;37ANMlF(llYQ=`_Zd;QP{IlEK3lXV;w7Cb6#W-Pvc fW2{7|t*-%w%9lV_-05zycJ1vapIU za5LzDD{AO7u);QsT&;px;-U5ZWy76rpzbA9>w_Hkj5EFl6 YLZu2v8GA!eYCaQ(tz9oyk066J0AS5O<^TWy literal 0 HcmV?d00001 diff --git a/src/assets/img/scorm/incomplete.gif b/src/assets/img/scorm/incomplete.gif new file mode 100644 index 0000000000000000000000000000000000000000..fe2c6ea9d4d4d0f83396f9751617241d03392bc5 GIT binary patch literal 597 zcmcK1TT9ac9Eb6r;HD1EdB|mLxj92!+w!ba+dOS!P3LK=&CNC45+VwMqOL;x&)lL2 zM5xHfY*RL=Ofj`>O+z9PA}R=41%VoMl^9Z2-|$8BynxTmqt$7u6g^zx(vFL;^8>%v zvHcBOU-0V_ey(A237gBeVf8Xz zU&O2PSUH22eOPuOYDd(87u|?-A!0?ug0LB(;|QNX$b^s)&pWW#hQ(Ga7_iWSc|GQv z@l1y~4d&FCZNO|DW>k1ui)jU(RNzr5rsSBCB6t`<2?B)( zkrwaiwRU!BZymFobUNK;KEJS}ys9F1Z(at3u=@~MUl9RNo|oz4@e}HbxlpB zMuw}Ci}oEUuWe{ltJ;`7%`(}3+0oDSU$sn9d%jR8|1PgqmKhi_9o*t=XJ-}{ce-r? zK@Kk^g)RLv-M|qQr7JHK^*mULOFdL;7L{rGSs7ftPgpAEYED=meW=K{x$LMogEat?NE&zm literal 0 HcmV?d00001 diff --git a/src/assets/img/scorm/passed.gif b/src/assets/img/scorm/passed.gif new file mode 100644 index 0000000000000000000000000000000000000000..536191528d7c913387d5799feb1c6486981f6aa0 GIT binary patch literal 190 zcmZ?wbhEHb6krfw_{_`j|Ns9p|IYxy%>OfIo|&0;CM|7dn(-N9mH+CVb6!EyXq(T9$wQqPf zzp*){x?n*@04t+IMWjfc+GC;37ANMlF(llYQ=`_Zd;QP{IlEK3lXV;w7Cb6#W-Pvc fS~7f?;rgC&mbno;N`_o zRAhMJ!pbL47?P7!otzkE&Sc==V7PmiAt*@l{{6!xB@9kZ0yl3mC@HZ_oEY@_HA817 z!ge)+;+WW))wjM_l)Ckv|xg9?KV$Y7A47}(k!CKY(p*a#d^ z34FXzV?Hy}iVr?+%v>sh2i`h~aWb)N@C{PxJl|}S!mTaj{-~gE@|!9CN{K6{xAJpI zS99}}rErL_2v;j{u`wv}s&KIINHX