492 lines
18 KiB
TypeScript

// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { ContextLevel } from '@/core/constants';
import { Injectable } from '@angular/core';
import { CoreSyncBlockedError } from '@classes/base-sync';
import { CoreNetworkError } from '@classes/errors/network-error';
import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync';
import { CoreCourseCommonModWSOptions } from '@features/course/services/course';
import { CoreCourseLogHelper } from '@features/course/services/log-helper';
import { CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader';
import { CoreRatingSync } from '@features/rating/services/rating-sync';
import { CoreApp } from '@services/app';
import { CoreFileEntry } from '@services/file-helper';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreSync } from '@services/sync';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUtils } from '@services/utils/utils';
import { Translate, makeSingleton } from '@singletons';
import { CoreEvents } from '@singletons/events';
import { AddonModDataProvider, AddonModData, AddonModDataData, AddonModDataAction } from './data';
import { AddonModDataHelper } from './data-helper';
import { AddonModDataOffline, AddonModDataOfflineAction } from './data-offline';
/**
* Service to sync databases.
*/
@Injectable({ providedIn: 'root' })
export class AddonModDataSyncProvider extends CoreCourseActivitySyncBaseProvider<AddonModDataSyncResult> {
static readonly AUTO_SYNCED = 'addon_mod_data_autom_synced';
protected componentTranslatableString = 'data';
constructor() {
super('AddonModDataSyncProvider');
}
/**
* Check if a database has data to synchronize.
*
* @param dataId Database ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with boolean: true if has data to sync, false otherwise.
*/
hasDataToSync(dataId: number, siteId?: string): Promise<boolean> {
return AddonModDataOffline.hasOfflineData(dataId, siteId);
}
/**
* Try to synchronize all the databases in a certain site or in all sites.
*
* @param siteId Site ID to sync. If not defined, sync all sites.
* @param force Wether to force sync not depending on last execution.
* @return Promise resolved if sync is successful, rejected if sync fails.
*/
syncAllDatabases(siteId?: string, force?: boolean): Promise<void> {
return this.syncOnSites('all databases', this.syncAllDatabasesFunc.bind(this, !!force), siteId);
}
/**
* Sync all pending databases on a site.
*
* @param force Wether to force sync not depending on last execution.
* @param siteId Site ID to sync. If not defined, sync all sites.
* @param Promise resolved if sync is successful, rejected if sync fails.
*/
protected async syncAllDatabasesFunc(force: boolean, siteId: string): Promise<void> {
const promises: Promise<unknown>[] = [];
// Get all data answers pending to be sent in the site.
promises.push(AddonModDataOffline.getAllEntries(siteId).then(async (offlineActions) => {
// Get data id.
let dataIds: number[] = offlineActions.map((action) => action.dataid);
// Get unique values.
dataIds = dataIds.filter((id, pos) => dataIds.indexOf(id) == pos);
const entriesPromises = dataIds.map(async (dataId) => {
const result = force
? await this.syncDatabase(dataId, siteId)
: await this.syncDatabaseIfNeeded(dataId, siteId);
if (result && result.updated) {
// Sync done. Send event.
CoreEvents.trigger(AddonModDataSyncProvider.AUTO_SYNCED, {
dataId: dataId,
warnings: result.warnings,
}, siteId);
}
});
await Promise.all(entriesPromises);
return;
}));
promises.push(this.syncRatings(undefined, force, siteId));
await Promise.all(promises);
}
/**
* Sync a database only if a certain time has passed since the last time.
*
* @param dataId Database ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the data is synced or if it doesn't need to be synced.
*/
async syncDatabaseIfNeeded(dataId: number, siteId?: string): Promise<AddonModDataSyncResult | undefined> {
const needed = await this.isSyncNeeded(dataId, siteId);
if (needed) {
return this.syncDatabase(dataId, siteId);
}
}
/**
* Synchronize a data.
*
* @param dataId Data ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved if sync is successful, rejected otherwise.
*/
syncDatabase(dataId: number, siteId?: string): Promise<AddonModDataSyncResult> {
siteId = siteId || CoreSites.getCurrentSiteId();
const currentSyncPromise = this.getOngoingSync(dataId, siteId);
if (currentSyncPromise) {
// There's already a sync ongoing for this database, return the promise.
return currentSyncPromise;
}
// Verify that database isn't blocked.
if (CoreSync.isBlocked(AddonModDataProvider.COMPONENT, dataId, siteId)) {
this.logger.debug(`Cannot sync database '${dataId}' because it is blocked.`);
throw new CoreSyncBlockedError(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate }));
}
this.logger.debug(`Try to sync data '${dataId}' in site ${siteId}'`);
const syncPromise = this.performSyncDatabase(dataId, siteId);
return this.addOngoingSync(dataId, syncPromise, siteId);
}
/**
* Perform the database syncronization.
*
* @param dataId Data ID.
* @param siteId Site ID.
* @return Promise resolved if sync is successful, rejected otherwise.
*/
protected async performSyncDatabase(dataId: number, siteId: string): Promise<AddonModDataSyncResult> {
// Sync offline logs.
await CoreUtils.ignoreErrors(
CoreCourseLogHelper.syncActivity(AddonModDataProvider.COMPONENT, dataId, siteId),
);
const result: AddonModDataSyncResult = {
warnings: [],
updated: false,
};
// Get answers to be sent.
const offlineActions: AddonModDataOfflineAction[] =
await CoreUtils.ignoreErrors(AddonModDataOffline.getDatabaseEntries(dataId, siteId), []);
if (!offlineActions.length) {
// Nothing to sync.
await CoreUtils.ignoreErrors(this.setSyncTime(dataId, siteId));
return result;
}
if (!CoreApp.isOnline()) {
// Cannot sync in offline.
throw new CoreNetworkError();
}
const courseId = offlineActions[0].courseid;
// Send the answers.
const database = await AddonModData.getDatabaseById(courseId, dataId, { siteId });
const offlineEntries: Record<number, AddonModDataOfflineAction[]> = {};
offlineActions.forEach((entry) => {
if (typeof offlineEntries[entry.entryid] == 'undefined') {
offlineEntries[entry.entryid] = [];
}
offlineEntries[entry.entryid].push(entry);
});
const promises = CoreUtils.objectToArray(offlineEntries).map((entryActions) =>
this.syncEntry(database, entryActions, result, siteId));
await Promise.all(promises);
if (result.updated) {
// Data has been sent to server. Now invalidate the WS calls.
await CoreUtils.ignoreErrors(AddonModData.invalidateContent(database.coursemodule, courseId, siteId));
}
// Sync finished, set sync time.
await CoreUtils.ignoreErrors(this.setSyncTime(dataId, siteId));
return result;
}
/**
* Synchronize an entry.
*
* @param database Database.
* @param entryActions Entry actions.
* @param result Object with the result of the sync.
* @param siteId Site ID.
* @return Promise resolved if success, rejected otherwise.
*/
protected async syncEntry(
database: AddonModDataData,
entryActions: AddonModDataOfflineAction[],
result: AddonModDataSyncResult,
siteId: string,
): Promise<void> {
const syncEntryResult = await this.performSyncEntry(database, entryActions, result, siteId);
if (syncEntryResult.discardError) {
// Submission was discarded, add a warning.
this.addOfflineDataDeletedWarning(result.warnings, database.name, syncEntryResult.discardError);
}
// Sync done. Send event.
CoreEvents.trigger(AddonModDataSyncProvider.AUTO_SYNCED, {
dataId: database.id,
entryId: syncEntryResult.entryId,
offlineEntryId: syncEntryResult.offlineId,
warnings: result.warnings,
deleted: syncEntryResult.deleted,
}, siteId);
}
/**
* Perform the synchronization of an entry.
*
* @param database Database.
* @param entryActions Entry actions.
* @param result Object with the result of the sync.
* @param siteId Site ID.
* @return Promise resolved if success, rejected otherwise.
*/
protected async performSyncEntry(
database: AddonModDataData,
entryActions: AddonModDataOfflineAction[],
result: AddonModDataSyncResult,
siteId: string,
): Promise<AddonModDataSyncEntryResult> {
let entryId = entryActions[0].entryid;
const entryResult: AddonModDataSyncEntryResult = {
deleted: false,
entryId: entryId,
};
const editAction = entryActions.find((action) =>
action.action == AddonModDataAction.ADD || action.action == AddonModDataAction.EDIT);
const approveAction = entryActions.find((action) =>
action.action == AddonModDataAction.APPROVE || action.action == AddonModDataAction.DISAPPROVE);
const deleteAction = entryActions.find((action) => action.action == AddonModDataAction.DELETE);
const options: CoreCourseCommonModWSOptions = {
cmId: database.coursemodule,
readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK,
siteId,
};
let timemodified = 0;
if (entryId > 0) {
try {
const entry = await AddonModData.getEntry(database.id, entryId, options);
timemodified = entry.entry.timemodified;
} catch (error) {
if (error && CoreUtils.isWebServiceError(error)) {
// The WebService has thrown an error, this means the entry has been deleted.
timemodified = -1;
} else {
throw error;
}
}
} else if (editAction) {
// New entry.
entryResult.offlineId = entryId;
timemodified = 0;
} else {
// New entry but the add action is missing, discard.
timemodified = -1;
}
if (timemodified < 0 || timemodified >= entryActions[0].timemodified) {
// The entry was not found in Moodle or the entry has been modified, discard the action.
result.updated = true;
entryResult.discardError = Translate.instant('addon.mod_data.warningsubmissionmodified');
await AddonModDataOffline.deleteAllEntryActions(database.id, entryId, siteId);
return entryResult;
}
if (deleteAction) {
try {
await AddonModData.deleteEntryOnline(entryId, siteId);
entryResult.deleted = true;
} catch (error) {
if (error && CoreUtils.isWebServiceError(error)) {
// The WebService has thrown an error, this means it cannot be performed. Discard.
entryResult.discardError = CoreTextUtils.getErrorMessageFromError(error);
} else {
// Couldn't connect to server, reject.
throw error;
}
}
// Delete the offline data.
result.updated = true;
await AddonModDataOffline.deleteAllEntryActions(deleteAction.dataid, deleteAction.entryid, siteId);
return entryResult;
}
if (editAction) {
try {
await Promise.all(editAction.fields.map(async (field) => {
// Upload Files if asked.
const value = CoreTextUtils.parseJSON<CoreFileUploaderStoreFilesResult | null>(field.value || '', null);
if (value && (value.online || value.offline)) {
let files: CoreFileEntry[] = value.online || [];
const offlineFiles = value.offline
? await AddonModDataHelper.getStoredFiles(editAction.dataid, entryId, field.fieldid)
: [];
files = files.concat(offlineFiles);
const filesResult = await AddonModDataHelper.uploadOrStoreFiles(
editAction.dataid,
0,
entryId,
field.fieldid,
files,
false,
siteId,
);
field.value = JSON.stringify(filesResult);
}
}));
if (editAction.action == AddonModDataAction.ADD) {
const result = await AddonModData.addEntryOnline(
editAction.dataid,
editAction.fields,
editAction.groupid,
siteId,
);
entryId = result.newentryid;
entryResult.entryId = entryId;
} else {
await AddonModData.editEntryOnline(entryId, editAction.fields, siteId);
}
} catch (error) {
if (error && CoreUtils.isWebServiceError(error)) {
// The WebService has thrown an error, this means it cannot be performed. Discard.
entryResult.discardError = CoreTextUtils.getErrorMessageFromError(error);
} else {
// Couldn't connect to server, reject.
throw error;
}
}
// Delete the offline data.
result.updated = true;
await AddonModDataOffline.deleteEntry(editAction.dataid, editAction.entryid, editAction.action, siteId);
}
if (approveAction) {
try {
await AddonModData.approveEntryOnline(entryId, approveAction.action == AddonModDataAction.APPROVE, siteId);
} catch (error) {
if (error && CoreUtils.isWebServiceError(error)) {
// The WebService has thrown an error, this means it cannot be performed. Discard.
entryResult.discardError = CoreTextUtils.getErrorMessageFromError(error);
} else {
// Couldn't connect to server, reject.
throw error;
}
}
// Delete the offline data.
result.updated = true;
await AddonModDataOffline.deleteEntry(approveAction.dataid, approveAction.entryid, approveAction.action, siteId);
}
return entryResult;
}
/**
* Synchronize offline ratings.
*
* @param cmId Course module to be synced. If not defined, sync all databases.
* @param force Wether to force sync not depending on last execution.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved if sync is successful, rejected otherwise.
*/
async syncRatings(cmId?: number, force?: boolean, siteId?: string): Promise<AddonModDataSyncResult> {
siteId = siteId || CoreSites.getCurrentSiteId();
const results = await CoreRatingSync.syncRatings('mod_data', 'entry', ContextLevel.MODULE, cmId, 0, force, siteId);
let updated = false;
const warnings = [];
const promises = results.map((result) =>
AddonModData.getDatabase(result.itemSet.courseId, result.itemSet.instanceId, { siteId })
.then((database) => {
const subPromises: Promise<void>[] = [];
if (result.updated.length) {
updated = true;
// Invalidate entry of updated ratings.
result.updated.forEach((itemId) => {
subPromises.push(AddonModData.invalidateEntryData(database.id, itemId, siteId));
});
}
if (result.warnings.length) {
result.warnings.forEach((warning) => {
this.addOfflineDataDeletedWarning(warnings, database.name, warning);
});
}
return CoreUtils.allPromises(subPromises);
}));
await Promise.all(promises);
return ({ updated, warnings });
}
}
export const AddonModDataSync = makeSingleton(AddonModDataSyncProvider);
/**
* Data returned by a database sync.
*/
export type AddonModDataSyncEntryResult = {
discardError?: string;
offlineId?: number;
entryId: number;
deleted: boolean;
};
/**
* Data returned by a database sync.
*/
export type AddonModDataSyncResult = {
warnings: string[]; // List of warnings.
updated: boolean; // Whether some data was sent to the server or offline data was updated.
};
export type AddonModDataAutoSyncData = {
dataId: number;
warnings: string[];
entryId?: number;
offlineEntryId?: number;
deleted?: boolean;
};