From 17ae2556e076c81bba55c2cd75e2a2419d56f0b1 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 23 Apr 2018 13:05:49 +0200 Subject: [PATCH 1/7] 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 Date: Mon, 23 Apr 2018 16:57:49 +0200 Subject: [PATCH 2/7] MOBILE-2350 scorm: Implement sync provider and prefetch handler --- .../mod/scorm/providers/prefetch-handler.ts | 425 +++++++++ src/addon/mod/scorm/providers/scorm-sync.ts | 832 ++++++++++++++++++ src/addon/mod/scorm/scorm.module.ts | 6 +- 3 files changed, 1262 insertions(+), 1 deletion(-) create mode 100644 src/addon/mod/scorm/providers/prefetch-handler.ts create mode 100644 src/addon/mod/scorm/providers/scorm-sync.ts diff --git a/src/addon/mod/scorm/providers/prefetch-handler.ts b/src/addon/mod/scorm/providers/prefetch-handler.ts new file mode 100644 index 000000000..de95bd520 --- /dev/null +++ b/src/addon/mod/scorm/providers/prefetch-handler.ts @@ -0,0 +1,425 @@ +// (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, Injector } from '@angular/core'; +import { CoreFileProvider } from '@providers/file'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreCourseModulePrefetchHandlerBase } from '@core/course/classes/module-prefetch-handler'; +import { AddonModScormProvider } from './scorm'; + +/** + * Progress event used when downloading a SCORM. + */ +export interface AddonModScormProgressEvent { + /** + * Whether the event is due to the download of a chunk of data. + * @type {boolean} + */ + downloading?: boolean; + + /** + * Progress event sent by the download. + * @type {ProgressEvent} + */ + progress?: ProgressEvent; + + /** + * A message related to the progress. This is usually used to notify that a certain step of the download has started. + * @type {string} + */ + message?: string; +} + +/** + * Handler to prefetch SCORMs. + */ +@Injectable() +export class AddonModScormPrefetchHandler extends CoreCourseModulePrefetchHandlerBase { + name = 'AddonModScorm'; + modName = 'scorm'; + component = AddonModScormProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^tracks$/; + + constructor(injector: Injector, protected fileProvider: CoreFileProvider, protected textUtils: CoreTextUtilsProvider, + protected scormProvider: AddonModScormProvider) { + super(injector); + } + + /** + * Download the module. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @param {string} [dirPath] Path of the directory where to store all the content files. + * @param {Function} [onProgress] Function to call on progress. + * @return {Promise} Promise resolved when all content is downloaded. + */ + download(module: any, courseId: number, dirPath?: string, onProgress?: (event: AddonModScormProgressEvent) => any) + : Promise { + + const siteId = this.sitesProvider.getCurrentSiteId(); + + return this.prefetchPackage(module, courseId, true, this.downloadOrPrefetchScorm.bind(this), siteId, false, onProgress); + } + + /** + * Download or prefetch a SCORM. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} single True if we're downloading a single module, false if we're downloading a whole section. + * @param {String} siteId Site ID. + * @param {boolean} prefetch True to prefetch, false to download right away. + * @param {Function} [onProgress] Function to call on progress. + * @return {Promise} Promise resolved with the "extra" data to store: the hash of the file. + */ + protected downloadOrPrefetchScorm(module: any, courseId: number, single: boolean, siteId: string, prefetch: boolean, + onProgress?: (event: AddonModScormProgressEvent) => any): Promise { + + let scorm; + + return this.scormProvider.getScorm(courseId, module.id, module.url, false, siteId).then((scormData) => { + scorm = scormData; + + const promises = [], + introFiles = this.getIntroFilesFromInstance(module, scorm); + + // Download WS data. + promises.push(this.fetchWSData(scorm, siteId).catch(() => { + // If prefetchData 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. + })); + + // Download the package. + promises.push(this.downloadOrPrefetchMainFileIfNeeded(scorm, prefetch, onProgress, siteId)); + + // Download intro files. + promises.push(this.filepoolProvider.downloadOrPrefetchFiles(siteId, introFiles, prefetch, false, this.component, + module.id).catch(() => { + // Ignore errors. + })); + + return Promise.all(promises); + }).then(() => { + // Success, return the hash. + return scorm.sha1hash; + }); + } + + /** + * Downloads/Prefetches and unzips the SCORM package. + * + * @param {any} scorm SCORM object. + * @param {boolean} [prefetch] True if prefetch, false otherwise. + * @param {Function} [onProgress] Function to call on progress. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the file is downloaded and unzipped. + */ + protected downloadOrPrefetchMainFile(scorm: any, prefetch?: boolean, onProgress?: (event: AddonModScormProgressEvent) => any, + siteId?: string): Promise { + + const packageUrl = this.scormProvider.getPackageUrl(scorm); + let dirPath; + + // Get the folder where the unzipped files will be. + return this.scormProvider.getScormFolder(scorm.moduleurl).then((path) => { + dirPath = path; + + // Notify that the download is starting. + onProgress && onProgress({message: 'core.downloading'}); + + // Download the ZIP file to the filepool. + if (prefetch) { + return this.filepoolProvider.addToQueueByUrl(siteId, packageUrl, this.component, scorm.coursemodule, undefined, + undefined, this.downloadProgress.bind(this, true, onProgress)); + } else { + return this.filepoolProvider.downloadUrl(siteId, packageUrl, true, this.component, scorm.coursemodule, + undefined, this.downloadProgress.bind(this, true, onProgress)); + } + }).then(() => { + // Remove the destination folder to prevent having old unused files. + return this.fileProvider.removeDir(dirPath).catch(() => { + // Ignore errors, it might have failed because the folder doesn't exist. + }); + }).then(() => { + // Get the ZIP file path. + return this.filepoolProvider.getFilePathByUrl(siteId, packageUrl); + }).then((zipPath) => { + // Notify that the unzip is starting. + onProgress && onProgress({message: 'core.unzipping'}); + + // Unzip and delete the zip when finished. + return this.fileProvider.unzipFile(zipPath, dirPath, this.downloadProgress.bind(this, false, onProgress)).then(() => { + return this.filepoolProvider.removeFileByUrl(siteId, packageUrl).catch(() => { + // Ignore errors. + }); + }); + }); + } + + /** + * Downloads/Prefetches and unzips the SCORM package if it should be downloaded. + * + * @param {any} scorm SCORM object. + * @param {boolean} [prefetch] True if prefetch, false otherwise. + * @param {Function} [onProgress] Function to call on progress. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the file is downloaded and unzipped. + */ + protected downloadOrPrefetchMainFileIfNeeded(scorm: any, prefetch?: boolean, + onProgress?: (event: AddonModScormProgressEvent) => any, siteId?: string): Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const result = this.scormProvider.isScormUnsupported(scorm); + + if (result) { + return Promise.reject(this.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. + return this.scormProvider.shouldDownloadMainFile(scorm, undefined, siteId).then((download) => { + if (download) { + return this.downloadOrPrefetchMainFile(scorm, prefetch, onProgress, siteId); + } + }); + } + + /** + * Function that converts a regular ProgressEvent into a AddonModScormProgressEvent. + * + * @param {Function} [onProgress] Function to call on progress. + * @param {ProgressEvent} [progress] Event returned by the download function. + */ + protected downloadProgress(downloading: boolean, onProgress?: (event: AddonModScormProgressEvent) => any, + progress?: ProgressEvent): void { + + if (onProgress && progress && progress.loaded) { + onProgress({ + downloading: downloading, + progress: progress + }); + } + } + + /** + * Get WS data for SCORM. + * + * @param {any} scorm SCORM object. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is prefetched. + */ + fetchWSData(scorm: any, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const promises = []; + + // Prefetch number of attempts (including not completed). + promises.push(this.scormProvider.getAttemptCountOnline(scorm.id, undefined, true, siteId).catch(() => { + // If it fails, assume we have no attempts. + return 0; + }).then((numAttempts) => { + if (numAttempts > 0) { + // Get user data for each attempt. + const dataPromises = []; + + for (let i = 1; i <= numAttempts; i++) { + dataPromises.push(this.scormProvider.getScormUserDataOnline(scorm.id, i, true, siteId).catch((err) => { + // Ignore failures of all the attempts that aren't the last one. + if (i == numAttempts) { + return Promise.reject(err); + } + })); + } + + return Promise.all(dataPromises); + } else { + // No attempts. We'll still try to get user data to be able to identify SCOs not visible and so. + return this.scormProvider.getScormUserDataOnline(scorm.id, 0, true, siteId); + } + })); + + // Prefetch SCOs. + promises.push(this.scormProvider.getScos(scorm.id, undefined, true, siteId)); + + return Promise.all(promises); + } + + /** + * Get the download size of a module. + * + * @param {any} module Module. + * @param {Number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise<{size: number, total: boolean}>} Promise resolved with the size and a boolean indicating if it was able + * to calculate the total size. + */ + getDownloadSize(module: any, courseId: any, single?: boolean): Promise<{ size: number, total: boolean }> { + return this.scormProvider.getScorm(courseId, module.id, module.url).then((scorm) => { + if (this.scormProvider.isScormUnsupported(scorm)) { + return {size: -1, total: false}; + } else if (!scorm.packagesize) { + // We don't have package size, try to calculate it. + return this.scormProvider.calculateScormSize(scorm).then((size) => { + return {size: size, total: true}; + }); + } else { + return {size: scorm.packagesize, total: true}; + } + }); + } + + /** + * Get the downloaded size of a module. If not defined, we'll use getFiles to calculate it (it can be slow). + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {number|Promise} Size, or promise resolved with the size. + */ + getDownloadedSize(module: any, courseId: number): number | Promise { + return this.scormProvider.getScorm(courseId, module.id, module.url).then((scorm) => { + // Get the folder where SCORM should be unzipped. + return this.scormProvider.getScormFolder(scorm.moduleurl); + }).then((path) => { + return this.fileProvider.getDirectorySize(path); + }); + } + + /** + * Get list of files. If not defined, we'll assume they're in module.contents. + * + * @param {any} module Module. + * @param {Number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise} Promise resolved with the list of files. + */ + getFiles(module: any, courseId: number, single?: boolean): Promise { + return this.scormProvider.getScorm(courseId, module.id, module.url).then((scorm) => { + return this.scormProvider.getScormFileList(scorm); + }).catch(() => { + // SCORM not found, return empty list. + return []; + }); + } + + /** + * Invalidate the prefetched content. + * + * @param {number} moduleId The module ID. + * @param {number} courseId The course ID the module belongs to. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number): Promise { + return this.scormProvider.invalidateContent(moduleId, courseId); + } + + /** + * Invalidate WS calls needed to determine module status. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when invalidated. + */ + invalidateModule(module: any, courseId: number): Promise { + // Invalidate the calls required to check if a SCORM is downloadable. + return this.scormProvider.invalidateScormData(courseId); + } + + /** + * Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {boolean|Promise} Whether the module can be downloaded. The promise should never be rejected. + */ + isDownloadable(module: any, courseId: number): boolean | Promise { + return this.scormProvider.getScorm(courseId, module.id, module.url).then((scorm) => { + if (scorm.warningMessage) { + // SCORM closed or not opened yet. + return false; + } + + if (this.scormProvider.isScormUnsupported(scorm)) { + return false; + } + + return true; + }); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} A boolean, or a promise resolved with a boolean, indicating if the handler is enabled. + */ + isEnabled(): boolean | Promise { + return true; + } + + /** + * Prefetch a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @param {string} [dirPath] Path of the directory where to store all the content files. + * @param {Function} [onProgress] Function to call on progress. + * @return {Promise} Promise resolved when done. + */ + prefetch(module: any, courseId?: number, single?: boolean, dirPath?: string, + onProgress?: (event: AddonModScormProgressEvent) => any): Promise { + + const siteId = this.sitesProvider.getCurrentSiteId(); + + return this.prefetchPackage(module, courseId, single, this.downloadOrPrefetchScorm.bind(this), siteId, true, onProgress); + } + + /** + * Remove module downloaded files. If not defined, we'll use getFiles to remove them (slow). + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when done. + */ + removeFiles(module: any, courseId: number): Promise { + const siteId = this.sitesProvider.getCurrentSiteId(); + let scorm; + + return this.scormProvider.getScorm(courseId, module.id, module.url, false, siteId).then((scormData) => { + scorm = scormData; + + // Get the folder where SCORM should be unzipped. + return this.scormProvider.getScormFolder(scorm.moduleurl); + }).then((path) => { + const promises = []; + + // Remove the unzipped folder. + promises.push(this.fileProvider.removeDir(path).catch((error) => { + if (error && error.code == 1) { + // Not found, ignore error. + } else { + return Promise.reject(error); + } + })); + + // Maybe the ZIP wasn't deleted for some reason. Try to delete it too. + promises.push(this.filepoolProvider.removeFileByUrl(siteId, this.scormProvider.getPackageUrl(scorm)).catch(() => { + // Ignore errors. + })); + + return Promise.all(promises); + }); + } +} diff --git a/src/addon/mod/scorm/providers/scorm-sync.ts b/src/addon/mod/scorm/providers/scorm-sync.ts new file mode 100644 index 000000000..f7fc6428c --- /dev/null +++ b/src/addon/mod/scorm/providers/scorm-sync.ts @@ -0,0 +1,832 @@ +// (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 { CoreAppProvider } from '@providers/app'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { AddonModScormProvider, AddonModScormAttemptCountResult } from './scorm'; +import { AddonModScormOfflineProvider } from './scorm-offline'; +import { AddonModScormPrefetchHandler } from './prefetch-handler'; + +/** + * Data returned by a SCORM sync. + */ +export interface AddonModScormSyncResult { + /** + * List of warnings. + * @type {string[]} + */ + warnings: string[]; + + /** + * Whether an attempt was finished in the site due to the sync, + * @type {boolean} + */ + attemptFinished: boolean; + + /** + * Whether some data was sent to the site. + * @type {boolean} + */ + updated: boolean; +} + +/** + * Service to sync SCORMs. + */ +@Injectable() +export class AddonModScormSyncProvider extends CoreSyncBaseProvider { + + static AUTO_SYNCED = 'addon_mod_scorm_autom_synced'; + static SYNC_TIME = 600000; + + protected componentTranslate: string; + + constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider, + syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService, + courseProvider: CoreCourseProvider, private eventsProvider: CoreEventsProvider, + private scormProvider: AddonModScormProvider, private scormOfflineProvider: AddonModScormOfflineProvider, + private prefetchHandler: AddonModScormPrefetchHandler, private utils: CoreUtilsProvider) { + + super('AddonModScormSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate); + + this.componentTranslate = courseProvider.translateModuleName('scorm'); + } + + /** + * 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 {number} scormId SCORM ID. + * @param {number} attempt The offline attempt to treat. + * @param {number} lastOffline Last offline attempt number. + * @param {number[]} newAttemptsSameOrder Attempts that'll be created as new attempts but keeping the current order. + * @param {any} newAttemptsAtEnd Object with attempts that'll be created at the end of the list of attempts (should be max 1). + * @param {number} lastOfflineCreated Time when the last offline attempt was created. + * @param {boolean} lastOfflineIncomplete Whether the last offline attempt is incomplete. + * @param {string[]} warnings Array where to add the warnings. + * @param {string} siteId Site ID. + * @return {Promise} Promise resolved when done. + */ + protected addToNewOrDelete(scormId: number, attempt: number, lastOffline: number, newAttemptsSameOrder: number[], + newAttemptsAtEnd: any, lastOfflineCreated: number, lastOfflineIncomplete: boolean, warnings: string[], + siteId: string): Promise { + + if (attempt == lastOffline) { + newAttemptsSameOrder.push(attempt); + + return Promise.resolve(); + } + + // Check if the attempt can be created. + return this.scormOfflineProvider.getAttemptCreationTime(scormId, attempt, siteId).then((time) => { + if (time > lastOfflineCreated) { + // 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.'); + + return this.scormOfflineProvider.deleteAttempt(scormId, attempt, siteId).then(() => { + warnings.push(this.translate.instant('addon.mod_scorm.warningofflinedatadeleted', {number: attempt})); + }).catch(() => { + // Maybe there's something wrong with the data or the storage implementation. + }); + } else { + // Add the attempt at the end. + newAttemptsAtEnd[time] = attempt; + } + + } else { + newAttemptsSameOrder.push(attempt); + } + }); + } + + /** + * Check if can retry an attempt synchronization. + * + * @param {number} scormId SCORM ID. + * @param {number} attempt Attempt number. + * @param {number} lastOnline Last online attempt number. + * @param {string} siteId Site ID. + * @return {Promise} Promise resolved if can retry the synchronization, rejected otherwise. + */ + protected canRetrySync(scormId: number, attempt: number, lastOnline: number, siteId: string): Promise { + // If it's the last attempt we don't need to ignore cache because we already did it. + const refresh = lastOnline != attempt; + + return this.scormProvider.getScormUserData(scormId, attempt, undefined, false, refresh, siteId).then((siteData) => { + // Get synchronization snapshot (if sync fails it should store a snapshot). + return this.scormOfflineProvider.getAttemptSnapshot(scormId, attempt, siteId).then((snapshot) => { + if (!snapshot || !Object.keys(snapshot).length || !this.snapshotEquals(snapshot, siteData)) { + // No snapshot or it doesn't match, we can't retry the synchronization. + return Promise.reject(null); + } + }); + }); + } + + /** + * Create new attempts at the end of the offline attempts list. + * + * @param {number} scormId SCORM ID. + * @param {any} newAttempts Object with the attempts to create. The keys are the timecreated, the values are the attempt number. + * @param {number} lastOffline Number of last offline attempt. + * @param {string} siteId Site ID. + * @return {Promise} Promise resolved when done. + */ + protected createNewAttemptsAtEnd(scormId: number, newAttempts: any, lastOffline: number, siteId: string): Promise { + const times = Object.keys(newAttempts).sort(), // Sort in ASC order. + promises = []; + + if (!times.length) { + return Promise.resolve(); + } + + times.forEach((time, index) => { + const attempt = newAttempts[time]; + + promises.push(this.scormOfflineProvider.changeAttemptNumber(scormId, attempt, lastOffline + index + 1, siteId)); + }); + + return this.utils.allPromises(promises); + } + + /** + * Finish a sync process: remove offline data if needed, prefetch SCORM data, set sync time and return the result. + * + * @param {string} siteId Site ID. + * @param {any} scorm SCORM. + * @param {string[]} warnings List of warnings generated by the sync. + * @param {number} [lastOnline] Last online attempt number before the sync. + * @param {boolean} [lastOnlineWasFinished] Whether the last online attempt was finished before the sync. + * @param {AddonModScormAttemptCountResult} [initialCount] Attempt count before the sync. + * @param {boolean} [updated] Whether some data was sent to the site. + * @return {Promise} Promise resolved on success. + */ + protected finishSync(siteId: string, scorm: any, warnings: string[], lastOnline?: number, lastOnlineWasFinished?: boolean, + initialCount?: AddonModScormAttemptCountResult, updated?: boolean): Promise { + + let promise; + + if (updated) { + // Update the WS data. + promise = this.scormProvider.invalidateAllScormData(scorm.id, siteId).catch(() => { + // Ignore errors. + }).then(() => { + return this.prefetchHandler.fetchWSData(scorm, siteId); + }); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + return this.setSyncTime(scorm.id, siteId).catch(() => { + // Ignore errors. + }); + }).then(() => { + // Check if an attempt was finished in Moodle. + if (initialCount) { + // Get attempt count again to check if an attempt was finished. + return this.scormProvider.getAttemptCount(scorm.id, undefined, false, siteId).then((attemptsData) => { + if (attemptsData.online.length > initialCount.online.length) { + return true; + } else if (!lastOnlineWasFinished && lastOnline > 0) { + // Last online attempt wasn't finished, let's check if it is now. + return this.scormProvider.isAttemptIncomplete(scorm.id, lastOnline, false, true, siteId).then((inc) => { + return !inc; + }); + } + + return false; + }); + } + + return false; + }).then((attemptFinished) => { + return { + warnings: warnings, + attemptFinished: attemptFinished, + updated: updated + }; + }); + } + + /** + * Get the creation time and the status (complete/incomplete) of an offline attempt. + * + * @param {number} scormId SCORM ID. + * @param {number} attempt Attempt number. + * @param {string} siteId Site ID. + * @return {Promise<{incomplete: boolean, timecreated: number}>} Promise resolved with the data. + */ + protected getOfflineAttemptData(scormId: number, attempt: number, siteId: string) + : Promise<{incomplete: boolean, timecreated: number}> { + + // Check if last offline attempt is incomplete. + return this.scormProvider.isAttemptIncomplete(scormId, attempt, true, false, siteId).then((incomplete) => { + return this.scormOfflineProvider.getAttemptCreationTime(scormId, attempt, siteId).then((timecreated) => { + return { + incomplete: incomplete, + timecreated: 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 {number} scormId SCORM ID. + * @param {number[]} newAttempts Attempts that need to be converted into new attempts. + * @param {number} lastOnline Last online attempt. + * @param {number} lastCollision Last attempt with collision (exists in online and offline). + * @param {number[]} offlineAttempts Numbers of offline attempts. + * @param {string} siteId Site ID. + * @return {Promise} Promise resolved when attempts have been moved. + */ + protected moveNewAttempts(scormId: any, newAttempts: number[], lastOnline: number, lastCollision: number, + offlineAttempts: number[], siteId: string): Promise { + + if (!newAttempts.length) { + return Promise.resolve(); + } + + let promise = Promise.resolve(), + lastSuccessful; + + // Sort offline attempts in DESC order. + offlineAttempts = offlineAttempts.sort((a, b) => { + return Number(a) <= Number(b) ? 1 : -1; + }); + + // First move the offline attempts after the collisions. + offlineAttempts.forEach((attempt) => { + if (attempt > lastCollision) { + // We use a chain of promises because we need to move them in order. + promise = promise.then(() => { + const newNumber = attempt + newAttempts.length; + + return this.scormOfflineProvider.changeAttemptNumber(scormId, attempt, newNumber, siteId).then(() => { + lastSuccessful = attempt; + }); + }); + } + }); + + return promise.then(() => { + const successful = []; + let promises = []; + + // Sort newAttempts in ASC order. + newAttempts = newAttempts.sort((a, b) => { + return Number(a) >= Number(b) ? 1 : -1; + }); + + // Now move the attempts in newAttempts. + newAttempts.forEach((attempt, index) => { + // No need to use chain of promises. + const newNumber = lastOnline + index + 1; + + promises.push(this.scormOfflineProvider.changeAttemptNumber(scormId, attempt, newNumber, siteId).then(() => { + successful.push(attempt); + })); + }); + + return Promise.all(promises).catch((error) => { + // Moving the new attempts failed (it shouldn't happen). Let's undo the new attempts move. + promises = []; + + successful.forEach((attempt) => { + const newNumber = lastOnline + newAttempts.indexOf(attempt) + 1; + + promises.push(this.scormOfflineProvider.changeAttemptNumber(scormId, newNumber, attempt, siteId)); + }); + + return this.utils.allPromises(promises).then(() => { + return Promise.reject(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) { + return Promise.reject(error); + } + + const attemptsToUndo = []; + let promise = Promise.resolve(); + + for (let i = lastSuccessful; offlineAttempts.indexOf(i) != -1; i++) { + attemptsToUndo.push(i); + } + + attemptsToUndo.forEach((attempt) => { + promise = promise.then(() => { + // Move it back. + return this.scormOfflineProvider.changeAttemptNumber(scormId, attempt + newAttempts.length, attempt, siteId); + }); + }); + + return promise.then(() => { + return Promise.reject(error); + }); + }); + } + + /** + * Save a snapshot from a synchronization. + * + * @param {number} scormId SCORM ID. + * @param {number} attempt Attemot number. + * @param {string} siteId Site ID. + * @return {Promise} Promise resolved when the snapshot is stored. + */ + protected saveSyncSnapshot(scormId: number, attempt: number, siteId: string): Promise { + // Try to get current state from the site. + return this.scormProvider.getScormUserData(scormId, attempt, undefined, false, true, siteId).then((data) => { + return this.scormOfflineProvider.setAttemptSnapshot(scormId, attempt, data, siteId); + }, () => { + // Error getting user data from the site. We'll have to build it ourselves. + // Let's try to get cached data about the attempt. + return this.scormProvider.getScormUserData(scormId, attempt, undefined, false, false, siteId).catch(() => { + // No cached data. + return {}; + }).then((data) => { + + // We need to add the synced data to the snapshot. + return this.scormOfflineProvider.getScormStoredData(scormId, attempt, false, true, siteId).then((synced) => { + synced.forEach((entry) => { + if (!data[entry.scoId]) { + data[entry.scoId] = { + scoid: entry.scoId, + userdata: {} + }; + } + data[entry.scoId].userdata[entry.element] = entry.value; + }); + + return this.scormOfflineProvider.setAttemptSnapshot(scormId, attempt, data, 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 {any} snapshot Attempt's snapshot. + * @param {any} userData Data retrieved from the site. + * @return {boolean} True if snapshot is equal to the user data, false otherwise. + */ + protected snapshotEquals(snapshot: any, userData: any): boolean { + // Check that snapshot contains the data from the site. + for (const scoId in userData) { + const siteSco = userData[scoId], + 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], + 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 {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllScorms(siteId?: string): Promise { + return this.syncOnSites('all SCORMs', this.syncAllScormsFunc.bind(this), [], siteId); + } + + /** + * Sync all SCORMs on a site. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + protected syncAllScormsFunc(siteId?: string): Promise { + + // Get all offline attempts. + return this.scormOfflineProvider.getAllAttempts(siteId).then((attempts) => { + const scorms = [], + ids = [], // To prevent duplicates. + promises = []; + + // Get the IDs of all the SCORMs that have something to be synced. + attempts.forEach((attempt) => { + if (ids.indexOf(attempt.scormId) == -1) { + ids.push(attempt.scormId); + + scorms.push({ + id: attempt.scormId, + courseId: attempt.courseId + }); + } + }); + + // Sync all SCORMs that haven't been synced for a while and that aren't attempted right now. + scorms.forEach((scorm) => { + if (!this.syncProvider.isBlocked(AddonModScormProvider.COMPONENT, scorm.id, siteId)) { + + promises.push(this.scormProvider.getScormById(scorm.courseId, scorm.id, '', false, siteId).then((scorm) => { + return this.syncScormIfNeeded(scorm, siteId).then((data) => { + if (typeof data != 'undefined') { + // We tried to sync. Send event. + this.eventsProvider.trigger(AddonModScormSyncProvider.AUTO_SYNCED, { + scormId: scorm.id, + attemptFinished: data.attemptFinished, + warnings: data.warnings, + updated: data.updated + }, siteId); + } + }); + })); + } + }); + + return Promise.all(promises); + }); + } + + /** + * Send data from a SCORM offline attempt to the site. + * + * @param {number} scormId SCORM ID. + * @param {number} attempt Attempt number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the attempt is successfully synced. + */ + protected syncAttempt(scormId: number, attempt: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + this.logger.debug('Try to sync attempt ' + attempt + ' in SCORM ' + scormId + ' and site ' + siteId); + + // Get only not synced entries. + return this.scormOfflineProvider.getScormStoredData(scormId, attempt, true, false, siteId).then((entries) => { + const scos = {}, + promises = []; + 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). + entries.forEach((entry) => { + if (entry.element.indexOf('.') > -1) { + if (!scos[entry.scoId]) { + scos[entry.scoId] = []; + } + + scos[entry.scoId].push({ + element: entry.element, + value: entry.value + }); + } + }); + + // Send the data in each SCO. + for (const id in scos) { + const scoId = Number(id), + tracks = scos[scoId]; + + promises.push(this.scormProvider.saveTracksOnline(scormId, scoId, attempt, tracks, siteId).then(() => { + // Sco data successfully sent. Mark them as synced. This is needed because some SCOs sync might fail. + return this.scormOfflineProvider.markAsSynced(scormId, attempt, scoId, siteId).catch(() => { + // Ignore errors. + }).then(() => { + somethingSynced = true; + }); + })); + } + + return this.utils.allPromises(promises).then(() => { + // Attempt has been sent. Let's delete it from local. + return this.scormOfflineProvider.deleteAttempt(scormId, attempt, siteId).catch(() => { + // Failed to delete (shouldn't happen). Let's retry once. + return this.scormOfflineProvider.deleteAttempt(scormId, attempt, siteId).catch(() => { + // Maybe there's something wrong with the data or the storage implementation. + this.logger.error('After sync: error deleting attempt ' + attempt + ' in SCORM ' + scormId); + }); + }); + }).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.'); + + return this.saveSyncSnapshot(scormId, attempt, siteId).then(() => { + return Promise.reject(error); + }); + } else { + this.logger.error('Error synchronizing attempt ' + attempt + ' in SCORM ' + scormId); + } + + return Promise.reject(error); + }); + }); + } + + /** + * Sync a SCORM only if a certain time has passed since the last time. + * + * @param {any} scorm SCORM. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the SCORM is synced or if it doesn't need to be synced. + */ + syncScormIfNeeded(scorm: any, siteId?: string): Promise { + return this.isSyncNeeded(scorm.id, siteId).then((needed) => { + if (needed) { + return this.syncScorm(scorm, siteId); + } + }); + } + + /** + * Try to synchronize a SCORM. + * The promise returned will be resolved with an array with warnings if the synchronization is successful. A successful + * synchronization doesn't mean that all the data has been sent to the site, it's possible that some attempt can't be sent. + * + * @param {any} scorm SCORM. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success. + */ + syncScorm(scorm: any, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + let warnings = [], + syncPromise, + initialCount, + lastOnline = 0, + lastOnlineWasFinished = false; + + 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 (this.syncProvider.isBlocked(AddonModScormProvider.COMPONENT, scorm.id, siteId)) { + this.logger.debug('Cannot sync SCORM ' + scorm.id + ' because it is blocked.'); + + return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); + } + + this.logger.debug('Try to sync SCORM ' + scorm.id + ' in site ' + siteId); + + // Get attempts data. We ignore cache for online attempts, so this call will fail if offline or server down. + syncPromise = this.scormProvider.getAttemptCount(scorm.id, false, true, siteId).then((attemptsData) => { + if (!attemptsData.offline || !attemptsData.offline.length) { + // Nothing to sync. + return this.finishSync(siteId, scorm, warnings, lastOnline, lastOnlineWasFinished); + } + + initialCount = attemptsData; + + const collisions = []; + + // 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. + const promise = lastOnline > 0 ? this.scormProvider.isAttemptIncomplete(scorm.id, lastOnline, false, true, siteId) : + Promise.resolve(false); + + return promise.then((incomplete) => { + lastOnlineWasFinished = !incomplete; + + if (!collisions.length && !incomplete) { + // No collisions and last attempt is complete. Send offline attempts to Moodle. + const promises = []; + + attemptsData.offline.forEach((attempt) => { + if (scorm.maxattempt == 0 || attempt <= scorm.maxattempt) { + promises.push(this.syncAttempt(scorm.id, attempt, siteId)); + } + }); + + return Promise.all(promises).then(() => { + // All data synced, finish. + return this.finishSync(siteId, scorm, warnings, lastOnline, lastOnlineWasFinished, initialCount, true); + }); + + } else if (collisions.length) { + // We have collisions, treat them. + return this.treatCollisions(scorm.id, collisions, lastOnline, attemptsData.offline, siteId).then((warns) => { + warnings = warnings.concat(warns); + + // The offline attempts might have changed since some collisions can be converted to new attempts. + return this.scormOfflineProvider.getAttempts(scorm.id, siteId).then((entries) => { + const promises = []; + let cannotSyncSome = false; + + entries = entries.map((entry) => { + return entry.attempt; // Get only the attempt number. + }); + + if (incomplete && entries.indexOf(lastOnline) > -1) { + // Last online was incomplete, but it was continued in offline. + incomplete = false; + } + + entries.forEach((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 == 0 || attempt <= scorm.maxattempt) { + promises.push(this.syncAttempt(scorm.id, attempt, siteId)); + } + } else { + cannotSyncSome = true; + } + }); + + return Promise.all(promises).then(() => { + if (cannotSyncSome) { + warnings.push(this.translate.instant('addon.mod_scorm.warningsynconlineincomplete')); + } + + return this.finishSync(siteId, scorm, warnings, lastOnline, lastOnlineWasFinished, initialCount, + true); + }); + }); + }); + } else { + // No collisions, but last online attempt is incomplete so we can't send offline attempts. + warnings.push(this.translate.instant('addon.mod_scorm.warningsynconlineincomplete')); + + return this.finishSync(siteId, scorm, warnings, lastOnline, lastOnlineWasFinished, initialCount, false); + } + }); + }); + + return this.addOngoingSync(scorm.id, syncPromise, siteId); + } + + /** + * Treat collisions found in a SCORM synchronization process. + * + * @param {number} scormId SCORM ID. + * @param {number[]} collisions Numbers of attempts that exist both in online and offline. + * @param {number} lastOnline Last online attempt. + * @param {number[]} offlineAttempts Numbers of offline attempts. + * @param {string} siteId Site ID. + * @return {Promise { + + const warnings = [], + newAttemptsSameOrder = [], // Attempts that will be created as new attempts but keeping the current order. + newAttemptsAtEnd = {}, // Attempts that will be created at the end of the list of attempts (should be max 1 attempt). + lastCollision = Math.max.apply(Math, collisions); + let lastOffline = Math.max.apply(Math, offlineAttempts); + + // Get needed data from the last offline attempt. + return this.getOfflineAttemptData(scormId, lastOffline, siteId).then((lastOfflineData) => { + const promises = []; + + collisions.forEach((attempt) => { + // First get synced entries to detect if it was a failed synchronization. + promises.push(this.scormOfflineProvider.getScormStoredData(scormId, attempt, false, true, siteId).then((synced) => { + if (synced && 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. + return this.scormOfflineProvider.getScormStoredData(scormId, attempt, true, false, siteId) + .then((entries) => { + + // Check if there are elements to sync. + let hasDataToSend = false; + for (const i in entries) { + const entry = entries[i]; + if (entry.element.indexOf('.') > -1) { + hasDataToSend = true; + break; + } + } + + if (hasDataToSend) { + // There are elements to sync. We need to check if it's possible to sync them or not. + return this.canRetrySync(scormId, attempt, lastOnline, siteId).catch(() => { + // 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 { + // Nothing to sync, delete the attempt. + return this.scormOfflineProvider.deleteAttempt(scormId, attempt, siteId).catch(() => { + // Maybe there's something wrong with the data or the storage implementation. + }); + } + }); + } else { + // It's not a failed synchronization. Check if it's an attempt continued in offline. + return this.scormOfflineProvider.getAttemptSnapshot(scormId, attempt, siteId).then((snapshot) => { + if (snapshot && Object.keys(snapshot).length) { + // 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; + + return this.scormProvider.getScormUserData(scormId, attempt, undefined, false, refresh, siteId) + .then((data) => { + + if (!this.snapshotEquals(snapshot, data)) { + // 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); + } + }); + } else { + // No snapshot, it's a different attempt. + newAttemptsSameOrder.push(attempt); + } + }); + } + })); + }); + + return Promise.all(promises).then(() => { + return this.moveNewAttempts(scormId, newAttemptsSameOrder, lastOnline, lastCollision, offlineAttempts, siteId) + .then(() => { + + // 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; + + return this.createNewAttemptsAtEnd(scormId, newAttemptsAtEnd, lastOffline, siteId).then(() => { + return warnings; + }); + }); + }); + }); + } +} diff --git a/src/addon/mod/scorm/scorm.module.ts b/src/addon/mod/scorm/scorm.module.ts index 252e60e9a..7a9adc132 100644 --- a/src/addon/mod/scorm/scorm.module.ts +++ b/src/addon/mod/scorm/scorm.module.ts @@ -15,6 +15,8 @@ import { NgModule } from '@angular/core'; import { AddonModScormProvider } from './providers/scorm'; import { AddonModScormOfflineProvider } from './providers/scorm-offline'; +import { AddonModScormPrefetchHandler } from './providers/prefetch-handler'; +import { AddonModScormSyncProvider } from './providers/scorm-sync'; @NgModule({ declarations: [ @@ -23,7 +25,9 @@ import { AddonModScormOfflineProvider } from './providers/scorm-offline'; ], providers: [ AddonModScormProvider, - AddonModScormOfflineProvider + AddonModScormOfflineProvider, + AddonModScormPrefetchHandler, + AddonModScormSyncProvider ] }) export class AddonModScormModule { } From 1b9ec1ac942cbe0957be045c325137d73bc1644e Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 24 Apr 2018 11:06:18 +0200 Subject: [PATCH 3/7] MOBILE-2350 scorm: Implement data model for 1.2 --- src/addon/mod/scorm/classes/data-model-12.ts | 905 +++++++++++++++++++ src/addon/mod/scorm/providers/scorm.ts | 6 + 2 files changed, 911 insertions(+) create mode 100644 src/addon/mod/scorm/classes/data-model-12.ts diff --git a/src/addon/mod/scorm/classes/data-model-12.ts b/src/addon/mod/scorm/classes/data-model-12.ts new file mode 100644 index 000000000..196d36fda --- /dev/null +++ b/src/addon/mod/scorm/classes/data-model-12.ts @@ -0,0 +1,905 @@ +// (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 { CoreEventsProvider } from '@providers/events'; +import { AddonModScormProvider } from '../providers/scorm'; + +/** + * SCORM data model implementation for version 1.2. + */ +export class AddonModScormDataModel12 { + + // Standard Data Type Definition. + protected CMI_STRING_256 = '^[\\u0000-\\uFFFF]{0,255}$'; + protected CMI_STRING_4096 = '^[\\u0000-\\uFFFF]{0,4096}$'; + protected CMI_TIME = '^([0-2]{1}[0-9]{1}):([0-5]{1}[0-9]{1}):([0-5]{1}[0-9]{1})(\.[0-9]{1,2})?$'; + protected CMI_TIMESPAN = '^([0-9]{2,4}):([0-9]{2}):([0-9]{2})(\.[0-9]{1,2})?$'; + protected CMI_INTEGER = '^\\d+$'; + protected CMI_SINTEGER = '^-?([0-9]+)$'; + protected CMI_DECIMAL = '^-?([0-9]{0,3})(\.[0-9]*)?$'; + protected CMI_IDENTIFIER = '^[\\u0021-\\u007E]{0,255}$'; + protected CMI_FEEDBACK = this.CMI_STRING_256; // This must be redefined. + protected CMI_INDEX = '[._](\\d+).'; + + // Vocabulary Data Type Definition. + protected CMI_STATUS = '^passed$|^completed$|^failed$|^incomplete$|^browsed$'; + protected CMI_STATUS_2 = '^passed$|^completed$|^failed$|^incomplete$|^browsed$|^not attempted$'; + protected CMI_EXIT = '^time-out$|^suspend$|^logout$|^$'; + protected CMI_TYPE = '^true-false$|^choice$|^fill-in$|^matching$|^performance$|^sequencing$|^likert$|^numeric$'; + protected CMI_RESULT = '^correct$|^wrong$|^unanticipated$|^neutral$|^([0-9]{0,3})?(\.[0-9]*)?$'; + protected NAV_EVENT = '^previous$|^continue$'; + + // Children lists. + protected CMI_CHILDREN = 'core,suspend_data,launch_data,comments,objectives,student_data,student_preference,interactions'; + protected CORE_CHILDREN = 'student_id,student_name,lesson_location,credit,lesson_status,entry,score,total_time,lesson_mode,' + + 'exit,session_time'; + protected SCORE_CHILDREN = 'raw,min,max'; + protected COMMENTS_CHILDREN = 'content,location,time'; + protected OBJECTIVES_CHILDREN = 'id,score,status'; + protected CORRECT_RESPONSES_CHILDREN = 'pattern'; + protected STUDENT_DATA_CHILDREN = 'mastery_score,max_time_allowed,time_limit_action'; + protected STUDENT_PREFERENCE_CHILDREN = 'audio,language,speed,text'; + protected INTERACTIONS_CHILDREN = 'id,objectives,time,type,correct_responses,weighting,student_response,result,latency'; + + // Data ranges. + protected SCORE_RANGE = '0#100'; + protected AUDIO_RANGE = '-1#100'; + protected SPEED_RANGE = '-100#100'; + protected WEIGHTING_RANGE = '-100#100'; + protected TEXT_RANGE = '-1#1'; + + // Error messages. + protected ERROR_STRINGS = { + 0: 'No error', + 101: 'General exception', + 201: 'Invalid argument error', + 202: 'Element cannot have children', + 203: 'Element not an array - cannot have count', + 301: 'Not initialized', + 401: 'Not implemented error', + 402: 'Invalid set value, element is a keyword', + 403: 'Element is read only', + 404: 'Element is write only', + 405: 'Incorrect data type' + }; + + protected currentUserData = {}; // Current user data. + protected def = {}; // Object containing the default values. + protected defExtra = {}; // Extra object that will contain the objectives and interactions data (all the .n. elements). + protected dataModel = {}; // The SCORM 1.2 data model. + + protected initialized = false; // Whether LMSInitialize has been called. + protected errorCode: string; // Last error. + protected timeout; // Timeout to commit changes. + + /** + * Constructor. + * + * @param {CoreEventsProvider} eventsProvider Events provider instance. + * @param {AddonModScormProvider} scormProvider SCORM provider instance. + * @param {any} scorm SCORM. + * @param {number} scoId Current SCO ID. + * @param {number} attempt Attempt number. + * @param {any} userData The user default data. + * @param {string} [mode] Mode being played. By default, MODENORMAL. + * @param {boolean} offline Whether the attempt is offline. + */ + constructor(protected eventsProvider: CoreEventsProvider, protected scormProvider: AddonModScormProvider, + protected siteId: string, protected scorm: any, protected scoId: number, protected attempt: number, + userData: any, protected mode?: string, protected offline?: boolean) { + + this.mode = mode || AddonModScormProvider.MODENORMAL; + this.offline = !!offline; + + this.init(userData); + } + + /** + * Utility function for adding two times in format hh:mm:ss. + * + * @param {string} first First time. + * @param {string} second Second time. + * @return {string} Total time. + */ + protected addTime(first: string, second: string): string { + const sFirst = first.split(':'), + sSecond = second.split(':'), + cFirst = sFirst[2].split('.'), + cSecond = sSecond[2].split('.'); + let change = 0; + + let firstCents = 0; // Cents. + if (cFirst.length > 1) { + firstCents = parseInt(cFirst[1], 10); + } + + let secondCents = 0; + if (cSecond.length > 1) { + secondCents = parseInt(cSecond[1], 10); + } + + let cents: string | number = firstCents + secondCents; + change = Math.floor(cents / 100); + cents = cents - (change * 100); + if (Math.floor(cents) < 10) { + cents = '0' + cents.toString(); + } + + let secs: string | number = parseInt(cFirst[0], 10) + parseInt(cSecond[0], 10) + change; // Seconds. + change = Math.floor(secs / 60); + secs = secs - (change * 60); + if (Math.floor(secs) < 10) { + secs = '0' + secs.toString(); + } + + let mins: string | number = parseInt(sFirst[1], 10) + parseInt(sSecond[1], 10) + change; // Minutes. + change = Math.floor(mins / 60); + mins = mins - (change * 60); + if (mins < 10) { + mins = '0' + mins.toString(); + } + + let hours: string | number = parseInt(sFirst[0], 10) + parseInt(sSecond[0], 10) + change; // Hours. + if (hours < 10) { + hours = '0' + hours.toString(); + } + + if (cents != '0') { + return hours + ':' + mins + ':' + secs + '.' + cents; + } else { + return hours + ':' + mins + ':' + secs; + } + } + + /** + * Utility function for cloning an object + * + * @param {any} obj The object to be cloned + * @return {any} The object cloned + */ + protected cloneObj(obj: any): any { + if (obj == null || typeof(obj) != 'object') { + return obj; + } + + const temp = new obj.constructor(); // Changed (twice). + for (const key in obj) { + temp[key] = this.cloneObj(obj[key]); + } + + return temp; + } + + /** + * Collect all the user tracking data that must be persisted in the system, this is usually called by LMSCommit(). + * + * @return {any[]} Collected data. + */ + protected collectData(): any[] { + const data = []; + + for (const element in this.currentUserData[this.scoId]) { + // Ommit for example the nav. elements. + if (element.substr(0, 3) == 'cmi') { + const expression = new RegExp(this.CMI_INDEX, 'g'); + + // Get the generic name for this element (e.g. convert 'cmi.interactions.1.id' to 'cmi.interactions.n.id') + const elementModel = String(element).replace(expression, '.n.'); + + // Ignore the session time element. + if (element != 'cmi.core.session_time') { + + // Check if this specific element is not defined in the datamodel, but the generic element name is. + if (typeof this.dataModel[this.scoId][element] == 'undefined' && + typeof this.dataModel[this.scoId][elementModel] != 'undefined') { + + // Add this element to the data model (by cloning the generic element) so we can track changes to it. + this.dataModel[this.scoId][element] = this.cloneObj(this.dataModel[this.scoId][elementModel]); + } + + // Check if the current element exists in the datamodel. + if (typeof this.dataModel[this.scoId][element] != 'undefined') { + + // Make sure this is not a read only element. + if (this.dataModel[this.scoId][element].mod != 'r') { + + const el = { + // Moodle stores the organizations and interactions using _n. instead .n. + element: element.replace(expression, '_$1.'), + value: this.getEl(element) + }; + + // Check if the element has a default value. + if (typeof this.dataModel[this.scoId][element].defaultvalue != 'undefined') { + + // Check if the default value is different from the current value. + if (this.dataModel[this.scoId][element].defaultvalue != el.value || + typeof this.dataModel[this.scoId][element].defaultvalue != typeof(el.value)) { + + data.push(el); + + // Update the element default to reflect the current committed value. + this.dataModel[this.scoId][element].defaultvalue = el.value; + } + } else { + data.push(el); + + // No default value for the element, so set it now. + this.dataModel[this.scoId][element].defaultvalue = el.value; + } + } + } + } + } + } + + return data; + } + + /** + * Get the value of the given element from the non-persistent (current) user data. + * + * @param {string} el The element + * @return {any} The element value + */ + protected getEl(el: string): any { + if (typeof this.currentUserData[this.scoId] != 'undefined' && typeof this.currentUserData[this.scoId][el] != 'undefined') { + return this.currentUserData[this.scoId][el]; + } + + return ''; + } + + /** + * Initialize the model. + * + * @param {any} userData The user default data. + */ + protected init(userData: any): void { + // Prepare the definition array containing the default values. + for (const scoId in userData) { + const sco = userData[scoId]; + this.def[scoId] = sco.defaultdata; + this.defExtra[scoId] = sco.userdata; + } + + // Set up data model for each SCO. + for (const scoId in this.def) { + this.dataModel[scoId] = { + 'cmi._children': { defaultvalue: this.CMI_CHILDREN, mod: 'r', writeerror: '402' }, + 'cmi._version': { defaultvalue: '3.4', mod: 'r', writeerror: '402' }, + 'cmi.core._children': { defaultvalue: this.CORE_CHILDREN, mod: 'r', writeerror: '402' }, + 'cmi.core.student_id': { defaultvalue: this.def[scoId]['cmi.core.student_id'], mod: 'r', writeerror: '403' }, + 'cmi.core.student_name': { defaultvalue: this.def[scoId]['cmi.core.student_name'], mod: 'r', writeerror: '403' }, + 'cmi.core.lesson_location': { defaultvalue: this.def[scoId]['cmi.core.lesson_location'], + format: this.CMI_STRING_256, mod: 'rw', writeerror: '405' }, + 'cmi.core.credit': { defaultvalue: this.def[scoId]['cmi.core.credit'], mod: 'r', writeerror: '403' }, + 'cmi.core.lesson_status': { defaultvalue: this.def[scoId]['cmi.core.lesson_status'], format: this.CMI_STATUS, + mod: 'rw', writeerror: '405' }, + 'cmi.core.entry': { defaultvalue: this.def[scoId]['cmi.core.entry'], mod: 'r', writeerror: '403' }, + 'cmi.core.score._children': { defaultvalue: this.SCORE_CHILDREN, mod: 'r', writeerror: '402' }, + 'cmi.core.score.raw': { defaultvalue: this.def[scoId]['cmi.core.score.raw'], format: this.CMI_DECIMAL, + range: this.SCORE_RANGE, mod: 'rw', writeerror: '405' }, + 'cmi.core.score.max': { defaultvalue: this.def[scoId]['cmi.core.score.max'], format: this.CMI_DECIMAL, + range: this.SCORE_RANGE, mod: 'rw', writeerror: '405' }, + 'cmi.core.score.min': { defaultvalue: this.def[scoId]['cmi.core.score.min'], format: this.CMI_DECIMAL, + range: this.SCORE_RANGE, mod: 'rw', writeerror: '405' }, + 'cmi.core.total_time': { defaultvalue: this.def[scoId]['cmi.core.total_time'], mod: 'r', writeerror: '403' }, + 'cmi.core.lesson_mode': { defaultvalue: this.def[scoId]['cmi.core.lesson_mode'], mod: 'r', writeerror: '403' }, + 'cmi.core.exit': { defaultvalue: this.def[scoId]['cmi.core.exit'], format: this.CMI_EXIT, mod: 'w', + readerror: '404', writeerror: '405' }, + 'cmi.core.session_time': { format: this.CMI_TIMESPAN, mod: 'w', defaultvalue: '00:00:00', readerror: '404', + writeerror: '405' }, + 'cmi.suspend_data': { defaultvalue: this.def[scoId]['cmi.suspend_data'], format: this.CMI_STRING_4096, + mod: 'rw', writeerror: '405' }, + 'cmi.launch_data': { defaultvalue: this.def[scoId]['cmi.launch_data'], mod: 'r', writeerror: '403' }, + 'cmi.comments': { defaultvalue: this.def[scoId]['cmi.comments'], format: this.CMI_STRING_4096, mod: 'rw', + writeerror: '405' }, + // Deprecated evaluation attributes. + 'cmi.evaluation.comments._count': { defaultvalue: '0', mod: 'r', writeerror: '402' }, + 'cmi.evaluation.comments._children': { defaultvalue: this.COMMENTS_CHILDREN, mod: 'r', writeerror: '402' }, + 'cmi.evaluation.comments.n.content': { defaultvalue: '', pattern: this.CMI_INDEX, format: this.CMI_STRING_256, + mod: 'rw', writeerror: '405' }, + 'cmi.evaluation.comments.n.location': { defaultvalue: '', pattern: this.CMI_INDEX, format: this.CMI_STRING_256, + mod: 'rw', writeerror: '405' }, + 'cmi.evaluation.comments.n.time': { defaultvalue: '', pattern: this.CMI_INDEX, format: this.CMI_TIME, + mod: 'rw', writeerror: '405' }, + 'cmi.comments_from_lms': { mod: 'r', writeerror: '403' }, + 'cmi.objectives._children': { defaultvalue: this.OBJECTIVES_CHILDREN, mod: 'r', writeerror: '402' }, + 'cmi.objectives._count': { mod: 'r', defaultvalue: '0', writeerror: '402' }, + 'cmi.objectives.n.id': { pattern: this.CMI_INDEX, format: this.CMI_IDENTIFIER, mod: 'rw', writeerror: '405' }, + 'cmi.objectives.n.score._children': { pattern: this.CMI_INDEX, mod: 'r', writeerror: '402' }, + 'cmi.objectives.n.score.raw': { defaultvalue: '', pattern: this.CMI_INDEX, format: this.CMI_DECIMAL, + range: this.SCORE_RANGE, mod: 'rw', writeerror: '405' }, + 'cmi.objectives.n.score.min': { defaultvalue: '', pattern: this.CMI_INDEX, format: this.CMI_DECIMAL, + range: this.SCORE_RANGE, mod: 'rw', writeerror: '405' }, + 'cmi.objectives.n.score.max': { defaultvalue: '', pattern: this.CMI_INDEX, format: this.CMI_DECIMAL, + range: this.SCORE_RANGE, mod: 'rw', writeerror: '405' }, + 'cmi.objectives.n.status': { pattern: this.CMI_INDEX, format: this.CMI_STATUS_2, mod: 'rw', writeerror: '405' }, + 'cmi.student_data._children': { defaultvalue: this.STUDENT_DATA_CHILDREN, mod: 'r', writeerror: '402' }, + 'cmi.student_data.mastery_score': { defaultvalue: this.def[scoId]['cmi.student_data.mastery_score'], mod: 'r', + writeerror: '403' }, + 'cmi.student_data.max_time_allowed': { defaultvalue: this.def[scoId]['cmi.student_data.max_time_allowed'], + mod: 'r', writeerror: '403' }, + 'cmi.student_data.time_limit_action': { defaultvalue: this.def[scoId]['cmi.student_data.time_limit_action'], + mod: 'r', writeerror: '403' }, + 'cmi.student_preference._children': { defaultvalue: this.STUDENT_PREFERENCE_CHILDREN, mod: 'r', + writeerror: '402' }, + 'cmi.student_preference.audio': { defaultvalue: this.def[scoId]['cmi.student_preference.audio'], + format: this.CMI_SINTEGER, range: this.AUDIO_RANGE, mod: 'rw', writeerror: '405' }, + 'cmi.student_preference.language': { defaultvalue: this.def[scoId]['cmi.student_preference.language'], + format: this.CMI_STRING_256, mod: 'rw', writeerror: '405' }, + 'cmi.student_preference.speed': { defaultvalue: this.def[scoId]['cmi.student_preference.speed'], + format: this.CMI_SINTEGER, range: this.SPEED_RANGE, mod: 'rw', writeerror: '405' }, + 'cmi.student_preference.text': { defaultvalue: this.def[scoId]['cmi.student_preference.text'], + format: this.CMI_SINTEGER, range: this.TEXT_RANGE, mod: 'rw', writeerror: '405' }, + 'cmi.interactions._children': { defaultvalue: this.INTERACTIONS_CHILDREN, mod: 'r', writeerror: '402' }, + 'cmi.interactions._count': { mod: 'r', defaultvalue: '0', writeerror: '402' }, + 'cmi.interactions.n.id': { pattern: this.CMI_INDEX, format: this.CMI_IDENTIFIER, mod: 'w', readerror: '404', + writeerror: '405' }, + 'cmi.interactions.n.objectives._count': { pattern: this.CMI_INDEX, mod: 'r', defaultvalue: '0', writeerror: '402' }, + 'cmi.interactions.n.objectives.n.id': { pattern: this.CMI_INDEX, format: this.CMI_IDENTIFIER, mod: 'w', + readerror: '404', writeerror: '405' }, + 'cmi.interactions.n.time': { pattern: this.CMI_INDEX, format: this.CMI_TIME, mod: 'w', readerror: '404', + writeerror: '405' }, + 'cmi.interactions.n.type': { pattern: this.CMI_INDEX, format: this.CMI_TYPE, mod: 'w', readerror: '404', + writeerror: '405' }, + 'cmi.interactions.n.correct_responses._count': { pattern: this.CMI_INDEX, mod: 'r', defaultvalue: '0', + writeerror: '402' }, + 'cmi.interactions.n.correct_responses.n.pattern': { pattern: this.CMI_INDEX, format: this.CMI_FEEDBACK, + mod: 'w', readerror: '404', writeerror: '405' }, + 'cmi.interactions.n.weighting': { pattern: this.CMI_INDEX, format: this.CMI_DECIMAL, + range: this.WEIGHTING_RANGE, mod: 'w', readerror: '404', writeerror: '405' }, + 'cmi.interactions.n.student_response': { pattern: this.CMI_INDEX, format: this.CMI_FEEDBACK, mod: 'w', + readerror: '404', writeerror: '405' }, + 'cmi.interactions.n.result': { pattern: this.CMI_INDEX, format: this.CMI_RESULT, mod: 'w', readerror: '404', + writeerror: '405' }, + 'cmi.interactions.n.latency': { pattern: this.CMI_INDEX, format: this.CMI_TIMESPAN, mod: 'w', + readerror: '404', writeerror: '405' }, + 'nav.event': { defaultvalue: '', format: this.NAV_EVENT, mod: 'w', readerror: '404', writeerror: '405' } + }; + + this.currentUserData[scoId] = {}; + + // Load default values. + for (const element in this.dataModel[scoId]) { + if (element.match(/\.n\./) === null) { + if (typeof this.dataModel[scoId][element].defaultvalue != 'undefined') { + this.currentUserData[scoId][element] = this.dataModel[scoId][element].defaultvalue; + } + } + } + + // Load initial user data for current SCO. + for (const element in this.def[scoId]) { + if (element.match(/\.n\./) === null) { + if (typeof this.dataModel[scoId][element].defaultvalue != 'undefined') { + this.currentUserData[scoId][element] = this.dataModel[scoId][element].defaultvalue; + } else if (typeof this.defExtra[scoId][element] != 'undefined') { + // Check in user data values. + this.currentUserData[scoId][element] = this.defExtra[scoId][element]; + } else { + this.currentUserData[scoId][element] = ''; + } + } + } + + // Load interactions and objectives, and init the counters. + const expression = new RegExp(this.CMI_INDEX, 'g'); + + for (const element in this.defExtra[scoId]) { + let counterElement = '', + currentCounterIndex: any = 0, + elementDotFormat, + currentN; + + // This check for an indexed element. cmi.objectives.1.id or cmi.objectives_1.id. + if (element.match(expression)) { + // Normalize to the expected value according the standard. + // Moodle stores this values using _n. instead .n. + elementDotFormat = element.replace(expression, '.$1.'); + this.currentUserData[scoId][elementDotFormat] = this.defExtra[scoId][element]; + + // Get the correct counter and current index. + if (elementDotFormat.indexOf('cmi.evaluation.comments') === 0) { + counterElement = 'cmi.evaluation.comments._count'; + currentCounterIndex = elementDotFormat.match(/.(\d+)./)[1]; + } else if (elementDotFormat.indexOf('cmi.objectives') === 0) { + counterElement = 'cmi.objectives._count'; + currentCounterIndex = elementDotFormat.match(/.(\d+)./)[1]; + } else if (elementDotFormat.indexOf('cmi.interactions') === 0) { + if (elementDotFormat.indexOf('.objectives.') > 0) { + currentN = elementDotFormat.match(/cmi.interactions.(\d+)./)[1]; + currentCounterIndex = elementDotFormat.match(/objectives.(\d+)./)[1]; + counterElement = 'cmi.interactions.' + currentN + '.objectives._count'; + } else if (elementDotFormat.indexOf('.correct_responses.') > 0) { + currentN = elementDotFormat.match(/cmi.interactions.(\d+)./)[1]; + currentCounterIndex = elementDotFormat.match(/correct_responses.(\d+)./)[1]; + counterElement = 'cmi.interactions.' + currentN + '.correct_responses._count'; + } else { + counterElement = 'cmi.interactions._count'; + currentCounterIndex = elementDotFormat.match(/.(\d+)./)[1]; + } + } + + if (counterElement) { + if (typeof this.currentUserData[scoId][counterElement] == 'undefined') { + this.currentUserData[scoId][counterElement] = 0; + } + // Check if we need to sum. + if (parseInt(currentCounterIndex) == parseInt(this.currentUserData[scoId][counterElement])) { + this.currentUserData[scoId][counterElement] = parseInt(this.currentUserData[scoId][counterElement]) + 1; + } + if (parseInt(currentCounterIndex) > parseInt(this.currentUserData[scoId][counterElement])) { + this.currentUserData[scoId][counterElement] = parseInt(currentCounterIndex) - 1; + } + } + + } + } + + // Set default status. + if (this.currentUserData[scoId]['cmi.core.lesson_status'] === '') { + this.currentUserData[scoId]['cmi.core.lesson_status'] = 'not attempted'; + } + + // Define mode and credit. + this.currentUserData[scoId]['cmi.core.credit'] = this.mode == AddonModScormProvider.MODENORMAL ? 'credit' : 'no-credit'; + this.currentUserData[scoId]['cmi.core.lesson_mode'] = this.mode; + } + } + + /** + * Commit the changes. + * + * @param {string} param Param. + * @return {string} "true" if success, "false" otherwise. + */ + LMSCommit(param: string): string { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + + this.errorCode = '0'; + if (param == '') { + if (this.initialized) { + const result = this.storeData(false); + + // Trigger TOC update. + this.triggerEvent(AddonModScormProvider.UPDATE_TOC_EVENT); + + this.errorCode = result ? '0' : '101'; + + // Conver to string representing a boolean. + return result ? 'true' : 'false'; + } else { + this.errorCode = '301'; + } + } else { + this.errorCode = '201'; + } + + return 'false'; + } + + /** + * Finish the data model. + * + * @param {string} param Param. + * @return {string} "true" if success, "false" otherwise. + */ + LMSFinish(param: string): string { + this.errorCode = '0'; + + if (param == '') { + if (this.initialized) { + this.initialized = false; + + const result = this.storeData(true); + if (this.getEl('nav.event') != '') { + if (this.getEl('nav.event') == 'continue') { + this.triggerEvent(AddonModScormProvider.LAUNCH_NEXT_SCO_EVENT); + } else { + this.triggerEvent(AddonModScormProvider.LAUNCH_PREV_SCO_EVENT); + } + } else { + if (this.scorm.auto == '1') { + this.triggerEvent(AddonModScormProvider.LAUNCH_NEXT_SCO_EVENT); + } + } + + this.errorCode = result ? '0' : '101'; + + // Trigger TOC update. + this.triggerEvent(AddonModScormProvider.UPDATE_TOC_EVENT); + + // Conver to string representing a boolean. + return result ? 'true' : 'false'; + } else { + this.errorCode = '301'; + } + } else { + this.errorCode = '201'; + } + + return 'false'; + } + + /** + * Get diagnostic. + * + * @param {string} param Param. + * @return {string} Result. + */ + LMSGetDiagnostic(param: string): string { + if (param == '') { + param = this.errorCode; + } + + return param; + } + + /** + * Get the error message for a certain code. + * + * @param {string} param Error code. + * @return {string} Error message. + */ + LMSGetErrorString(param: string): string { + if (param != '') { + return this.ERROR_STRINGS[param]; + } else { + return ''; + } + } + + /** + * Get the last error code. + * + * @return {string} Last error code. + */ + LMSGetLastError(): string { + return this.errorCode; + } + + /** + * Get the value of a certain element. + * + * @param {string} element Name of the element to get. + * @return {string} Value. + */ + LMSGetValue(element: string): string { + this.errorCode = '0'; + + if (this.initialized) { + if (element != '') { + const expression = new RegExp(this.CMI_INDEX, 'g'), + elementModel = String(element).replace(expression, '.n.'); + + if (typeof this.dataModel[this.scoId][elementModel] != 'undefined') { + if (this.dataModel[this.scoId][elementModel].mod != 'w') { + this.errorCode = '0'; + + return this.getEl(element); + } else { + this.errorCode = this.dataModel[this.scoId][elementModel].readerror; + } + } else { + const childrenStr = '._children', + countStr = '._count'; + + if (elementModel.substr(elementModel.length - childrenStr.length, elementModel.length) == childrenStr) { + const parentModel = elementModel.substr(0, elementModel.length - childrenStr.length); + + if (typeof this.dataModel[this.scoId][parentModel] != 'undefined') { + this.errorCode = '202'; + } else { + this.errorCode = '201'; + } + } else if (elementModel.substr(elementModel.length - countStr.length, elementModel.length) == countStr) { + const parentModel = elementModel.substr(0, elementModel.length - countStr.length); + + if (typeof this.dataModel[this.scoId][parentModel] != 'undefined') { + this.errorCode = '203'; + } else { + this.errorCode = '201'; + } + } else { + this.errorCode = '201'; + } + } + } else { + this.errorCode = '201'; + } + } else { + this.errorCode = '301'; + } + + return ''; + } + + /** + * Initialize the data model. + * + * @param {string} param Param. + * @return {string} "true" if initialized, "false" otherwise. + */ + LMSInitialize(param: string): string { + this.errorCode = '0'; + + if (param == '') { + if (!this.initialized) { + this.initialized = true; + this.errorCode = '0'; + + return 'true'; + } else { + this.errorCode = '101'; + } + } else { + this.errorCode = '201'; + } + + return 'false'; + } + + /** + * Set the value of a certain element. + * + * @param {string} element Name of the element to set. + * @param {any} value Value to set. + * @return {string} "true" if success, "false" otherwise. + */ + LMSSetValue(element: string, value: any): string { + this.errorCode = '0'; + + if (this.initialized) { + if (element != '') { + let expression = new RegExp(this.CMI_INDEX, 'g'); + const elementModel = String(element).replace(expression, '.n.'); + + if (typeof this.dataModel[this.scoId][elementModel] != 'undefined') { + if (this.dataModel[this.scoId][elementModel].mod != 'r') { + expression = new RegExp(this.dataModel[this.scoId][elementModel].format); + value = value + ''; + + const matches = value.match(expression); + + if (matches != null) { + // Create dynamic data model element. + if (element != elementModel) { + + // Init default counters and values. + if (element.indexOf('cmi.objectives') === 0) { + const currentN = element.match(/cmi.objectives.(\d+)./)[1], + counterElement = 'cmi.objectives.' + currentN + '.score'; + + if (typeof this.currentUserData[this.scoId][counterElement + '._children'] == 'undefined') { + this.setEl(this.currentUserData[this.scoId][counterElement + '._children'], + this.SCORE_CHILDREN); + this.setEl(this.currentUserData[this.scoId][counterElement + '.raw'], ''); + this.setEl(this.currentUserData[this.scoId][counterElement + '.min'], ''); + this.setEl(this.currentUserData[this.scoId][counterElement + '.max'], ''); + } + + } else if (element.indexOf('cmi.interactions') === 0) { + const currentN = element.match(/cmi.interactions.(\d+)./)[1]; + let counterElement = 'cmi.interactions.' + currentN + '.objectives._count'; + + if (typeof this.currentUserData[this.scoId][counterElement] == 'undefined') { + this.setEl(counterElement, 0); + } + + counterElement = 'cmi.interactions.' + currentN + '.correct_responses._count'; + if (typeof this.currentUserData[this.scoId][counterElement] == 'undefined') { + this.setEl(counterElement, 0); + } + } + + const elementIndexes = element.split('.'); + let subElement = 'cmi'; + + for (let i = 1; i < elementIndexes.length - 1; i++) { + const elementIndex = elementIndexes[i]; + + if (elementIndexes[i + 1].match(/^\d+$/)) { + const counterElement = subElement + '.' + elementIndex + '._count'; + + if (typeof this.currentUserData[this.scoId][counterElement] == 'undefined') { + this.setEl(counterElement, 0); + } + + if (elementIndexes[i + 1] == this.getEl(counterElement)) { + const count = this.getEl(counterElement); + this.setEl(counterElement, parseInt(count, 10) + 1); + } + + if (elementIndexes[i + 1] > this.getEl(counterElement)) { + this.errorCode = '201'; + } + + subElement = subElement.concat('.' + elementIndex + '.' + elementIndexes[i + 1]); + i++; + } else { + subElement = subElement.concat('.' + elementIndex); + } + } + + element = subElement.concat('.' + elementIndexes[elementIndexes.length - 1]); + } + + // Store data. + if (this.errorCode == '0') { + if (this.scorm.autocommit && !(this.timeout)) { + this.timeout = setTimeout(this.LMSCommit.bind(this), 60000, ['']); + } + + if (typeof this.dataModel[this.scoId][elementModel].range != 'undefined') { + const range = this.dataModel[this.scoId][elementModel].range, + ranges = range.split('#'); + + value = value * 1.0; + if ((value >= ranges[0]) && (value <= ranges[1])) { + this.setEl(element, value); + this.errorCode = '0'; + + return 'true'; + } else { + this.errorCode = this.dataModel[this.scoId][elementModel].writeerror; + } + } else { + if (element == 'cmi.comments') { + this.setEl('cmi.comments', this.getEl('cmi.comments') + value); + } else { + this.setEl(element, value); + } + this.errorCode = '0'; + + return 'true'; + } + } + } else { + this.errorCode = this.dataModel[this.scoId][elementModel].writeerror; + } + } else { + this.errorCode = this.dataModel[this.scoId][elementModel].writeerror; + } + } else { + this.errorCode = '201'; + } + } else { + this.errorCode = '201'; + } + } else { + this.errorCode = '301'; + } + + return 'false'; + } + + /** + * Set a SCO ID. + * The scoId is like a pointer to be able to retrieve the SCO default values and set the new ones in the overall SCORM + * data structure. + * + * @param {number} scoId The new SCO id. + */ + loadSco(scoId: number): void { + this.scoId = scoId; + } + + /** + * Set the value of the given element in the non-persistent (current) user data. + * + * @param {string} el The element. + * @param {any} value The value. + */ + protected setEl(el: string, value: any): void { + if (typeof this.currentUserData[this.scoId] == 'undefined') { + this.currentUserData[this.scoId] = {}; + } + + this.currentUserData[this.scoId][el] = value; + } + + /** + * Set offline mode to true or false. + * + * @param {boolean} offline True if offline, false otherwise. + */ + setOffline(offline: boolean): void { + this.offline = offline; + } + + /** + * Persist the current user data (this is usually called by LMSCommit). + * + * @param {boolean} storeTotalTime If true, we need to calculate the total time too. + * @return {boolean} True if success, false otherwise. + */ + protected storeData(storeTotalTime?: boolean): boolean { + let tracks; + + if (storeTotalTime) { + if (this.getEl('cmi.core.lesson_status') == 'not attempted') { + this.setEl('cmi.core.lesson_status', 'completed'); + } + + if (this.getEl('cmi.core.lesson_mode') == AddonModScormProvider.MODENORMAL) { + if (this.getEl('cmi.core.credit') == 'credit') { + if (this.getEl('cmi.student_data.mastery_score') !== '' && this.getEl('cmi.core.score.raw') !== '') { + if (parseFloat(this.getEl('cmi.core.score.raw')) >= + parseFloat(this.getEl('cmi.student_data.mastery_score'))) { + this.setEl('cmi.core.lesson_status', 'passed'); + } else { + this.setEl('cmi.core.lesson_status', 'failed'); + } + } + } + } + + if (this.getEl('cmi.core.lesson_mode') == AddonModScormProvider.MODEBROWSE) { + if (this.dataModel[this.scoId]['cmi.core.lesson_status'].defaultvalue == '' && + this.getEl('cmi.core.lesson_status') == 'not attempted') { + this.setEl('cmi.core.lesson_status', 'browsed'); + } + } + + tracks = this.collectData(); + tracks.push(this.totalTime()); + } else { + tracks = this.collectData(); + } + + const success = this.scormProvider.saveTracksSync(this.scoId, this.attempt, tracks, this.scorm, this.offline, + this.currentUserData); + + if (!this.offline && !success) { + // Failure storing data in online. Go offline. + this.offline = true; + + this.triggerEvent(AddonModScormProvider.GO_OFFLINE_EVENT); + + return this.scormProvider.saveTracksSync(this.scoId, this.attempt, tracks, this.scorm, this.offline, + this.currentUserData); + } + + return success; + } + + /** + * Utility function for calculating the total time spent in the SCO. + * + * @return {any} Total time element. + */ + protected totalTime(): any { + const totalTime = this.addTime(this.getEl('cmi.core.total_time'), this.getEl('cmi.core.session_time')); + + return { element: 'cmi.core.total_time', value: totalTime }; + } + + /** + * Convenience function to trigger events. + * + * @param {string} name Name of the event to trigger. + */ + protected triggerEvent(name: string): void { + this.eventsProvider.trigger(name, { + scormId: this.scorm.id, + scoId: this.scoId, + attempt: this.attempt + }, this.siteId); + } +} diff --git a/src/addon/mod/scorm/providers/scorm.ts b/src/addon/mod/scorm/providers/scorm.ts index 3b6a41dbe..b71ac4800 100644 --- a/src/addon/mod/scorm/providers/scorm.ts +++ b/src/addon/mod/scorm/providers/scorm.ts @@ -77,6 +77,12 @@ export class AddonModScormProvider { static MODENORMAL = 'normal'; static MODEREVIEW = 'review'; + // Events. + static LAUNCH_NEXT_SCO_EVENT = 'addon_mod_scorm_launch_next_sco'; + static LAUNCH_PREV_SCO_EVENT = 'addon_mod_scorm_launch_prev_sco'; + static UPDATE_TOC_EVENT = 'addon_mod_scorm_update_toc'; + static GO_OFFLINE_EVENT = 'addon_mod_scorm_go_offline'; + // Protected constants. protected VALID_STATUSES = ['notattempted', 'passed', 'completed', 'failed', 'incomplete', 'browsed', 'suspend']; protected STATUSES = { From b2bd25fdcf20a7d540c1995921bba480734f3248 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 24 Apr 2018 16:50:15 +0200 Subject: [PATCH 4/7] MOBILE-2350 scorm: Implement handlers --- .../mod/scorm/providers/grade-link-handler.ts | 32 +++++++++ .../mod/scorm/providers/index-link-handler.ts | 29 ++++++++ .../mod/scorm/providers/module-handler.ts | 71 +++++++++++++++++++ .../mod/scorm/providers/pluginfile-handler.ts | 49 +++++++++++++ .../mod/scorm/providers/sync-cron-handler.ts | 47 ++++++++++++ src/addon/mod/scorm/scorm.module.ts | 30 +++++++- src/app/app.module.ts | 2 + 7 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 src/addon/mod/scorm/providers/grade-link-handler.ts create mode 100644 src/addon/mod/scorm/providers/index-link-handler.ts create mode 100644 src/addon/mod/scorm/providers/module-handler.ts create mode 100644 src/addon/mod/scorm/providers/pluginfile-handler.ts create mode 100644 src/addon/mod/scorm/providers/sync-cron-handler.ts diff --git a/src/addon/mod/scorm/providers/grade-link-handler.ts b/src/addon/mod/scorm/providers/grade-link-handler.ts new file mode 100644 index 000000000..9d3dd8674 --- /dev/null +++ b/src/addon/mod/scorm/providers/grade-link-handler.ts @@ -0,0 +1,32 @@ +// (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 { CoreSitesProvider } from '@providers/sites'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreContentLinksModuleGradeHandler } from '@core/contentlinks/classes/module-grade-handler'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; + +/** + * Handler to treat links to SCORM grade. + */ +@Injectable() +export class AddonModScormGradeLinkHandler extends CoreContentLinksModuleGradeHandler { + name = 'AddonModScormGradeLinkHandler'; + canReview = false; + + constructor(courseHelper: CoreCourseHelperProvider, domUtils: CoreDomUtilsProvider, sitesProvider: CoreSitesProvider) { + super(courseHelper, domUtils, sitesProvider, 'AddonModScorm', 'scorm'); + } +} diff --git a/src/addon/mod/scorm/providers/index-link-handler.ts b/src/addon/mod/scorm/providers/index-link-handler.ts new file mode 100644 index 000000000..3ade9ccf3 --- /dev/null +++ b/src/addon/mod/scorm/providers/index-link-handler.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 { Injectable } from '@angular/core'; +import { CoreContentLinksModuleIndexHandler } from '@core/contentlinks/classes/module-index-handler'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; + +/** + * Handler to treat links to SCORM index. + */ +@Injectable() +export class AddonModScormIndexLinkHandler extends CoreContentLinksModuleIndexHandler { + name = 'AddonModScormIndexLinkHandler'; + + constructor(courseHelper: CoreCourseHelperProvider) { + super(courseHelper, 'AddonModScorm', 'scorm'); + } +} diff --git a/src/addon/mod/scorm/providers/module-handler.ts b/src/addon/mod/scorm/providers/module-handler.ts new file mode 100644 index 000000000..217c0c432 --- /dev/null +++ b/src/addon/mod/scorm/providers/module-handler.ts @@ -0,0 +1,71 @@ +// (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 { NavController, NavOptions } from 'ionic-angular'; +import { AddonModScormIndexComponent } from '../components/index/index'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@core/course/providers/module-delegate'; +import { CoreCourseProvider } from '@core/course/providers/course'; + +/** + * Handler to support SCORM modules. + */ +@Injectable() +export class AddonModScormModuleHandler implements CoreCourseModuleHandler { + name = 'AddonModScorm'; + modName = 'scorm'; + + constructor(private courseProvider: CoreCourseProvider) { } + + /** + * Check if the handler is enabled on a site level. + * + * @return {boolean} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean { + return true; + } + + /** + * Get the data required to display the module in the course contents view. + * + * @param {any} module The module object. + * @param {number} courseId The course ID. + * @param {number} sectionId The section ID. + * @return {CoreCourseModuleHandlerData} Data to render the module. + */ + getData(module: any, courseId: number, sectionId: number): CoreCourseModuleHandlerData { + return { + icon: this.courseProvider.getModuleIconSrc('scorm'), + title: module.name, + class: 'addon-mod_scorm-handler', + showDownloadButton: true, + action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void { + navCtrl.push('AddonModScormIndexPage', {module: module, courseId: courseId}, options); + } + }; + } + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. + * + * @param {any} course The course object. + * @param {any} module The module object. + * @return {any} The component to use, undefined if not found. + */ + getMainComponent(course: any, module: any): any { + return AddonModScormIndexComponent; + } +} diff --git a/src/addon/mod/scorm/providers/pluginfile-handler.ts b/src/addon/mod/scorm/providers/pluginfile-handler.ts new file mode 100644 index 000000000..6910b67b4 --- /dev/null +++ b/src/addon/mod/scorm/providers/pluginfile-handler.ts @@ -0,0 +1,49 @@ +// (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 { CorePluginFileHandler } from '@providers/plugin-file-delegate'; + +/** + * Handler to treat file URLs in SCORM. + */ +@Injectable() +export class AddonModScormPluginFileHandler implements CorePluginFileHandler { + name = 'AddonModScormPluginFileHandler'; + + /** + * Return the RegExp to match the revision on pluginfile URLs. + * + * @param {string[]} args Arguments of the pluginfile URL defining component and filearea at least. + * @return {RegExp} RegExp to match the revision on pluginfile URLs. + */ + getComponentRevisionRegExp(args: string[]): RegExp { + // Check filearea. + if (args[2] == 'content') { + // Component + Filearea + Revision + return new RegExp('/mod_resource/content/([0-9]+)/'); + } + } + + /** + * Should return the string to remove the revision on pluginfile url. + * + * @param {string[]} args Arguments of the pluginfile URL defining component and filearea at least. + * @return {string} String to remove the revision on pluginfile url. + */ + getComponentRevisionReplace(args: string[]): string { + // Component + Filearea + Revision + return '/mod_scorm/content/0/'; + } +} diff --git a/src/addon/mod/scorm/providers/sync-cron-handler.ts b/src/addon/mod/scorm/providers/sync-cron-handler.ts new file mode 100644 index 000000000..0d3acecd5 --- /dev/null +++ b/src/addon/mod/scorm/providers/sync-cron-handler.ts @@ -0,0 +1,47 @@ +// (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 { CoreCronHandler } from '@providers/cron'; +import { AddonModScormSyncProvider } from './scorm-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class AddonModScormSyncCronHandler implements CoreCronHandler { + name = 'AddonModScormSyncCronHandler'; + + constructor(private scormSync: AddonModScormSyncProvider) {} + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @return {Promise} Promise resolved when done, rejected if failure. + */ + execute(siteId?: string): Promise { + return this.scormSync.syncAllScorms(siteId); + } + + /** + * Get the time between consecutive executions. + * + * @return {number} Time between consecutive executions (in ms). + */ + getInterval(): number { + return AddonModScormSyncProvider.SYNC_TIME; + } +} diff --git a/src/addon/mod/scorm/scorm.module.ts b/src/addon/mod/scorm/scorm.module.ts index 7a9adc132..2d9e6c6b3 100644 --- a/src/addon/mod/scorm/scorm.module.ts +++ b/src/addon/mod/scorm/scorm.module.ts @@ -13,21 +13,47 @@ // limitations under the License. import { NgModule } from '@angular/core'; +import { CoreCronDelegate } from '@providers/cron'; +import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; +import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; import { AddonModScormProvider } from './providers/scorm'; import { AddonModScormOfflineProvider } from './providers/scorm-offline'; +import { AddonModScormModuleHandler } from './providers/module-handler'; import { AddonModScormPrefetchHandler } from './providers/prefetch-handler'; +import { AddonModScormSyncCronHandler } from './providers/sync-cron-handler'; +import { AddonModScormIndexLinkHandler } from './providers/index-link-handler'; +import { AddonModScormGradeLinkHandler } from './providers/grade-link-handler'; import { AddonModScormSyncProvider } from './providers/scorm-sync'; +import { AddonModScormComponentsModule } from './components/components.module'; @NgModule({ declarations: [ ], imports: [ + AddonModScormComponentsModule ], providers: [ AddonModScormProvider, AddonModScormOfflineProvider, + AddonModScormSyncProvider, + AddonModScormModuleHandler, AddonModScormPrefetchHandler, - AddonModScormSyncProvider + AddonModScormSyncCronHandler, + AddonModScormIndexLinkHandler, + AddonModScormGradeLinkHandler ] }) -export class AddonModScormModule { } +export class AddonModScormModule { + constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModScormModuleHandler, + prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModScormPrefetchHandler, + cronDelegate: CoreCronDelegate, syncHandler: AddonModScormSyncCronHandler, linksDelegate: CoreContentLinksDelegate, + indexHandler: AddonModScormIndexLinkHandler, gradeHandler: AddonModScormGradeLinkHandler) { + + moduleDelegate.registerHandler(moduleHandler); + prefetchDelegate.registerHandler(prefetchHandler); + cronDelegate.register(syncHandler); + linksDelegate.registerHandler(indexHandler); + linksDelegate.registerHandler(gradeHandler); + } +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 90831a8a8..3f85cb6bf 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -86,6 +86,7 @@ import { AddonModFeedbackModule } from '@addon/mod/feedback/feedback.module'; import { AddonModFolderModule } from '@addon/mod/folder/folder.module'; import { AddonModPageModule } from '@addon/mod/page/page.module'; import { AddonModQuizModule } from '@addon/mod/quiz/quiz.module'; +import { AddonModScormModule } from '@addon/mod/scorm/scorm.module'; import { AddonModUrlModule } from '@addon/mod/url/url.module'; import { AddonModSurveyModule } from '@addon/mod/survey/survey.module'; import { AddonModImscpModule } from '@addon/mod/imscp/imscp.module'; @@ -185,6 +186,7 @@ export const CORE_PROVIDERS: any[] = [ AddonModFolderModule, AddonModPageModule, AddonModQuizModule, + AddonModScormModule, AddonModUrlModule, AddonModSurveyModule, AddonModImscpModule, From dffc8c3c6ccc668864ac1c0c1c5d2f5bd6b1dd04 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 25 Apr 2018 10:06:04 +0200 Subject: [PATCH 5/7] MOBILE-2350 scorm: Implement index page and component --- src/addon/mod/quiz/components/index/index.ts | 8 +- .../mod/scorm/components/components.module.ts | 45 ++ .../mod/scorm/components/index/index.html | 170 ++++++ .../mod/scorm/components/index/index.scss | 9 + src/addon/mod/scorm/components/index/index.ts | 531 ++++++++++++++++++ src/addon/mod/scorm/pages/index/index.html | 16 + .../mod/scorm/pages/index/index.module.ts | 33 ++ src/addon/mod/scorm/pages/index/index.ts | 62 ++ src/addon/mod/scorm/providers/helper.ts | 121 ++++ src/addon/mod/scorm/scorm.module.ts | 2 + src/app/app.ios.scss | 7 + src/app/app.md.scss | 7 + src/app/app.scss | 2 +- src/app/app.wp.scss | 7 + .../course/classes/main-activity-component.ts | 7 +- 15 files changed, 1018 insertions(+), 9 deletions(-) create mode 100644 src/addon/mod/scorm/components/components.module.ts create mode 100644 src/addon/mod/scorm/components/index/index.html create mode 100644 src/addon/mod/scorm/components/index/index.scss create mode 100644 src/addon/mod/scorm/components/index/index.ts create mode 100644 src/addon/mod/scorm/pages/index/index.html create mode 100644 src/addon/mod/scorm/pages/index/index.module.ts create mode 100644 src/addon/mod/scorm/pages/index/index.ts create mode 100644 src/addon/mod/scorm/providers/helper.ts diff --git a/src/addon/mod/quiz/components/index/index.ts b/src/addon/mod/quiz/components/index/index.ts index 2e60facfa..d43378381 100644 --- a/src/addon/mod/quiz/components/index/index.ts +++ b/src/addon/mod/quiz/components/index/index.ts @@ -16,7 +16,6 @@ import { Component, Optional, Injector } from '@angular/core'; import { Content, NavController } from 'ionic-angular'; import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component'; import { CoreQuestionBehaviourDelegate } from '@core/question/providers/behaviour-delegate'; -import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { AddonModQuizProvider } from '../../providers/quiz'; import { AddonModQuizHelperProvider } from '../../providers/helper'; import { AddonModQuizOfflineProvider } from '../../providers/quiz-offline'; @@ -71,8 +70,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp constructor(injector: Injector, protected quizProvider: AddonModQuizProvider, @Optional() content: Content, protected quizHelper: AddonModQuizHelperProvider, protected quizOffline: AddonModQuizOfflineProvider, protected quizSync: AddonModQuizSyncProvider, protected behaviourDelegate: CoreQuestionBehaviourDelegate, - protected prefetchHandler: AddonModQuizPrefetchHandler, protected navCtrl: NavController, - protected prefetchDelegate: CoreCourseModulePrefetchDelegate) { + protected prefetchHandler: AddonModQuizPrefetchHandler, protected navCtrl: NavController) { super(injector, content); } @@ -117,7 +115,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp // If the site doesn't support check updates, always prefetch it because we cannot tell if there's something new. const isDownloaded = this.currentStatus == CoreConstants.DOWNLOADED; - if (!isDownloaded || !this.prefetchDelegate.canCheckUpdates()) { + if (!isDownloaded || !this.modulePrefetchDelegate.canCheckUpdates()) { // Prefetch the quiz. this.showStatusSpinner = true; @@ -125,7 +123,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp // Success downloading, open quiz. this.openQuiz(); }).catch((error) => { - if (this.hasOffline || (isDownloaded && !this.prefetchDelegate.canCheckUpdates())) { + if (this.hasOffline || (isDownloaded && !this.modulePrefetchDelegate.canCheckUpdates())) { // Error downloading but there is something offline, allow continuing it. // If the site doesn't support check updates, continue too because we cannot tell if there's something new. this.openQuiz(); diff --git a/src/addon/mod/scorm/components/components.module.ts b/src/addon/mod/scorm/components/components.module.ts new file mode 100644 index 000000000..2a9c6569b --- /dev/null +++ b/src/addon/mod/scorm/components/components.module.ts @@ -0,0 +1,45 @@ +// (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 { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreCourseComponentsModule } from '@core/course/components/components.module'; +import { AddonModScormIndexComponent } from './index/index'; + +@NgModule({ + declarations: [ + AddonModScormIndexComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CoreCourseComponentsModule + ], + providers: [ + ], + exports: [ + AddonModScormIndexComponent + ], + entryComponents: [ + AddonModScormIndexComponent + ] +}) +export class AddonModScormComponentsModule {} diff --git a/src/addon/mod/scorm/components/index/index.html b/src/addon/mod/scorm/components/index/index.html new file mode 100644 index 000000000..a9bb0b3d0 --- /dev/null +++ b/src/addon/mod/scorm/components/index/index.html @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + +
+ + {{ scorm.warningMessage }} +
+ +
+ + + +

