MOBILE-4269 h5pactivity: Support save state in offline
parent
abac134cc5
commit
8cb4b4ec6d
|
@ -2521,6 +2521,7 @@
|
|||
"core.viewprofile": "moodle",
|
||||
"core.wanttochangesite": "local_moodlemobileapp",
|
||||
"core.warningofflinedatadeleted": "local_moodlemobileapp",
|
||||
"core.warningofflinedatadeletedreason": "local_moodlemobileapp",
|
||||
"core.warnopeninbrowser": "local_moodlemobileapp",
|
||||
"core.week": "moodle",
|
||||
"core.weeks": "moodle",
|
||||
|
|
|
@ -39,6 +39,7 @@ import {
|
|||
AddonModH5PActivityXAPIPostStateData,
|
||||
AddonModH5PActivityXAPIStateData,
|
||||
AddonModH5PActivityXAPIStatementsData,
|
||||
MOD_H5PACTIVITY_STATE_ID,
|
||||
} from '../../services/h5pactivity';
|
||||
import {
|
||||
AddonModH5PActivitySync,
|
||||
|
@ -48,6 +49,7 @@ import {
|
|||
import { CoreFileHelper } from '@services/file-helper';
|
||||
import { AddonModH5PActivityModuleHandlerService } from '../../services/handlers/module';
|
||||
import { CoreTextUtils } from '@services/utils/text';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
|
||||
/**
|
||||
* Component that displays an H5P activity entry page.
|
||||
|
@ -114,13 +116,17 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
|
|||
async ngOnInit(): Promise<void> {
|
||||
super.ngOnInit();
|
||||
|
||||
this.loadContent();
|
||||
this.loadContent(false, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected async fetchContent(refresh?: boolean, sync = false, showErrors = false): Promise<void> {
|
||||
// Always show loading and stop playing, the package needs to be reloaded with the latest data.
|
||||
this.showLoading = true;
|
||||
this.playing = false;
|
||||
|
||||
this.h5pActivity = await AddonModH5PActivity.getH5PActivity(this.courseId, this.module.id, {
|
||||
siteId: this.siteId,
|
||||
});
|
||||
|
@ -176,7 +182,7 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
|
|||
return;
|
||||
}
|
||||
|
||||
this.hasOffline = await CoreXAPIOffline.contextHasStatements(this.h5pActivity.context, this.siteId);
|
||||
this.hasOffline = await CoreXAPIOffline.contextHasData(this.h5pActivity.context, this.siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -229,7 +235,7 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
|
|||
* Load the content's state (if enabled and there's any).
|
||||
*/
|
||||
protected async loadContentState(): Promise<void> {
|
||||
if (!this.h5pActivity?.enabletracking || !this.h5pActivity.enablesavestate || !this.accessInfo?.cansubmit) {
|
||||
if (!this.h5pActivity || !this.accessInfo || !AddonModH5PActivity.isSaveStateEnabled(this.h5pActivity, this.accessInfo)) {
|
||||
this.saveStateEnabled = false;
|
||||
|
||||
return;
|
||||
|
@ -238,10 +244,10 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
|
|||
this.saveStateEnabled = true;
|
||||
this.saveFreq = this.h5pActivity.savestatefreq;
|
||||
|
||||
const contentState = await CoreXAPI.getStateOnline(
|
||||
const contentState = await CoreXAPI.getState(
|
||||
AddonModH5PActivityProvider.TRACK_COMPONENT,
|
||||
this.h5pActivity.context,
|
||||
'state',
|
||||
MOD_H5PACTIVITY_STATE_ID,
|
||||
{
|
||||
appComponent: AddonModH5PActivityProvider.COMPONENT,
|
||||
appComponentId: this.h5pActivity.coursemodule,
|
||||
|
@ -256,7 +262,7 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
|
|||
const contentStateObj = CoreTextUtils.parseJSON<{h5p: string}>(contentState, { h5p: '{}' });
|
||||
|
||||
// The H5P state doesn't always use JSON, so an h5p property was added to jsonize it.
|
||||
this.contentState = contentStateObj.h5p;
|
||||
this.contentState = contentStateObj.h5p ?? '{}';
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -381,6 +387,9 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
|
|||
this.downloading = true;
|
||||
this.progressMessage = 'core.downloading';
|
||||
|
||||
// Delete offline states when downloading the package because it means the package has changed or user deleted it.
|
||||
this.deleteOfflineStates();
|
||||
|
||||
try {
|
||||
await CoreFilepool.downloadUrl(
|
||||
this.site.getId(),
|
||||
|
@ -567,6 +576,7 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
|
|||
);
|
||||
|
||||
this.hasOffline = !sent;
|
||||
this.deleteOfflineStates(); // Posting statements means attempt has finished, delete any offline state.
|
||||
|
||||
if (sent) {
|
||||
try {
|
||||
|
@ -626,16 +636,23 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
|
|||
*/
|
||||
protected async postState(data: AddonModH5PActivityXAPIPostStateData): Promise<void> {
|
||||
try {
|
||||
await CoreXAPI.postStateOnline(
|
||||
const options = {
|
||||
offline: this.hasOffline,
|
||||
courseId: this.courseId,
|
||||
extra: this.h5pActivity?.name,
|
||||
siteId: this.site.getId(),
|
||||
};
|
||||
|
||||
const sent = await CoreXAPI.postState(
|
||||
data.component,
|
||||
data.activityId,
|
||||
JSON.stringify(data.agent),
|
||||
data.agent,
|
||||
data.stateId,
|
||||
data.stateData,
|
||||
{
|
||||
siteId: this.site.getId(),
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
this.hasOffline = !sent;
|
||||
} catch (error) {
|
||||
CoreDomUtils.showErrorModalDefault(error, 'Error sending tracking data.');
|
||||
}
|
||||
|
@ -648,10 +665,10 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
|
|||
*/
|
||||
protected async deleteState(data: AddonModH5PActivityXAPIStateData): Promise<void> {
|
||||
try {
|
||||
await CoreXAPI.deleteStateOnline(
|
||||
await CoreXAPI.deleteState(
|
||||
data.component,
|
||||
data.activityId,
|
||||
JSON.stringify(data.agent),
|
||||
data.agent,
|
||||
data.stateId,
|
||||
{
|
||||
siteId: this.site.getId(),
|
||||
|
@ -662,6 +679,19 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete offline states for current activity.
|
||||
*/
|
||||
protected async deleteOfflineStates(): Promise<void> {
|
||||
if (!this.h5pActivity) {
|
||||
return;
|
||||
}
|
||||
|
||||
await CoreUtils.ignoreErrors(CoreXAPIOffline.deleteStates(AddonModH5PActivityProvider.TRACK_COMPONENT, {
|
||||
itemId: this.h5pActivity.context,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
|
|
|
@ -19,13 +19,23 @@ import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/act
|
|||
import { CoreSyncResult } from '@services/sync';
|
||||
import { CoreCourseLogHelper } from '@features/course/services/log-helper';
|
||||
import { CoreXAPIOffline } from '@features/xapi/services/offline';
|
||||
import { CoreXAPI } from '@features/xapi/services/xapi';
|
||||
import { CoreXAPI, XAPI_STATE_DELETED } from '@features/xapi/services/xapi';
|
||||
import { CoreNetwork } from '@services/network';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { makeSingleton } from '@singletons';
|
||||
import { makeSingleton, Translate } from '@singletons';
|
||||
import { CoreEvents } from '@singletons/events';
|
||||
import { AddonModH5PActivity, AddonModH5PActivityProvider } from './h5pactivity';
|
||||
import {
|
||||
AddonModH5PActivity,
|
||||
AddonModH5PActivityAttempt,
|
||||
AddonModH5PActivityData,
|
||||
AddonModH5PActivityProvider,
|
||||
} from './h5pactivity';
|
||||
import { CoreXAPIStateDBRecord, CoreXAPIStatementDBRecord } from '@features/xapi/services/database/xapi';
|
||||
import { CoreTextUtils } from '@services/utils/text';
|
||||
import { CoreXAPIIRI } from '@features/xapi/classes/iri';
|
||||
import { CoreXAPIItemAgent } from '@features/xapi/classes/item-agent';
|
||||
import { CoreWSError } from '@classes/errors/wserror';
|
||||
|
||||
/**
|
||||
* Service to sync H5P activities.
|
||||
|
@ -60,17 +70,23 @@ export class AddonModH5PActivitySyncProvider extends CoreCourseActivitySyncBaseP
|
|||
* @returns Promise resolved if sync is successful, rejected if sync fails.
|
||||
*/
|
||||
protected async syncAllActivitiesFunc(force: boolean, siteId?: string): Promise<void> {
|
||||
const entries = await CoreXAPIOffline.getAllStatements(siteId);
|
||||
const [statements, states] = await Promise.all([
|
||||
CoreXAPIOffline.getAllStatements(siteId),
|
||||
CoreXAPIOffline.getAllStates(siteId),
|
||||
]);
|
||||
|
||||
// Sync all responses.
|
||||
const promises = entries.map(async (response) => {
|
||||
const result = await (force ? this.syncActivity(response.contextid, siteId) :
|
||||
this.syncActivityIfNeeded(response.contextid, siteId));
|
||||
const entries = (<(CoreXAPIStatementDBRecord|CoreXAPIStateDBRecord)[]> statements).concat(states);
|
||||
const contextIds = CoreUtils.uniqueArray(entries.map(entry => 'contextid' in entry ? entry.contextid : entry.itemid));
|
||||
|
||||
// Sync all activities.
|
||||
const promises = contextIds.map(async (contextId) => {
|
||||
const result = await (force ? this.syncActivity(contextId, siteId) :
|
||||
this.syncActivityIfNeeded(contextId, siteId));
|
||||
|
||||
if (result?.updated) {
|
||||
// Sync successful, send event.
|
||||
CoreEvents.trigger(AddonModH5PActivitySyncProvider.AUTO_SYNCED, {
|
||||
contextId: response.contextid,
|
||||
contextId,
|
||||
warnings: result.warnings,
|
||||
}, siteId);
|
||||
}
|
||||
|
@ -129,34 +145,102 @@ export class AddonModH5PActivitySyncProvider extends CoreCourseActivitySyncBaseP
|
|||
|
||||
this.logger.debug(`Try to sync H5P activity with context ID '${contextId}'`);
|
||||
|
||||
let h5pActivity: AddonModH5PActivityData | null = null;
|
||||
const result: AddonModH5PActivitySyncResult = {
|
||||
warnings: [],
|
||||
updated: false,
|
||||
};
|
||||
|
||||
// Get all the statements stored for the activity.
|
||||
const entries = await CoreXAPIOffline.getContextStatements(contextId, siteId);
|
||||
const [statements, states] = await Promise.all([
|
||||
CoreXAPIOffline.getContextStatements(contextId, siteId),
|
||||
CoreXAPIOffline.getItemStates(contextId, siteId),
|
||||
]);
|
||||
|
||||
if (!entries || !entries.length) {
|
||||
// Nothing to sync.
|
||||
const deleteOfflineData = async (): Promise<void> => {
|
||||
await Promise.all([
|
||||
statements.length ? CoreXAPIOffline.deleteStatementsForContext(contextId, siteId) : undefined,
|
||||
states.length ? CoreXAPIOffline.deleteStates(AddonModH5PActivityProvider.TRACK_COMPONENT, {
|
||||
itemId: contextId,
|
||||
siteId,
|
||||
}) : undefined,
|
||||
]);
|
||||
|
||||
result.updated = true;
|
||||
};
|
||||
const finishSync = async (): Promise<AddonModH5PActivitySyncResult> => {
|
||||
await this.setSyncTime(contextId, siteId);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
if (!statements.length && !states.length) {
|
||||
// Nothing to sync.
|
||||
return finishSync();
|
||||
}
|
||||
|
||||
// Get the activity instance.
|
||||
const courseId = entries[0].courseid!;
|
||||
const courseId = (statements.find(statement => !!statement.courseid) ?? states.find(state => !!state.courseid))?.courseid;
|
||||
if (!courseId) {
|
||||
// Data not valid (shouldn't happen), delete it.
|
||||
await deleteOfflineData();
|
||||
|
||||
const h5pActivity = await AddonModH5PActivity.getH5PActivityByContextId(courseId, contextId, { siteId });
|
||||
return finishSync();
|
||||
}
|
||||
|
||||
try {
|
||||
h5pActivity = await AddonModH5PActivity.getH5PActivityByContextId(courseId, contextId, { siteId });
|
||||
} catch (error) {
|
||||
if (
|
||||
CoreUtils.isWebServiceError(error) ||
|
||||
CoreTextUtils.getErrorMessageFromError(error) === Translate.instant('core.course.modulenotfound')
|
||||
) {
|
||||
// Activity no longer accessible. Delete the data and finish the sync.
|
||||
await deleteOfflineData();
|
||||
|
||||
return finishSync();
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Sync offline logs.
|
||||
await CoreUtils.ignoreErrors(
|
||||
CoreCourseLogHelper.syncActivity(AddonModH5PActivityProvider.COMPONENT, h5pActivity.id, siteId),
|
||||
);
|
||||
|
||||
const results = await Promise.all([
|
||||
this.syncStatements(h5pActivity.id, statements, siteId),
|
||||
this.syncStates(h5pActivity, states, siteId),
|
||||
]);
|
||||
|
||||
result.updated = results[0].updated || results[1].updated;
|
||||
result.warnings = results[0].warnings.concat(results[1].warnings);
|
||||
|
||||
return finishSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync statements.
|
||||
*
|
||||
* @param id H5P activity ID.
|
||||
* @param statements Statements to sync.
|
||||
* @param siteId Site ID.
|
||||
* @returns Promise resolved with the sync result.
|
||||
*/
|
||||
protected async syncStatements(
|
||||
id: number,
|
||||
statements: CoreXAPIStatementDBRecord[],
|
||||
siteId: string,
|
||||
): Promise<AddonModH5PActivitySyncResult> {
|
||||
const result: AddonModH5PActivitySyncResult = {
|
||||
warnings: [],
|
||||
updated: false,
|
||||
};
|
||||
|
||||
// Send the statements in order.
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
for (let i = 0; i < statements.length; i++) {
|
||||
const entry = statements[i];
|
||||
|
||||
try {
|
||||
await CoreXAPI.postStatementsOnline(entry.component, entry.statements, siteId);
|
||||
|
@ -174,19 +258,126 @@ export class AddonModH5PActivitySyncProvider extends CoreCourseActivitySyncBaseP
|
|||
|
||||
await CoreXAPIOffline.deleteStatements(entry.id, siteId);
|
||||
|
||||
// Responses deleted, add a warning.
|
||||
// Statements deleted, add a warning.
|
||||
this.addOfflineDataDeletedWarning(result.warnings, entry.extra || '', error);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (result.updated) {
|
||||
// Data has been sent to server, invalidate attempts.
|
||||
await CoreUtils.ignoreErrors(AddonModH5PActivity.invalidateUserAttempts(h5pActivity.id, undefined, siteId));
|
||||
await CoreUtils.ignoreErrors(AddonModH5PActivity.invalidateUserAttempts(id, undefined, siteId));
|
||||
}
|
||||
|
||||
// Sync finished, set sync time.
|
||||
await this.setSyncTime(contextId, siteId);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync states.
|
||||
*
|
||||
* @param h5pActivity H5P activity instance.
|
||||
* @param states States to sync.
|
||||
* @param siteId Site ID.
|
||||
* @returns Promise resolved with the sync result.
|
||||
*/
|
||||
protected async syncStates(
|
||||
h5pActivity: AddonModH5PActivityData,
|
||||
states: CoreXAPIStateDBRecord[],
|
||||
siteId: string,
|
||||
): Promise<AddonModH5PActivitySyncResult> {
|
||||
const result: AddonModH5PActivitySyncResult = {
|
||||
warnings: [],
|
||||
updated: false,
|
||||
};
|
||||
|
||||
if (!states.length) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const [site, activityIRI] = await Promise.all([
|
||||
CoreSites.getSite(siteId),
|
||||
CoreXAPIIRI.generate(h5pActivity.context, 'activity', siteId),
|
||||
]);
|
||||
const agent = JSON.stringify(CoreXAPIItemAgent.createFromSite(site).getData());
|
||||
|
||||
let lastAttempt: AddonModH5PActivityAttempt | undefined;
|
||||
try {
|
||||
const attemptsData = await AddonModH5PActivity.getUserAttempts(h5pActivity.id, {
|
||||
cmId: h5pActivity.context,
|
||||
readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK,
|
||||
});
|
||||
lastAttempt = attemptsData.attempts.pop();
|
||||
} catch (error) {
|
||||
// Error getting attempts. If the WS has thrown an exception it means the user cannot retrieve the attempts for
|
||||
// some reason (it shouldn't happen), continue synchronizing in that case.
|
||||
if (!CoreUtils.isWebServiceError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(states.map(async (state) => {
|
||||
try {
|
||||
if (lastAttempt && state.timecreated <= lastAttempt.timecreated) {
|
||||
// State was created before the last attempt. It means the user finished an attempt in another device.
|
||||
throw new CoreWSError({
|
||||
message: Translate.instant('core.warningofflinedatadeletedreason'),
|
||||
errorcode: 'offlinedataoutdated',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if there is a newer state in LMS.
|
||||
const onlineStates = await CoreXAPI.getStatesSince(state.component, h5pActivity.context, {
|
||||
registration: state.registration,
|
||||
since: state.timecreated,
|
||||
siteId,
|
||||
});
|
||||
|
||||
if (onlineStates.length) {
|
||||
// There is newer data in the server, discard the offline data.
|
||||
throw new CoreWSError({
|
||||
message: Translate.instant('core.warningofflinedatadeletedreason'),
|
||||
errorcode: 'offlinedataoutdated',
|
||||
});
|
||||
}
|
||||
|
||||
if (state.statedata === XAPI_STATE_DELETED) {
|
||||
await CoreXAPI.deleteStateOnline(state.component, activityIRI, agent, state.stateid, {
|
||||
registration: state.registration,
|
||||
siteId,
|
||||
});
|
||||
} else if (state.statedata) {
|
||||
await CoreXAPI.postStateOnline(state.component, activityIRI, agent, state.stateid, state.statedata, {
|
||||
registration: state.registration,
|
||||
siteId,
|
||||
});
|
||||
}
|
||||
|
||||
result.updated = true;
|
||||
|
||||
await CoreXAPIOffline.deleteStates(state.component, {
|
||||
itemId: h5pActivity.context,
|
||||
stateId: state.stateid,
|
||||
registration: state.registration,
|
||||
siteId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!CoreUtils.isWebServiceError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// The WebService has thrown an error, this means the state cannot be submitted. Delete it.
|
||||
result.updated = true;
|
||||
|
||||
await CoreXAPIOffline.deleteStates(state.component, {
|
||||
itemId: h5pActivity.context,
|
||||
stateId: state.stateid,
|
||||
registration: state.registration,
|
||||
siteId,
|
||||
});
|
||||
|
||||
// State deleted, add a warning.
|
||||
this.addOfflineDataDeletedWarning(result.warnings, state.extra || '', error);
|
||||
}
|
||||
}));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
@ -28,6 +28,8 @@ import { CoreError } from '@classes/errors/error';
|
|||
import { AddonModH5PActivityAutoSyncData, AddonModH5PActivitySyncProvider } from './h5pactivity-sync';
|
||||
import { CoreTime } from '@singletons/time';
|
||||
|
||||
export const MOD_H5PACTIVITY_STATE_ID = 'state';
|
||||
|
||||
const ROOT_CACHE_KEY = 'mmaModH5PActivity:';
|
||||
|
||||
/**
|
||||
|
@ -759,6 +761,17 @@ export class AddonModH5PActivityProvider {
|
|||
return site.wsAvailable('mod_h5pactivity_get_h5pactivities_by_courses');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if save state is enabled for a certain activity.
|
||||
*
|
||||
* @param h5pActivity Activity.
|
||||
* @param accessInfo Access info.
|
||||
* @returns Whether save state is enabled.
|
||||
*/
|
||||
isSaveStateEnabled(h5pActivity: AddonModH5PActivityData, accessInfo?: AddonModH5PActivityAccessInfo): boolean {
|
||||
return !!(h5pActivity.enabletracking && h5pActivity.enablesavestate && (!accessInfo || accessInfo.cansubmit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an H5P activity as being viewed.
|
||||
*
|
||||
|
@ -1210,4 +1223,5 @@ export type AddonModH5PActivityStatement = {
|
|||
id: string;
|
||||
display: Record<string, string>;
|
||||
};
|
||||
timestamp?: string;
|
||||
};
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { CoreConstants } from '@/core/constants';
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler';
|
||||
|
@ -19,11 +20,21 @@ import { CoreCourseAnyModuleData } from '@features/course/services/course';
|
|||
import { CoreH5PHelper } from '@features/h5p/classes/helper';
|
||||
import { CoreH5P } from '@features/h5p/services/h5p';
|
||||
import { CoreUser } from '@features/user/services/user';
|
||||
import { CoreXAPIOffline } from '@features/xapi/services/offline';
|
||||
import { CoreXAPI } from '@features/xapi/services/xapi';
|
||||
import { CoreFileHelper } from '@services/file-helper';
|
||||
import { CoreFilepool } from '@services/filepool';
|
||||
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { CoreWSFile } from '@services/ws';
|
||||
import { makeSingleton } from '@singletons';
|
||||
import { AddonModH5PActivity, AddonModH5PActivityData, AddonModH5PActivityProvider } from '../h5pactivity';
|
||||
import {
|
||||
AddonModH5PActivity,
|
||||
AddonModH5PActivityAccessInfo,
|
||||
AddonModH5PActivityData,
|
||||
AddonModH5PActivityProvider,
|
||||
MOD_H5PACTIVITY_STATE_ID,
|
||||
} from '../h5pactivity';
|
||||
|
||||
/**
|
||||
* Handler to prefetch h5p activity.
|
||||
|
@ -129,6 +140,18 @@ export class AddonModH5PActivityPrefetchHandlerService extends CoreCourseActivit
|
|||
siteId: siteId,
|
||||
});
|
||||
|
||||
if (AddonModH5PActivity.isSaveStateEnabled(h5pActivity)) {
|
||||
// If the file needs to be downloaded, delete the states because it means the package has changed or user deleted it.
|
||||
const fileState = await CoreFilepool.getFileStateByUrl(siteId, CoreFileHelper.getFileUrl(deployedFile));
|
||||
|
||||
if (fileState !== CoreConstants.DOWNLOADED) {
|
||||
await CoreUtils.ignoreErrors(CoreXAPIOffline.deleteStates(AddonModH5PActivityProvider.TRACK_COMPONENT, {
|
||||
itemId: h5pActivity.context,
|
||||
siteId,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
await CoreFilepool.addFilesToQueue(siteId, [deployedFile], AddonModH5PActivityProvider.COMPONENT, module.id);
|
||||
}
|
||||
|
||||
|
@ -140,13 +163,31 @@ export class AddonModH5PActivityPrefetchHandlerService extends CoreCourseActivit
|
|||
* @returns Promise resolved when done.
|
||||
*/
|
||||
protected async prefetchWSData(h5pActivity: AddonModH5PActivityData, siteId: string): Promise<void> {
|
||||
|
||||
const accessInfo = await AddonModH5PActivity.getAccessInformation(h5pActivity.id, {
|
||||
cmId: h5pActivity.coursemodule,
|
||||
readingStrategy: CoreSitesReadingStrategy.PREFER_CACHE,
|
||||
siteId,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
this.prefetchAttempts(h5pActivity, accessInfo, siteId),
|
||||
this.prefetchState(h5pActivity, accessInfo, siteId),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch attempts.
|
||||
*
|
||||
* @param h5pActivity Activity instance.
|
||||
* @param accessInfo Access info.
|
||||
* @param siteId Site ID.
|
||||
* @returns Promise resolved when done.
|
||||
*/
|
||||
protected async prefetchAttempts(
|
||||
h5pActivity: AddonModH5PActivityData,
|
||||
accessInfo: AddonModH5PActivityAccessInfo,
|
||||
siteId: string,
|
||||
): Promise<void> {
|
||||
const options = {
|
||||
cmId: h5pActivity.coursemodule,
|
||||
readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK,
|
||||
|
@ -179,6 +220,36 @@ export class AddonModH5PActivityPrefetchHandlerService extends CoreCourseActivit
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch state.
|
||||
*
|
||||
* @param h5pActivity Activity instance.
|
||||
* @param accessInfo Access info.
|
||||
* @param siteId Site ID.
|
||||
* @returns Promise resolved when done.
|
||||
*/
|
||||
protected async prefetchState(
|
||||
h5pActivity: AddonModH5PActivityData,
|
||||
accessInfo: AddonModH5PActivityAccessInfo,
|
||||
siteId: string,
|
||||
): Promise<void> {
|
||||
if (!AddonModH5PActivity.isSaveStateEnabled(h5pActivity, accessInfo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await CoreXAPI.getStateFromServer(
|
||||
AddonModH5PActivityProvider.TRACK_COMPONENT,
|
||||
h5pActivity.context,
|
||||
MOD_H5PACTIVITY_STATE_ID,
|
||||
{
|
||||
appComponent: AddonModH5PActivityProvider.COMPONENT,
|
||||
appComponentId: h5pActivity.coursemodule,
|
||||
readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK,
|
||||
siteId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const AddonModH5PActivityPrefetchHandler = makeSingleton(AddonModH5PActivityPrefetchHandlerService);
|
||||
|
|
|
@ -109,6 +109,10 @@ export class CoreH5PFramework {
|
|||
|
||||
const db = await CoreSites.getSiteDb(siteId);
|
||||
|
||||
// The user content should be reset (instead of removed), because this method is called when H5P content needs
|
||||
// to be updated too (and the previous states must be kept, but reset).
|
||||
await this.resetContentUserData(id, siteId);
|
||||
|
||||
await Promise.all([
|
||||
// Delete the content data.
|
||||
db.deleteRecords(CONTENT_TABLE_NAME, { id }),
|
||||
|
@ -645,7 +649,8 @@ export class CoreH5PFramework {
|
|||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async resetContentUserData(contentId: number, siteId?: string): Promise<void> {
|
||||
// Currently, we do not store user data for a content.
|
||||
// In LMS, all the states of the component are deleted here.
|
||||
// This isn't possible in the app because we lack the course ID, which is needed for example by h5pactivity.
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -18,9 +18,10 @@ import { CoreSiteSchema } from '@services/sites';
|
|||
* Database variables for CoreXAPIOfflineProvider service.
|
||||
*/
|
||||
export const STATEMENTS_TABLE_NAME = 'core_xapi_statements';
|
||||
export const STATES_TABLE_NAME = 'core_xapi_states';
|
||||
export const SITE_SCHEMA: CoreSiteSchema = {
|
||||
name: 'CoreXAPIOfflineProvider',
|
||||
version: 1,
|
||||
version: 2,
|
||||
tables: [
|
||||
{
|
||||
name: STATEMENTS_TABLE_NAME,
|
||||
|
@ -57,6 +58,58 @@ export const SITE_SCHEMA: CoreSiteSchema = {
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: STATES_TABLE_NAME,
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'INTEGER',
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
{
|
||||
name: 'component',
|
||||
type: 'TEXT',
|
||||
notNull: true,
|
||||
},
|
||||
{
|
||||
name: 'itemid',
|
||||
type: 'INTEGER',
|
||||
notNull: true,
|
||||
},
|
||||
{
|
||||
name: 'stateid',
|
||||
type: 'TEXT',
|
||||
notNull: true,
|
||||
},
|
||||
{
|
||||
name: 'statedata',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
name: 'registration',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
name: 'timecreated',
|
||||
type: 'INTEGER',
|
||||
notNull: true,
|
||||
},
|
||||
{
|
||||
name: 'timemodified',
|
||||
type: 'INTEGER',
|
||||
notNull: true,
|
||||
},
|
||||
{
|
||||
name: 'courseid',
|
||||
type: 'INTEGER',
|
||||
},
|
||||
{
|
||||
name: 'extra',
|
||||
type: 'TEXT',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
@ -72,3 +125,19 @@ export type CoreXAPIStatementDBRecord = {
|
|||
courseid?: number; // Course ID if the context is inside a course.
|
||||
extra?: string; // Extra data.
|
||||
};
|
||||
|
||||
/**
|
||||
* Structure of state data stored in DB.
|
||||
*/
|
||||
export type CoreXAPIStateDBRecord = {
|
||||
id: number; // ID.
|
||||
component: string; // Component name.
|
||||
itemid: number; // The Agent Id (usually the plugin instance).
|
||||
stateid: string; // Component identified for the state data.
|
||||
statedata?: string; // JSON state data.
|
||||
registration?: string; // Optional registration identifier.
|
||||
timecreated: number; // When was the state modified.
|
||||
timemodified: number; // When was the state modified.
|
||||
courseid?: number; // Course ID if the activity is inside a course.
|
||||
extra?: string; // Extra data.
|
||||
};
|
||||
|
|
|
@ -15,8 +15,10 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { makeSingleton } from '@singletons';
|
||||
import { CoreXAPIStatementDBRecord, STATEMENTS_TABLE_NAME } from './database/xapi';
|
||||
import { CoreXAPIStateDBRecord, CoreXAPIStatementDBRecord, STATEMENTS_TABLE_NAME, STATES_TABLE_NAME } from './database/xapi';
|
||||
import { CoreXAPIStateOptions } from './xapi';
|
||||
|
||||
/**
|
||||
* Service to handle offline xAPI.
|
||||
|
@ -24,6 +26,22 @@ import { CoreXAPIStatementDBRecord, STATEMENTS_TABLE_NAME } from './database/xap
|
|||
@Injectable({ providedIn: 'root' })
|
||||
export class CoreXAPIOfflineProvider {
|
||||
|
||||
/**
|
||||
* Check if there are offline data to send for a context.
|
||||
*
|
||||
* @param contextId Context ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @returns Promise resolved with boolean: true if has offline data, false otherwise.
|
||||
*/
|
||||
async contextHasData(contextId: number, siteId?: string): Promise<boolean> {
|
||||
const [hasStatements, hasSates] = await Promise.all([
|
||||
this.contextHasStatements(contextId, siteId),
|
||||
this.itemHasStates(contextId, siteId),
|
||||
]);
|
||||
|
||||
return hasStatements || hasSates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are offline statements to send for a context.
|
||||
*
|
||||
|
@ -63,6 +81,35 @@ export class CoreXAPIOfflineProvider {
|
|||
await db.deleteRecords(STATEMENTS_TABLE_NAME, { contextid: contextId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all states from a component that fulfill the supplied condition.
|
||||
*
|
||||
* @param component Component.
|
||||
* @param options Options.
|
||||
* @returns Promise resolved when done.
|
||||
*/
|
||||
async deleteStates(
|
||||
component: string,
|
||||
options: CoreXAPIOfflineDeleteStatesOptions = {},
|
||||
): Promise<void> {
|
||||
const db = await CoreSites.getSiteDb(options.siteId);
|
||||
|
||||
const conditions: Partial<CoreXAPIStateDBRecord> = {
|
||||
component,
|
||||
};
|
||||
if (options.itemId !== undefined) {
|
||||
conditions.itemid = options.itemId;
|
||||
}
|
||||
if (options.stateId !== undefined) {
|
||||
conditions.stateid = options.stateId;
|
||||
}
|
||||
if (options.registration !== undefined) {
|
||||
conditions.registration = options.registration;
|
||||
}
|
||||
|
||||
await db.deleteRecords(STATES_TABLE_NAME, conditions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all offline statements.
|
||||
*
|
||||
|
@ -75,6 +122,18 @@ export class CoreXAPIOfflineProvider {
|
|||
return db.getRecords(STATEMENTS_TABLE_NAME, undefined, 'timecreated ASC');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all offline states.
|
||||
*
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @returns Promise resolved with all the data.
|
||||
*/
|
||||
async getAllStates(siteId?: string): Promise<CoreXAPIStateDBRecord[]> {
|
||||
const db = await CoreSites.getSiteDb(siteId);
|
||||
|
||||
return db.getRecords(STATES_TABLE_NAME, undefined, 'timemodified ASC');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statements for a context.
|
||||
*
|
||||
|
@ -88,6 +147,19 @@ export class CoreXAPIOfflineProvider {
|
|||
return db.getRecords<CoreXAPIStatementDBRecord>(STATEMENTS_TABLE_NAME, { contextid: contextId }, 'timecreated ASC');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get states for an item.
|
||||
*
|
||||
* @param itemId item ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @returns Promise resolved with the data.
|
||||
*/
|
||||
async getItemStates(itemId: number, siteId?: string): Promise<CoreXAPIStateDBRecord[]> {
|
||||
const db = await CoreSites.getSiteDb(siteId);
|
||||
|
||||
return db.getRecords<CoreXAPIStateDBRecord>(STATES_TABLE_NAME, { itemid: itemId }, 'timecreated ASC');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get certain statements.
|
||||
*
|
||||
|
@ -101,6 +173,98 @@ export class CoreXAPIOfflineProvider {
|
|||
return db.getRecord<CoreXAPIStatementDBRecord>(STATEMENTS_TABLE_NAME, { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a certain state (if it exists).
|
||||
*
|
||||
* @param component Component.
|
||||
* @param itemId The Agent Id (usually the plugin instance).
|
||||
* @param stateId The xAPI state ID.
|
||||
* @param options Options.
|
||||
* @returns Promise resolved when done.
|
||||
*/
|
||||
async getState(
|
||||
component: string,
|
||||
itemId: number,
|
||||
stateId: string,
|
||||
options: CoreXAPIStateOptions = {},
|
||||
): Promise<CoreXAPIStateDBRecord> {
|
||||
const db = await CoreSites.getSiteDb(options?.siteId);
|
||||
|
||||
const conditions: Partial<CoreXAPIStateDBRecord> = {
|
||||
component,
|
||||
itemid: itemId,
|
||||
stateid: stateId,
|
||||
};
|
||||
if (options.registration) {
|
||||
conditions.registration = options.registration;
|
||||
}
|
||||
|
||||
return db.getRecord(STATES_TABLE_NAME, conditions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are offline states to send for an item.
|
||||
*
|
||||
* @param itemId Item ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @returns Promise resolved with boolean: true if has offline states, false otherwise.
|
||||
*/
|
||||
async itemHasStates(itemId: number, siteId?: string): Promise<boolean> {
|
||||
const statesList = await this.getItemStates(itemId, siteId);
|
||||
|
||||
return statesList && statesList.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save state.
|
||||
*
|
||||
* @param component Component.
|
||||
* @param itemId The Agent Id (usually the plugin instance).
|
||||
* @param stateId The xAPI state ID.
|
||||
* @param stateData JSON object with the state data.
|
||||
* @param options Options.
|
||||
* @returns Promise resolved when state is successfully saved.
|
||||
*/
|
||||
async saveState(
|
||||
component: string,
|
||||
itemId: number,
|
||||
stateId: string,
|
||||
stateData: string,
|
||||
options: CoreXAPIOfflineSaveStateOptions = {},
|
||||
): Promise<void> {
|
||||
const db = await CoreSites.getSiteDb(options?.siteId);
|
||||
|
||||
const storedState = await CoreUtils.ignoreErrors(this.getState(component, itemId, stateId, options));
|
||||
|
||||
if (storedState) {
|
||||
const newData: Partial<CoreXAPIStateDBRecord> = {
|
||||
statedata: stateData,
|
||||
timemodified: Date.now(),
|
||||
};
|
||||
const conditions: Partial<CoreXAPIStateDBRecord> = {
|
||||
component,
|
||||
itemid: itemId,
|
||||
stateid: stateId,
|
||||
registration: options?.registration,
|
||||
};
|
||||
|
||||
await db.updateRecords(STATES_TABLE_NAME, newData, conditions);
|
||||
} else {
|
||||
const entry: Omit<CoreXAPIStateDBRecord, 'id'> = {
|
||||
component,
|
||||
itemid: itemId,
|
||||
stateid: stateId,
|
||||
statedata: stateData,
|
||||
timecreated: Date.now(),
|
||||
timemodified: Date.now(),
|
||||
courseid: options?.courseId,
|
||||
extra: options?.extra,
|
||||
};
|
||||
|
||||
await db.insertRecord(STATES_TABLE_NAME, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save statements.
|
||||
*
|
||||
|
@ -135,10 +299,30 @@ export class CoreXAPIOfflineProvider {
|
|||
export const CoreXAPIOffline = makeSingleton(CoreXAPIOfflineProvider);
|
||||
|
||||
/**
|
||||
* Options to pass to saveStatements function.
|
||||
* Common options to pass to save functions.
|
||||
*/
|
||||
export type CoreXAPIOfflineSaveStatementsOptions = {
|
||||
export type CoreXAPIOfflineSaveCommonOptions = {
|
||||
courseId?: number; // Course ID if the context is inside a course.
|
||||
extra?: string; // Extra data to store.
|
||||
siteId?: string; // Site ID. If not defined, current site.
|
||||
};
|
||||
|
||||
/**
|
||||
* Options to pass to saveStatements function.
|
||||
*/
|
||||
export type CoreXAPIOfflineSaveStatementsOptions = CoreXAPIOfflineSaveCommonOptions;
|
||||
|
||||
/**
|
||||
* Options to pass to saveStatements function.
|
||||
*/
|
||||
export type CoreXAPIOfflineSaveStateOptions = CoreXAPIOfflineSaveCommonOptions & {
|
||||
registration?: string; // The xAPI registration UUID.
|
||||
};
|
||||
|
||||
/**
|
||||
* Options to pass to deleteStates function.
|
||||
*/
|
||||
export type CoreXAPIOfflineDeleteStatesOptions = CoreXAPIStateOptions & {
|
||||
itemId?: number;
|
||||
stateId?: string;
|
||||
};
|
||||
|
|
|
@ -22,6 +22,10 @@ import { CoreXAPIOffline, CoreXAPIOfflineSaveStatementsOptions } from './offline
|
|||
import { makeSingleton } from '@singletons';
|
||||
import { CoreXAPIItemAgent } from '../classes/item-agent';
|
||||
import { CoreXAPIIRI } from '../classes/iri';
|
||||
import { CoreError } from '@classes/errors/error';
|
||||
import { CoreLogger } from '@singletons/logger';
|
||||
|
||||
export const XAPI_STATE_DELETED = 'STATE_DELETED';
|
||||
|
||||
/**
|
||||
* Service to provide XAPI functionalities.
|
||||
|
@ -31,6 +35,8 @@ export class CoreXAPIProvider {
|
|||
|
||||
static readonly ROOT_CACHE_KEY = 'CoreXAPI:';
|
||||
|
||||
protected logger = CoreLogger.getInstance('CoreXAPIProvider');
|
||||
|
||||
/**
|
||||
* Returns whether or not WS to post XAPI statement is available.
|
||||
*
|
||||
|
@ -57,6 +63,78 @@ export class CoreXAPIProvider {
|
|||
return !!(site && site.wsAvailable('core_xapi_statement_post'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a state both online and offline.
|
||||
*
|
||||
* @param component Component.
|
||||
* @param activityIRI XAPI activity ID IRI.
|
||||
* @param agent The xAPI agent data.
|
||||
* @param stateId The xAPI state ID.
|
||||
* @param options Options.
|
||||
* @returns Promise resolved when done.
|
||||
*/
|
||||
async deleteState(
|
||||
component: string,
|
||||
activityIRI: string,
|
||||
agent: Record<string, unknown>,
|
||||
stateId: string,
|
||||
options: CoreXAPIStateSendDataOptions = {},
|
||||
): Promise<boolean> {
|
||||
options.siteId = options.siteId || CoreSites.getCurrentSiteId();
|
||||
|
||||
const storeOffline = async (): Promise<boolean> => {
|
||||
const itemIdString = await CoreXAPIIRI.extract(activityIRI, 'activity', options.siteId);
|
||||
const itemId = Number(itemIdString);
|
||||
|
||||
if (isNaN(itemId)) {
|
||||
throw new CoreError('Invalid activity ID sent to xAPI delete state.');
|
||||
}
|
||||
|
||||
// Save an offline state as deleted.
|
||||
await CoreXAPIOffline.saveState(component, itemId, stateId, XAPI_STATE_DELETED, options);
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
if (!CoreNetwork.isOnline() || options.offline) {
|
||||
// App is offline, store the action.
|
||||
return storeOffline();
|
||||
}
|
||||
|
||||
try {
|
||||
await this.deleteStateOnline(component, activityIRI, JSON.stringify(agent), stateId, options);
|
||||
|
||||
const itemIdString = await CoreXAPIIRI.extract(activityIRI, 'activity', options.siteId);
|
||||
const itemId = Number(itemIdString);
|
||||
|
||||
if (!isNaN(itemId)) {
|
||||
// Delete offline state if it exists.
|
||||
await CoreUtils.ignoreErrors(CoreXAPIOffline.deleteStates(component, {
|
||||
itemId,
|
||||
stateId,
|
||||
registration: options.registration,
|
||||
siteId: options.siteId,
|
||||
}));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (CoreUtils.isWebServiceError(error)) {
|
||||
// The WebService has thrown an error, this means that the state cannot be deleted.
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Couldn't connect to server, store it offline.
|
||||
try {
|
||||
return await storeOffline();
|
||||
} catch (offlineError) {
|
||||
this.logger.error('Error storing a DELETED xAPI state in offline storage.', offlineError);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete state. It will fail if offline or cannot connect.
|
||||
*
|
||||
|
@ -88,7 +166,33 @@ export class CoreXAPIProvider {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get cache key for H5P activity data WS calls.
|
||||
* Get state from WS.
|
||||
*
|
||||
* @param component Component.
|
||||
* @param activityId Activity ID.
|
||||
* @param stateId The xAPI state ID.
|
||||
* @param options Options.
|
||||
* @returns Promise resolved when done.
|
||||
*/
|
||||
async getState(
|
||||
component: string,
|
||||
activityId: number,
|
||||
stateId: string,
|
||||
options: CoreXAPIGetStateOptions = {},
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const offlineState = await CoreXAPIOffline.getState(component, activityId, stateId, options);
|
||||
|
||||
return offlineState.statedata !== XAPI_STATE_DELETED ? (offlineState.statedata ?? null) : null;
|
||||
} catch {
|
||||
// No offline state.
|
||||
}
|
||||
|
||||
return this.getStateFromServer(component, activityId, stateId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache key for H5P get state WS calls.
|
||||
*
|
||||
* @param siteUrl Site URL.
|
||||
* @param component Component.
|
||||
|
@ -116,7 +220,7 @@ export class CoreXAPIProvider {
|
|||
* @param options Options.
|
||||
* @returns Promise resolved when done.
|
||||
*/
|
||||
async getStateOnline(
|
||||
async getStateFromServer(
|
||||
component: string,
|
||||
activityId: number,
|
||||
stateId: string,
|
||||
|
@ -151,6 +255,37 @@ export class CoreXAPIProvider {
|
|||
return site.read('core_xapi_get_state', data, preSets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get states after a certain timestamp. It will fail if offline or cannot connect.
|
||||
*
|
||||
* @param component Component.
|
||||
* @param activityId Activity ID.
|
||||
* @param options Options.
|
||||
* @returns Promise resolved when done.
|
||||
*/
|
||||
async getStatesSince(
|
||||
component: string,
|
||||
activityId: number,
|
||||
options: CoreXAPIGetStatesOptions = {},
|
||||
): Promise<string[]> {
|
||||
const [site, activityIRI] = await Promise.all([
|
||||
CoreSites.getSite(options.siteId),
|
||||
CoreXAPIIRI.generate(activityId, 'activity'),
|
||||
]);
|
||||
|
||||
const data: CoreXAPIGetStatesWSParams = {
|
||||
component,
|
||||
activityId: activityIRI,
|
||||
agent: JSON.stringify(CoreXAPIItemAgent.createFromSite(site).getData()),
|
||||
registration: options.registration,
|
||||
};
|
||||
if (options.since) {
|
||||
data.since = String(Math.floor(options.since / 1000));
|
||||
}
|
||||
|
||||
return site.write<string[]>('core_xapi_get_states', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get URL for XAPI events.
|
||||
*
|
||||
|
@ -203,13 +338,10 @@ export class CoreXAPIProvider {
|
|||
contextId: number,
|
||||
component: string,
|
||||
json: string,
|
||||
options?: CoreXAPIPostStatementsOptions,
|
||||
options: CoreXAPIPostStatementsOptions = {},
|
||||
): Promise<boolean> {
|
||||
|
||||
options = options || {};
|
||||
options.siteId = options.siteId || CoreSites.getCurrentSiteId();
|
||||
|
||||
// Convenience function to store a message to be synchronized later.
|
||||
const storeOffline = async (): Promise<boolean> => {
|
||||
await CoreXAPIOffline.saveStatements(contextId, component, json, options);
|
||||
|
||||
|
@ -227,7 +359,7 @@ export class CoreXAPIProvider {
|
|||
return true;
|
||||
} catch (error) {
|
||||
if (CoreUtils.isWebServiceError(error)) {
|
||||
// The WebService has thrown an error, this means that responses cannot be submitted.
|
||||
// The WebService has thrown an error, this means that statements cannot be submitted.
|
||||
throw error;
|
||||
} else {
|
||||
// Couldn't connect to server, store it offline.
|
||||
|
@ -256,6 +388,66 @@ export class CoreXAPIProvider {
|
|||
return site.write('core_xapi_statement_post', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post state. It will fail if offline or cannot connect.
|
||||
*
|
||||
* @param component Component.
|
||||
* @param activityIRI XAPI activity ID IRI.
|
||||
* @param agent The xAPI agent data.
|
||||
* @param stateId The xAPI state ID.
|
||||
* @param stateData JSON object with the state data.
|
||||
* @param options Options.
|
||||
* @returns Promise resolved when done.
|
||||
*/
|
||||
async postState(
|
||||
component: string,
|
||||
activityIRI: string,
|
||||
agent: Record<string, unknown>,
|
||||
stateId: string,
|
||||
stateData: string,
|
||||
options: CoreXAPIStateSendDataOptions = {},
|
||||
): Promise<boolean> {
|
||||
options.siteId = options.siteId || CoreSites.getCurrentSiteId();
|
||||
|
||||
const storeOffline = async (): Promise<boolean> => {
|
||||
const itemIdString = await CoreXAPIIRI.extract(activityIRI, 'activity', options.siteId);
|
||||
const itemId = Number(itemIdString);
|
||||
|
||||
if (isNaN(itemId)) {
|
||||
throw new CoreError('Invalid activity ID sent to xAPI post state.');
|
||||
}
|
||||
|
||||
await CoreXAPIOffline.saveState(component, itemId, stateId, stateData, options);
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
if (!CoreNetwork.isOnline() || options.offline) {
|
||||
// App is offline, store the action.
|
||||
return storeOffline();
|
||||
}
|
||||
|
||||
try {
|
||||
await this.postStateOnline(component, activityIRI, JSON.stringify(agent), stateId, stateData, options);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (CoreUtils.isWebServiceError(error)) {
|
||||
// The WebService has thrown an error, this means that state cannot be submitted.
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Couldn't connect to server, store it offline.
|
||||
try {
|
||||
return await storeOffline();
|
||||
} catch (offlineError) {
|
||||
this.logger.error('Error storing xAPI state in offline storage.', offlineError);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post state. It will fail if offline or cannot connect.
|
||||
*
|
||||
|
@ -273,7 +465,7 @@ export class CoreXAPIProvider {
|
|||
agent: string,
|
||||
stateId: string,
|
||||
stateData: string,
|
||||
options: CoreXAPIPostStateOptions = {},
|
||||
options: CoreXAPIStateOptions = {},
|
||||
): Promise<boolean> {
|
||||
const site = await CoreSites.getSite(options.siteId);
|
||||
|
||||
|
@ -325,9 +517,16 @@ export type CoreXAPIGetStateOptions = CoreXAPIStateOptions & CoreSitesCommonWSOp
|
|||
};
|
||||
|
||||
/**
|
||||
* Options to pass to postState function.
|
||||
* Options to pass to getStatesSince function.
|
||||
*/
|
||||
export type CoreXAPIPostStateOptions = CoreXAPIStateOptions & {
|
||||
export type CoreXAPIGetStatesOptions = CoreXAPIStateOptions & {
|
||||
since?: number; // Timestamp (in milliseconds) to filter the states.
|
||||
};
|
||||
|
||||
/**
|
||||
* Options to pass to postState and deleteState functions.
|
||||
*/
|
||||
export type CoreXAPIStateSendDataOptions = CoreXAPIStateOptions & {
|
||||
offline?: boolean; // Whether to force storing it in offline.
|
||||
};
|
||||
|
||||
|
@ -364,3 +563,14 @@ export type CoreXAPIGetStateWSParams = {
|
|||
stateId: string; // The xAPI state ID.
|
||||
registration?: string; // The xAPI registration UUID.
|
||||
};
|
||||
|
||||
/**
|
||||
* Params of core_xapi_get_states WS.
|
||||
*/
|
||||
export type CoreXAPIGetStatesWSParams = {
|
||||
component: string; // Component name.
|
||||
activityId: string; // XAPI activity ID IRI.
|
||||
agent: string; // The xAPI agent json.
|
||||
registration?: string; // The xAPI registration UUID.
|
||||
since?: string; // Filter ids stored since the timestamp (exclusive).
|
||||
};
|
||||
|
|
|
@ -354,7 +354,8 @@
|
|||
"viewembeddedcontent": "View embedded content",
|
||||
"viewprofile": "View profile",
|
||||
"wanttochangesite": "Want to change sites or log out?",
|
||||
"warningofflinedatadeleted": "Offline data from {{component}} '{{name}}' has been deleted. {{error}}",
|
||||
"warningofflinedatadeleted": "Offline changes to {{component}} '{{name}}' have been discarded. {{error}}",
|
||||
"warningofflinedatadeletedreason": "Newer changes to this activity have been made from a different device.",
|
||||
"warnopeninbrowser": "<p>You are about to leave the app to open the following URL in your device's browser. Do you want to continue?</p>\n<p><b>{{url}}</b></p>",
|
||||
"week": "Week",
|
||||
"weeks": "weeks",
|
||||
|
|
Loading…
Reference in New Issue