MOBILE-2350 scorm: Implement sync provider and prefetch handler
parent
17ae2556e0
commit
d4fe21c9a3
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 { }
|
||||||
|
|
Loading…
Reference in New Issue