{{ 'addon.mod_scorm.attempts' | translate }}

+
+ + + +

{{ 'addon.mod_scorm.noattemptsallowed' | translate }}

+

{{ 'core.unlimited' | translate }}

+

{{ scorm.maxattempt }}

+
+ +

{{ 'addon.mod_scorm.noattemptsmade' | translate }}

+

{{ scorm.numAttempts }}

+
+ +

{{ 'addon.mod_scorm.gradeforattempt' | translate }} {{attempt.number}}

+

{{ attempt.grade }}

+

{{ 'addon.mod_scorm.cannotcalculategrade' | translate }}

+
+
+ +

{{ 'addon.mod_scorm.gradeforattempt' | translate }} {{attempt.number}}

+

{{ attempt.grade }}

+

{{ 'addon.mod_scorm.cannotcalculategrade' | translate }}

+

{{ 'addon.mod_scorm.offlineattemptnote' | translate }}

+

{{ 'addon.mod_scorm.offlineattemptovermax' | translate }}

+
+ +

{{ 'addon.mod_scorm.grademethod' | translate }}

+

{{ scorm.gradeMethodReadable }}

+
+ +

{{ 'addon.mod_scorm.gradereported' | translate }}

+

{{ scorm.grade }}

+

{{ 'addon.mod_scorm.cannotcalculategrade' | translate }}

