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 { }