diff --git a/src/addon/mod/glossary/providers/helper.ts b/src/addon/mod/glossary/providers/helper.ts new file mode 100644 index 000000000..55a36960b --- /dev/null +++ b/src/addon/mod/glossary/providers/helper.ts @@ -0,0 +1,121 @@ +// (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 { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader'; +import { CoreFileProvider } from '@providers/file'; +import { AddonModGlossaryProvider } from './glossary'; +import { AddonModGlossaryOfflineProvider } from './offline'; + +/** + * Helper to gather some common functions for glossary. + */ +@Injectable() +export class AddonModGlossaryHelperProvider { + + constructor(private fileProvider: CoreFileProvider, + private uploaderProvider: CoreFileUploaderProvider, + private glossaryOffline: AddonModGlossaryOfflineProvider) {} + + /** + * Delete stored attachment files for a new discussion. + * + * @param {number} glossaryId Glossary ID. + * @param {string} entryName The name of the entry. + * @param {number} timeCreated The time the entry was created. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when deleted. + */ + deleteStoredFiles(glossaryId: number, entryName: string, timeCreated: number, siteId?: string): Promise { + return this.glossaryOffline.getEntryFolder(glossaryId, entryName, timeCreated, siteId).then((folderPath) => { + this.fileProvider.removeDir(folderPath).catch(() => { + // Ignore any errors, CoreFileProvider.removeDir fails if folder doesn't exists. + }); + }); + } + + /** + * Get a list of stored attachment files for a new entry. See AddonModGlossaryHelperProvider#storeFiles. + * + * @param {number} glossaryId lossary ID. + * @param {string} entryName The name of the entry. + * @param {number} [timeCreated] The time the entry was created. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the files. + */ + getStoredFiles(glossaryId: number, entryName: string, timeCreated: number, siteId?: string): Promise { + return this.glossaryOffline.getEntryFolder(glossaryId, entryName, timeCreated, siteId).then((folderPath) => { + return this.uploaderProvider.getStoredFiles(folderPath); + }); + } + + /** + * Check if the data of an entry has changed. + * + * @param {any} entry Current data. + * @param {any[]} files Files attached. + * @param {any} original Original content. + * @return {boolean} True if data has changed, false otherwise. + */ + hasEntryDataChanged(entry: any, files: any[], original: any): boolean { + if (!original || typeof original.concept == 'undefined') { + // There is no original data. + return entry.definition || entry.concept || files.length > 0; + } + + if (original.definition != entry.definition || original.concept != entry.concept) { + return true; + } + + return this.uploaderProvider.areFileListDifferent(files, original.files); + } + + /** + * Given a list of files (either online files or local files), store the local files in a local folder + * to be submitted later. + * + * @param {number} glossaryId Glossary ID. + * @param {string} entryName The name of the entry. + * @param {number} [timeCreated] The time the entry was created. + * @param {any[]} files List of files. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if success, rejected otherwise. + */ + storeFiles(glossaryId: number, entryName: string, timeCreated: number, files: any[], siteId?: string): Promise { + // Get the folder where to store the files. + return this.glossaryOffline.getEntryFolder(glossaryId, entryName, timeCreated, siteId).then((folderPath) => { + return this.uploaderProvider.storeFilesToUpload(folderPath, files); + }); + } + + /** + * Upload or store some files, depending if the user is offline or not. + * + * @param {number} glossaryId Glossary ID. + * @param {string} entryName The name of the entry. + * @param {number} [timeCreated] The time the entry was created. + * @param {any[]} files List of files. + * @param {boolean} offline True if files sould be stored for offline, false to upload them. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if success. + */ + uploadOrStoreFiles(glossaryId: number, entryName: string, timeCreated: number, files: any[], offline: boolean, + siteId?: string): Promise { + if (offline) { + return this.storeFiles(glossaryId, entryName, timeCreated, files, siteId); + } else { + return this.uploaderProvider.uploadOrReuploadFiles(files, AddonModGlossaryProvider.COMPONENT, glossaryId, siteId); + } + } +} diff --git a/src/addon/mod/glossary/providers/sync-cron-handler.ts b/src/addon/mod/glossary/providers/sync-cron-handler.ts new file mode 100644 index 000000000..fdc3d5921 --- /dev/null +++ b/src/addon/mod/glossary/providers/sync-cron-handler.ts @@ -0,0 +1,47 @@ +// (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 { CoreCronHandler } from '@providers/cron'; +import { AddonModGlossarySyncProvider } from './sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class AddonModGlossarySyncCronHandler implements CoreCronHandler { + name = 'AddonModGlossarySyncCronHandler'; + + constructor(private glossarySync: AddonModGlossarySyncProvider) {} + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @return {Promise} Promise resolved when done, rejected if failure. + */ + execute(siteId?: string): Promise { + return this.glossarySync.syncAllGlossaries(siteId); + } + + /** + * Get the time between consecutive executions. + * + * @return {number} Time between consecutive executions (in ms). + */ + getInterval(): number { + return AddonModGlossarySyncProvider.SYNC_TIME; + } +} diff --git a/src/addon/mod/glossary/providers/sync.ts b/src/addon/mod/glossary/providers/sync.ts new file mode 100644 index 000000000..ea509a690 --- /dev/null +++ b/src/addon/mod/glossary/providers/sync.ts @@ -0,0 +1,301 @@ +// (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 { CoreSyncBaseProvider } from '@classes/base-sync'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader'; +import { CoreAppProvider } from '@providers/app'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { AddonModGlossaryProvider } from './glossary'; +import { AddonModGlossaryHelperProvider } from './helper'; +import { AddonModGlossaryOfflineProvider } from './offline'; + +/** + * Service to sync glossaries. + */ +@Injectable() +export class AddonModGlossarySyncProvider extends CoreSyncBaseProvider { + + static AUTO_SYNCED = 'addon_mod_glossary_autom_synced'; + static SYNC_TIME = 600000; // 10 minutes. + + protected componentTranslate: string; + + constructor(translate: TranslateService, + appProvider: CoreAppProvider, + courseProvider: CoreCourseProvider, + private eventsProvider: CoreEventsProvider, + loggerProvider: CoreLoggerProvider, + sitesProvider: CoreSitesProvider, + syncProvider: CoreSyncProvider, + textUtils: CoreTextUtilsProvider, + private uploaderProvider: CoreFileUploaderProvider, + private utils: CoreUtilsProvider, + private glossaruProvider: AddonModGlossaryProvider, + private glossaryHelper: AddonModGlossaryHelperProvider, + private glossaryOffline: AddonModGlossaryOfflineProvider) { + + super('AddonModGlossarySyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate); + + this.componentTranslate = courseProvider.translateModuleName('glossary'); + } + + /** + * Try to synchronize all the glossaries in a certain site or in all sites. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllGlossaries(siteId?: string): Promise { + return this.syncOnSites('all glossaries', this.syncAllGlossariesFunc.bind(this), [], siteId); + } + + /** + * Sync all glossaries on a site. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + protected syncAllGlossariesFunc(siteId?: string): Promise { + // Sync all new entries + return this.glossaryOffline.getAllNewEntries(siteId).then((entries) => { + const promises = {}; + + // Do not sync same glossary twice. + for (const i in entries) { + const entry = entries[i]; + + if (typeof promises[entry.glossaryid] != 'undefined') { + continue; + } + + promises[entry.glossaryid] = this.syncGlossaryEntriesIfNeeded(entry.glossaryid, entry.userid, siteId) + .then((result) => { + if (result && result.updated) { + // Sync successful, send event. + this.eventsProvider.trigger(AddonModGlossarySyncProvider.AUTO_SYNCED, { + glossaryId: entry.glossaryid, + userId: entry.userid, + warnings: result.warnings + }, siteId); + } + }); + } + + // Promises will be an object so, convert to an array first; + return Promise.all(this.utils.objectToArray(promises)); + }); + } + + /** + * Sync a glossary only if a certain time has passed since the last time. + * + * @param {number} glossaryId Glossary ID. + * @param {number} userId User the entry belong to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the glossary is synced or if it doesn't need to be synced. + */ + syncGlossaryEntriesIfNeeded(glossaryId: number, userId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const syncId = this.getGlossarySyncId(glossaryId, userId); + + return this.isSyncNeeded(syncId, siteId).then((needed) => { + if (needed) { + return this.syncGlossaryEntries(glossaryId, userId, siteId); + } + }); + } + + /** + * Synchronize all offline entries of a glossary. + * + * @param {number} glossaryId Glossary ID to be synced. + * @param {number} [userId] User the entries belong to. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + syncGlossaryEntries(glossaryId: number, userId?: number, siteId?: string): Promise { + userId = userId || this.sitesProvider.getCurrentSiteUserId(); + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const syncId = this.getGlossarySyncId(glossaryId, userId); + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing for this glossary, return the promise. + return this.getOngoingSync(syncId, siteId); + } + + // Verify that glossary isn't blocked. + if (this.syncProvider.isBlocked(AddonModGlossaryProvider.COMPONENT, syncId, siteId)) { + this.logger.debug('Cannot sync glossary ' + glossaryId + ' because it is blocked.'); + + return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); + } + + this.logger.debug('Try to sync glossary ' + glossaryId + ' for user ' + userId); + + let courseId; + const result = { + warnings: [], + updated: false + }; + + // Get offline responses to be sent. + const syncPromise = this.glossaryOffline.getGlossaryNewEntries(glossaryId, siteId, userId).catch(() => { + // No offline data found, return empty object. + return []; + }).then((entries) => { + if (!entries.length) { + // Nothing to sync. + return; + } else if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } + + const promises = []; + + entries.forEach((data) => { + let promise; + + courseId = data.courseid; + + // First of all upload the attachments (if any). + promise = this.uploadAttachments(glossaryId, data, siteId).then((itemId) => { + // Now try to add the entry. + return this.glossaruProvider.addEntryOnline( + glossaryId, data.concept, data.definition, data.options, itemId, siteId); + }); + + promises.push(promise.then(() => { + result.updated = true; + + return this.deleteAddEntry(glossaryId, data.concept, data.timecreated, siteId); + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. Delete them. + result.updated = true; + + return this.deleteAddEntry(glossaryId, data.concept, data.timecreated, siteId).then(() => { + // Responses deleted, add a warning. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: data.concept, + error: error.error + })); + }); + } else { + // Couldn't connect to server, reject. + return Promise.reject(error); + } + })); + }); + + return Promise.all(promises); + }).then(() => { + if (result.updated && courseId) { + // Data has been sent to server. Now invalidate the WS calls. + return this.glossaruProvider.getGlossaryById(courseId, glossaryId).then((glossary) => { + return this.glossaruProvider.invalidateGlossaryEntries(glossary, true); + }).catch(() => { + // Ignore errors. + }); + } + }).then(() => { + // Sync finished, set sync time. + return this.setSyncTime(syncId, siteId).catch(() => { + // Ignore errors. + }); + }).then(() => { + // All done, return the warnings. + return result; + }); + + return this.addOngoingSync(syncId, syncPromise, siteId); + } + + /** + * Delete a new entry. + * + * @param {number} glossaryId Glossary ID. + * @param {string} concept Glossary entry concept. + * @param {number} timeCreated Time to allow duplicated entries. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when deleted. + */ + protected deleteAddEntry(glossaryId: number, concept: string, timeCreated: number, siteId?: string): Promise { + const promises = []; + + promises.push(this.glossaryOffline.deleteNewEntry(glossaryId, concept, timeCreated, siteId)); + promises.push(this.glossaryHelper.deleteStoredFiles(glossaryId, concept, timeCreated, siteId).catch(() => { + // Ignore errors, maybe there are no files. + })); + + return Promise.all(promises); + } + + /** + * Upload attachments of an offline entry. + * + * @param {number} glossaryId Glossary ID. + * @param {any} entry Offline entry. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with draftid if uploaded, resolved with 0 if nothing to upload. + */ + protected uploadAttachments(glossaryId: number, entry: any, siteId?: string): Promise { + if (entry.attachments) { + // Has some attachments to sync. + let files = entry.attachments.online || []; + let promise; + + if (entry.attachments.offline) { + // Has offline files. + promise = this.glossaryHelper.getStoredFiles(glossaryId, entry.concept, entry.timecreated, siteId).then((atts) => { + files = files.concat(atts); + }).catch(() => { + // Folder not found, no files to add. + }); + } else { + promise = Promise.resolve(0); + } + + return promise.then(() => { + return this.uploaderProvider.uploadOrReuploadFiles(files, AddonModGlossaryProvider.COMPONENT, glossaryId, siteId); + }); + } + + // No attachments, resolve. + return Promise.resolve(0); + } + + /** + * Get the ID of a glossary sync. + * + * @param {number} glossaryId Glossary ID. + * @param {number} [userId] User the entries belong to.. If not defined, current user. + * @return {string} Sync ID. + */ + protected getGlossarySyncId(glossaryId: number, userId?: number): string { + userId = userId || this.sitesProvider.getCurrentSiteUserId(); + + return 'glossary#' + glossaryId + '#' + userId; + } +}