+
+ +

{{ 'core.lastsync' | translate }}

+

{{ syncTime }}

+
+
+
+ + +
+ + {{ 'core.hasdatatosync' | translate: {$a: moduleName} }} +
+ + + + +

{{ 'addon.mod_scorm.contents' | translate }}

+
+ + + {{ 'addon.mod_scorm.organizations' | translate }} + + {{ org.title }} + + + + + + + +

{{ 'addon.mod_scorm.dataattemptshown' | translate:{number: attemptToContinue} }}

+

{{ currentOrganization.title }}

+
+

+ + {{ sco.title }} + {{ sco.title }} +

+
+
+
+
+ + + + +

{{ errorMessage | translate }}

+
+ + + {{ 'core.openinbrowser' | translate }} + + + +
+ + + + +

{{ 'addon.mod_scorm.exceededmaxattempts' | translate }}

+
+
+ + + + + +
+ +

{{ 'addon.mod_scorm.mode' | translate }}

+
+ + {{ 'addon.mod_scorm.browse' | translate }} + + + + {{ 'addon.mod_scorm.normal' | translate }} + + +
+ + + + {{ 'addon.mod_scorm.newattempt' | translate }} + + + + + + + +

{{ statusMessage | translate }}

+
+ + {{ 'addon.mod_scorm.enter' | translate }} + +
+ + + + +

