From fbc78e5a4e2d096ee1787ca11798229b26318c85 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 22 May 2018 13:07:15 +0200 Subject: [PATCH] MOBILE-2353 wiki: Implement sync and prefetch handler --- .../mod/wiki/providers/prefetch-handler.ts | 221 ++++++++++ src/addon/mod/wiki/providers/wiki-sync.ts | 387 ++++++++++++++++++ src/addon/mod/wiki/wiki.module.ts | 14 +- 3 files changed, 620 insertions(+), 2 deletions(-) create mode 100644 src/addon/mod/wiki/providers/prefetch-handler.ts create mode 100644 src/addon/mod/wiki/providers/wiki-sync.ts diff --git a/src/addon/mod/wiki/providers/prefetch-handler.ts b/src/addon/mod/wiki/providers/prefetch-handler.ts new file mode 100644 index 000000000..b275bfe9f --- /dev/null +++ b/src/addon/mod/wiki/providers/prefetch-handler.ts @@ -0,0 +1,221 @@ +// (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 { CoreFilepoolProvider } from '@providers/filepool'; +import { CoreGroupsProvider } from '@providers/groups'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreCourseModulePrefetchHandlerBase } from '@core/course/classes/module-prefetch-handler'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { CoreGradesHelperProvider } from '@core/grades/providers/helper'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { AddonModWikiProvider } from './wiki'; + +/** + * Handler to prefetch wikis. + */ +@Injectable() +export class AddonModWikiPrefetchHandler extends CoreCourseModulePrefetchHandlerBase { + name = 'AddonModWiki'; + modName = 'wiki'; + component = AddonModWikiProvider.COMPONENT; + updatesNames = /^.*files$|^pages$/; + + constructor(protected injector: Injector, protected wikiProvider: AddonModWikiProvider, + protected textUtils: CoreTextUtilsProvider, protected courseProvider: CoreCourseProvider, + protected courseHelper: CoreCourseHelperProvider, protected filepoolProvider: CoreFilepoolProvider, + protected groupsProvider: CoreGroupsProvider, protected gradesHelper: CoreGradesHelperProvider, + protected userProvider: CoreUserProvider) { + 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. @see downloadOrPrefetch. + * @return {Promise} Promise resolved when all content is downloaded. + */ + download(module: any, courseId: number, dirPath?: string): Promise { + // Same implementation for download or prefetch. + return this.prefetch(module, courseId, false, dirPath); + } + + /** + * Returns a list of pages that can be downloaded. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId The course ID. + * @param {boolean} [offline] Whether it should return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} List of pages. + */ + protected getAllPages(module: any, courseId: number, offline?: boolean, ignoreCache?: boolean, siteId?: string) + : Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.wikiProvider.getWiki(courseId, module.id, offline, siteId).then((wiki) => { + return this.wikiProvider.getWikiPageList(wiki, offline, ignoreCache, siteId); + }).catch(() => { + // Wiki not found, return empty list. + return []; + }); + } + + /** + * 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: number, single?: boolean): Promise<{ size: number, total: boolean }> { + const promises = [], + siteId = this.sitesProvider.getCurrentSiteId(); + + promises.push(this.getFiles(module, courseId, single, siteId).then((files) => { + return this.utils.sumFileSizes(files); + })); + + promises.push(this.getAllPages(module, courseId, false, true, siteId).then((pages) => { + let size = 0; + + pages.forEach((page) => { + if (page.contentsize) { + size = size + page.contentsize; + } + }); + + return {size: size, total: true}; + })); + + return Promise.all(promises).then((sizes) => { + // Sum values in the array. + return sizes.reduce((a, b) => { + return {size: a.size + b.size, total: a.total && b.total}; + }, {size: 0, total: true}); + }); + } + + /** + * 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. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the list of files. + */ + getFiles(module: any, courseId: number, single?: boolean, siteId?: string): Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.wikiProvider.getWiki(courseId, module.id, false, siteId).then((wiki) => { + const introFiles = this.getIntroFilesFromInstance(module, wiki); + + return this.wikiProvider.getWikiFileList(wiki, false, false, siteId).then((files) => { + return introFiles.concat(files); + }); + }).catch(() => { + // Wiki 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} Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number): Promise { + return this.wikiProvider.invalidateContent(moduleId, courseId); + } + + /** + * 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. @see downloadOrPrefetch. + * @return {Promise} Promise resolved when done. + */ + prefetch(module: any, courseId?: number, single?: boolean, dirPath?: string): Promise { + // Get the download time of the package before starting the download (otherwise we'd always get current time). + const siteId = this.sitesProvider.getCurrentSiteId(); + + return this.filepoolProvider.getPackageData(siteId, this.component, module.id).catch(() => { + // No package data yet. + }).then((data) => { + const downloadTime = (data && data.downloadTime) || 0; + + return this.prefetchPackage(module, courseId, single, this.prefetchWiki.bind(this), siteId, [downloadTime]); + }); + } + + /** + * Prefetch a wiki. + * + * @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 {number} downloadTime The previous download time, 0 if no previous download. + * @return {Promise} Promise resolved when done. + */ + protected prefetchWiki(module: any, courseId: number, single: boolean, siteId: string, downloadTime: number): Promise { + const userId = this.sitesProvider.getCurrentSiteUserId(); + + // Get the list of pages. + return this.getAllPages(module, courseId, false, true, siteId).then((pages) => { + const promises = []; + + pages.forEach((page) => { + // Fetch page contents if it needs to be fetched. + if (page.timemodified > downloadTime) { + promises.push(this.wikiProvider.getPageContents(page.id, false, true, siteId)); + } + }); + + // Fetch group data. + promises.push(this.groupsProvider.getActivityGroupMode(module.id, siteId).then((groupMode) => { + if (groupMode === CoreGroupsProvider.SEPARATEGROUPS || groupMode === CoreGroupsProvider.VISIBLEGROUPS) { + // Get the groups available for the user. + return this.groupsProvider.getActivityAllowedGroups(module.id, userId, siteId); + } + })); + + // Fetch info to provide wiki links. + promises.push(this.wikiProvider.getWiki(courseId, module.id, false, siteId).then((wiki) => { + return this.courseHelper.getModuleCourseIdByInstance(wiki.id, 'wiki', siteId); + })); + promises.push(this.courseProvider.getModuleBasicInfo(module.id, siteId)); + + // Get related page files and fetch them. + promises.push(this.getFiles(module, courseId, single, siteId).then((files) => { + return this.filepoolProvider.addFilesToQueue(siteId, files, this.component, module.id); + })); + + return Promise.all(promises); + }); + } +} diff --git a/src/addon/mod/wiki/providers/wiki-sync.ts b/src/addon/mod/wiki/providers/wiki-sync.ts new file mode 100644 index 000000000..ec6bc79e2 --- /dev/null +++ b/src/addon/mod/wiki/providers/wiki-sync.ts @@ -0,0 +1,387 @@ +// (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 { CoreGroupsProvider } from '@providers/groups'; +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 { AddonModWikiProvider } from './wiki'; +import { AddonModWikiOfflineProvider } from './wiki-offline'; + +/** + * Data returned by a subwiki sync. + */ +export interface AddonModWikiSyncSubwikiResult { + /** + * List of warnings. + * @type {string[]} + */ + warnings: string[]; + + /** + * Whether data was updated in the site. + * @type {boolean} + */ + updated: boolean; + + /** + * List of created pages. + * @type {{pageId: number, title: string}} + */ + created: {pageId: number, title: string}[]; + + /** + * List of discarded pages. + * @type {{title: string, warning: string}} + */ + discarded: {title: string, warning: string}[]; +} + +/** + * Data returned by a wiki sync. + */ +export interface AddonModWikiSyncWikiResult { + /** + * List of warnings. + * @type {string[]} + */ + warnings: string[]; + + /** + * Whether data was updated in the site. + * @type {boolean} + */ + updated: boolean; + + /** + * List of subwikis. + * @type {{[subwikiId: number]: {created: {pageId: number, title: string}, discarded: {title: string, warning: string}}}} + */ + subwikis: {[subwikiId: number]: { + created: {pageId: number, title: string}[], + discarded: {title: string, warning: string}[] + }}; + + /** + * Site ID. + * @type {string} + */ + siteId: string; +} + +/** + * Service to sync wikis. + */ +@Injectable() +export class AddonModWikiSyncProvider extends CoreSyncBaseProvider { + + static AUTO_SYNCED = 'addon_mod_wiki_autom_synced'; + static SYNC_TIME = 300000; + + protected componentTranslate: string; + + constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider, + syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService, + courseProvider: CoreCourseProvider, private eventsProvider: CoreEventsProvider, + private wikiProvider: AddonModWikiProvider, private wikiOfflineProvider: AddonModWikiOfflineProvider, + private utils: CoreUtilsProvider, private groupsProvider: CoreGroupsProvider) { + + super('AddonModWikiSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate); + + this.componentTranslate = courseProvider.translateModuleName('wiki'); + } + + /** + * Get a string to identify a subwiki. If it doesn't have a subwiki ID it will be identified by wiki ID, user ID and group ID. + * + * @param {number} subwikiId Subwiki ID. If not defined, wikiId, userId and groupId should be defined. + * @param {number} [wikiId] Wiki ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param {number} [userId] User ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param {number} [groupId] Group ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @return {string} Identifier. + */ + getSubwikiBlockId(subwikiId: number, wikiId?: number, userId?: number, groupId?: number): string { + subwikiId = this.wikiOfflineProvider.convertToPositiveNumber(subwikiId); + + if (subwikiId && subwikiId > 0) { + return String(subwikiId); + } + + wikiId = this.wikiOfflineProvider.convertToPositiveNumber(wikiId); + if (wikiId) { + userId = this.wikiOfflineProvider.convertToPositiveNumber(userId); + groupId = this.wikiOfflineProvider.convertToPositiveNumber(groupId); + + return wikiId + ':' + userId + ':' + groupId; + } + } + + /** + * Try to synchronize all the wikis 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. + */ + syncAllWikis(siteId?: string): Promise { + return this.syncOnSites('all wikis', this.syncAllWikisFunc.bind(this), [], siteId); + } + + /** + * Sync all wikis on a site. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + protected syncAllWikisFunc(siteId?: string): Promise { + // Get all the pages created in offline. + return this.wikiOfflineProvider.getAllNewPages(siteId).then((pages) => { + const promises = [], + subwikis = {}; + + // Get subwikis to sync. + pages.forEach((page) => { + const index = this.getSubwikiBlockId(page.subwikiid, page.wikiid, page.userid, page.groupid); + subwikis[index] = page; + }); + + // Sync all subwikis. + for (const id in subwikis) { + const subwiki = subwikis[id]; + + promises.push(this.syncSubwikiIfNeeded(subwiki.subwikiid, subwiki.wikiid, subwiki.userid, subwiki.groupid, + siteId).then((result) => { + + if (result && result.updated) { + // Sync successful, send event. + this.eventsProvider.trigger(AddonModWikiSyncProvider.AUTO_SYNCED, { + siteId: siteId, + subwikiId: subwiki.subwikiid, + wikiId: subwiki.wikiid, + userId: subwiki.userid, + groupId: subwiki.groupid, + created: result.created, + discarded: result.discarded, + warnings: result.warnings + }); + } + })); + } + + return Promise.all(promises); + }); + } + + /** + * Sync a subwiki only if a certain time has passed since the last time. + * + * @param {number} subwikiId Subwiki ID. If not defined, wikiId, userId and groupId should be defined. + * @param {number} [wikiId] Wiki ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param {number} [userId] User ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param {number} [groupId] Group ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when subwiki is synced or doesn't need to be synced. + */ + syncSubwikiIfNeeded(subwikiId: number, wikiId?: number, userId?: number, groupId?: number, siteId?: string) + : Promise { + + const blockId = this.getSubwikiBlockId(subwikiId, wikiId, userId, groupId); + + return this.isSyncNeeded(blockId, siteId).then((needed) => { + if (needed) { + return this.syncSubwiki(subwikiId, wikiId, userId, groupId, siteId); + } + }); + } + + /** + * Synchronize a subwiki. + * + * @param {number} subwikiId Subwiki ID. If not defined, wikiId, userId and groupId should be defined. + * @param {number} [wikiId] Wiki ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param {number} [userId] User ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param {number} [groupId] Group ID. Optional, will be used to create the subwiki if subwiki ID not provided. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + syncSubwiki(subwikiId: number, wikiId?: number, userId?: number, groupId?: number, siteId?: string) + : Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const result: AddonModWikiSyncSubwikiResult = { + warnings: [], + updated: false, + created: [], + discarded: [] + }, + subwikiBlockId = this.getSubwikiBlockId(subwikiId, wikiId, userId, groupId); + + if (this.isSyncing(subwikiBlockId, siteId)) { + // There's already a sync ongoing for this subwiki, return the promise. + return this.getOngoingSync(subwikiBlockId, siteId); + } + + // Verify that subwiki isn't blocked. + if (this.syncProvider.isBlocked(AddonModWikiProvider.COMPONENT, subwikiBlockId, siteId)) { + this.logger.debug('Cannot sync subwiki ' + subwikiBlockId + ' because it is blocked.'); + + return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); + } + + this.logger.debug('Try to sync subwiki ' + subwikiBlockId); + + // Get offline responses to be sent. + const syncPromise = this.wikiOfflineProvider.getSubwikiNewPages(subwikiId, wikiId, userId, groupId, siteId).catch(() => { + // No offline data found, return empty array. + return []; + }).then((pages) => { + if (!pages || !pages.length) { + // Nothing to sync. + return; + } else if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(this.translate.instant('core.networkerrormsg')); + } + + const promises = []; + + // Send the pages. + pages.forEach((page) => { + promises.push(this.wikiProvider.newPageOnline(page.title, page.cachedcontent, subwikiId, wikiId, userId, groupId, + siteId).then((pageId) => { + + result.updated = true; + + // Add page to created pages array. + result.created.push({ + pageId: pageId, + title: page.title + }); + + // Delete the local page. + return this.wikiOfflineProvider.deleteNewPage(page.title, subwikiId, wikiId, userId, groupId, siteId); + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that the page cannot be submitted. Delete it. + return this.wikiOfflineProvider.deleteNewPage(page.title, subwikiId, wikiId, userId, groupId, siteId) + .then(() => { + + result.updated = true; + + // Page deleted, add the page to discarded pages and add a warning. + const warning = this.translate.instant('core.warningofflinedatadeleted', { + component: this.translate.instant('addon.mod_wiki.wikipage'), + name: page.title, + error: error + }); + + result.discarded.push({ + title: page.title, + warning: warning + }); + + result.warnings.push(warning); + }); + } else { + // Couldn't connect to server, reject. + return Promise.reject(error); + } + })); + }); + + return Promise.all(promises); + }).then(() => { + // Sync finished, set sync time. + return this.setSyncTime(subwikiBlockId, siteId).catch(() => { + // Ignore errors. + }); + }).then(() => { + // All done, return the warnings. + return result; + }); + + return this.addOngoingSync(subwikiBlockId, syncPromise, siteId); + } + + /** + * Tries to synchronize a wiki. + * + * @param {number} wikiId Wiki ID. + * @param {number} [courseId] Course ID. + * @param {number} [cmId] Wiki course module ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if sync is successful, rejected otherwise. + */ + syncWiki(wikiId: number, courseId?: number, cmId?: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Sync is done at subwiki level, get all the subwikis. + return this.wikiProvider.getSubwikis(wikiId).then((subwikis) => { + const promises = [], + result: AddonModWikiSyncWikiResult = { + warnings: [], + updated: false, + subwikis: {}, + siteId: siteId + }; + + subwikis.forEach((subwiki) => { + promises.push(this.syncSubwiki(subwiki.id, subwiki.wikiid, subwiki.userid, subwiki.groupid, siteId).then((data) => { + if (data && data.updated) { + result.warnings = result.warnings.concat(data.warnings); + result.updated = true; + result.subwikis[subwiki.id] = { + created: data.created, + discarded: data.discarded + }; + } + })); + }); + + return Promise.all(promises).then(() => { + const promises = []; + + if (result.updated) { + // Something has changed, invalidate data. + if (wikiId) { + promises.push(this.wikiProvider.invalidateSubwikis(wikiId)); + promises.push(this.wikiProvider.invalidateSubwikiPages(wikiId)); + promises.push(this.wikiProvider.invalidateSubwikiFiles(wikiId)); + } + if (courseId) { + promises.push(this.wikiProvider.invalidateWikiData(courseId)); + } + if (cmId) { + promises.push(this.groupsProvider.invalidateActivityAllowedGroups(cmId)); + promises.push(this.groupsProvider.invalidateActivityGroupMode(cmId)); + } + } + + return Promise.all(promises).catch(() => { + // Ignore errors. + }).then(() => { + return result; + }); + }); + }); + } +} diff --git a/src/addon/mod/wiki/wiki.module.ts b/src/addon/mod/wiki/wiki.module.ts index 2af061880..c7e83bdbd 100644 --- a/src/addon/mod/wiki/wiki.module.ts +++ b/src/addon/mod/wiki/wiki.module.ts @@ -13,8 +13,11 @@ // limitations under the License. import { NgModule } from '@angular/core'; +import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { AddonModWikiProvider } from './providers/wiki'; import { AddonModWikiOfflineProvider } from './providers/wiki-offline'; +import { AddonModWikiSyncProvider } from './providers/wiki-sync'; +import { AddonModWikiPrefetchHandler } from './providers/prefetch-handler'; @NgModule({ declarations: [ @@ -23,7 +26,14 @@ import { AddonModWikiOfflineProvider } from './providers/wiki-offline'; ], providers: [ AddonModWikiProvider, - AddonModWikiOfflineProvider + AddonModWikiOfflineProvider, + AddonModWikiSyncProvider, + AddonModWikiPrefetchHandler ] }) -export class AddonModWikiModule { } +export class AddonModWikiModule { + constructor(prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModWikiPrefetchHandler) { + + prefetchDelegate.registerHandler(prefetchHandler); + } +}