MOBILE-2350 scorm: Implement sync provider and prefetch handler

main
Dani Palou 2018-04-23 16:57:49 +02:00
parent 17ae2556e0
commit d4fe21c9a3
3 changed files with 1262 additions and 1 deletions

View File

@ -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<any>} Promise resolved when all content is downloaded.
*/
download(module: any, courseId: number, dirPath?: string, onProgress?: (event: AddonModScormProgressEvent) => any)
: Promise<any> {
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<any>} 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<string> {
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<any>} Promise resolved when the file is downloaded and unzipped.
*/
protected downloadOrPrefetchMainFile(scorm: any, prefetch?: boolean, onProgress?: (event: AddonModScormProgressEvent) => any,
siteId?: string): Promise<any> {
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<any>} Promise resolved when the file is downloaded and unzipped.
*/
protected downloadOrPrefetchMainFileIfNeeded(scorm: any, prefetch?: boolean,
onProgress?: (event: AddonModScormProgressEvent) => any, siteId?: string): Promise<any> {
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<any>} Promise resolved when the data is prefetched.
*/
fetchWSData(scorm: any, siteId?: string): Promise<any> {
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<number>} Size, or promise resolved with the size.
*/
getDownloadedSize(module: any, courseId: number): number | Promise<number> {
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<any[]>} Promise resolved with the list of files.
*/
getFiles(module: any, courseId: number, single?: boolean): Promise<any[]> {
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<any>} Promise resolved when the data is invalidated.
*/
invalidateContent(moduleId: number, courseId: number): Promise<any> {
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<any>} Promise resolved when invalidated.
*/
invalidateModule(module: any, courseId: number): Promise<any> {
// 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<boolean>} Whether the module can be downloaded. The promise should never be rejected.
*/
isDownloadable(module: any, courseId: number): boolean | Promise<boolean> {
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<boolean>} A boolean, or a promise resolved with a boolean, indicating if the handler is enabled.
*/
isEnabled(): boolean | Promise<boolean> {
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<any>} Promise resolved when done.
*/
prefetch(module: any, courseId?: number, single?: boolean, dirPath?: string,
onProgress?: (event: AddonModScormProgressEvent) => any): Promise<any> {
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<any>} Promise resolved when done.
*/
removeFiles(module: any, courseId: number): Promise<any> {
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);
});
}
}

View File

@ -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<any>} 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<any> {
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<any>} Promise resolved if can retry the synchronization, rejected otherwise.
*/
protected canRetrySync(scormId: number, attempt: number, lastOnline: number, siteId: string): Promise<any> {
// 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<any>} Promise resolved when done.
*/
protected createNewAttemptsAtEnd(scormId: number, newAttempts: any, lastOffline: number, siteId: string): Promise<any> {
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<AddonModScormSyncResult>} Promise resolved on success.
*/
protected finishSync(siteId: string, scorm: any, warnings: string[], lastOnline?: number, lastOnlineWasFinished?: boolean,
initialCount?: AddonModScormAttemptCountResult, updated?: boolean): Promise<AddonModScormSyncResult> {
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<any>} Promise resolved when attempts have been moved.
*/
protected moveNewAttempts(scormId: any, newAttempts: number[], lastOnline: number, lastCollision: number,
offlineAttempts: number[], siteId: string): Promise<any> {
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<any>} Promise resolved when the snapshot is stored.
*/
protected saveSyncSnapshot(scormId: number, attempt: number, siteId: string): Promise<any> {
// 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<any>} Promise resolved if sync is successful, rejected if sync fails.
*/
syncAllScorms(siteId?: string): Promise<any> {
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<any>} Promise resolved if sync is successful, rejected if sync fails.
*/
protected syncAllScormsFunc(siteId?: string): Promise<any> {
// 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<any>} Promise resolved when the attempt is successfully synced.
*/
protected syncAttempt(scormId: number, attempt: number, siteId?: string): Promise<any> {
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<any>} Promise resolved when the SCORM is synced or if it doesn't need to be synced.
*/
syncScormIfNeeded(scorm: any, siteId?: string): Promise<any> {
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<AddonModScormSyncResult>} Promise resolved in success.
*/
syncScorm(scorm: any, siteId?: string): Promise<AddonModScormSyncResult> {
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<string[]} Promise resolved when the collisions have been treated. It returns warnings array.
* @description
*
* Treat collisions found in a SCORM synchronization process. A collision is when an attempt exists both in offline
* and online. A collision can be:
*
* - Two different attempts.
* - An online attempt continued in offline.
* - A failure in a previous sync.
*
* This function will move into new attempts the collisions that can't be merged. It will usually keep the order of the
* offline attempts EXCEPT if the offline attempt was created after the last offline attempt (edge case).
*
* Edge case: A user creates offline attempts and when he syncs we retrieve an incomplete online attempt, so the offline
* attempts cannot be synced. Then the user continues that online attempt and goes offline, so a collision is created.
* When we perform the next sync we detect that this collision cannot be merged, so this offline attempt needs to be
* created as a new attempt. Since this attempt was created after the last offline attempt, it will be added ot the end
* of the list if the last attempt is completed. If the last attempt is not completed then the offline data will de deleted
* because we can't create a new attempt.
*/
protected treatCollisions(scormId: number, collisions: number[], lastOnline: number, offlineAttempts: number[], siteId: string)
: Promise<string[]> {
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;
});
});
});
});
}
}

View File

@ -15,6 +15,8 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { AddonModScormProvider } from './providers/scorm'; import { AddonModScormProvider } from './providers/scorm';
import { AddonModScormOfflineProvider } from './providers/scorm-offline'; import { AddonModScormOfflineProvider } from './providers/scorm-offline';
import { AddonModScormPrefetchHandler } from './providers/prefetch-handler';
import { AddonModScormSyncProvider } from './providers/scorm-sync';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -23,7 +25,9 @@ import { AddonModScormOfflineProvider } from './providers/scorm-offline';
], ],
providers: [ providers: [
AddonModScormProvider, AddonModScormProvider,
AddonModScormOfflineProvider AddonModScormOfflineProvider,
AddonModScormPrefetchHandler,
AddonModScormSyncProvider
] ]
}) })
export class AddonModScormModule { } export class AddonModScormModule { }