{{ progressMessage | translate }}

+

{{ 'core.percentagenumber' | translate:{$a: percentage} }}

+
+
+
+
+
diff --git a/src/addon/mod/scorm/components/index/index.scss b/src/addon/mod/scorm/components/index/index.scss new file mode 100644 index 000000000..9cfb94875 --- /dev/null +++ b/src/addon/mod/scorm/components/index/index.scss @@ -0,0 +1,9 @@ +addon-mod-scorm-index { + + .addon-mod_scorm-toc { + img { + width: auto; + display: inline; + } + } +} diff --git a/src/addon/mod/scorm/components/index/index.ts b/src/addon/mod/scorm/components/index/index.ts new file mode 100644 index 000000000..6a18cc836 --- /dev/null +++ b/src/addon/mod/scorm/components/index/index.ts @@ -0,0 +1,531 @@ +// (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 { Component, Optional, Injector } from '@angular/core'; +import { Content, NavController } from 'ionic-angular'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component'; +import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; +import { AddonModScormProvider, AddonModScormAttemptCountResult } from '../../providers/scorm'; +import { AddonModScormHelperProvider } from '../../providers/helper'; +import { AddonModScormOfflineProvider } from '../../providers/scorm-offline'; +import { AddonModScormSyncProvider } from '../../providers/scorm-sync'; +import { AddonModScormPrefetchHandler } from '../../providers/prefetch-handler'; +import { CoreConstants } from '@core/constants'; + +/** + * Component that displays a SCORM entry page. + */ +@Component({ + selector: 'addon-mod-scorm-index', + templateUrl: 'index.html', +}) +export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityComponent { + component = AddonModScormProvider.COMPONENT; + moduleName = 'scorm'; + + scorm: any; // The SCORM object. + currentOrganization: any = {}; // Selected organization. + scormOptions: any = { // Options to open the SCORM. + mode: AddonModScormProvider.MODENORMAL, + newAttempt: false + }; + modeNormal = AddonModScormProvider.MODENORMAL; // Normal open mode. + modeBrowser = AddonModScormProvider.MODEBROWSE; // Browser open mode. + errorMessage: string; // Error message. + syncTime: string; // Last sync time. + hasOffline: boolean; // Whether the SCORM has offline data. + attemptToContinue: number; // The attempt to continue or review. + statusMessage: string; // Message about the status. + downloading: boolean; // Whether the SCORM is being downloaded. + percentage: string; // Download/unzip percentage. + progressMessage: string; // Message about download/unzip. + organizations: any[]; // List of organizations. + loadingToc: boolean; // Whether the TOC is being loaded. + toc: any[]; // Table of contents (structure). + + protected fetchContentDefaultError = 'addon.mod_scorm.errorgetscorm'; // Default error to show when loading contents. + protected syncEventName = AddonModScormSyncProvider.AUTO_SYNCED; + protected attempts: AddonModScormAttemptCountResult; // Data about online and offline attempts. + protected lastAttempt: number; // Last attempt. + protected lastIsOffline: boolean; // Whether the last attempt is offline. + protected hasPlayed = false; // Whether the user has opened the player page. + + constructor(injector: Injector, protected scormProvider: AddonModScormProvider, @Optional() protected content: Content, + protected scormHelper: AddonModScormHelperProvider, protected scormOffline: AddonModScormOfflineProvider, + protected scormSync: AddonModScormSyncProvider, protected prefetchHandler: AddonModScormPrefetchHandler, + protected navCtrl: NavController, protected prefetchDelegate: CoreCourseModulePrefetchDelegate, + protected utils: CoreUtilsProvider) { + super(injector, content); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + super.ngOnInit(); + + this.loadContent(false, true).then(() => { + if (!this.scorm) { + return; + } + + this.scormProvider.logView(this.scorm.id).then(() => { + this.checkCompletion(); + }).catch((error) => { + // Ignore errors. + }); + }); + } + + /** + * Check the completion. + */ + protected checkCompletion(): void { + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + } + + /** + * Download a SCORM package or restores an ongoing download. + * + * @return {Promise} Promise resolved when done. + */ + protected downloadScormPackage(): Promise { + this.downloading = true; + + return this.prefetchHandler.download(this.module, this.courseId, undefined, (data) => { + if (!data) { + return; + } + + if (data.downloading) { + // Downloading package. + if (this.scorm.packagesize && data.progress) { + this.percentage = (Number(data.progress.loaded / this.scorm.packagesize) * 100).toFixed(1); + } + } else if (data.message) { + // Show a message. + this.progressMessage = data.message; + this.percentage = undefined; + } else if (data.progress && data.progress.loaded && data.progress.total) { + // Unzipping package. + this.percentage = (Number(data.progress.loaded / data.progress.total) * 100).toFixed(1); + } else { + this.percentage = undefined; + } + + }).finally(() => { + this.progressMessage = undefined; + this.percentage = undefined; + this.downloading = false; + }); + } + + /** + * Get the SCORM data. + * + * @param {boolean} [refresh=false] If it's refreshing content. + * @param {boolean} [sync=false] If the refresh is needs syncing. + * @param {boolean} [showErrors=false] If show errors to the user of hide them. + * @return {Promise} Promise resolved when done. + */ + protected fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + + // Get the SCORM instance. + return this.scormProvider.getScorm(this.courseId, this.module.id, this.module.url).then((scormData) => { + this.scorm = scormData; + + this.dataRetrieved.emit(this.scorm); + this.description = this.scorm.intro || this.description; + + const result = this.scormProvider.isScormUnsupported(this.scorm); + if (result) { + this.errorMessage = result; + } else { + this.errorMessage = ''; + } + + if (this.scorm.warningMessage) { + return; // SCORM is closed or not open yet, we can't get more data. + } + + let promise; + if (sync) { + // Try to synchronize the assign. + promise = this.syncActivity(showErrors).catch(() => { + // Ignore errors. + }); + } else { + promise = Promise.resolve(); + } + + return promise.catch(() => { + // Ignore errors, keep getting data even if sync fails. + }).then(() => { + + // No need to return this promise, it should be faster than the rest. + this.scormSync.getReadableSyncTime(this.scorm.id).then((syncTime) => { + this.syncTime = syncTime; + }); + + // Get the number of attempts. + return this.scormProvider.getAttemptCount(this.scorm.id); + }).then((attemptsData) => { + this.attempts = attemptsData; + this.hasOffline = !!this.attempts.offline.length; + + // Determine the attempt that will be continued or reviewed. + return this.scormHelper.determineAttemptToContinue(this.scorm, this.attempts); + }).then((attempt) => { + this.lastAttempt = attempt.number; + this.lastIsOffline = attempt.offline; + + if (this.lastAttempt != this.attempts.lastAttempt.number) { + this.attemptToContinue = this.lastAttempt; + } else { + this.attemptToContinue = undefined; + } + + // Check if the last attempt is incomplete. + return this.scormProvider.isAttemptIncomplete(this.scorm.id, this.lastAttempt, this.lastIsOffline); + }).then((incomplete) => { + const promises = []; + + this.scorm.incomplete = incomplete; + this.scorm.numAttempts = this.attempts.total; + this.scorm.gradeMethodReadable = this.scormProvider.getScormGradeMethod(this.scorm); + this.scorm.attemptsLeft = this.scormProvider.countAttemptsLeft(this.scorm, this.attempts.lastAttempt.number); + if (this.scorm.forceattempt && this.scorm.incomplete) { + this.scormOptions.newAttempt = true; + } + + promises.push(this.getReportedGrades()); + + promises.push(this.fetchStructure()); + + if (!this.scorm.packagesize && this.errorMessage === '') { + // SCORM is supported but we don't have package size. Try to calculate it. + promises.push(this.scormProvider.calculateScormSize(this.scorm).then((size) => { + this.scorm.packagesize = size; + })); + } + + // Handle status. + this.setStatusListener(); + + return Promise.all(promises); + }); + }).then(() => { + // All data obtained, now fill the context menu. + this.fillContextMenu(refresh); + }); + } + + /** + * Fetch the structure of the SCORM (TOC). + * + * @return {Promise} Promise resolved when done. + */ + protected fetchStructure(): Promise { + return this.scormProvider.getOrganizations(this.scorm.id).then((organizations) => { + this.organizations = organizations; + + if (!this.currentOrganization.identifier) { + // Load first organization (if any). + if (organizations.length) { + this.currentOrganization.identifier = organizations[0].identifier; + } else { + this.currentOrganization.identifier = ''; + } + } + + return this.loadOrganizationToc(this.currentOrganization.identifier); + }); + } + + /** + * Get the grade of an attempt and add it to the scorm attempts list. + * + * @param {number} attempt The attempt number. + * @param {boolean} offline Whether it's an offline attempt. + * @param {any} attempts Object where to add the attempt. + * @return {Promise} Promise resolved when done. + */ + protected getAttemptGrade(attempt: number, offline: boolean, attempts: any): Promise { + return this.scormProvider.getAttemptGrade(this.scorm, attempt, offline).then((grade) => { + attempts[attempt] = { + number: attempt, + grade: grade + }; + }); + } + + /** + * Get the grades of each attempt and the grade of the SCORM. + * + * @return {Promise} Promise resolved when done. + */ + protected getReportedGrades(): Promise { + const promises = [], + onlineAttempts = {}, + offlineAttempts = {}; + + // Calculate the grade for each attempt. + this.attempts.online.forEach((attempt) => { + // Check that attempt isn't in offline to prevent showing the same attempt twice. Offline should be more recent. + if (this.attempts.offline.indexOf(attempt) == -1) { + promises.push(this.getAttemptGrade(attempt, false, onlineAttempts)); + } + }); + + this.attempts.offline.forEach((attempt) => { + promises.push(this.getAttemptGrade(attempt, true, offlineAttempts)); + }); + + return Promise.all(promises).then(() => { + + // Calculate the grade of the whole SCORM. We only use online attempts to calculate this data. + this.scorm.grade = this.scormProvider.calculateScormGrade(this.scorm, onlineAttempts); + + // Add the attempts to the SCORM in array format in ASC order, and format the grades. + this.scorm.onlineAttempts = this.utils.objectToArray(onlineAttempts); + this.scorm.offlineAttempts = this.utils.objectToArray(offlineAttempts); + this.scorm.onlineAttempts.sort((a, b) => { + return a.number - b.number; + }); + this.scorm.offlineAttempts.sort((a, b) => { + return a.number - b.number; + }); + + // Now format the grades. + this.scorm.onlineAttempts.forEach((attempt) => { + attempt.grade = this.scormProvider.formatGrade(this.scorm, attempt.grade); + }); + this.scorm.offlineAttempts.forEach((attempt) => { + attempt.grade = this.scormProvider.formatGrade(this.scorm, attempt.grade); + }); + + this.scorm.grade = this.scormProvider.formatGrade(this.scorm, this.scorm.grade); + }); + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param {any} result Data returned on the sync function. + * @return {boolean} If suceed or not. + */ + protected hasSyncSucceed(result: any): boolean { + if (result.updated) { + // Check completion status. + this.checkCompletion(); + } + + return true; + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + super.ionViewDidEnter(); + + if (this.hasPlayed) { + this.hasPlayed = false; + this.scormOptions.newAttempt = false; // Uncheck new attempt. + + // Add a delay to make sure the player has started the last writing calls so we can detect conflicts. + setTimeout(() => { + // Refresh data. + this.showLoadingAndRefresh(true, false); + }, 500); + } + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + super.ionViewDidLeave(); + + if (this.navCtrl.getActive().component.name == 'AddonModScormPlayerPage') { + this.hasPlayed = true; + } + } + + /** + * Perform the invalidate content function. + * + * @return {Promise} Resolved when done. + */ + protected invalidateContent(): Promise { + const promises = []; + + promises.push(this.scormProvider.invalidateScormData(this.courseId)); + + if (this.scorm) { + promises.push(this.scormProvider.invalidateAllScormData(this.scorm.id)); + } + + return Promise.all(promises); + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param {any} syncEventData Data receiven on sync observer. + * @return {boolean} True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: any): boolean { + if (syncEventData.updated && this.scorm && syncEventData.scormId == this.scorm.id) { + // Check completion status. + this.checkCompletion(); + + return true; + } + + return false; + } + + /** + * Load a organization's TOC. + */ + loadOrganization(): void { + this.loadOrganizationToc(this.currentOrganization.identifier).catch((error) => { + this.domUtils.showErrorModalDefault(error, this.fetchContentDefaultError, true); + }); + } + + /** + * Load the TOC of a certain organization. + * + * @param {string} organizationId The organization id. + * @return {Promise} Promise resolved when done. + */ + protected loadOrganizationToc(organizationId: string): Promise { + if (!this.scorm.displaycoursestructure) { + // TOC is not displayed, no need to load it. + return Promise.resolve(); + } + + this.loadingToc = true; + + return this.scormProvider.getOrganizationToc(this.scorm.id, this.lastAttempt, organizationId, this.lastIsOffline) + .then((toc) => { + + this.toc = this.scormProvider.formatTocToArray(toc); + + // Get images for each SCO. + this.toc.forEach((sco) => { + sco.image = this.scormProvider.getScoStatusIcon(sco, this.scorm.incomplete); + }); + + // Search organization title. + this.organizations.forEach((org) => { + if (org.identifier == organizationId) { + this.currentOrganization.title = org.title; + } + }); + }).finally(() => { + this.loadingToc = false; + }); + } + + // Open a SCORM. It will download the SCORM package if it's not downloaded or it has changed. + // The scoId param indicates the SCO that needs to be loaded when the SCORM is opened. If not defined, load first SCO. + open(e: Event, scoId: number): void { + e.preventDefault(); + e.stopPropagation(); + + if (this.downloading) { + // Scope is being downloaded, abort. + return; + } + + const isOutdated = this.currentStatus == CoreConstants.OUTDATED; + + if (isOutdated || this.currentStatus == CoreConstants.NOT_DOWNLOADED) { + // SCORM needs to be downloaded. + this.scormHelper.confirmDownload(this.scorm, isOutdated).then(() => { + // Invalidate WS data if SCORM is outdated. + const promise = isOutdated ? this.scormProvider.invalidateAllScormData(this.scorm.id) : Promise.resolve(); + + promise.finally(() => { + this.downloadScormPackage().then(() => { + // Success downloading, open SCORM if user hasn't left the view. + if (!this.isDestroyed) { + this.openScorm(scoId); + } + }).catch((error) => { + if (!this.isDestroyed) { + this.domUtils.showErrorModalDefault(error, this.translate.instant( + 'addon.mod_scorm.errordownloadscorm', {name: this.scorm.name})); + } + }); + }); + }); + } else { + this.openScorm(scoId); + } + } + + /** + * Open a SCORM package. + * + * @param {number} scoId SCO ID. + */ + protected openScorm(scoId: number): void { + this.navCtrl.push('AddonModScormPlayerPage', { + scorm: this.scorm, + mode: this.scormOptions.mode, + newAttempt: !!this.scormOptions.newAttempt, + organizationId: this.currentOrganization.identifier, + scoId: scoId + }); + } + + /** + * Displays some data based on the current status. + * + * @param {string} status The current status. + * @param {string} [previousStatus] The previous status. If not defined, there is no previous status. + */ + protected showStatus(status: string, previousStatus?: string): void { + + if (status == CoreConstants.OUTDATED && this.scorm) { + // Only show the outdated message if the file should be downloaded. + this.scormProvider.shouldDownloadMainFile(this.scorm, true).then((download) => { + this.statusMessage = download ? 'addon.mod_scorm.scormstatusoutdated' : ''; + }); + } else if (status == CoreConstants.NOT_DOWNLOADED) { + this.statusMessage = 'addon.mod_scorm.scormstatusnotdownloaded'; + } else if (status == CoreConstants.DOWNLOADING) { + if (!this.downloading) { + // It's being downloaded right now but the view isn't tracking it. "Restore" the download. + this.downloadScormPackage(); + } + } else { + this.statusMessage = ''; + } + } + + /** + * Performs the sync of the activity. + * + * @return {Promise} Promise resolved when done. + */ + protected sync(): Promise { + return this.scormSync.syncScorm(this.scorm); + } +} diff --git a/src/addon/mod/scorm/pages/index/index.html b/src/addon/mod/scorm/pages/index/index.html new file mode 100644 index 000000000..62099e9bf --- /dev/null +++ b/src/addon/mod/scorm/pages/index/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/addon/mod/scorm/pages/index/index.module.ts b/src/addon/mod/scorm/pages/index/index.module.ts new file mode 100644 index 000000000..68bd2e47b --- /dev/null +++ b/src/addon/mod/scorm/pages/index/index.module.ts @@ -0,0 +1,33 @@ +// (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 { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModScormComponentsModule } from '../../components/components.module'; +import { AddonModScormIndexPage } from './index'; + +@NgModule({ + declarations: [ + AddonModScormIndexPage, + ], + imports: [ + CoreDirectivesModule, + AddonModScormComponentsModule, + IonicPageModule.forChild(AddonModScormIndexPage), + TranslateModule.forChild() + ], +}) +export class AddonModScormIndexPageModule {} diff --git a/src/addon/mod/scorm/pages/index/index.ts b/src/addon/mod/scorm/pages/index/index.ts new file mode 100644 index 000000000..9179fc6d1 --- /dev/null +++ b/src/addon/mod/scorm/pages/index/index.ts @@ -0,0 +1,62 @@ +// (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 { Component, ViewChild } from '@angular/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { AddonModScormIndexComponent } from '../../components/index/index'; + +/** + * Page that displays the SCORM entry page. + */ +@IonicPage({ segment: 'addon-mod-scorm-index' }) +@Component({ + selector: 'page-addon-mod-scorm-index', + templateUrl: 'index.html', +}) +export class AddonModScormIndexPage { + @ViewChild(AddonModScormIndexComponent) scormComponent: AddonModScormIndexComponent; + + title: string; + module: any; + courseId: number; + + constructor(navParams: NavParams) { + this.module = navParams.get('module') || {}; + this.courseId = navParams.get('courseId'); + this.title = this.module.name; + } + + /** + * Update some data based on the SCORM instance. + * + * @param {any} scorm SCORM instance. + */ + updateData(scorm: any): void { + this.title = scorm.name || this.title; + } + + /** + * User entered the page. + */ + ionViewDidEnter(): void { + this.scormComponent.ionViewDidEnter(); + } + + /** + * User left the page. + */ + ionViewDidLeave(): void { + this.scormComponent.ionViewDidLeave(); + } +} diff --git a/src/addon/mod/scorm/providers/helper.ts b/src/addon/mod/scorm/providers/helper.ts new file mode 100644 index 000000000..5c0849fee --- /dev/null +++ b/src/addon/mod/scorm/providers/helper.ts @@ -0,0 +1,121 @@ +// (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 { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { AddonModScormProvider, AddonModScormAttemptCountResult } from './scorm'; + +/** + * Helper service that provides some features for SCORM. + */ +@Injectable() +export class AddonModScormHelperProvider { + + protected div = document.createElement('div'); // A div element to search in HTML code. + + constructor(private domUtils: CoreDomUtilsProvider, private scormProvider: AddonModScormProvider) { } + + /** + * Show a confirm dialog if needed. If SCORM doesn't have size, try to calculate it. + * + * @param {any} scorm SCORM to download. + * @param {boolean} [isOutdated] True if package outdated, false if not outdated, undefined to calculate it. + * @return {Promise} Promise resolved if the user confirms or no confirmation needed. + */ + confirmDownload(scorm: any, isOutdated?: boolean): Promise { + // Check if file should be downloaded. + return this.scormProvider.shouldDownloadMainFile(scorm, isOutdated).then((download) => { + if (download) { + let subPromise; + + if (!scorm.packagesize) { + // We don't have package size, try to calculate it. + subPromise = this.scormProvider.calculateScormSize(scorm).then((size) => { + // Store it so we don't have to calculate it again when using the same object. + scorm.packagesize = size; + + return size; + }); + } else { + subPromise = Promise.resolve(scorm.packagesize); + } + + return subPromise.then((size) => { + return this.domUtils.confirmDownloadSize({size: size, total: true}); + }); + } + }); + } + + /** + * 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 {any} scorm SCORM object. + * @param {AddonModScormAttemptCountResult} attempts Attempts count. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise<{number: number, offline: boolean}>} Promise resolved with the attempt data. + */ + determineAttemptToContinue(scorm: any, attempts: AddonModScormAttemptCountResult, siteId?: string) + : Promise<{number: number, offline: boolean}> { + + let lastOnline; + + // Get last online attempt. + if (attempts.online.length) { + lastOnline = Math.max.apply(Math, attempts.online); + } + + if (lastOnline) { + // Check if last online incomplete. + const hasOffline = attempts.offline.indexOf(lastOnline) > -1; + + return this.scormProvider.isAttemptIncomplete(scorm.id, lastOnline, hasOffline, false, siteId).then((incomplete) => { + if (incomplete) { + return { + number: lastOnline, + offline: hasOffline + }; + } else { + return this.getLastBeforeMax(scorm, attempts); + } + }); + } else { + return Promise.resolve(this.getLastBeforeMax(scorm, attempts)); + } + } + + /** + * 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 {any} scorm SCORM object. + * @param {AddonModScormAttemptCountResult} attempts Attempts count. + * @return {{number: number, offline: boolean}} Last attempt data. + */ + protected getLastBeforeMax(scorm: any, attempts: AddonModScormAttemptCountResult): {number: number, offline: boolean} { + if (scorm.maxattempt != 0 && attempts.lastAttempt.number > scorm.maxattempt) { + return { + number: scorm.maxattempt, + offline: attempts.offline.indexOf(scorm.maxattempt) > -1 + }; + } else { + return { + number: attempts.lastAttempt.number, + offline: attempts.lastAttempt.offline + }; + } + } +} diff --git a/src/addon/mod/scorm/scorm.module.ts b/src/addon/mod/scorm/scorm.module.ts index 2d9e6c6b3..ea48f5699 100644 --- a/src/addon/mod/scorm/scorm.module.ts +++ b/src/addon/mod/scorm/scorm.module.ts @@ -18,6 +18,7 @@ import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; import { AddonModScormProvider } from './providers/scorm'; +import { AddonModScormHelperProvider } from './providers/helper'; import { AddonModScormOfflineProvider } from './providers/scorm-offline'; import { AddonModScormModuleHandler } from './providers/module-handler'; import { AddonModScormPrefetchHandler } from './providers/prefetch-handler'; @@ -36,6 +37,7 @@ import { AddonModScormComponentsModule } from './components/components.module'; providers: [ AddonModScormProvider, AddonModScormOfflineProvider, + AddonModScormHelperProvider, AddonModScormSyncProvider, AddonModScormModuleHandler, AddonModScormPrefetchHandler, diff --git a/src/app/app.ios.scss b/src/app/app.ios.scss index 6ec16b1d5..a7410863d 100644 --- a/src/app/app.ios.scss +++ b/src/app/app.ios.scss @@ -104,3 +104,10 @@ } } } + +// Different levels of padding. +@for $i from 0 through 15 { + .ios .core-padding-#{$i} { + padding-left: 15px * $i + $item-ios-padding-start; + } +} diff --git a/src/app/app.md.scss b/src/app/app.md.scss index f96fff139..866b98788 100644 --- a/src/app/app.md.scss +++ b/src/app/app.md.scss @@ -105,3 +105,10 @@ } } } + +// Different levels of padding. +@for $i from 0 through 15 { + .md .core-padding-#{$i} { + padding-left: 15px * $i + $item-md-padding-start; + } +} diff --git a/src/app/app.scss b/src/app/app.scss index 624b27ebd..efc43f114 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -621,7 +621,7 @@ canvas[core-chart] { color: $color-base; } - .text-#{$color-name}, p.#{$color-name}, .item p.text-#{$color-name} { + .text-#{$color-name}, p.text-#{$color-name}, .item p.text-#{$color-name}, .card p.text-#{$color-name} { color: $color-base; } } diff --git a/src/app/app.wp.scss b/src/app/app.wp.scss index 431dfdb08..0e7342032 100644 --- a/src/app/app.wp.scss +++ b/src/app/app.wp.scss @@ -40,3 +40,10 @@ top: $navbar-wp-height; height: calc(100% - #{($navbar-wp-height)}); } + +// Different levels of padding. +@for $i from 0 through 15 { + .wp .core-padding-#{$i} { + padding-left: 15px * $i + $item-wp-padding-start; + } +} diff --git a/src/core/course/classes/main-activity-component.ts b/src/core/course/classes/main-activity-component.ts index 875fec056..f1fe1bf30 100644 --- a/src/core/course/classes/main-activity-component.ts +++ b/src/core/course/classes/main-activity-component.ts @@ -46,7 +46,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR protected courseProvider: CoreCourseProvider; protected appProvider: CoreAppProvider; protected eventsProvider: CoreEventsProvider; - protected modulePrefetchProvider: CoreCourseModulePrefetchDelegate; + protected modulePrefetchDelegate: CoreCourseModulePrefetchDelegate; constructor(injector: Injector, protected content?: Content) { super(injector); @@ -55,6 +55,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR this.courseProvider = injector.get(CoreCourseProvider); this.appProvider = injector.get(CoreAppProvider); this.eventsProvider = injector.get(CoreEventsProvider); + this.modulePrefetchDelegate = injector.get(CoreCourseModulePrefetchDelegate); const network = injector.get(Network); @@ -158,7 +159,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR this.loaded = false; this.content && this.content.scrollToTop(); - return this.refreshContent(true, showErrors); + return this.refreshContent(sync, showErrors); } /** @@ -226,7 +227,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR }, this.siteId); // Also, get the current status. - this.modulePrefetchProvider.getModuleStatus(this.module, this.courseId).then((status) => { + this.modulePrefetchDelegate.getModuleStatus(this.module, this.courseId).then((status) => { this.currentStatus = status; this.showStatus(status); }); From 5793af108dcb66300e2c45afafaeec1a0fa29015 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 26 Apr 2018 09:59:00 +0200 Subject: [PATCH 6/7] MOBILE-2350 scorm: Fix unzip in browser and sync WS calls --- src/core/emulator/emulator.module.ts | 7 +- src/core/emulator/providers/zip.ts | 93 +++++++++++++++++++----- src/core/settings/pages/about/about.html | 2 +- src/providers/file.ts | 4 + src/providers/ws.ts | 4 +- 5 files changed, 86 insertions(+), 24 deletions(-) diff --git a/src/core/emulator/emulator.module.ts b/src/core/emulator/emulator.module.ts index 9afd878fc..7dc283b4d 100644 --- a/src/core/emulator/emulator.module.ts +++ b/src/core/emulator/emulator.module.ts @@ -53,7 +53,6 @@ import { CoreEmulatorCaptureHelperProvider } from './providers/capture-helper'; import { CoreAppProvider } from '@providers/app'; import { CoreFileProvider } from '@providers/file'; import { CoreTextUtilsProvider } from '@providers/utils/text'; -import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; import { CoreUrlUtilsProvider } from '@providers/utils/url'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreInitDelegate } from '@providers/init'; @@ -184,10 +183,10 @@ export const IONIC_NATIVE_PROVIDERS = [ SQLite, { provide: Zip, - deps: [CoreAppProvider, File, CoreMimetypeUtilsProvider, CoreTextUtilsProvider], - useFactory: (appProvider: CoreAppProvider, file: File, mimeUtils: CoreMimetypeUtilsProvider): Zip => { + deps: [CoreAppProvider, File, CoreTextUtilsProvider], + useFactory: (appProvider: CoreAppProvider, file: File, textUtils: CoreTextUtilsProvider): Zip => { // Use platform instead of CoreAppProvider to prevent circular dependencies. - return appProvider.isMobile() ? new Zip() : new ZipMock(file, mimeUtils); + return appProvider.isMobile() ? new Zip() : new ZipMock(file, textUtils); } }, ] diff --git a/src/core/emulator/providers/zip.ts b/src/core/emulator/providers/zip.ts index 9bc3d315a..f7d93f4c1 100644 --- a/src/core/emulator/providers/zip.ts +++ b/src/core/emulator/providers/zip.ts @@ -14,9 +14,9 @@ import { Injectable } from '@angular/core'; import { Zip } from '@ionic-native/zip'; -import { JSZip } from 'jszip'; +import * as JSZip from 'jszip'; import { File } from '@ionic-native/file'; -import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; /** * Emulates the Cordova Zip plugin in desktop apps and in browser. @@ -24,10 +24,36 @@ import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; @Injectable() export class ZipMock extends Zip { - constructor(private file: File, private mimeUtils: CoreMimetypeUtilsProvider) { + constructor(private file: File, private textUtils: CoreTextUtilsProvider) { super(); } + /** + * Create a directory. It creates all the foldes in dirPath 1 by 1 to prevent errors. + * + * @param {string} destination Destination parent folder. + * @param {string} dirPath Relative path to the folder. + * @return {Promise} Promise resolved when done. + */ + protected createDir(destination: string, dirPath: string): Promise { + // Create all the folders 1 by 1 in order, otherwise it fails. + const folders = dirPath.split('/'); + let promise = Promise.resolve(); + + for (let i = 0; i < folders.length; i++) { + const folder = folders[i]; + + promise = promise.then(() => { + return this.file.createDir(destination, folder, true).then(() => { + // Folder created, add it to the destination path. + destination = this.textUtils.concatenatePaths(destination, folder); + }); + }); + } + + return promise; + } + /** * Extracts files from a ZIP archive. * @@ -37,35 +63,68 @@ export class ZipMock extends Zip { * @return {Promise} Promise that resolves with a number. 0 is success, -1 is error. */ unzip(source: string, destination: string, onProgress?: Function): Promise { + // Replace all %20 with spaces. source = source.replace(/%20/g, ' '); destination = destination.replace(/%20/g, ' '); const sourceDir = source.substring(0, source.lastIndexOf('/')), - sourceName = source.substr(source.lastIndexOf('/') + 1); + sourceName = source.substr(source.lastIndexOf('/') + 1), + zip = new JSZip(); + // Read the file first. return this.file.readAsArrayBuffer(sourceDir, sourceName).then((data) => { - const zip = new JSZip(data), - promises = [], - total = Object.keys(zip.files).length; - let loaded = 0; - if (!zip.files || !zip.files.length) { + // Now load the file using the JSZip library. + return zip.loadAsync(data); + }).then((): any => { + + if (!zip.files || !Object.keys(zip.files).length) { // Nothing to extract. return 0; } - zip.files.forEach((file, name) => { - let type, - promise; + // First of all, create the directory where the files will be unzipped. + const destParent = destination.substring(0, source.lastIndexOf('/')), + destFolderName = destination.substr(source.lastIndexOf('/') + 1); + + return this.file.createDir(destParent, destFolderName, false); + }).then(() => { + + const promises = [], + total = Object.keys(zip.files).length; + let loaded = 0; + + for (const name in zip.files) { + const file = zip.files[name]; + let promise; if (!file.dir) { - // It's a file. Get the mimetype and write the file. - type = this.mimeUtils.getMimeType(this.mimeUtils.getFileExtension(name)); - promise = this.file.writeFile(destination, name, new Blob([file.asArrayBuffer()], { type: type })); + // It's a file. + const fileDir = name.substring(0, name.lastIndexOf('/')), + fileName = name.substr(name.lastIndexOf('/') + 1), + filePromises = []; + let fileData; + + if (fileDir) { + // The file is in a subfolder, create it first. + filePromises.push(this.createDir(destination, fileDir)); + } + + // Read the file contents as a Blob. + filePromises.push(file.async('blob').then((data) => { + fileData = data; + })); + + promise = Promise.all(filePromises).then(() => { + // File read and parent folder created, now write the file. + const parentFolder = this.textUtils.concatenatePaths(destination, fileDir); + + return this.file.writeFile(parentFolder, fileName, fileData, {replace: true}); + }); } else { // It's a folder, create it if it doesn't exist. - promise = this.file.createDir(destination, name, false); + promise = this.createDir(destination, name); } promises.push(promise.then(() => { @@ -73,7 +132,7 @@ export class ZipMock extends Zip { loaded++; onProgress && onProgress({ loaded: loaded, total: total }); })); - }); + } return Promise.all(promises).then(() => { return 0; diff --git a/src/core/settings/pages/about/about.html b/src/core/settings/pages/about/about.html index b05a037ec..c7dc40e2e 100644 --- a/src/core/settings/pages/about/about.html +++ b/src/core/settings/pages/about/about.html @@ -38,7 +38,7 @@

{{ 'core.settings.filesystemroot' | translate}}

-

{{ filesystemroot }}

+

{{ fileSystemRoot }}

{{ fileSystemRoot }}

diff --git a/src/providers/file.ts b/src/providers/file.ts index 06a65452e..836685ab6 100644 --- a/src/providers/file.ts +++ b/src/providers/file.ts @@ -731,6 +731,10 @@ export class CoreFileProvider { destFolder = this.addBasePathIfNeeded(destFolder || this.mimeUtils.removeExtension(path)); return this.zip.unzip(fileEntry.toURL(), destFolder, onProgress); + }).then((result) => { + if (result == -1) { + return Promise.reject(null); + } }); } diff --git a/src/providers/ws.ts b/src/providers/ws.ts index 267ac509d..896917693 100644 --- a/src/providers/ws.ts +++ b/src/providers/ws.ts @@ -706,8 +706,8 @@ export class CoreWSProvider { data = ('response' in xhr) ? xhr.response : xhr.responseText; // Check status. - xhr.status = Math.max(xhr.status === 1223 ? 204 : xhr.status, 0); - if (xhr.status < 200 || xhr.status >= 300) { + const status = Math.max(xhr.status === 1223 ? 204 : xhr.status, 0); + if (status < 200 || status >= 300) { // Request failed. errorResponse.message = data; From 8e577becc7f1014d5cf48148825d07942836aede Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 26 Apr 2018 12:09:48 +0200 Subject: [PATCH 7/7] MOBILE-2350 scorm: Implement SCORM player --- .../mod/scorm/components/components.module.ts | 10 +- .../components/toc-popover/toc-popover.html | 19 + .../components/toc-popover/toc-popover.scss | 3 + .../components/toc-popover/toc-popover.ts | 54 +++ src/addon/mod/scorm/pages/player/player.html | 19 + .../mod/scorm/pages/player/player.module.ts | 33 ++ src/addon/mod/scorm/pages/player/player.ts | 450 ++++++++++++++++++ src/addon/mod/scorm/providers/helper.ts | 213 ++++++++- src/components/iframe/iframe.ts | 7 + 9 files changed, 803 insertions(+), 5 deletions(-) create mode 100644 src/addon/mod/scorm/components/toc-popover/toc-popover.html create mode 100644 src/addon/mod/scorm/components/toc-popover/toc-popover.scss create mode 100644 src/addon/mod/scorm/components/toc-popover/toc-popover.ts create mode 100644 src/addon/mod/scorm/pages/player/player.html create mode 100644 src/addon/mod/scorm/pages/player/player.module.ts create mode 100644 src/addon/mod/scorm/pages/player/player.ts diff --git a/src/addon/mod/scorm/components/components.module.ts b/src/addon/mod/scorm/components/components.module.ts index 2a9c6569b..197d63ad1 100644 --- a/src/addon/mod/scorm/components/components.module.ts +++ b/src/addon/mod/scorm/components/components.module.ts @@ -20,10 +20,12 @@ import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; import { CoreCourseComponentsModule } from '@core/course/components/components.module'; import { AddonModScormIndexComponent } from './index/index'; +import { AddonModScormTocPopoverComponent } from './toc-popover/toc-popover'; @NgModule({ declarations: [ - AddonModScormIndexComponent + AddonModScormIndexComponent, + AddonModScormTocPopoverComponent ], imports: [ CommonModule, @@ -36,10 +38,12 @@ import { AddonModScormIndexComponent } from './index/index'; providers: [ ], exports: [ - AddonModScormIndexComponent + AddonModScormIndexComponent, + AddonModScormTocPopoverComponent ], entryComponents: [ - AddonModScormIndexComponent + AddonModScormIndexComponent, + AddonModScormTocPopoverComponent ] }) export class AddonModScormComponentsModule {} diff --git a/src/addon/mod/scorm/components/toc-popover/toc-popover.html b/src/addon/mod/scorm/components/toc-popover/toc-popover.html new file mode 100644 index 000000000..0518f03f7 --- /dev/null +++ b/src/addon/mod/scorm/components/toc-popover/toc-popover.html @@ -0,0 +1,19 @@ + + +

{{ 'addon.mod_scorm.dataattemptshown' | translate:{number: attemptToContinue} }}

+
+ +

{{ 'addon.mod_scorm.mod_scorm.browsemode' }}

+
+ +

{{ 'addon.mod_scorm.mod_scorm.reviewmode' }}

+
+ + + + + + {{ sco.title }} + + +
diff --git a/src/addon/mod/scorm/components/toc-popover/toc-popover.scss b/src/addon/mod/scorm/components/toc-popover/toc-popover.scss new file mode 100644 index 000000000..ed116481c --- /dev/null +++ b/src/addon/mod/scorm/components/toc-popover/toc-popover.scss @@ -0,0 +1,3 @@ +addon-mod-scorm-toc-popover { + +} diff --git a/src/addon/mod/scorm/components/toc-popover/toc-popover.ts b/src/addon/mod/scorm/components/toc-popover/toc-popover.ts new file mode 100644 index 000000000..aaa3aea88 --- /dev/null +++ b/src/addon/mod/scorm/components/toc-popover/toc-popover.ts @@ -0,0 +1,54 @@ +// (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 { Component } from '@angular/core'; +import { NavParams, ViewController } from 'ionic-angular'; +import { AddonModScormProvider } from '../../providers/scorm'; + +/** + * Component to display the TOC of a SCORM. + */ +@Component({ + selector: 'addon-mod-scorm-toc-popover', + templateUrl: 'toc-popover.html' +}) +export class AddonModScormTocPopoverComponent { + toc: any[]; + isBrowse: boolean; + isReview: boolean; + attemptToContinue: number; + + constructor(navParams: NavParams, private viewCtrl: ViewController) { + this.toc = navParams.get('toc') || []; + this.attemptToContinue = navParams.get('attemptToContinue'); + + const mode = navParams.get('mode'); + + this.isBrowse = mode === AddonModScormProvider.MODEBROWSE; + this.isReview = mode === AddonModScormProvider.MODEREVIEW; + } + + /** + * Function called when a SCO is clicked. + * + * @param {any} sco Clicked SCO. + */ + loadSco(sco: any): void { + if (!sco.prereq || !sco.isvisible || !sco.launch) { + return; + } + + this.viewCtrl.dismiss(sco); + } +} diff --git a/src/addon/mod/scorm/pages/player/player.html b/src/addon/mod/scorm/pages/player/player.html new file mode 100644 index 000000000..8ffa420a7 --- /dev/null +++ b/src/addon/mod/scorm/pages/player/player.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + +

{{ errorMessage | translate }}

+
+
diff --git a/src/addon/mod/scorm/pages/player/player.module.ts b/src/addon/mod/scorm/pages/player/player.module.ts new file mode 100644 index 000000000..dd5e85b1f --- /dev/null +++ b/src/addon/mod/scorm/pages/player/player.module.ts @@ -0,0 +1,33 @@ +// (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 { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModScormPlayerPage } from './player'; + +@NgModule({ + declarations: [ + AddonModScormPlayerPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(AddonModScormPlayerPage), + TranslateModule.forChild() + ], +}) +export class AddonModScormPlayerPageModule {} diff --git a/src/addon/mod/scorm/pages/player/player.ts b/src/addon/mod/scorm/pages/player/player.ts new file mode 100644 index 000000000..37f9f8110 --- /dev/null +++ b/src/addon/mod/scorm/pages/player/player.ts @@ -0,0 +1,450 @@ +// (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 { Component, OnInit, OnDestroy } from '@angular/core'; +import { IonicPage, NavParams, PopoverController } from 'ionic-angular'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { AddonModScormProvider, AddonModScormAttemptCountResult } from '../../providers/scorm'; +import { AddonModScormHelperProvider } from '../../providers/helper'; +import { AddonModScormSyncProvider } from '../../providers/scorm-sync'; +import { AddonModScormDataModel12 } from '../../classes/data-model-12'; +import { AddonModScormTocPopoverComponent } from '../../components/toc-popover/toc-popover'; + +/** + * Page that allows playing a SCORM. + */ +@IonicPage({ segment: 'addon-mod-scorm-player' }) +@Component({ + selector: 'page-addon-mod-scorm-player', + templateUrl: 'player.html', +}) +export class AddonModScormPlayerPage implements OnInit, OnDestroy { + + title: string; // Title. + scorm: any; // The SCORM object. + showToc: boolean; // Whether to show the table of contents (TOC). + loadingToc = true; // Whether the TOC is being loaded. + toc: any[]; // List of SCOs. + loaded: boolean; // Whether the data has been loaded. + previousSco: any; // Previous SCO. + nextSco: any; // Next SCO. + src: string; // Iframe src. + errorMessage: string; // Error message. + + protected siteId: string; + protected mode: string; // Mode to play the SCORM. + protected newAttempt: boolean; // Whether to start a new attempt. + protected organizationId: string; // Organization ID to load. + protected attempt: number; // The attempt number. + protected offline = false; // Whether it's offline mode. + protected userData: any; // User data. + protected initialScoId: number; // Initial SCO ID to load. + protected currentSco: any; // Current SCO. + protected dataModel: AddonModScormDataModel12; // Data Model. + protected attemptToContinue: number; // Attempt to continue (for the popover). + + // Observers. + protected tocObserver: any; + protected launchNextObserver: any; + protected launchPrevObserver: any; + protected goOfflineObserver: any; + + constructor(navParams: NavParams, protected popoverCtrl: PopoverController, protected eventsProvider: CoreEventsProvider, + protected sitesProvider: CoreSitesProvider, protected syncProvider: CoreSyncProvider, + protected domUtils: CoreDomUtilsProvider, protected timeUtils: CoreTimeUtilsProvider, + protected scormProvider: AddonModScormProvider, protected scormHelper: AddonModScormHelperProvider, + protected scormSyncProvider: AddonModScormSyncProvider) { + + this.scorm = navParams.get('scorm') || {}; + this.mode = navParams.get('mode') || AddonModScormProvider.MODENORMAL; + this.newAttempt = !!navParams.get('newAttempt'); + this.organizationId = navParams.get('organizationId'); + this.initialScoId = navParams.get('scoId'); + this.siteId = this.sitesProvider.getCurrentSiteId(); + + // We use SCORM name at start, later we'll use the SCO title. + this.title = this.scorm.name; + + // Block the SCORM so it cannot be synchronized. + this.syncProvider.blockOperation(AddonModScormProvider.COMPONENT, this.scorm.id, 'player'); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + + this.showToc = this.scormProvider.displayTocInPlayer(this.scorm); + + if (this.scorm.popup) { + // If we receive a value <= 100 we need to assume it's a percentage. + if (this.scorm.width <= 100) { + this.scorm.width = this.scorm.width + '%'; + } + if (this.scorm.height <= 100) { + this.scorm.height = this.scorm.height + '%'; + } + } + + // Fetch the SCORM data. + this.fetchData().then(() => { + if (this.currentSco) { + // Set start time if it's a new attempt. + const promise = this.newAttempt ? this.setStartTime(this.currentSco.id) : Promise.resolve(); + + return promise.catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true); + }).finally(() => { + // Load SCO. + this.loadSco(this.currentSco); + }); + } + }).finally(() => { + this.loaded = true; + }); + + // Listen for events to update the TOC, navigate through SCOs and go offline. + this.tocObserver = this.eventsProvider.on(AddonModScormProvider.UPDATE_TOC_EVENT, (data) => { + if (data.scormId === this.scorm.id) { + if (this.offline) { + // Wait a bit to make sure data is stored. + setTimeout(this.refreshToc.bind(this), 100); + } else { + this.refreshToc(); + } + } + }, this.siteId); + + this.launchNextObserver = this.eventsProvider.on(AddonModScormProvider.LAUNCH_NEXT_SCO_EVENT, (data) => { + if (data.scormId === this.scorm.id && this.nextSco) { + this.loadSco(this.nextSco); + } + }, this.siteId); + + this.launchPrevObserver = this.eventsProvider.on(AddonModScormProvider.LAUNCH_PREV_SCO_EVENT, (data) => { + if (data.scormId === this.scorm.id && this.previousSco) { + this.loadSco(this.previousSco); + } + }, this.siteId); + + this.goOfflineObserver = this.eventsProvider.on(AddonModScormProvider.GO_OFFLINE_EVENT, (data) => { + if (data.scormId === this.scorm.id && !this.offline) { + this.offline = true; + + // Wait a bit to prevent collisions between this store and SCORM API's store. + setTimeout(() => { + this.scormHelper.convertAttemptToOffline(this.scorm, this.attempt).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.error', true); + }).then(() => { + this.refreshToc(); + }); + }, 200); + } + }, this.siteId); + } + + /** + * Calculate the next and previous SCO. + * + * @param {number} scoId Current SCO ID. + */ + protected calculateNextAndPreviousSco(scoId: number): void { + this.previousSco = this.scormHelper.getPreviousScoFromToc(this.toc, scoId); + this.nextSco = this.scormHelper.getNextScoFromToc(this.toc, scoId); + } + + /** + * Determine the attempt to use, the mode (normal/preview) and if it's offline or online. + * + * @param {AddonModScormAttemptCountResult} attemptsData Attempts count. + * @return {Promise} Promise resolved when done. + */ + protected determineAttemptAndMode(attemptsData: AddonModScormAttemptCountResult): Promise { + let result; + + return this.scormHelper.determineAttemptToContinue(this.scorm, attemptsData).then((data) => { + this.attempt = data.number; + this.offline = data.offline; + + if (this.attempt != attemptsData.lastAttempt.number) { + this.attemptToContinue = this.attempt; + } + + // Check if current attempt is incomplete. + if (this.attempt > 0) { + return this.scormProvider.isAttemptIncomplete(this.scorm.id, this.attempt, this.offline); + } else { + // User doesn't have attempts. Last attempt is not incomplete (since he doesn't have any). + return false; + } + }).then((incomplete) => { + // Determine mode and attempt to use. + result = this.scormProvider.determineAttemptAndMode(this.scorm, this.mode, this.attempt, this.newAttempt, incomplete); + + if (result.attempt > this.attempt) { + // We're creating a new attempt. + if (this.offline) { + // Last attempt was offline, so we'll create a new offline attempt. + return this.scormHelper.createOfflineAttempt(this.scorm, result.attempt, attemptsData.online.length); + } else { + // Last attempt was online, verify that we can create a new online attempt. We ignore cache. + return this.scormProvider.getScormUserData(this.scorm.id, result.attempt, undefined, false, true).catch(() => { + // Cannot communicate with the server, create an offline attempt. + this.offline = true; + + return this.scormHelper.createOfflineAttempt(this.scorm, result.attempt, attemptsData.online.length); + }); + } + } + }).then(() => { + this.mode = result.mode; + this.newAttempt = result.newAttempt; + this.attempt = result.attempt; + }); + } + + /** + * Fetch data needed to play the SCORM. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchData(): Promise { + // Wait for any ongoing sync to finish. We won't sync a SCORM while it's being played. + return this.scormSyncProvider.waitForSync(this.scorm.id).then(() => { + // Get attempts data. + return this.scormProvider.getAttemptCount(this.scorm.id).then((attemptsData) => { + return this.determineAttemptAndMode(attemptsData).then(() => { + // Fetch TOC and get user data. + const promises = []; + + promises.push(this.fetchToc()); + promises.push(this.scormProvider.getScormUserData(this.scorm.id, this.attempt, undefined, this.offline) + .then((data) => { + this.userData = data; + })); + + return Promise.all(promises); + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true); + }); + }); + } + + /** + * Fetch the TOC. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchToc(): Promise { + this.loadingToc = true; + + // We need to check incomplete again: attempt number or status might have changed. + return this.scormProvider.isAttemptIncomplete(this.scorm.id, this.attempt, this.offline).then((incomplete) => { + this.scorm.incomplete = incomplete; + + // Get TOC. + return this.scormProvider.getOrganizationToc(this.scorm.id, this.attempt, this.organizationId, this.offline); + }).then((toc) => { + this.toc = this.scormProvider.formatTocToArray(toc); + + // Get images for each SCO. + this.toc.forEach((sco) => { + sco.image = this.scormProvider.getScoStatusIcon(sco, this.scorm.incomplete); + }); + + // Determine current SCO if we received an ID.. + if (this.initialScoId > 0) { + // SCO set by parameter, get it from TOC. + this.currentSco = this.scormHelper.getScoFromToc(this.toc, this.initialScoId); + } + + if (!this.currentSco) { + // No SCO defined. Get the first valid one. + return this.scormHelper.getFirstSco(this.scorm.id, this.attempt, this.toc, this.organizationId, this.offline) + .then((sco) => { + + if (sco) { + this.currentSco = sco; + } else { + // We couldn't find a SCO to load: they're all inactive or without launch URL. + this.errorMessage = 'addon.mod_scorm.errornovalidsco'; + } + }); + } + }).finally(() => { + this.loadingToc = false; + }); + } + + /** + * Page will leave. + */ + ionViewWillLeave(): void { + // Empty src when leaving the state so unload event is triggered in the iframe. + this.src = ''; + } + + /** + * Load a SCO. + * + * @param {any} sco The SCO to load. + */ + protected loadSco(sco: any): void { + if (!this.dataModel) { + // Create the model. + this.dataModel = new AddonModScormDataModel12(this.eventsProvider, this.scormProvider, this.siteId, this.scorm, sco.id, + this.attempt, this.userData, this.mode, this.offline); + + // Add the model to the window so the SCORM can access it. + ( window).API = this.dataModel; + } else { + // Load the SCO in the existing model. + this.dataModel.loadSco(sco.id); + } + + this.currentSco = sco; + this.title = sco.title || this.scorm.name; // Try to use SCO title. + + this.calculateNextAndPreviousSco(sco.id); + + // Load the SCO source. + this.scormProvider.getScoSrc(this.scorm, sco).then((src) => { + if (src == this.src) { + // Re-loading same page. Set it to empty and then re-set the src in the next digest so it detects it has changed. + this.src = ''; + + setTimeout(() => { + this.src = src; + }); + } else { + this.src = src; + } + }); + + if (sco.scormtype == 'asset') { + // Mark the asset as completed. + const tracks = [{ + element: 'cmi.core.lesson_status', + value: 'completed' + }]; + + this.scormProvider.saveTracks(sco.id, this.attempt, tracks, this.scorm, this.offline).catch(() => { + // Error saving data. We'll go offline if we're online and the asset is not marked as completed already. + if (!this.offline) { + return this.scormProvider.getScormUserData(this.scorm.id, this.attempt, undefined, false).then((data) => { + if (!data[sco.id] || data[sco.id].userdata['cmi.core.lesson_status'] != 'completed') { + // Go offline. + return this.scormHelper.convertAttemptToOffline(this.scorm, this.attempt).then(() => { + this.offline = true; + this.dataModel.setOffline(true); + + return this.scormProvider.saveTracks(sco.id, this.attempt, tracks, this.scorm, true); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.error', true); + }); + } + }); + } + }).then(() => { + // Refresh TOC, some prerequisites might have changed. + this.refreshToc(); + }); + } + + // Trigger SCO launch event. + this.scormProvider.logLaunchSco(this.scorm.id, sco.id).catch(() => { + // Ignore errors. + }); + } + + /** + * Show the TOC. + * + * @param {MouseEvent} event Event. + */ + openToc(event: MouseEvent): void { + const popover = this.popoverCtrl.create(AddonModScormTocPopoverComponent, { + toc: this.toc, + attemptToContinue: this.attemptToContinue, + mode: this.mode + }); + + // If the popover sends back a SCO, load it. + popover.onDidDismiss((sco) => { + if (sco) { + this.loadSco(sco); + } + }); + + popover.present({ + ev: event + }); + } + + /** + * Refresh the TOC. + * + * @return {Promise} Promise resolved when done. + */ + protected refreshToc(): Promise { + return this.scormProvider.invalidateAllScormData(this.scorm.id).catch(() => { + // Ignore errors. + }).then(() => { + return this.fetchToc(); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true); + }); + } + + /** + * Set SCORM start time. + * + * @param {number} scoId SCO ID. + * @return {Promise} Promise resolved when done. + */ + protected setStartTime(scoId: number): Promise { + const tracks = [{ + element: 'x.start.time', + value: this.timeUtils.timestamp() + }]; + + return this.scormProvider.saveTracks(scoId, this.attempt, tracks, this.scorm, this.offline).then(() => { + if (!this.offline) { + // New online attempt created, update cached data about online attempts. + this.scormProvider.getAttemptCount(this.scorm.id, false, true).catch(() => { + // Ignore errors. + }); + } + }); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + // Stop listening for events. + this.tocObserver && this.tocObserver.off(); + this.launchNextObserver && this.launchNextObserver.off(); + this.launchPrevObserver && this.launchPrevObserver.off(); + this.goOfflineObserver && this.goOfflineObserver.off(); + + // Unblock the SCORM so it can be synced. + this.syncProvider.unblockOperation(AddonModScormProvider.COMPONENT, this.scorm.id, 'player'); + } +} diff --git a/src/addon/mod/scorm/providers/helper.ts b/src/addon/mod/scorm/providers/helper.ts index 5c0849fee..721148e85 100644 --- a/src/addon/mod/scorm/providers/helper.ts +++ b/src/addon/mod/scorm/providers/helper.ts @@ -13,8 +13,12 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreUtilsProvider } from '@providers/utils/utils'; import { AddonModScormProvider, AddonModScormAttemptCountResult } from './scorm'; +import { AddonModScormOfflineProvider } from './scorm-offline'; /** * Helper service that provides some features for SCORM. @@ -22,9 +26,13 @@ import { AddonModScormProvider, AddonModScormAttemptCountResult } from './scorm' @Injectable() export class AddonModScormHelperProvider { - protected div = document.createElement('div'); // A div element to search in HTML code. + // List of elements we want to ignore when copying attempts (they're calculated). + protected elementsToIgnore = ['status', 'score_raw', 'total_time', 'session_time', 'student_id', 'student_name', 'credit', + 'mode', 'entry']; - constructor(private domUtils: CoreDomUtilsProvider, private scormProvider: AddonModScormProvider) { } + constructor(private sitesProvider: CoreSitesProvider, private translate: TranslateService, + private domUtils: CoreDomUtilsProvider, private utils: CoreUtilsProvider, + private scormProvider: AddonModScormProvider, private scormOfflineProvider: AddonModScormOfflineProvider) { } /** * Show a confirm dialog if needed. If SCORM doesn't have size, try to calculate it. @@ -58,6 +66,93 @@ export class AddonModScormHelperProvider { }); } + /** + * Creates a new offline attempt based on an existing online attempt. + * + * @param {any} scorm SCORM. + * @param {number} attempt Number of the online attempt. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the attempt is created. + */ + convertAttemptToOffline(scorm: any, attempt: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Get data from the online attempt. + return this.scormProvider.getScormUserData(scorm.id, attempt, undefined, false, false, siteId).then((onlineData) => { + // The SCORM API might have written some data to the offline attempt already. + // We don't want to override it with cached online data. + return this.scormOfflineProvider.getScormUserData(scorm.id, attempt, undefined, siteId).catch(() => { + // Ignore errors. + }).then((offlineData) => { + const dataToStore = this.utils.clone(onlineData); + + // Filter the data to copy. + for (const scoId in dataToStore) { + const sco = dataToStore[scoId]; + + // Delete calculated data. + this.elementsToIgnore.forEach((el) => { + delete sco.userdata[el]; + }); + + // Don't override offline data. + if (offlineData && offlineData[sco.scoid] && offlineData[sco.scoid].userdata) { + const scoUserData = {}; + + 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; + } + } + + return this.scormOfflineProvider.createNewAttempt(scorm, attempt, dataToStore, onlineData, siteId); + }); + }).catch(() => { + // Shouldn't happen. + return Promise.reject(this.translate.instant('addon.mod_scorm.errorcreateofflineattempt')); + }); + } + + /** + * Creates a new offline attempt. + * + * @param {any} scorm SCORM. + * @param {number} newAttempt Number of the new attempt. + * @param {number} lastOnline Number of the last online attempt. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the attempt is created. + */ + createOfflineAttempt(scorm: any, newAttempt: number, lastOnline: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Try to get data from online attempts. + return this.searchOnlineAttemptUserData(scorm.id, lastOnline, siteId).then((userData) => { + // 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], + filtered = {}; + + for (const element in sco.userdata) { + if (element.indexOf('.') == -1 && this.elementsToIgnore.indexOf(element) == -1) { + // The element doesn't use a dot notation, probably SCO data. + filtered[element] = sco.userdata[element]; + } + } + + sco.userdata = filtered; + } + + return this.scormOfflineProvider.createNewAttempt(scorm, newAttempt, userData, undefined, siteId); + }).catch(() => { + return Promise.reject(this.translate.instant('addon.mod_scorm.errorcreateofflineattempt')); + }); + } + /** * 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. @@ -97,6 +192,41 @@ export class AddonModScormHelperProvider { } } + /** + * Get the first SCO to load in a SCORM. If a non-empty TOC is provided, it will be the first valid SCO in the TOC. + * Otherwise, it will be the first valid SCO returned by $mmaModScorm#getScos. + * + * @param {number} scormId Scorm ID. + * @param {number} attempt Attempt number. + * @param {any[]} [toc] SCORM's TOC. + * @param {string} [organization] Organization to use. + * @param {boolean} [offline] Whether the attempt is offline. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the first SCO. + */ + getFirstSco(scormId: number, attempt: number, toc?: any[], organization?: string, offline?: boolean, siteId?: string) + : Promise { + + let promise; + if (toc && toc.length) { + promise = Promise.resolve(toc); + } else { + // SCORM doesn't have a TOC. Get all the scos. + promise = this.scormProvider.getScosWithData(scormId, attempt, organization, offline, false, siteId); + } + + return promise.then((scos) => { + // Search the first valid SCO. + for (let i = 0; i < scos.length; i++) { + const sco = scos[i]; + + if (sco.isvisible && sco.prereq && sco.launch) { + return sco; + } + } + }); + } + /** * 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. @@ -118,4 +248,83 @@ export class AddonModScormHelperProvider { }; } } + + /** + * Given a TOC in array format and a scoId, return the next available SCO. + * + * @param {any[]} toc SCORM's TOC. + * @param {number} scoId SCO ID. + * @return {any} Next SCO. + */ + getNextScoFromToc(toc: any, scoId: number): any { + for (let i = 0; i < toc.length; i++) { + if (toc[i].id == scoId) { + // We found the current SCO. Now let's 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 {any[]} toc SCORM's TOC. + * @param {number} scoId SCO ID. + * @return {any} Previous SCO. + */ + getPreviousScoFromToc(toc: any, scoId: number): any { + for (let i = 0; i < toc.length; i++) { + if (toc[i].id == scoId) { + // 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 {any[]} toc SCORM's TOC. + * @param {number} scoId SCO ID. + * @return {any} SCO. + */ + getScoFromToc(toc: any[], scoId: number): any { + for (let i = 0; i < toc.length; i++) { + if (toc[i].id == scoId) { + return toc[i]; + } + } + } + + /** + * Searches user data for an online attempt. If the data can't be retrieved, re-try with the previous online attempt. + * + * @param {number} scormId SCORM ID. + * @param {number} attempt Online attempt to get the data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with user data. + */ + searchOnlineAttemptUserData(scormId: number, attempt: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.scormProvider.getScormUserData(scormId, attempt, undefined, false, false, siteId).catch(() => { + if (attempt > 0) { + // We couldn't retrieve the data. Try again with the previous online attempt. + return this.searchOnlineAttemptUserData(scormId, attempt - 1, siteId); + } else { + // No more attempts to try. Reject + return Promise.reject(null); + } + }); + } } diff --git a/src/components/iframe/iframe.ts b/src/components/iframe/iframe.ts index 1f50b217e..f86bb4644 100644 --- a/src/components/iframe/iframe.ts +++ b/src/components/iframe/iframe.ts @@ -139,6 +139,13 @@ export class CoreIframeComponent implements OnInit, OnChanges { winAndDoc = this.getContentWindowAndDocument(element); this.redefineWindowOpen(element, winAndDoc.window, winAndDoc.document); this.treatLinks(element, winAndDoc.document); + + if (winAndDoc.window) { + // Send a resize events to the iframe so it calculates the right size if needed. + setTimeout(() => { + winAndDoc.window.dispatchEvent(new Event('resize')); + }, 1000); + } }); } }