From 27129c373e1e9af8baf53a8d574879b6c4b01861 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 22 May 2018 09:03:29 +0200 Subject: [PATCH 01/11] MOBILE-2353 wiki: Implement wiki and offline providers --- src/addon/mod/wiki/lang/en.json | 21 + src/addon/mod/wiki/providers/wiki-offline.ts | 272 ++++++ src/addon/mod/wiki/providers/wiki.ts | 840 +++++++++++++++++++ src/addon/mod/wiki/wiki.module.ts | 29 + 4 files changed, 1162 insertions(+) create mode 100644 src/addon/mod/wiki/lang/en.json create mode 100644 src/addon/mod/wiki/providers/wiki-offline.ts create mode 100644 src/addon/mod/wiki/providers/wiki.ts create mode 100644 src/addon/mod/wiki/wiki.module.ts diff --git a/src/addon/mod/wiki/lang/en.json b/src/addon/mod/wiki/lang/en.json new file mode 100644 index 000000000..d8b4dcca2 --- /dev/null +++ b/src/addon/mod/wiki/lang/en.json @@ -0,0 +1,21 @@ +{ + "cannoteditpage": "You can not edit this page.", + "createpage": "Create page", + "editingpage": "Editing this page '{{$a}}'", + "errorloadingpage": "An error occurred while loading the page.", + "errornowikiavailable": "This wiki does not have any content yet.", + "gowikihome": "Go to the wiki first page", + "map": "Map", + "newpagehdr": "New page", + "newpagetitle": "New page title", + "nocontent": "There is no content for this page", + "notingroup": "Not in group", + "page": "Page", + "pageexists": "This page already exists.", + "pagename": "Page name", + "subwiki": "Sub-wiki", + "titleshouldnotbeempty": "The title should not be empty", + "viewpage": "View page", + "wikipage": "Wiki page", + "wrongversionlock": "Another user has edited this page while you were editing and your content is obsolete." +} \ No newline at end of file diff --git a/src/addon/mod/wiki/providers/wiki-offline.ts b/src/addon/mod/wiki/providers/wiki-offline.ts new file mode 100644 index 000000000..7ea26138f --- /dev/null +++ b/src/addon/mod/wiki/providers/wiki-offline.ts @@ -0,0 +1,272 @@ +// (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 { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; + +/** + * Service to handle offline wiki. + */ +@Injectable() +export class AddonModWikiOfflineProvider { + + protected logger; + + // Variables for database. + protected NEW_PAGES_TABLE = 'addon_mod_wiki_new_pages_store'; + protected tablesSchema = [ + { + name: this.NEW_PAGES_TABLE, + columns: [ + { + name: 'wikiid', + type: 'INTEGER' + }, + { + name: 'subwikiid', + type: 'INTEGER' + }, + { + name: 'userid', + type: 'INTEGER' + }, + { + name: 'groupid', + type: 'INTEGER' + }, + { + name: 'title', + type: 'TEXT' + }, + { + name: 'cachedcontent', + type: 'TEXT' + }, + { + name: 'contentformat', + type: 'TEXT' + }, + { + name: 'courseid', + type: 'INTEGER' + }, + { + name: 'timecreated', + type: 'INTEGER' + }, + { + name: 'timemodified', + type: 'INTEGER' + }, + { + name: 'caneditpage', + type: 'INTEGER' + } + ], + primaryKeys: ['wikiid', 'subwikiid', 'userid', 'groupid', 'title'] + } + ]; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider) { + this.logger = logger.getInstance('AddonModWikiOfflineProvider'); + this.sitesProvider.createTablesFromSchema(this.tablesSchema); + } + + /** + * Convert a value to a positive number. If not a number or less than 0, 0 will be returned. + * + * @param {any} value Value to convert. + * @return {number} Converted value. + */ + convertToPositiveNumber(value: any): number { + value = parseInt(value, 10); + + return value > 0 ? value : 0; + } + + /** + * Delete a new page. + * + * @param {string} title Title of the page. + * @param {number} [subwikiId] Subwiki ID. If not defined, wikiId, userId and groupId should be defined. + * @param {number} [wikiId] Wiki ID. Optional, will be used create subwiki if not informed. + * @param {number} [userId] User ID. Optional, will be used create subwiki if not informed. + * @param {number} [groupId] Group ID. Optional, will be used create subwiki if not informed. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if deleted, rejected if failure. + */ + deleteNewPage(title: string, subwikiId?: number, wikiId?: number, userId?: number, groupId?: number, siteId?: string) + : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + + subwikiId = this.convertToPositiveNumber(subwikiId); + wikiId = this.convertToPositiveNumber(wikiId); + userId = this.convertToPositiveNumber(userId); + groupId = this.convertToPositiveNumber(groupId); + + return site.getDb().deleteRecords(this.NEW_PAGES_TABLE, { + subwikiid: subwikiId, + wikiid: wikiId, + userid: userId, + groupid: groupId, + title: title + }); + }); + } + + /** + * Get all the stored new pages from all the wikis. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with pages. + */ + getAllNewPages(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getAllRecords(this.NEW_PAGES_TABLE); + }); + } + + /** + * Get a stored new page. + * + * @param {string} title Title of the page. + * @param {number} [subwikiId] Subwiki ID. If not defined, wikiId, userId and groupId should be defined. + * @param {number} [wikiId] Wiki ID. Optional, will be used create subwiki if not informed. + * @param {number} [userId] User ID. Optional, will be used create subwiki if not informed. + * @param {number} [groupId] Group ID. Optional, will be used create subwiki if not informed. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with page. + */ + getNewPage(title: string, subwikiId?: number, wikiId?: number, userId?: number, groupId?: number, siteId?: string) + : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + + subwikiId = this.convertToPositiveNumber(subwikiId); + wikiId = this.convertToPositiveNumber(wikiId); + userId = this.convertToPositiveNumber(userId); + groupId = this.convertToPositiveNumber(groupId); + + return site.getDb().getRecord(this.NEW_PAGES_TABLE, { + subwikiid: subwikiId, + wikiid: wikiId, + userid: userId, + groupid: groupId, + title: title + }); + }); + } + + /** + * Get all the stored new pages from a certain 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 create subwiki if not informed. + * @param {number} [userId] User ID. Optional, will be used create subwiki if not informed. + * @param {number} [groupId] Group ID. Optional, will be used create subwiki if not informed. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with pages. + */ + getSubwikiNewPages(subwikiId?: number, wikiId?: number, userId?: number, groupId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + + subwikiId = this.convertToPositiveNumber(subwikiId); + wikiId = this.convertToPositiveNumber(wikiId); + userId = this.convertToPositiveNumber(userId); + groupId = this.convertToPositiveNumber(groupId); + + return site.getDb().getRecords(this.NEW_PAGES_TABLE, { + subwikiid: subwikiId, + wikiid: wikiId, + userid: userId, + groupid: groupId + }); + }); + } + + /** + * Get all the stored new pages from a list of subwikis. + * + * @param {any[]} subwikis List of subwiki. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with pages. + */ + getSubwikisNewPages(subwikis: any[], siteId?: string): Promise { + const promises = []; + let pages = []; + + subwikis.forEach((subwiki) => { + promises.push(this.getSubwikiNewPages(subwiki.id, subwiki.wikiid, subwiki.userid, subwiki.groupid, siteId) + .then((subwikiPages) => { + pages = pages.concat(subwikiPages); + })); + }); + + return Promise.all(promises).then(() => { + return pages; + }); + } + + /** + * Save a new page to be sent later. + * + * @param {string} title Title of the page. + * @param {string} content Content of the page. + * @param {number} [subwikiId] Subwiki ID. If not defined, wikiId, userId and groupId should be defined. + * @param {number} [wikiId] Wiki ID. Optional, will be used create subwiki if not informed. + * @param {number} [userId] User ID. Optional, will be used create subwiki if not informed. + * @param {number} [groupId] Group ID. Optional, will be used create subwiki if not informed. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if stored, rejected if failure. + */ + saveNewPage(title: string, content: string, subwikiId?: number, wikiId?: number, userId?: number, groupId?: number, + siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const now = new Date().getTime(), + entry = { + title: title, + cachedcontent: content, + subwikiid: this.convertToPositiveNumber(subwikiId), + wikiid: this.convertToPositiveNumber(wikiId), + userid: this.convertToPositiveNumber(userId), + groupid: this.convertToPositiveNumber(groupId), + contentformat: 'html', + timecreated: now, + timemodified: now, + caneditpage: true + }; + + return site.getDb().insertRecord(this.NEW_PAGES_TABLE, entry); + }); + } + + /** + * Check if a list of subwikis have offline data stored. + * + * @param {any[]} subwikis List of subwikis. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return{Promise} Promise resolved with boolean: whether it has offline data. + */ + subwikisHaveOfflineData(subwikis: any[], siteId?: string): Promise { + return this.getSubwikisNewPages(subwikis, siteId).then((pages) => { + return !!pages.length; + }).catch(() => { + // Error, return false. + return false; + }); + } +} diff --git a/src/addon/mod/wiki/providers/wiki.ts b/src/addon/mod/wiki/providers/wiki.ts new file mode 100644 index 000000000..030085614 --- /dev/null +++ b/src/addon/mod/wiki/providers/wiki.ts @@ -0,0 +1,840 @@ +// (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 { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreAppProvider } from '@providers/app'; +import { CoreFilepoolProvider } from '@providers/filepool'; +import { AddonModWikiOfflineProvider } from './wiki-offline'; +import { CoreSiteWSPreSets } from '@classes/site'; + +export interface AddonModWikiSubwikiListData { + /** + * Number of subwikis. + * @type {number} + */ + count: number; + + /** + * Subwiki ID currently selected. + * @type {number} + */ + subwikiSelected: number; + + /** + * User of the subwiki currently selected. + * @type {number} + */ + userSelected: number; + + /** + * Group of the subwiki currently selected. + * @type {number} + */ + groupSelected: number; + + /** + * List of subwikis. + * @type {any[]} + */ + subwikis: any[]; +} + +/** + * Service that provides some features for wikis. + */ +@Injectable() +export class AddonModWikiProvider { + static COMPONENT = 'mmaModWiki'; + + protected ROOT_CACHE_KEY = 'mmaModWiki:'; + protected logger; + protected subwikiListsCache: {[wikiId: number]: AddonModWikiSubwikiListData} = {}; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private appProvider: CoreAppProvider, + private filepoolProvider: CoreFilepoolProvider, private utils: CoreUtilsProvider, private translate: TranslateService, + private wikiOffline: AddonModWikiOfflineProvider) { + this.logger = logger.getInstance('AddonModWikiProvider'); + } + + /** + * Clear subwiki list cache for a certain wiki or all of them. + * + * @param {number} [wikiId] wiki Id, if not provided all will be cleared. + */ + clearSubwikiList(wikiId: number): void { + if (typeof wikiId == 'undefined') { + this.subwikiListsCache = {}; + } else { + delete this.subwikiListsCache[wikiId]; + } + } + + /** + * Save wiki contents on a page or section. + * + * @param {number} pageId Page ID. + * @param {string} content content to be saved. + * @param {string} [section] section to get. + * @return {Promise} Promise resolved with the page ID. + */ + editPage(pageId: number, content: string, section?: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = { + pageid: pageId, + content: content + }; + + if (section) { + params.section = section; + } + + return site.write('mod_wiki_edit_page', params).then((response) => { + return response.pageid || Promise.reject(null); + }); + }); + } + + /** + * Get a wiki page contents. + * + * @param {number} pageId Page 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} Promise resolved with the page data. + */ + getPageContents(pageId: number, offline?: boolean, ignoreCache?: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + pageid: pageId + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getPageContentsCacheKey(pageId) + }; + + if (offline) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_wiki_get_page_contents', params, preSets).then((response) => { + return response.page || Promise.reject(null); + }); + }); + } + + /** + * Get cache key for wiki Pages Contents WS calls. + * + * @param {number} pageId Wiki Page ID. + * @return {string} Cache key. + */ + protected getPageContentsCacheKey(pageId: number): string { + return this.ROOT_CACHE_KEY + 'page:' + pageId; + } + + /** + * Get a wiki page contents for editing. It does not cache calls. + * + * @param {number} pageId Page ID. + * @param {string} [section] Section to get. + * @param {boolean} [lockOnly] Just renew lock and not return content. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with page contents. + */ + getPageForEditing(pageId: number, section?: string, lockOnly?: boolean, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = { + pageid: pageId + }; + + if (section) { + params.section = section; + } + + // This parameter requires Moodle 3.2. It saves network usage. + if (lockOnly && site.isVersionGreaterEqualThan('3.2')) { + params.lockonly = 1; + } + + return site.write('mod_wiki_get_page_for_editing', params).then((response) => { + return response.pagesection || Promise.reject(null); + }); + }); + } + + /** + * Gets the list of files from a specific subwiki. + * + * @param {number} wikiId Wiki ID. + * @param {number} [groupId] Group to get files from. + * @param {number} [userId] User to get files from. + * @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} Promise resolved with subwiki files. + */ + getSubwikiFiles(wikiId: number, groupId?: number, userId?: number, offline?: boolean, ignoreCache?: boolean, siteId?: string) + : Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + groupId = groupId || -1; + userId = userId || 0; + + const params = { + wikiid: wikiId, + groupid: groupId, + userid: userId + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getSubwikiFilesCacheKey(wikiId, groupId, userId) + }; + + if (offline) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_wiki_get_subwiki_files', params, preSets).then((response) => { + return response.files || Promise.reject(null); + }); + }); + } + + /** + * Get cache key for wiki Subwiki Files WS calls. + * + * @param {number} wikiId Wiki ID. + * @param {number} groupId Group ID. + * @param {number} userId User ID. + * @return {string} Cache key. + */ + protected getSubwikiFilesCacheKey(wikiId: number, groupId: number, userId: number): string { + return this.getSubwikiFilesCacheKeyPrefix(wikiId) + ':' + groupId + ':' + userId; + } + + /** + * Get cache key for all wiki Subwiki Files WS calls. + * + * @param {number} wikiId Wiki ID. + * @return {string} Cache key. + */ + protected getSubwikiFilesCacheKeyPrefix(wikiId: number): string { + return this.ROOT_CACHE_KEY + 'subwikifiles:' + wikiId; + } + + /** + * Get a list of subwikis and related data for a certain wiki from the cache. + * + * @param {number} wikiId wiki Id + * @return {AddonModWikiSubwikiListData} Subwiki list and related data. + */ + getSubwikiList(wikiId: number): AddonModWikiSubwikiListData { + return this.subwikiListsCache[wikiId]; + } + + /** + * Get the list of Pages of a SubWiki. + * + * @param {number} wikiId Wiki ID. + * @param {number} [groupId] Group to get pages from. + * @param {number} [userId] User to get pages from. + * @param {string} [sortBy=title] The attribute to sort the returned list. + * @param {string} [sortDirection=ASC] Direction to sort the returned list (ASC | DESC). + * @param {boolean} [includeContent] Whether the pages have to include its content. Default: false. + * @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} Promise resolved with wiki subwiki pages. + */ + getSubwikiPages(wikiId: number, groupId?: number, userId?: number, sortBy: string = 'title', sortDirection: string = 'ASC', + includeContent?: boolean, offline?: boolean, ignoreCache?: boolean, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + groupId = groupId || -1; + userId = userId || 0; + sortBy = sortBy || 'title'; + sortDirection = sortDirection || 'ASC'; + includeContent = includeContent || false; + + const params = { + wikiid: wikiId, + groupid: groupId, + userid: userId, + options: { + sortby: sortBy, + sortdirection: sortDirection, + includecontent: includeContent ? 1 : 0 + } + + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getSubwikiPagesCacheKey(wikiId, groupId, userId) + }; + + if (offline) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_wiki_get_subwiki_pages', params, preSets).then((response) => { + return response.pages || Promise.reject(null); + }); + }); + } + + /** + * Get cache key for wiki Subwiki Pages WS calls. + * + * @param {number} wikiId Wiki ID. + * @param {number} groupId Group ID. + * @param {number} userId User ID. + * @return {string} Cache key. + */ + protected getSubwikiPagesCacheKey(wikiId: number, groupId: number, userId: number): string { + return this.getSubwikiPagesCacheKeyPrefix(wikiId) + ':' + groupId + ':' + userId; + } + + /** + * Get cache key for all wiki Subwiki Pages WS calls. + * + * @param {number} wikiId Wiki ID. + * @return {string} Cache key. + */ + protected getSubwikiPagesCacheKeyPrefix(wikiId: number): string { + return this.ROOT_CACHE_KEY + 'subwikipages:' + wikiId; + } + + /** + * Get all the subwikis of a wiki. + * + * @param {number} wikiId Wiki 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} Promise resolved with subwikis. + */ + getSubwikis(wikiId: number, offline?: boolean, ignoreCache?: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + wikiid: wikiId + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getSubwikisCacheKey(wikiId) + }; + + if (offline) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('mod_wiki_get_subwikis', params, preSets).then((response) => { + return response.subwikis || Promise.reject(null); + }); + }); + } + + /** + * Get cache key for get wiki subWikis WS calls. + * + * @param {number} wikiId Wiki ID. + * @return {string} Cache key. + */ + protected getSubwikisCacheKey(wikiId: number): string { + return this.ROOT_CACHE_KEY + 'subwikis:' + wikiId; + } + + /** + * Get a wiki by module ID. + * + * @param {number} courseId Course ID. + * @param {number} cmId Course module ID. + * @param {boolean} [forceCache] Whether it should always return cached data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the wiki is retrieved. + */ + getWiki(courseId: number, cmId: number, forceCache?: boolean, siteId?: string): Promise { + return this.getWikiByField(courseId, 'coursemodule', cmId, forceCache, siteId); + } + + /** + * Get a wiki with key=value. If more than one is found, only the first will be returned. + * + * @param {number} courseId Course ID. + * @param {string} key Name of the property to check. + * @param {any} value Value to search. + * @param {boolean} [forceCache] Whether it should always return cached data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the wiki is retrieved. + */ + protected getWikiByField(courseId: number, key: string, value: any, forceCache?: boolean, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + courseids: [courseId] + }, + preSets = { + cacheKey: this.getWikiDataCacheKey(courseId) + }; + + return site.read('mod_wiki_get_wikis_by_courses', params, preSets).then((response) => { + if (response.wikis) { + const currentWiki = response.wikis.find((wiki) => { + return wiki[key] == value; + }); + + if (currentWiki) { + return currentWiki; + } + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get a wiki by wiki ID. + * + * @param {number} courseId Course ID. + * @param {number} id Wiki ID. + * @param {boolean} [forceCache] Whether it should always return cached data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the wiki is retrieved. + */ + getWikiById(courseId: number, id: number, forceCache?: boolean, siteId?: string): Promise { + return this.getWikiByField(courseId, 'id', id, forceCache, siteId); + } + + /** + * Get cache key for wiki data WS calls. + * + * @param {number} courseId Course ID. + * @return {string} Cache key. + */ + protected getWikiDataCacheKey(courseId: number): string { + return this.ROOT_CACHE_KEY + 'wiki:' + courseId; + } + + /** + * Gets a list of files to download for a wiki, using a format similar to module.contents from get_course_contents. + * + * @param {any} wiki Wiki. + * @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} Promise resolved with the list of files. + */ + getWikiFileList(wiki: any, offline?: boolean, ignoreCache?: boolean, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + let files = []; + + return this.getSubwikis(wiki.id, offline, ignoreCache, siteId).then((subwikis) => { + const promises = []; + + subwikis.forEach((subwiki) => { + promises.push(this.getSubwikiFiles(subwiki.wikiid, subwiki.groupid, subwiki.userid, offline, ignoreCache, siteId) + .then((swFiles) => { + files = files.concat(swFiles); + })); + }); + + return Promise.all(promises).then(() => { + return files; + }); + }); + } + + /** + * Gets a list of all pages for a Wiki. + * + * @param {any} wiki Wiki. + * @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} Page list. + */ + getWikiPageList(wiki: any, offline?: boolean, ignoreCache?: boolean, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + let pages = []; + + return this.getSubwikis(wiki.id, offline, ignoreCache, siteId).then((subwikis) => { + const promises = []; + + subwikis.forEach((subwiki) => { + promises.push(this.getSubwikiPages(subwiki.wikiid, subwiki.groupid, subwiki.userid, undefined, undefined, + undefined, offline, ignoreCache, siteId).then((subwikiPages) => { + pages = pages.concat(subwikiPages); + })); + }); + + return Promise.all(promises).then(() => { + return pages; + }); + }); + } + + /** + * Invalidate the prefetched content except files. + * To invalidate files, use invalidateFiles. + * + * @param {number} moduleId The module ID. + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + invalidateContent(moduleId: number, courseId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.getWiki(courseId, moduleId, false, siteId).then((wiki) => { + const promises = []; + + promises.push(this.invalidateWikiData(courseId, siteId)); + promises.push(this.invalidateSubwikis(wiki.id, siteId)); + promises.push(this.invalidateSubwikiPages(wiki.id, siteId)); + promises.push(this.invalidateSubwikiFiles(wiki.id, siteId)); + + return Promise.all(promises); + }); + } + + /** + * Invalidate the prefetched files. + * + * @param {number} moduleId The module ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the files are invalidated. + */ + invalidateFiles(moduleId: number, siteId?: string): Promise { + return this.filepoolProvider.invalidateFilesByComponent(siteId, AddonModWikiProvider.COMPONENT, moduleId); + } + + /** + * Invalidates page content WS call for a certain page. + * + * @param {number} pageId Wiki Page ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidatePage(pageId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getPageContentsCacheKey(pageId)); + }); + } + + /** + * Invalidates all the subwiki files WS calls for a certain wiki. + * + * @param {number} wikiId Wiki ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateSubwikiFiles(wikiId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getSubwikiFilesCacheKeyPrefix(wikiId)); + }); + } + + /** + * Invalidates all the subwiki pages WS calls for a certain wiki. + * + * @param {Number} wikiId Wiki ID. + * @param {String} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateSubwikiPages(wikiId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getSubwikiPagesCacheKeyPrefix(wikiId)); + }); + } + + /** + * Invalidates all the get subwikis WS calls for a certain wiki. + * + * @param {number} wikiId Wiki ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateSubwikis(wikiId: number, siteId?: string): Promise { + this.clearSubwikiList(wikiId); + + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getSubwikisCacheKey(wikiId)); + }); + } + + /** + * Invalidates wiki data. + * + * @param {Number} courseId Course ID. + * @param {String} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateWikiData(courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getWikiDataCacheKey(courseId)); + }); + } + + /** + * Check if a page title is already used. + * + * @param {number} wikiId Wiki ID. + * @param {number} subwikiId Subwiki ID. + * @param {string} title Page title. + * @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} Promise resolved with true if used, resolved with false if not used or cannot determine. + */ + isTitleUsed(wikiId: number, subwikiId: number, title: string, offline?: boolean, ignoreCache?: boolean, siteId?: string) + : Promise { + + // First get the subwiki. + return this.getSubwikis(wikiId, offline, ignoreCache, siteId).then((subwikis) => { + // Search the subwiki. + const subwiki = subwikis.find((subwiki) => { + return subwiki.id == subwikiId; + }); + + return subwiki || Promise.reject(null); + }).then((subwiki) => { + // Now get all the pages of the subwiki. + return this.getSubwikiPages(wikiId, subwiki.groupid, subwiki.userid, undefined, undefined, false, offline, + ignoreCache, siteId); + }).then((pages) => { + // Check if there's any page with the same title. + const page = pages.find((page) => { + return page.title == title; + }); + + return !!page; + }).catch(() => { + return false; + }); + } + + /** + * Report a wiki page as being viewed. + * + * @param {string} id Page ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logPageView(id: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + pageid: id + }; + + return site.write('mod_wiki_view_page', params); + }); + } + + /** + * Report the wiki as being viewed. + * + * @param {number} id Wiki ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logView(id: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + wikiid: id + }; + + return site.write('mod_wiki_view_wiki', params); + }); + } + + /** + * Create a new page on a subwiki. + * + * @param {string} title Title to create the page. + * @param {string} content Content to save on the page. + * @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 a new subwiki if subwikiId not supplied. + * @param {number} [userId] User ID. Optional, will be used to create a new subwiki if subwikiId not supplied. + * @param {number} [groupId] Group ID. Optional, will be used to create a new subwiki if subwikiId not supplied. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with page ID if page was created in server, -1 if stored in device. + */ + newPage(title: string, content: string, subwikiId?: number, wikiId?: number, userId?: number, groupId?: number, + siteId?: string): Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Convenience function to store a new page to be synchronized later. + const storeOffline = (): Promise => { + let promise; + + if (wikiId) { + // We have wiki ID, check if there's already an online page with this title and subwiki. + promise = this.isTitleUsed(wikiId, subwikiId, title, true, false, siteId).catch(() => { + // Error, assume not used. + return false; + }).then((used) => { + if (used) { + return Promise.reject(this.translate.instant('addon.mod_wiki.pageexists')); + } + }); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + return this.wikiOffline.saveNewPage(title, content, subwikiId, wikiId, userId, groupId, siteId).then(() => { + return -1; + }); + }); + }; + + if (!this.appProvider.isOnline()) { + // App is offline, store the action. + return storeOffline(); + } + + // Discard stored content for this page. If it exists it means the user is editing it. + return this.wikiOffline.deleteNewPage(title, subwikiId, wikiId, userId, groupId, siteId).then(() => { + // Try to create it in online. + return this.newPageOnline(title, content, 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 added. + return Promise.reject(error); + } else { + // Couldn't connect to server, store in offline. + return storeOffline(); + } + }); + }); + } + + /** + * Create a new page on a subwiki. It will fail if offline or cannot connect. + * + * @param {string} title Title to create the page. + * @param {string} content Content to save on the page. + * @param {number} [subwikiId] Subwiki ID. If not defined, wikiId, userId and groupId should be defined. + * @param {number} [wikiId] Wiki ID. Optional, will be used create subwiki if not informed. + * @param {number} [userId] User ID. Optional, will be used create subwiki if not informed. + * @param {number} [groupId] Group ID. Optional, will be used create subwiki if not informed. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the page ID if created, rejected otherwise. + */ + newPageOnline(title: string, content: string, subwikiId?: number, wikiId?: number, userId?: number, groupId?: number, + siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const params: any = { + title: title, + content: content, + contentformat: 'html' + }; + + subwikiId = this.wikiOffline.convertToPositiveNumber(subwikiId); + wikiId = this.wikiOffline.convertToPositiveNumber(wikiId); + + if (subwikiId && subwikiId > 0) { + params.subwikiid = subwikiId; + } else if (wikiId) { + params.wikiid = wikiId; + params.userid = this.wikiOffline.convertToPositiveNumber(userId); + params.groupid = this.wikiOffline.convertToPositiveNumber(groupId); + } + + return site.write('mod_wiki_new_page', params).then((response) => { + return response.pageid || Promise.reject(null); + }); + }); + } + + /** + * Save subwiki list for a wiki to the cache. + * + * @param {number} wikiId Wiki Id. + * @param {any[]} subwikis List of subwikis. + * @param {number} count Number of subwikis in the subwikis list. + * @param {number} subwikiId Subwiki Id currently selected. + * @param {number} userId User Id currently selected. + * @param {number} groupId Group Id currently selected. + */ + setSubwikiList(wikiId: number, subwikis: any[], count: number, subwikiId: number, userId: number, groupId: number): void { + this.subwikiListsCache[wikiId] = { + count: count, + subwikiSelected: subwikiId, + userSelected: userId, + groupSelected: groupId, + subwikis: subwikis + }; + } + + /** + * Sort an array of wiki pages by title. + * + * @param {any[]} pages Pages to sort. + * @param {boolean} [desc] True to sort in descendent order, false to sort in ascendent order. Defaults to false. + * @return {any[]} Sorted pages. + */ + sortPagesByTitle(pages: any[], desc?: boolean): any[] { + return pages.sort((a, b) => { + let result = a.title >= b.title ? 1 : -1; + + if (desc) { + result = -result; + } + + return result; + }); + } + + /** + * Check if a wiki has a certain subwiki. + * + * @param {number} wikiId Wiki ID. + * @param {number} subwikiId Subwiki ID to search. + * @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} Promise resolved with true if it has subwiki, resolved with false otherwise. + */ + wikiHasSubwiki(wikiId: number, subwikiId: number, offline?: boolean, ignoreCache?: boolean, siteId?: string): Promise { + // Get the subwikis to check if any of them matches the one passed as param. + return this.getSubwikis(wikiId, offline, ignoreCache, siteId).then((subwikis) => { + const subwiki = subwikis.find((subwiki) => { + return subwiki.id == subwikiId; + }); + + return !!subwiki; + }).catch(() => { + // Not found, return false. + return false; + }); + } +} diff --git a/src/addon/mod/wiki/wiki.module.ts b/src/addon/mod/wiki/wiki.module.ts new file mode 100644 index 000000000..2af061880 --- /dev/null +++ b/src/addon/mod/wiki/wiki.module.ts @@ -0,0 +1,29 @@ +// (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 { NgModule } from '@angular/core'; +import { AddonModWikiProvider } from './providers/wiki'; +import { AddonModWikiOfflineProvider } from './providers/wiki-offline'; + +@NgModule({ + declarations: [ + ], + imports: [ + ], + providers: [ + AddonModWikiProvider, + AddonModWikiOfflineProvider + ] +}) +export class AddonModWikiModule { } From fb59c5944ba727d1d8f3d10b16148e9606042ab3 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 22 May 2018 13:04:22 +0200 Subject: [PATCH 02/11] MOBILE-2352 login: Fix error handling in login --- src/core/login/providers/helper.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/core/login/providers/helper.ts b/src/core/login/providers/helper.ts index a2f704e28..e0cfbb6ec 100644 --- a/src/core/login/providers/helper.ts +++ b/src/core/login/providers/helper.ts @@ -950,12 +950,10 @@ export class CoreLoginHelperProvider { * @param {any} error Error object containing errorcode and error message. */ treatUserTokenError(siteUrl: string, error: any): void { - if (typeof error == 'string') { - this.domUtils.showErrorModal(error); - } else if (error.errorcode == 'forcepasswordchangenotice') { - this.openChangePassword(siteUrl, error.error); + if (error.errorcode == 'forcepasswordchangenotice') { + this.openChangePassword(siteUrl, error.error || error.message || error.body || error.content); } else { - this.domUtils.showErrorModal(error.error); + this.domUtils.showErrorModal(error); } } From fbc78e5a4e2d096ee1787ca11798229b26318c85 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 22 May 2018 13:07:15 +0200 Subject: [PATCH 03/11] 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); + } +} From 4949f9307afc60825ecc76d3fd32f4ded5937775 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 23 May 2018 08:41:20 +0200 Subject: [PATCH 04/11] MOBILE-2353 wiki: Implement module and sync handlers --- .../mod/wiki/providers/module-handler.ts | 71 +++++++++++++++++++ .../mod/wiki/providers/sync-cron-handler.ts | 47 ++++++++++++ src/addon/mod/wiki/wiki.module.ts | 14 +++- 3 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 src/addon/mod/wiki/providers/module-handler.ts create mode 100644 src/addon/mod/wiki/providers/sync-cron-handler.ts diff --git a/src/addon/mod/wiki/providers/module-handler.ts b/src/addon/mod/wiki/providers/module-handler.ts new file mode 100644 index 000000000..80a514580 --- /dev/null +++ b/src/addon/mod/wiki/providers/module-handler.ts @@ -0,0 +1,71 @@ +// (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 { NavController, NavOptions } from 'ionic-angular'; +import { AddonModWikiIndexComponent } from '../components/index/index'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@core/course/providers/module-delegate'; +import { CoreCourseProvider } from '@core/course/providers/course'; + +/** + * Handler to support wiki modules. + */ +@Injectable() +export class AddonModWikiModuleHandler implements CoreCourseModuleHandler { + name = 'AddonModWiki'; + modName = 'wiki'; + + constructor(private courseProvider: CoreCourseProvider) { } + + /** + * Check if the handler is enabled on a site level. + * + * @return {boolean} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean { + return true; + } + + /** + * Get the data required to display the module in the course contents view. + * + * @param {any} module The module object. + * @param {number} courseId The course ID. + * @param {number} sectionId The section ID. + * @return {CoreCourseModuleHandlerData} Data to render the module. + */ + getData(module: any, courseId: number, sectionId: number): CoreCourseModuleHandlerData { + return { + icon: this.courseProvider.getModuleIconSrc('wiki'), + title: module.name, + class: 'addon-mod_wiki-handler', + showDownloadButton: true, + action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void { + navCtrl.push('AddonModWikiIndexPage', {module: module, courseId: courseId}, options); + } + }; + } + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. + * + * @param {any} course The course object. + * @param {any} module The module object. + * @return {any} The component to use, undefined if not found. + */ + getMainComponent(course: any, module: any): any { + return AddonModWikiIndexComponent; + } +} diff --git a/src/addon/mod/wiki/providers/sync-cron-handler.ts b/src/addon/mod/wiki/providers/sync-cron-handler.ts new file mode 100644 index 000000000..ed5fa443f --- /dev/null +++ b/src/addon/mod/wiki/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 { AddonModWikiSyncProvider } from './wiki-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class AddonModWikiSyncCronHandler implements CoreCronHandler { + name = 'AddonModWikiSyncCronHandler'; + + constructor(private wikiSync: AddonModWikiSyncProvider) {} + + /** + * 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.wikiSync.syncAllWikis(siteId); + } + + /** + * Get the time between consecutive executions. + * + * @return {number} Time between consecutive executions (in ms). + */ + getInterval(): number { + return 600000; // 10 minutes. + } +} diff --git a/src/addon/mod/wiki/wiki.module.ts b/src/addon/mod/wiki/wiki.module.ts index c7e83bdbd..b8eb3b00c 100644 --- a/src/addon/mod/wiki/wiki.module.ts +++ b/src/addon/mod/wiki/wiki.module.ts @@ -13,11 +13,15 @@ // limitations under the License. import { NgModule } from '@angular/core'; +import { CoreCronDelegate } from '@providers/cron'; +import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; 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 { AddonModWikiModuleHandler } from './providers/module-handler'; import { AddonModWikiPrefetchHandler } from './providers/prefetch-handler'; +import { AddonModWikiSyncCronHandler } from './providers/sync-cron-handler'; @NgModule({ declarations: [ @@ -28,12 +32,18 @@ import { AddonModWikiPrefetchHandler } from './providers/prefetch-handler'; AddonModWikiProvider, AddonModWikiOfflineProvider, AddonModWikiSyncProvider, - AddonModWikiPrefetchHandler + AddonModWikiModuleHandler, + AddonModWikiPrefetchHandler, + AddonModWikiSyncCronHandler ] }) export class AddonModWikiModule { - constructor(prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModWikiPrefetchHandler) { + constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModWikiModuleHandler, + prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModWikiPrefetchHandler, + cronDelegate: CoreCronDelegate, syncHandler: AddonModWikiSyncCronHandler, ) { + moduleDelegate.registerHandler(moduleHandler); prefetchDelegate.registerHandler(prefetchHandler); + cronDelegate.register(syncHandler); } } From d61eae4f6697a8eb66655adfa5c36c41649cfcce Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 24 May 2018 12:33:53 +0200 Subject: [PATCH 05/11] MOBILE-2353 wiki: Implement index page and component --- .../mod/wiki/components/components.module.ts | 49 + .../mod/wiki/components/index/index.html | 74 ++ .../mod/wiki/components/index/index.scss | 50 + src/addon/mod/wiki/components/index/index.ts | 1063 +++++++++++++++++ .../subwiki-picker/subwiki-picker.html | 11 + .../subwiki-picker/subwiki-picker.scss | 14 + .../subwiki-picker/subwiki-picker.ts | 67 ++ src/addon/mod/wiki/pages/index/index.html | 16 + .../mod/wiki/pages/index/index.module.ts | 33 + src/addon/mod/wiki/pages/index/index.ts | 83 ++ src/addon/mod/wiki/providers/wiki-sync.ts | 1 + src/addon/mod/wiki/providers/wiki.ts | 1 + src/addon/mod/wiki/wiki.module.ts | 2 + src/app/app.module.ts | 2 + .../course/classes/main-activity-component.ts | 19 + 15 files changed, 1485 insertions(+) create mode 100644 src/addon/mod/wiki/components/components.module.ts create mode 100644 src/addon/mod/wiki/components/index/index.html create mode 100644 src/addon/mod/wiki/components/index/index.scss create mode 100644 src/addon/mod/wiki/components/index/index.ts create mode 100644 src/addon/mod/wiki/components/subwiki-picker/subwiki-picker.html create mode 100644 src/addon/mod/wiki/components/subwiki-picker/subwiki-picker.scss create mode 100644 src/addon/mod/wiki/components/subwiki-picker/subwiki-picker.ts create mode 100644 src/addon/mod/wiki/pages/index/index.html create mode 100644 src/addon/mod/wiki/pages/index/index.module.ts create mode 100644 src/addon/mod/wiki/pages/index/index.ts diff --git a/src/addon/mod/wiki/components/components.module.ts b/src/addon/mod/wiki/components/components.module.ts new file mode 100644 index 000000000..39372cfe2 --- /dev/null +++ b/src/addon/mod/wiki/components/components.module.ts @@ -0,0 +1,49 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreCourseComponentsModule } from '@core/course/components/components.module'; +import { AddonModWikiIndexComponent } from './index/index'; +import { AddonModWikiSubwikiPickerComponent } from './subwiki-picker/subwiki-picker'; + +@NgModule({ + declarations: [ + AddonModWikiIndexComponent, + AddonModWikiSubwikiPickerComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CoreCourseComponentsModule + ], + providers: [ + ], + exports: [ + AddonModWikiIndexComponent, + AddonModWikiSubwikiPickerComponent + ], + entryComponents: [ + AddonModWikiIndexComponent, + AddonModWikiSubwikiPickerComponent + ] +}) +export class AddonModWikiComponentsModule {} diff --git a/src/addon/mod/wiki/components/index/index.html b/src/addon/mod/wiki/components/index/index.html new file mode 100644 index 000000000..335b5ea75 --- /dev/null +++ b/src/addon/mod/wiki/components/index/index.html @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + {{ 'core.hasdatatosync' | translate:{$a: pageStr} }} + {{ 'core.hasdatatosync' | translate:{$a: moduleName} }} +
+ + +
+ + {{ pageWarning }} +
+ +
+ + +
+
+
+
+ + + + + + + + {{ letter.label }} + + + {{ page.title }} + {{ 'core.offline' | translate }} + + + + + +
+ +
diff --git a/src/addon/mod/wiki/components/index/index.scss b/src/addon/mod/wiki/components/index/index.scss new file mode 100644 index 000000000..383937d2f --- /dev/null +++ b/src/addon/mod/wiki/components/index/index.scss @@ -0,0 +1,50 @@ +$addon-mod-wiki-toc-level-padding: 12px !default; +$addon-mod-wiki-newentry-link-color: $red !default; +$addon-mod-wiki-toc-title-color: $gray-darker !default; +$addon-mod-wiki-toc-border-color: $gray-dark !default; +$addon-mod-wiki-toc-background-color: $gray-light !default; + +addon-mod-wiki-index { + background-color: $white; + + .core-tabs-content-container, .addon-mod_wiki-page-content { + background-color: $white; + } + + .wiki-toc { + border: 1px solid $addon-mod-wiki-toc-border-color; + background: $addon-mod-wiki-toc-background-color; + margin: 16px; + padding: 8px; + } + + .wiki-toc-title { + color: $addon-mod-wiki-toc-title-color; + font-size: 1.1em; + font-variant: small-caps; + text-align: center; + } + + .wiki-toc-section { + padding: 0; + margin: 2px 8px; + } + + .wiki-toc-section-2 { + padding-left: $addon-mod-wiki-toc-level-padding; + } + + .wiki-toc-section-3 { + padding-left: $addon-mod-wiki-toc-level-padding * 2; + } + + .wiki_newentry { + color: $addon-mod-wiki-newentry-link-color; + font-style: italic; + } + + /* Hide edit section links */ + .addon-mod_wiki-noedit a.wiki_edit_section { + display: none; + } +} diff --git a/src/addon/mod/wiki/components/index/index.ts b/src/addon/mod/wiki/components/index/index.ts new file mode 100644 index 000000000..4abff5c7d --- /dev/null +++ b/src/addon/mod/wiki/components/index/index.ts @@ -0,0 +1,1063 @@ +// (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 { Component, Optional, Injector, Input, ViewChild } from '@angular/core'; +import { Content, NavController, PopoverController, ViewController } from 'ionic-angular'; +import { CoreGroupsProvider } from '@providers/groups'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { AddonModWikiProvider, AddonModWikiSubwikiListData } from '../../providers/wiki'; +import { AddonModWikiOfflineProvider } from '../../providers/wiki-offline'; +import { AddonModWikiSyncProvider } from '../../providers/wiki-sync'; +import { CoreTabsComponent } from '@components/tabs/tabs'; +import { AddonModWikiSubwikiPickerComponent } from '../../components/subwiki-picker/subwiki-picker'; + +/** + * Component that displays a wiki entry page. + */ +@Component({ + selector: 'addon-mod-wiki-index', + templateUrl: 'index.html', +}) +export class AddonModWikiIndexComponent extends CoreCourseModuleMainActivityComponent { + @ViewChild(CoreTabsComponent) tabs: CoreTabsComponent; + + @Input() action: string; + @Input() pageId: number; + @Input() pageTitle: string; + @Input() wikiId: number; + @Input() subwikiId: number; + @Input() userId: number; + @Input() groupId: number; + + component = AddonModWikiProvider.COMPONENT; + componentId: number; + moduleName = 'wiki'; + + wiki: any; // The wiki instance. + isMainPage: boolean; // Whether the user is viewing wiki's main page (just entered the wiki). + canEdit = false; // Whether user can edit the page. + pageStr = this.translate.instant('addon.mod_wiki.page'); + pageWarning: string; // Message telling that the page was discarded. + loadedSubwikis: any[] = []; // The loaded subwikis. + pageIsOffline: boolean; // Whether the loaded page is an offline page. + pageContent: string; // Page content to display. + showHomeButton: boolean; // Whether to display the home button. + selectedTab = 0; // Tab to select at start. + map: any[] = []; // Map of pages, categorized by letter. + subwikiData: AddonModWikiSubwikiListData = { // Data for the subwiki selector. + subwikiSelected: 0, + userSelected: 0, + groupSelected: 0, + subwikis: [], + count: 0 + }; + + protected syncEventName = AddonModWikiSyncProvider.AUTO_SYNCED; + protected currentSubwiki: any; // Current selected subwiki. + protected currentPage: number; // Current loaded page ID. + protected currentPageObj: any; // Object of the current loaded page. + protected subwikiPages: any[]; // List of subwiki pages. + protected newPageObserver: any; // Observer to check for new pages. + protected ignoreManualSyncEvent: boolean; // Whether manual sync event should be ignored. + protected manualSyncObserver: any; // An observer to watch for manual sync events. + protected currentUserId: number; // Current user ID. + protected hasEdited = false; // Whether the user has opened the edit page. + protected mapInitialized = false; // Whether the map was initialized. + protected initHomeButton = true; // Whether the init home button must be initialized. + + constructor(injector: Injector, protected wikiProvider: AddonModWikiProvider, @Optional() protected content: Content, + protected wikiOffline: AddonModWikiOfflineProvider, protected wikiSync: AddonModWikiSyncProvider, + protected navCtrl: NavController, protected utils: CoreUtilsProvider, protected groupsProvider: CoreGroupsProvider, + protected userProvider: CoreUserProvider, private popoverCtrl: PopoverController) { + super(injector, content); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + super.ngOnInit(); + + this.currentUserId = this.sitesProvider.getCurrentSiteUserId(); + this.isMainPage = !this.pageId && !this.pageTitle; + this.currentPage = this.pageId; + this.selectedTab = this.action == 'map' ? 1 : 0; + + this.loadContent(false, true).then(() => { + if (!this.wiki) { + return; + } + + if (this.isMainPage) { + this.wikiProvider.logView(this.wiki.id).then(() => { + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + }).catch((error) => { + // Ignore errors. + }); + } else { + this.wikiProvider.logPageView(this.pageId).catch(() => { + // Ignore errors. + }); + } + }); + + // Listen for manual sync events. + this.manualSyncObserver = this.eventsProvider.on(AddonModWikiSyncProvider.MANUAL_SYNCED, (data) => { + if (data && this.wiki && data.wikiId == this.wiki.id) { + if (this.ignoreManualSyncEvent) { + // Event needs to be ignored. + this.ignoreManualSyncEvent = false; + + return; + } + + if (this.currentSubwiki) { + this.checkPageCreatedOrDiscarded(data.subwikis[this.currentSubwiki.id]); + } + + if (!this.pageWarning) { + this.showLoadingAndFetch(false, false); + } + } + }, this.siteId); + } + + /** + * Check if the current page was created or discarded. + * + * @param {any} data Data about created and deleted pages. + */ + protected checkPageCreatedOrDiscarded(data: any): void { + if (!this.currentPage && data) { + // This is an offline page. Check if the page was created. + let pageId; + + for (let i = 0, len = data.created.length; i < len; i++) { + const page = data.created[i]; + if (page.title == this.pageTitle) { + pageId = page.pageId; + break; + } + } + + if (pageId) { + // Page was created, set the ID so it's retrieved from server. + this.currentPage = pageId; + this.pageIsOffline = false; + } else { + // Page not found in created list, check if it was discarded. + for (let i = 0, len = data.discarded.length; i < len; i++) { + const page = data.discarded[i]; + if (page.title == this.pageTitle) { + // Page discarded, show warning. + this.pageWarning = page.warning; + this.pageContent = ''; + this.pageIsOffline = false; + this.hasOffline = false; + } + } + } + } + } + + /** + * Construct the map of pages. + * + * @param {any[]} subwikiPages List of pages. + */ + constructMap(subwikiPages: any[]): void { + let letter, + initialLetter; + + this.map = []; + this.mapInitialized = true; + subwikiPages.sort((a, b) => { + const compareA = a.title.toLowerCase().trim(), + compareB = b.title.toLowerCase().trim(); + + return compareA.localeCompare(compareB); + }); + + subwikiPages.forEach((page) => { + const letterCandidate = page.title.charAt(0).toLocaleUpperCase(); + + // Should we create a new grouping? + if (letterCandidate !== initialLetter) { + initialLetter = letterCandidate; + letter = {label: letterCandidate, pages: []}; + + this.map.push(letter); + } + + // Add the subwiki to the currently active grouping. + letter.pages.push(page); + }); + } + + /** + * Get the wiki data. + * + * @param {boolean} [refresh=false] If it's refreshing content. + * @param {boolean} [sync=false] If the refresh is needs syncing. + * @param {boolean} [showErrors=false] If show errors to the user of hide them. + * @return {Promise} Promise resolved when done. + */ + protected fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + + // Get the wiki instance. + let promise; + if (this.module.id) { + promise = this.wikiProvider.getWiki(this.courseId, this.module.id); + } else { + promise = this.wikiProvider.getWikiById(this.courseId, this.wikiId); + } + + return promise.then((wiki) => { + this.wiki = wiki; + + this.dataRetrieved.emit(this.wiki); + + if (sync) { + // Try to synchronize the wiki. + return this.syncActivity(showErrors).catch(() => { + // Ignore errors. + }); + } + }).then(() => { + if (this.pageWarning) { + // Page discarded, stop getting data. + return Promise.reject(null); + } + + if (this.isCurrentView || this.initHomeButton) { + this.initHomeButton = false; + this.showHomeButton = !!this.getWikiHomeView(); + } + + // Get module instance if it's empty. + let promise; + if (!this.module.id) { + promise = this.courseProvider.getModule(this.wiki.coursemodule, this.wiki.course, undefined, true); + } else { + promise = Promise.resolve(this.module); + } + + return promise.then((mod) => { + this.module = mod; + + this.description = this.wiki.intro || this.module.description; + this.externalUrl = this.module.url; + this.componentId = this.module.id; + + // Get real groupmode, in case it's forced by the course. + return this.groupsProvider.getActivityGroupMode(this.wiki.coursemodule).then((groupMode) => { + + if (groupMode === CoreGroupsProvider.SEPARATEGROUPS || groupMode === CoreGroupsProvider.VISIBLEGROUPS) { + // Get the groups available for the user. + promise = this.groupsProvider.getActivityAllowedGroups(this.wiki.coursemodule); + } else { + promise = Promise.resolve([]); + } + + return promise.then((userGroups) => { + return this.fetchSubwikis(this.wiki.id).then(() => { + // Get the subwiki list data from the cache. + const subwikiList = this.wikiProvider.getSubwikiList(this.wiki.id); + + if (!subwikiList) { + // Not found in cache, create a new one. + return this.createSubwikiList(userGroups); + } + + this.subwikiData.count = subwikiList.count; + this.setSelectedWiki(this.subwikiId, this.userId, this.groupId); + + // If nothing was selected using nav params, use the selected from cache. + if (!this.isAnySubwikiSelected()) { + this.setSelectedWiki(subwikiList.subwikiSelected, subwikiList.userSelected, + subwikiList.groupSelected); + } + + this.subwikiData.subwikis = subwikiList.subwikis; + }); + }).then(() => { + + if (!this.isAnySubwikiSelected() || this.subwikiData.count <= 0) { + return Promise.reject(this.translate.instant('addon.mod_wiki.errornowikiavailable')); + } + }).then(() => { + return this.fetchWikiPage(); + }); + }); + }); + }).then(() => { + // All data obtained, now fill the context menu. + this.fillContextMenu(refresh); + }).catch((error) => { + if (this.pageWarning) { + // Warning is already shown in screen, no need to show a modal. + return; + } + + return Promise.reject(error); + }); + } + + /** + * Get wiki page contents. + * + * @param {number} pageId Page to get. + * @return {Promise} Promise resolved with the page data. + */ + protected fetchPageContents(pageId: number): Promise { + if (!pageId) { + const title = this.pageTitle || this.wiki.firstpagetitle; + + // No page ID but we received a title. This means we're trying to load an offline page. + return this.wikiOffline.getNewPage(title, this.currentSubwiki.id, this.currentSubwiki.wikiid, + this.currentSubwiki.userid, this.currentSubwiki.groupid).then((offlinePage) => { + + this.pageIsOffline = true; + if (!this.newPageObserver) { + // It's an offline page, listen for new pages event to detect if the user goes to Edit and submits the page. + this.newPageObserver = this.eventsProvider.on(AddonModWikiProvider.PAGE_CREATED_EVENT, (data) => { + if (data.subwikiId == this.currentSubwiki.id && data.pageTitle == title) { + // The page has been submitted. Get the page from the server. + this.currentPage = data.pageId; + + this.showLoadingAndFetch(true, false).then(() => { + this.wikiProvider.logPageView(this.currentPage); + }); + + // Stop listening for new page events. + this.newPageObserver.off(); + this.newPageObserver = undefined; + } + }, this.sitesProvider.getCurrentSiteId()); + } + + return offlinePage; + }).catch(() => { + // Page not found, ignore. + }); + } + + this.pageIsOffline = false; + + return this.wikiProvider.getPageContents(pageId); + } + + /** + * Fetch the list of pages of a subwiki. + * + * @param {any} subwiki Subwiki. + */ + protected fetchSubwikiPages(subwiki: any): Promise { + let subwikiPages; + + return this.wikiProvider.getSubwikiPages(subwiki.wikiid, subwiki.groupid, subwiki.userid).then((pages) => { + subwikiPages = pages; + + // If no page specified, search first page. + if (!this.currentPage && !this.pageTitle) { + for (const i in subwikiPages) { + const page = subwikiPages[i]; + if (page.firstpage) { + this.currentPage = page.id; + break; + } + } + } + + // Now get the offline pages. + return this.wikiOffline.getSubwikiNewPages(subwiki.id, subwiki.wikiid, subwiki.userid, subwiki.groupid); + }).then((offlinePages) => { + + // If no page specified, search page title in the offline pages. + if (!this.currentPage) { + const searchTitle = this.pageTitle ? this.pageTitle : this.wiki.firstpagetitle, + pageExists = offlinePages.some((page) => { + return page.title == searchTitle; + }); + + if (pageExists) { + this.pageTitle = searchTitle; + } + } + + this.subwikiPages = this.wikiProvider.sortPagesByTitle(subwikiPages.concat(offlinePages)); + this.constructMap(this.subwikiPages); + + // Reject if no currentPage selected from the subwikis given (if no subwikis available, do not reject). + if (!this.currentPage && !this.pageTitle && this.subwikiPages.length > 0) { + return Promise.reject(null); + } + }); + } + + /** + * Get the subwikis. + * + * @param {number} wikiId Wiki ID. + */ + protected fetchSubwikis(wikiId: number): Promise { + return this.wikiProvider.getSubwikis(wikiId).then((subwikis) => { + this.loadedSubwikis = subwikis; + + return this.wikiOffline.subwikisHaveOfflineData(subwikis).then((hasOffline) => { + this.hasOffline = hasOffline; + }); + }); + } + + /** + * Fetch the page to be shown. + * + * @return {Promise} [description] + */ + protected fetchWikiPage(): Promise { + // Search the current Subwiki. + this.currentSubwiki = this.loadedSubwikis.find((subwiki) => { + return this.isSubwikiSelected(subwiki); + }); + + if (!this.currentSubwiki) { + return Promise.reject(null); + } + + this.setSelectedWiki(this.currentSubwiki.id, this.currentSubwiki.userid, this.currentSubwiki.groupid); + + return this.fetchSubwikiPages(this.currentSubwiki).then(() => { + // Check can edit before to have the value if there's no valid page. + this.canEdit = this.currentSubwiki.canedit; + + return this.fetchPageContents(this.currentPage).then((pageContents) => { + if (pageContents) { + this.dataRetrieved.emit(pageContents.title); + this.setSelectedWiki(pageContents.subwikiid, pageContents.userid, pageContents.groupid); + + this.pageContent = this.replaceEditLinks(pageContents.cachedcontent); + this.canEdit = pageContents.caneditpage; + this.currentPageObj = pageContents; + } + }); + }); + } + + /** + * Get the wiki home view. If cannot determine or it's current view, return undefined. + * + * @return {ViewController} The view controller of the home view + */ + protected getWikiHomeView(): ViewController { + + if (!this.wiki.id) { + return; + } + + const views = this.navCtrl.getViews(); + + // Go back in history until we find a page that doesn't belong to current wiki. + for (let i = views.length - 2; i >= 0; i--) { + const view = views[i]; + + if (view.component.name != 'AddonModWikiIndexPage') { + if (i == views.length - 2) { + // Next view is current view, return undefined. + return; + } + + // This view is no longer from wiki, return the next view. + return views[i + 1]; + } + + // Check that the view belongs to the same wiki as current view. + const wikiId = view.data.wikiId ? view.data.wikiId : view.data.module.instance; + + if (!wikiId || wikiId != this.wiki.id) { + // Wiki has changed, return the next view. + return views[i + 1]; + } + } + } + + /** + * Go back to the initial page of the wiki. + */ + goToWikiHome(): void { + const homeView = this.getWikiHomeView(); + + if (homeView) { + this.navCtrl.popTo(homeView); + } + } + + /** + * Open the view to create the first page of the wiki. + */ + protected goToCreateFirstPage(): void { + this.navCtrl.push('AddonModWikiEditPage', { + module: this.module, + courseId: this.courseId, + pageTitle: this.wiki.firstpagetitle, + wikiId: this.currentSubwiki.wikiid, + userId: this.currentSubwiki.userid, + groupId: this.currentSubwiki.groupid + }); + } + + /** + * Open the view to edit the current page. + */ + goToEditPage(): void { + if (!this.canEdit) { + return; + } + + if (this.currentPageObj) { + // Current page exists, go to edit it. + const pageParams: any = { + module: this.module, + courseId: this.courseId, + pageId: this.currentPageObj.id, + pageTitle: this.currentPageObj.title, + subwikiId: this.currentPageObj.subwikiid + }; + + if (this.currentSubwiki) { + pageParams.wikiId = this.currentSubwiki.wikiid; + pageParams.userId = this.currentSubwiki.userid; + pageParams.groupId = this.currentSubwiki.groupid; + } + + this.navCtrl.push('AddonModWikiEditPage', pageParams); + } else if (this.currentSubwiki) { + // No page loaded, the wiki doesn't have first page. + this.goToCreateFirstPage(); + } + } + + /** + * Go to the view to create a new page. + */ + goToNewPage(): void { + if (!this.canEdit) { + return; + } + + if (this.currentPageObj) { + // Current page exists, go to edit it. + const pageParams: any = { + module: this.module, + courseId: this.courseId, + subwikiId: this.currentPageObj.subwikiid + }; + + if (this.currentSubwiki) { + pageParams.wikiId = this.currentSubwiki.wikiid; + pageParams.userId = this.currentSubwiki.userid; + pageParams.groupId = this.currentSubwiki.groupid; + } + + this.navCtrl.push('AddonModWikiEditPage', pageParams); + } else if (this.currentSubwiki) { + // No page loaded, the wiki doesn't have first page. + this.goToCreateFirstPage(); + } + } + + /** + * Go to view a certain page. + * + * @param {any} page Page to view. + */ + goToPage(page: any): void { + if (!page.id) { + // It's an offline page. Check if we are already in the same offline page. + if (this.currentPage || !this.pageTitle || page.title != this.pageTitle) { + this.navCtrl.push('AddonModWikiIndexPage', { + module: this.module, + courseId: this.courseId, + pageTitle: page.title, + wikiId: this.wiki.id, + subwikiId: page.subwikiid, + action: 'page' + }); + + return; + } + } else if (this.currentPage != page.id) { + // Add a new State. + this.fetchPageContents(page.id).then((page) => { + this.navCtrl.push('AddonModWikiIndexPage', { + module: this.module, + courseId: this.courseId, + pageTitle: page.title, + pageId: page.id, + wikiId: page.wikiid, + subwikiId: page.subwikiid, + action: 'page' + }); + }); + + return; + } + + // No changes done. + this.tabs.selectTab(0); + } + + /** + * Go to the page to view a certain subwiki. + * + * @param {number} subwikiId Subwiki ID. + * @param {number} userId User ID of the subwiki. + * @param {number} groupId Group ID of the subwiki. + * @param {boolean} canEdit Whether the subwiki can be edited. + */ + goToSubwiki(subwikiId: number, userId: number, groupId: number, canEdit: boolean): void { + // Check if the subwiki is disabled. + if (subwikiId > 0 || canEdit) { + if (subwikiId != this.currentSubwiki.id || userId != this.currentSubwiki.userid || + groupId != this.currentSubwiki.groupid) { + + this.navCtrl.push('AddonModWikiIndexPage', { + module: this.module, + courseId: this.courseId, + wikiId: this.wiki.id, + subwikiId: subwikiId, + userId: userId, + groupId: groupId, + action: this.tabs.selected == 0 ? 'page' : 'map' + }); + } + } + } + + /** + * Checks if there is any subwiki selected. + * + * @return {boolean} Whether there is any subwiki selected. + */ + protected isAnySubwikiSelected(): boolean { + return this.subwikiData.subwikiSelected > 0 || this.subwikiData.userSelected > 0 || this.subwikiData.groupSelected > 0; + } + + /** + * Checks if the given subwiki is the one picked on the subwiki picker. + * + * @param {any} subwiki Subwiki to check. + * @return {boolean} Whether it's the selected subwiki. + */ + protected isSubwikiSelected(subwiki: any): boolean { + const subwikiId = parseInt(subwiki.id, 10) || 0; + + if (subwikiId > 0 && this.subwikiData.subwikiSelected > 0) { + return subwikiId == this.subwikiData.subwikiSelected; + } + + const userId = parseInt(subwiki.userid, 10) || 0, + groupId = parseInt(subwiki.groupid, 10) || 0; + + return userId == this.subwikiData.userSelected && groupId == this.subwikiData.groupSelected; + } + + /** + * Replace edit links to have full url. + * + * @param {string} content Content to treat. + * @return {string} Treated content. + */ + protected replaceEditLinks(content: string): string { + content = content.trim(); + + if (content.length > 0) { + const editUrl = this.textUtils.concatenatePaths(this.sitesProvider.getCurrentSite().getURL(), '/mod/wiki/edit.php'); + content = content.replace(/href="edit\.php/g, 'href="' + editUrl); + } + + return content; + } + + /** + * Sets the selected subwiki for the subwiki picker. + * + * @param {number} subwikiId Subwiki ID to select. + * @param {number} userId User ID of the subwiki to select. + * @param {number} groupId Group ID of the subwiki to select. + */ + protected setSelectedWiki(subwikiId: number, userId: number, groupId: number): void { + this.subwikiData.subwikiSelected = this.wikiOffline.convertToPositiveNumber(subwikiId); + this.subwikiData.userSelected = this.wikiOffline.convertToPositiveNumber(userId); + this.subwikiData.groupSelected = this.wikiOffline.convertToPositiveNumber(groupId); + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param {any} result Data returned on the sync function. + * @return {boolean} If suceed or not. + */ + protected hasSyncSucceed(result: any): boolean { + result.wikiId = this.wiki.id; + + if (result.updated) { + // Trigger event. + this.ignoreManualSyncEvent = true; + this.eventsProvider.trigger(AddonModWikiSyncProvider.MANUAL_SYNCED, result); + } + + if (this.currentSubwiki) { + this.checkPageCreatedOrDiscarded(result.subwikis[this.currentSubwiki.id]); + } + + return result.updated; + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + super.ionViewDidEnter(); + + if (this.hasEdited) { + this.hasEdited = false; + this.showLoadingAndRefresh(true, false); + } + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + super.ionViewDidLeave(); + + if (this.navCtrl.getActive().component.name == 'AddonModWikiEditPage') { + this.hasEdited = true; + } + } + + /** + * Perform the invalidate content function. + * + * @return {Promise} Resolved when done. + */ + protected invalidateContent(): Promise { + const promises = []; + + promises.push(this.wikiProvider.invalidateWikiData(this.courseId)); + + if (this.wiki) { + promises.push(this.wikiProvider.invalidateSubwikis(this.wiki.id)); + promises.push(this.groupsProvider.invalidateActivityAllowedGroups(this.wiki.coursemodule)); + promises.push(this.groupsProvider.invalidateActivityGroupMode(this.wiki.coursemodule)); + } + + if (this.currentSubwiki) { + promises.push(this.wikiProvider.invalidateSubwikiPages(this.currentSubwiki.wikiid)); + promises.push(this.wikiProvider.invalidateSubwikiFiles(this.currentSubwiki.wikiid)); + } + + if (this.currentPage) { + promises.push(this.wikiProvider.invalidatePage(this.currentPage)); + } + + return Promise.all(promises); + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param {any} syncEventData Data receiven on sync observer. + * @return {boolean} True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: any): boolean { + if (this.currentSubwiki && syncEventData.subwikiId == this.currentSubwiki.id && + syncEventData.wikiId == this.currentSubwiki.wikiid && syncEventData.userId == this.currentSubwiki.userid && + syncEventData.groupId == this.currentSubwiki.groupid) { + + if (this.isCurrentView && syncEventData.warnings && syncEventData.warnings.length) { + // Show warnings. + this.domUtils.showErrorModal(syncEventData.warnings[0]); + } + + // Check if current page was created or discarded. + this.checkPageCreatedOrDiscarded(syncEventData); + } + + return !this.pageWarning; + } + + /** + * Show the TOC. + * + * @param {MouseEvent} event Event. + */ + showSubwikiPicker(event: MouseEvent): void { + const popover = this.popoverCtrl.create(AddonModWikiSubwikiPickerComponent, { + subwikis: this.subwikiData.subwikis, + currentSubwiki: this.currentSubwiki + }); + + popover.onDidDismiss((subwiki) => { + this.goToSubwiki(subwiki.id, subwiki.userid, subwiki.groupid, subwiki.canedit); + }); + + popover.present({ + ev: event + }); + } + + /** + * Performs the sync of the activity. + * + * @return {Promise} Promise resolved when done. + */ + protected sync(): Promise { + return this.wikiSync.syncWiki(this.wiki.id, this.courseId, this.wiki.coursemodule); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + + this.manualSyncObserver && this.manualSyncObserver.off(); + this.newPageObserver && this.newPageObserver.off(); + } + + /** + * Create the subwiki list for the selector and store it in the cache. + * + * @param {any[]} userGroups Groups. + * @return {Promise} Promise resolved when done. + */ + protected createSubwikiList(userGroups: any[]): Promise { + const subwikiList = [], + promises = []; + let userGroupsIds = [], + allParticipants = false, + showMyGroupsLabel = false, + multiLevelList = false; + + this.subwikiData.subwikis = []; + this.setSelectedWiki(this.subwikiId, this.userId, this.groupId); + this.subwikiData.count = 0; + + // Group mode available. + if (userGroups.length > 0) { + userGroupsIds = userGroups.map((g) => { + return g.id; + }); + } + + // Add the subwikis to the subwikiList. + this.loadedSubwikis.forEach((subwiki) => { + const groupId = parseInt(subwiki.groupid, 10), + userId = parseInt(subwiki.userid, 10); + let groupLabel = ''; + + if (groupId == 0 && userId == 0) { + // Add 'All participants' subwiki if needed at the start. + if (!allParticipants) { + subwikiList.unshift({ + name: this.translate.instant('core.allparticipants'), + id: subwiki.id, + userid: userId, + groupid: groupId, + groupLabel: '', + canedit: subwiki.canedit + }); + allParticipants = true; + } + } else { + if (groupId != 0 && userGroupsIds.length > 0) { + // Get groupLabel if it has groupId. + const groupIdPosition = userGroupsIds.indexOf(groupId); + if (groupIdPosition > -1) { + groupLabel = userGroups[groupIdPosition].name; + } + } else { + groupLabel = this.translate.instant('addon.mod_wiki.notingroup'); + } + + if (userId != 0) { + // Get user if it has userId. + promises.push(this.userProvider.getProfile(userId, this.courseId, true).then((user) => { + subwikiList.push({ + name: user.fullname, + id: subwiki.id, + userid: userId, + groupid: groupId, + groupLabel: groupLabel, + canedit: subwiki.canedit + }); + + })); + + if (!multiLevelList && groupId != 0) { + multiLevelList = true; + } + } else { + subwikiList.push({ + name: groupLabel, + id: subwiki.id, + userid: userId, + groupid: groupId, + groupLabel: groupLabel, + canedit: subwiki.canedit + }); + showMyGroupsLabel = true; + } + } + }); + + return Promise.all(promises).then(() => { + this.fillSubwikiData(subwikiList, showMyGroupsLabel, multiLevelList); + }); + } + + /** + * Fill the subwiki data. + * + * @param {any[]} subwikiList List of subwikis. + * @param {boolean} showMyGroupsLabel Whether subwikis should be grouped in "My groups" and "Other groups". + * @param {boolean} multiLevelList Whether it's a multi level list. + */ + protected fillSubwikiData(subwikiList: any[], showMyGroupsLabel: boolean, multiLevelList: boolean): void { + let groupValue = -1, + grouping; + + subwikiList.sort((a, b) => { + return a.groupid - b.groupid; + }); + + this.subwikiData.count = subwikiList.length; + + // If no subwiki is received as view param, select always the most appropiate. + if ((!this.subwikiId || (!this.userId && !this.groupId)) && !this.isAnySubwikiSelected() && subwikiList.length > 0) { + let firstCanEdit, + candidateNoFirstPage, + candidateFirstPage; + + for (const i in subwikiList) { + const subwiki = subwikiList[i]; + + if (subwiki.canedit) { + let candidateSubwikiId; + if (subwiki.userid > 0) { + // Check if it's the current user. + if (this.currentUserId == subwiki.userid) { + candidateSubwikiId = subwiki.id; + } + } else if (subwiki.groupid > 0) { + // Check if it's a current user' group. + if (showMyGroupsLabel) { + candidateSubwikiId = subwiki.id; + } + } else if (subwiki.id > 0) { + candidateSubwikiId = subwiki.id; + } + + if (typeof candidateSubwikiId != 'undefined') { + if (candidateSubwikiId > 0) { + // Subwiki found and created, no need to keep looking. + candidateFirstPage = i; + break; + } else if (typeof candidateNoFirstPage == 'undefined') { + candidateNoFirstPage = i; + } + } else if (typeof firstCanEdit == 'undefined') { + firstCanEdit = i; + } + } + } + + let subWikiToTake; + if (typeof candidateFirstPage != 'undefined') { + // Take the candidate that already has the first page created. + subWikiToTake = candidateFirstPage; + } else if (typeof candidateNoFirstPage != 'undefined') { + // No first page created, take the first candidate. + subWikiToTake = candidateNoFirstPage; + } else if (typeof firstCanEdit != 'undefined') { + // None selected, take the first the user can edit. + subWikiToTake = firstCanEdit; + } else { + // Otherwise take the very first. + subWikiToTake = 0; + } + + const subwiki = subwikiList[subWikiToTake]; + if (typeof subwiki != 'undefined') { + this.setSelectedWiki(subwiki.id, subwiki.userid, subwiki.groupid); + } + } + + if (multiLevelList) { + // As we loop over each subwiki, add it to the current group + for (const i in subwikiList) { + const subwiki = subwikiList[i]; + + // Should we create a new grouping? + if (subwiki.groupid !== groupValue) { + grouping = {label: subwiki.groupLabel, subwikis: []}; + groupValue = subwiki.groupid; + + this.subwikiData.subwikis.push(grouping); + } + + // Add the subwiki to the currently active grouping. + grouping.subwikis.push(subwiki); + } + } else if (showMyGroupsLabel) { + const noGrouping = {label: '', subwikis: []}, + myGroupsGrouping = {label: this.translate.instant('core.mygroups'), subwikis: []}, + otherGroupsGrouping = {label: this.translate.instant('core.othergroups'), subwikis: []}; + + // As we loop over each subwiki, add it to the current group + for (const i in subwikiList) { + const subwiki = subwikiList[i]; + + // Add the subwiki to the currently active grouping. + if (typeof subwiki.canedit == 'undefined') { + noGrouping.subwikis.push(subwiki); + } else if (subwiki.canedit) { + myGroupsGrouping.subwikis.push(subwiki); + } else { + otherGroupsGrouping.subwikis.push(subwiki); + } + } + + // Add each grouping to the subwikis + if (noGrouping.subwikis.length > 0) { + this.subwikiData.subwikis.push(noGrouping); + } + if (myGroupsGrouping.subwikis.length > 0) { + this.subwikiData.subwikis.push(myGroupsGrouping); + } + if (otherGroupsGrouping.subwikis.length > 0) { + this.subwikiData.subwikis.push(otherGroupsGrouping); + } + } else { + this.subwikiData.subwikis.push({label: '', subwikis: subwikiList}); + } + + this.wikiProvider.setSubwikiList(this.wiki.id, this.subwikiData.subwikis, this.subwikiData.count, + this.subwikiData.subwikiSelected, this.subwikiData.userSelected, this.subwikiData.groupSelected); + } +} diff --git a/src/addon/mod/wiki/components/subwiki-picker/subwiki-picker.html b/src/addon/mod/wiki/components/subwiki-picker/subwiki-picker.html new file mode 100644 index 000000000..1cb1c3c43 --- /dev/null +++ b/src/addon/mod/wiki/components/subwiki-picker/subwiki-picker.html @@ -0,0 +1,11 @@ + + + + {{ group.label }} + + + {{ subwiki.name }} + + + + diff --git a/src/addon/mod/wiki/components/subwiki-picker/subwiki-picker.scss b/src/addon/mod/wiki/components/subwiki-picker/subwiki-picker.scss new file mode 100644 index 000000000..d33abf979 --- /dev/null +++ b/src/addon/mod/wiki/components/subwiki-picker/subwiki-picker.scss @@ -0,0 +1,14 @@ +addon-mod-wiki-subwiki-picker { + + .item-divider, .item-divider .label { + font-weight: bold; + } + + .item.addon-mod_wiki-subwiki-selected { + background-color: $gray-light; + + .icon { + font-size: 24px; + } + } +} diff --git a/src/addon/mod/wiki/components/subwiki-picker/subwiki-picker.ts b/src/addon/mod/wiki/components/subwiki-picker/subwiki-picker.ts new file mode 100644 index 000000000..31a6978e6 --- /dev/null +++ b/src/addon/mod/wiki/components/subwiki-picker/subwiki-picker.ts @@ -0,0 +1,67 @@ +// (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 { Component } from '@angular/core'; +import { NavParams, ViewController } from 'ionic-angular'; + +/** + * Component to display the a list of subwikis in a wiki. + */ +@Component({ + selector: 'addon-mod-wiki-subwiki-picker', + templateUrl: 'subwiki-picker.html' +}) +export class AddonModWikiSubwikiPickerComponent { + subwikis: any[]; + currentSubwiki: any; + + constructor(navParams: NavParams, private viewCtrl: ViewController) { + this.subwikis = navParams.get('subwikis'); + this.currentSubwiki = navParams.get('currentSubwiki'); + } + + /** + * Checks if the given subwiki is the one currently selected. + * + * @param {any} subwiki Subwiki to check. + * @return {boolean} Whether it's the selected subwiki. + */ + protected isSubwikiSelected(subwiki: any): boolean { + const subwikiId = parseInt(subwiki.id, 10) || 0; + + if (subwikiId > 0 && this.currentSubwiki.id > 0) { + return subwikiId == this.currentSubwiki.id; + } + + const userId = parseInt(subwiki.userid, 10) || 0, + groupId = parseInt(subwiki.groupid, 10) || 0; + + return userId == this.currentSubwiki.userid && groupId == this.currentSubwiki.groupid; + } + + /** + * Function called when a subwiki is clicked. + * + * @param {any} subwiki The subwiki to open. + */ + openSubwiki(subwiki: any): void { + // Check if the subwiki is disabled. + if (subwiki.id > 0 || subwiki.canedit) { + // Check if it isn't current subwiki. + if (subwiki != this.currentSubwiki) { + this.viewCtrl.dismiss(subwiki); + } + } + } +} diff --git a/src/addon/mod/wiki/pages/index/index.html b/src/addon/mod/wiki/pages/index/index.html new file mode 100644 index 000000000..59c61a28d --- /dev/null +++ b/src/addon/mod/wiki/pages/index/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/addon/mod/wiki/pages/index/index.module.ts b/src/addon/mod/wiki/pages/index/index.module.ts new file mode 100644 index 000000000..190bf0fb7 --- /dev/null +++ b/src/addon/mod/wiki/pages/index/index.module.ts @@ -0,0 +1,33 @@ +// (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 { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModWikiComponentsModule } from '../../components/components.module'; +import { AddonModWikiIndexPage } from './index'; + +@NgModule({ + declarations: [ + AddonModWikiIndexPage, + ], + imports: [ + CoreDirectivesModule, + AddonModWikiComponentsModule, + IonicPageModule.forChild(AddonModWikiIndexPage), + TranslateModule.forChild() + ], +}) +export class AddonModWikiIndexPageModule {} diff --git a/src/addon/mod/wiki/pages/index/index.ts b/src/addon/mod/wiki/pages/index/index.ts new file mode 100644 index 000000000..8acca3028 --- /dev/null +++ b/src/addon/mod/wiki/pages/index/index.ts @@ -0,0 +1,83 @@ +// (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 { Component, ViewChild } from '@angular/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { AddonModWikiIndexComponent } from '../../components/index/index'; + +/** + * Page that displays a wiki page. + */ +@IonicPage({ segment: 'addon-mod-wiki-index' }) +@Component({ + selector: 'page-addon-mod-wiki-index', + templateUrl: 'index.html', +}) +export class AddonModWikiIndexPage { + @ViewChild(AddonModWikiIndexComponent) wikiComponent: AddonModWikiIndexComponent; + + title: string; + module: any; + courseId: number; + action: string; + pageId: number; + pageTitle: string; + wikiId: number; + subwikiId: number; + userId: number; + groupId: number; + + constructor(navParams: NavParams) { + this.module = navParams.get('module') || {}; + this.courseId = navParams.get('courseId'); + this.action = navParams.get('action') || 'page'; + this.pageId = navParams.get('pageId'); + this.pageTitle = navParams.get('pageTitle'); + this.wikiId = navParams.get('wikiId'); + this.subwikiId = navParams.get('subwikiId'); + this.userId = navParams.get('userId'); + this.groupId = navParams.get('groupId'); + + this.title = this.pageTitle || this.module.name; + } + + /** + * Update some data based on the data received. + * + * @param {any} data The data received. + */ + updateData(data: any): void { + if (typeof data == 'string') { + // We received the title to display. + this.title = data; + } else { + // We received a wiki instance. + this.title = this.pageTitle || data.title || this.title; + } + } + + /** + * User entered the page. + */ + ionViewDidEnter(): void { + this.wikiComponent.ionViewDidEnter(); + } + + /** + * User left the page. + */ + ionViewDidLeave(): void { + this.wikiComponent.ionViewDidLeave(); + } +} diff --git a/src/addon/mod/wiki/providers/wiki-sync.ts b/src/addon/mod/wiki/providers/wiki-sync.ts index ec6bc79e2..186c75ac5 100644 --- a/src/addon/mod/wiki/providers/wiki-sync.ts +++ b/src/addon/mod/wiki/providers/wiki-sync.ts @@ -95,6 +95,7 @@ export interface AddonModWikiSyncWikiResult { export class AddonModWikiSyncProvider extends CoreSyncBaseProvider { static AUTO_SYNCED = 'addon_mod_wiki_autom_synced'; + static MANUAL_SYNCED = 'addon_mod_wiki_manual_synced'; static SYNC_TIME = 300000; protected componentTranslate: string; diff --git a/src/addon/mod/wiki/providers/wiki.ts b/src/addon/mod/wiki/providers/wiki.ts index 030085614..fd46d3474 100644 --- a/src/addon/mod/wiki/providers/wiki.ts +++ b/src/addon/mod/wiki/providers/wiki.ts @@ -60,6 +60,7 @@ export interface AddonModWikiSubwikiListData { @Injectable() export class AddonModWikiProvider { static COMPONENT = 'mmaModWiki'; + static PAGE_CREATED_EVENT = 'addon_mod_wiki_page_created'; protected ROOT_CACHE_KEY = 'mmaModWiki:'; protected logger; diff --git a/src/addon/mod/wiki/wiki.module.ts b/src/addon/mod/wiki/wiki.module.ts index b8eb3b00c..ed21a0d9d 100644 --- a/src/addon/mod/wiki/wiki.module.ts +++ b/src/addon/mod/wiki/wiki.module.ts @@ -16,6 +16,7 @@ import { NgModule } from '@angular/core'; import { CoreCronDelegate } from '@providers/cron'; import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; +import { AddonModWikiComponentsModule } from './components/components.module'; import { AddonModWikiProvider } from './providers/wiki'; import { AddonModWikiOfflineProvider } from './providers/wiki-offline'; import { AddonModWikiSyncProvider } from './providers/wiki-sync'; @@ -27,6 +28,7 @@ import { AddonModWikiSyncCronHandler } from './providers/sync-cron-handler'; declarations: [ ], imports: [ + AddonModWikiComponentsModule ], providers: [ AddonModWikiProvider, diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 7c565ae77..f8449f5ee 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -93,6 +93,7 @@ import { AddonModScormModule } from '@addon/mod/scorm/scorm.module'; import { AddonModUrlModule } from '@addon/mod/url/url.module'; import { AddonModSurveyModule } from '@addon/mod/survey/survey.module'; import { AddonModImscpModule } from '@addon/mod/imscp/imscp.module'; +import { AddonModWikiModule } from '@addon/mod/wiki/wiki.module'; import { AddonMessageOutputModule } from '@addon/messageoutput/messageoutput.module'; import { AddonMessageOutputAirnotifierModule } from '@addon/messageoutput/airnotifier/airnotifier.module'; import { AddonMessagesModule } from '@addon/messages/messages.module'; @@ -195,6 +196,7 @@ export const CORE_PROVIDERS: any[] = [ AddonModUrlModule, AddonModSurveyModule, AddonModImscpModule, + AddonModWikiModule, AddonMessageOutputModule, AddonMessageOutputAirnotifierModule, AddonMessagesModule, diff --git a/src/core/course/classes/main-activity-component.ts b/src/core/course/classes/main-activity-component.ts index d0d8489f0..5a76726cf 100644 --- a/src/core/course/classes/main-activity-component.ts +++ b/src/core/course/classes/main-activity-component.ts @@ -127,6 +127,25 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR }); } + /** + * Show loading and perform the load content function. + * + * @param {boolean} [sync=false] If the fetch needs syncing. + * @param {boolean} [showErrors=false] Wether to show errors to the user or hide them. + * @return {Promise} Resolved when done. + */ + protected showLoadingAndFetch(sync: boolean = false, showErrors: boolean = false): Promise { + this.refreshIcon = 'spinner'; + this.syncIcon = 'spinner'; + this.loaded = false; + this.content && this.content.scrollToTop(); + + return this.loadContent(false, sync, showErrors).finally(() => { + this.refreshIcon = 'refresh'; + this.syncIcon = 'sync'; + }); + } + /** * Show loading and perform the refresh content function. * From ccad29bb985266f40e8f3e02d06c0486c9cf01e2 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 25 May 2018 08:54:55 +0200 Subject: [PATCH 06/11] MOBILE-2353 wiki: Implement edit page --- src/addon/mod/assign/pages/edit/edit.ts | 2 +- src/addon/mod/wiki/pages/edit/edit.html | 27 + src/addon/mod/wiki/pages/edit/edit.module.ts | 33 ++ src/addon/mod/wiki/pages/edit/edit.ts | 538 ++++++++++++++++++ src/addon/mod/wiki/providers/wiki.ts | 1 + .../rich-text-editor/rich-text-editor.ts | 29 +- 6 files changed, 623 insertions(+), 7 deletions(-) create mode 100644 src/addon/mod/wiki/pages/edit/edit.html create mode 100644 src/addon/mod/wiki/pages/edit/edit.module.ts create mode 100644 src/addon/mod/wiki/pages/edit/edit.ts diff --git a/src/addon/mod/assign/pages/edit/edit.ts b/src/addon/mod/assign/pages/edit/edit.ts index a3513e908..21ca6e70a 100644 --- a/src/addon/mod/assign/pages/edit/edit.ts +++ b/src/addon/mod/assign/pages/edit/edit.ts @@ -329,7 +329,7 @@ export class AddonModAssignEditPage implements OnInit, OnDestroy { * Component being destroyed. */ ngOnDestroy(): void { - this.isDestroyed = false; + this.isDestroyed = true; // Unblock the assignment. if (this.assign) { diff --git a/src/addon/mod/wiki/pages/edit/edit.html b/src/addon/mod/wiki/pages/edit/edit.html new file mode 100644 index 000000000..e41b3541c --- /dev/null +++ b/src/addon/mod/wiki/pages/edit/edit.html @@ -0,0 +1,27 @@ + + + + + + + + + + + +
+ + + + + + + + + + {{ 'addon.mod_wiki.wrongversionlock' | translate }} +
+
+
diff --git a/src/addon/mod/wiki/pages/edit/edit.module.ts b/src/addon/mod/wiki/pages/edit/edit.module.ts new file mode 100644 index 000000000..b24ab2f09 --- /dev/null +++ b/src/addon/mod/wiki/pages/edit/edit.module.ts @@ -0,0 +1,33 @@ +// (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 { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModWikiEditPage } from './edit'; + +@NgModule({ + declarations: [ + AddonModWikiEditPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(AddonModWikiEditPage), + TranslateModule.forChild() + ], +}) +export class AddonModWikiEditPageModule {} diff --git a/src/addon/mod/wiki/pages/edit/edit.ts b/src/addon/mod/wiki/pages/edit/edit.ts new file mode 100644 index 000000000..fef89d36b --- /dev/null +++ b/src/addon/mod/wiki/pages/edit/edit.ts @@ -0,0 +1,538 @@ +// (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 { Component, OnInit, OnDestroy } from '@angular/core'; +import { IonicPage, NavController, NavParams } from 'ionic-angular'; +import { FormControl, FormGroup, FormBuilder } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { AddonModWikiProvider } from '../../providers/wiki'; +import { AddonModWikiOfflineProvider } from '../../providers/wiki-offline'; +import { AddonModWikiSyncProvider } from '../../providers/wiki-sync'; + +/** + * Page that allows adding or editing a wiki page. + */ +@IonicPage({ segment: 'addon-mod-wiki-edit' }) +@Component({ + selector: 'page-addon-mod-wiki-edit', + templateUrl: 'edit.html', +}) +export class AddonModWikiEditPage implements OnInit, OnDestroy { + + title: string; // Title to display. + pageForm: FormGroup; // The form group. + contentControl: FormControl; // The FormControl for the page content. + canEditTitle: boolean; // Whether title can be edited. + loaded: boolean; // Whether the data has been loaded. + component = AddonModWikiProvider.COMPONENT; // Component to link the files to. + componentId: number; // Component ID to link the files to. + wrongVersionLock: boolean; // Whether the page lock doesn't match the initial one. + + protected module: any; // Wiki module instance. + protected courseId: number; // Course the wiki belongs to. + protected subwikiId: number; // Subwiki ID the page belongs to. + protected initialSubwikiId: number; // Same as subwikiId, but it won't be updated, it'll always be the value received. + protected wikiId: number; // Wiki ID the page belongs to. + protected pageId: number; // The page ID (if editing a page). + protected section: string; // The section being edited. + protected groupId: number; // The group the subwiki belongs to. + protected userId: number; // The user the subwiki belongs to. + protected blockId: string; // ID to block the subwiki. + protected editing: boolean; // Whether the user is editing a page (true) or creating a new one (false). + protected editOffline: boolean; // Whether the user is editing an offline page. + protected rteEnabled: boolean; // Whether rich text editor is enabled. + protected subwikiFiles: any[]; // List of files of the subwiki. + protected originalContent: string; // The original page content. + protected version: number; // Page version. + protected renewLockInterval: any; // An interval to renew the lock every certain time. + protected forceLeave = false; // To allow leaving the page without checking for changes. + protected isDestroyed = false; // Whether the page has been destroyed. + protected pageParamsToLoad: any; // Params of the page to load when this page is closed. + + constructor(navParams: NavParams, fb: FormBuilder, protected navCtrl: NavController, protected sitesProvider: CoreSitesProvider, + protected syncProvider: CoreSyncProvider, protected domUtils: CoreDomUtilsProvider, + protected translate: TranslateService, protected courseProvider: CoreCourseProvider, + protected eventsProvider: CoreEventsProvider, protected wikiProvider: AddonModWikiProvider, + protected wikiOffline: AddonModWikiOfflineProvider, protected wikiSync: AddonModWikiSyncProvider, + protected textUtils: CoreTextUtilsProvider, protected courseHelper: CoreCourseHelperProvider) { + + this.module = navParams.get('module') || {}; + this.courseId = navParams.get('courseId'); + this.subwikiId = navParams.get('subwikiId'); + this.wikiId = navParams.get('wikiId'); + this.pageId = navParams.get('pageId'); + this.section = navParams.get('section'); + this.groupId = navParams.get('groupId'); + this.userId = navParams.get('userId'); + + let pageTitle = navParams.get('pageTitle'); + pageTitle = pageTitle ? pageTitle.replace(/\+/g, ' ') : ''; + + this.initialSubwikiId = this.subwikiId; + this.componentId = this.module.id; + this.canEditTitle = !pageTitle; + this.title = pageTitle ? this.translate.instant('addon.mod_wiki.editingpage', {$a: pageTitle}) : + this.translate.instant('addon.mod_wiki.newpagehdr'); + this.blockId = this.wikiSync.getSubwikiBlockId(this.subwikiId, this.wikiId, this.userId, this.groupId); + + // Create the form group and its controls. + this.contentControl = fb.control(''); + this.pageForm = fb.group({ + title: pageTitle + }); + this.pageForm.addControl('text', this.contentControl); + + // Block the wiki so it cannot be synced. + this.syncProvider.blockOperation(this.component, this.blockId); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.fetchWikiPageData().then((success) => { + if (success && this.blockId && !this.isDestroyed) { + // Block the subwiki now that we have blockId for sure. + const newBlockId = this.wikiSync.getSubwikiBlockId(this.subwikiId, this.wikiId, this.userId, this.groupId); + if (newBlockId != this.blockId) { + this.syncProvider.unblockOperation(this.component, this.blockId); + this.blockId = newBlockId; + this.syncProvider.blockOperation(this.component, this.blockId); + } + } + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Convenience function to get wiki page data. + * + * @return {Promise} Promise resolved with boolean: whether it was successful. + */ + protected fetchWikiPageData(): Promise { + let promise, + canEdit = false; + + if (this.pageId) { + // Editing a page that already exists. + this.canEditTitle = false; + this.editing = true; + this.editOffline = false; // Cannot edit pages in offline. + + // Get page contents to obtain title and editing permission + promise = this.wikiProvider.getPageContents(this.pageId).then((pageContents) => { + this.pageForm.controls.title.setValue(pageContents.title); // Set the title in the form group. + this.wikiId = pageContents.wikiid; + this.subwikiId = pageContents.subwikiid; + this.title = this.translate.instant('addon.mod_wiki.editingpage', {$a: pageContents.title}); + this.groupId = pageContents.groupid; + this.userId = pageContents.userid; + canEdit = pageContents.caneditpage; + + // Wait for sync to be over (if any). + return this.wikiSync.waitForSync(this.blockId); + }).then(() => { + // Check if rich text editor is enabled. + return this.domUtils.isRichTextEditorEnabled(); + }).then((enabled) => { + this.rteEnabled = enabled; + + if (enabled) { + // Get subwiki files, needed to replace URLs for rich text editor. + return this.wikiProvider.getSubwikiFiles(this.wikiId, this.groupId, this.userId); + } + }).then((files) => { + this.subwikiFiles = files; + + // Get editable text of the page/section. + return this.wikiProvider.getPageForEditing(this.pageId, this.section); + }).then((editContents) => { + // Get the original page contents, treating file URLs if needed. + const content = this.rteEnabled ? this.textUtils.replacePluginfileUrls(editContents.content, this.subwikiFiles) : + editContents.content; + + this.contentControl.setValue(content); + this.originalContent = content; + this.version = editContents.version; + + if (canEdit) { + // Renew the lock every certain time. + this.renewLockInterval = setInterval(() => { + this.renewLock(); + }, AddonModWikiProvider.RENEW_LOCK_TIME); + } + }); + } else { + // New page. Wait for sync to be over (if any). + promise = this.wikiSync.waitForSync(this.blockId); + + if (this.contentControl.value) { + // Check if there's already some offline data for this page. + promise = promise.then(() => { + return this.wikiOffline.getNewPage(this.pageForm.controls.title.value, this.subwikiId, this.wikiId, + this.userId, this.groupId); + }).then((page) => { + // Load offline content. + this.contentControl.setValue(page.cachedcontent); + this.originalContent = page.cachedcontent; + this.editOffline = true; + }).catch(() => { + // No offline data found. + this.editOffline = false; + }); + } else { + this.editOffline = false; + } + + promise.then(() => { + this.editing = false; + canEdit = !!this.blockId; // If no blockId, the user cannot edit the page. + }); + } + + return promise.then(() => { + return true; + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting wiki data.'); + + // Go back. + this.forceLeavePage(); + + return false; + }).finally(() => { + if (!canEdit) { + // Cannot edit, show alert and go back. + this.domUtils.showAlert(this.translate.instant('core.notice'), + this.translate.instant('addon.mod_wiki.cannoteditpage')); + this.forceLeavePage(); + } + }); + } + + /** + * Force leaving the page, without checking for changes. + */ + protected forceLeavePage(): void { + this.forceLeave = true; + this.navCtrl.pop(); + } + + /** + * Navigate to a new offline page. + * + * @param {string} title Page title. + */ + protected goToNewOfflinePage(title: string): void { + if (this.courseId && (this.module.id || this.wikiId)) { + // We have enough data to navigate to the page. + if (!this.editOffline || this.previousViewPageIsDifferentOffline(title)) { + this.pageParamsToLoad = { + module: this.module, + courseId: this.courseId, + pageId: null, + pageTitle: title, + wikiId: this.wikiId, + subwikiId: this.subwikiId, + userId: this.userId, + groupId: this.groupId + }; + } + } else { + this.domUtils.showAlert(this.translate.instant('core.success'), this.translate.instant('core.datastoredoffline')); + } + + this.forceLeavePage(); + } + + /** + * Check if we need to navigate to a new state. + * + * @param {string} title Page title. + * @return {Promise} Promise resolved when done. + */ + protected gotoPage(title: string): Promise { + return this.retrieveModuleInfo(this.wikiId).then(() => { + let openPage = false; + + // Not the firstpage. + if (this.initialSubwikiId) { + if (!this.editing && this.editOffline && this.previousViewPageIsDifferentOffline(title)) { + // The user submitted an offline page that isn't loaded in the back view, open it. + openPage = true; + } else if (!this.editOffline && this.previousViewIsDifferentPageOnline()) { + // The user submitted an offline page that isn't loaded in the back view, open it. + openPage = true; + } + } + + if (openPage) { + // Setting that will do the app navigate to the page. + this.pageParamsToLoad = { + module: this.module, + courseId: this.courseId, + pageId: this.pageId, + pageTitle: title, + wikiId: this.wikiId, + subwikiId: this.subwikiId, + userId: this.userId, + groupId: this.groupId + }; + } + + this.forceLeavePage(); + }).catch(() => { + // Go back if it fails. + this.forceLeavePage(); + }); + } + + /** + * Check if data has changed. + * + * @return {boolean} Whether data has changed. + */ + protected hasDataChanged(): boolean { + const values = this.pageForm.value; + + return !(this.originalContent == values.text || (!this.editing && !values.text && !values.title)); + } + + /** + * Check if we can leave the page or not. + * + * @return {boolean|Promise} Resolved if we can leave it, rejected if not. + */ + ionViewCanLeave(): boolean | Promise { + if (this.forceLeave) { + return true; + } + + // Check if data has changed. + if (this.hasDataChanged()) { + return this.domUtils.showConfirm(this.translate.instant('core.confirmcanceledit')); + } + + return true; + } + + /** + * View left. + */ + ionViewDidLeave(): void { + if (this.pageParamsToLoad) { + // Go to the page we've just created/edited. + this.navCtrl.push('AddonModWikiIndexPage', this.pageParamsToLoad); + } + } + + /** + * In case we are NOT editing an offline page, check if the page loaded in previous view is different than this view. + * + * @return {boolean} Whether previous view wiki page is different than current page. + */ + protected previousViewIsDifferentPageOnline(): boolean { + // We cannot precisely detect when the state is the same but this is close to it. + const previousView = this.navCtrl.getPrevious(); + + return !this.editing || previousView.component.name != 'AddonModWikiIndexPage' || + previousView.data.module.id != this.module.id || previousView.data.pageId != this.pageId; + } + + /** + * In case we're editing an offline page, check if the page loaded in previous view is different than this view. + * + * @param {string} title The current page title. + * @return {boolean} Whether previous view wiki page is different than current page. + */ + protected previousViewPageIsDifferentOffline(title: string): boolean { + // We cannot precisely detect when the state is the same but this is close to it. + const previousView = this.navCtrl.getPrevious(); + + if (previousView.component.name != 'AddonModWikiIndexPage' || previousView.data.module.id != this.module.id || + previousView.data.wikiId != this.wikiId || previousView.data.pageTitle != title) { + return true; + } + + // Check subwiki using subwiki or user and group. + const previousSubwikiId = parseInt(previousView.data.subwikiId, 10) || 0; + if (previousSubwikiId > 0 && this.subwikiId > 0) { + return previousSubwikiId != this.subwikiId; + } + + const previousUserId = parseInt(previousView.data.userId, 10) || 0, + previousGroupId = parseInt(previousView.data.groupId, 10) || 0; + + return this.userId != previousUserId || this.groupId != previousGroupId; + } + + /** + * Save the data. + */ + save(): void { + const values = this.pageForm.value, + title = values.title, + modal = this.domUtils.showModalLoading('core.sending', true); + let promise, + text = values.text; + + if (this.rteEnabled) { + text = this.textUtils.restorePluginfileUrls(text, this.subwikiFiles); + } else { + text = this.textUtils.formatHtmlLines(text); + } + + if (this.editing) { + // Edit existing page. + promise = this.wikiProvider.editPage(this.pageId, text, this.section).then(() => { + // Invalidate page since it changed. + return this.wikiProvider.invalidatePage(this.pageId).then(() => { + return this.gotoPage(title); + }); + }); + } else { + // Creating a new page. + if (!title) { + // Title is mandatory, stop. + this.domUtils.showAlert(this.translate.instant('core.notice'), + this.translate.instant('addon.mod_wiki.titleshouldnotbeempty')); + modal.dismiss(); + + return; + } + + if (!this.editOffline) { + // Check if the user has an offline page with the same title. + promise = this.wikiOffline.getNewPage(title, this.subwikiId, this.wikiId, this.userId, this.groupId).then(() => { + // There's a page with same name, reject with error message. + return Promise.reject(this.translate.instant('addon.mod_wiki.pageexists')); + }, () => { + // Not found, page can be sent. + }); + } else { + promise = Promise.resolve(); + } + + promise = promise.then(() => { + // Try to send the page. + let wikiId = this.wikiId || (this.module && this.module.instance); + + return this.wikiProvider.newPage(title, text, this.subwikiId, wikiId, this.userId, this.groupId).then((id) => { + if (id > 0) { + // Page was created, get its data and go to the page. + this.pageId = id; + + return this.wikiProvider.getPageContents(this.pageId).then((pageContents) => { + const promises = []; + + wikiId = parseInt(pageContents.wikiid, 10); + if (!this.subwikiId) { + // Subwiki was not created, invalidate subwikis as well. + promises.push(this.wikiProvider.invalidateSubwikis(wikiId)); + } + + this.subwikiId = parseInt(pageContents.subwikiid, 10); + this.userId = parseInt(pageContents.userid, 10); + this.groupId = parseInt(pageContents.groupid, 10); + + // Invalidate subwiki pages since there are new. + promises.push(this.wikiProvider.invalidateSubwikiPages(wikiId)); + + return Promise.all(promises).then(() => { + return this.gotoPage(title); + }); + }).finally(() => { + // Notify page created. + this.eventsProvider.trigger(AddonModWikiProvider.PAGE_CREATED_EVENT, { + pageId: this.pageId, + subwikiId: this.subwikiId, + pageTitle: title, + siteId: this.sitesProvider.getCurrentSiteId() + }); + }); + } else { + // Page stored in offline. Go to see the offline page. + this.goToNewOfflinePage(title); + } + }); + }); + } + + return promise.catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error saving wiki data.'); + }).finally(() => { + modal.dismiss(); + }); + } + + /** + * Renew lock and control versions. + */ + protected renewLock(): void { + this.wikiProvider.getPageForEditing(this.pageId, this.section, true).then((response) => { + if (response.version && this.version != response.version) { + this.wrongVersionLock = true; + } + }); + } + + /** + * Fetch module information to redirect when needed. + * + * @param {number} wikiId Wiki ID. + * @return {Promise} Promise resolved when done. + */ + protected retrieveModuleInfo(wikiId: number): Promise { + if (this.module.id && this.courseId) { + // We have enough data. + return Promise.resolve(); + } + + const promise = this.module.id ? Promise.resolve(this.module) : + this.courseProvider.getModuleBasicInfoByInstance(wikiId, 'wiki'); + + return promise.then((mod) => { + this.module = mod; + this.componentId = this.module.id; + + if (!this.courseId && this.module.course) { + this.courseId = this.module.course; + } else if (!this.courseId) { + return this.courseHelper.getModuleCourseIdByInstance(wikiId, 'wiki').then((course) => { + this.courseId = course; + }); + } + }); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.isDestroyed = true; + clearInterval(this.renewLockInterval); + + // Unblock the subwiki. + if (this.blockId) { + this.syncProvider.unblockOperation(this.component, this.blockId); + } + } +} diff --git a/src/addon/mod/wiki/providers/wiki.ts b/src/addon/mod/wiki/providers/wiki.ts index fd46d3474..511d245c3 100644 --- a/src/addon/mod/wiki/providers/wiki.ts +++ b/src/addon/mod/wiki/providers/wiki.ts @@ -61,6 +61,7 @@ export interface AddonModWikiSubwikiListData { export class AddonModWikiProvider { static COMPONENT = 'mmaModWiki'; static PAGE_CREATED_EVENT = 'addon_mod_wiki_page_created'; + static RENEW_LOCK_TIME = 30000; // Milliseconds. protected ROOT_CACHE_KEY = 'mmaModWiki:'; protected logger; diff --git a/src/components/rich-text-editor/rich-text-editor.ts b/src/components/rich-text-editor/rich-text-editor.ts index c0a9ed6c4..4eac21804 100644 --- a/src/components/rich-text-editor/rich-text-editor.ts +++ b/src/components/rich-text-editor/rich-text-editor.ts @@ -12,11 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, Output, EventEmitter, ViewChild, ElementRef } from '@angular/core'; +import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, AfterContentInit, OnDestroy } from '@angular/core'; import { TextInput } from 'ionic-angular'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { FormControl } from '@angular/forms'; import { Keyboard } from '@ionic-native/keyboard'; +import { Subscription } from 'rxjs'; /** * Directive to display a rich text editor if enabled. @@ -36,7 +37,7 @@ import { Keyboard } from '@ionic-native/keyboard'; selector: 'core-rich-text-editor', templateUrl: 'rich-text-editor.html' }) -export class CoreRichTextEditorComponent { +export class CoreRichTextEditorComponent implements AfterContentInit, OnDestroy { // Based on: https://github.com/judgewest2000/Ionic3RichText/ // @todo: Resize, images, anchor button, fullscreen... @@ -53,6 +54,8 @@ export class CoreRichTextEditorComponent { uniqueId = `rte{Math.floor(Math.random() * 1000000)}`; editorElement: HTMLDivElement; + protected valueChangeSubscription: Subscription; + constructor(private domUtils: CoreDomUtilsProvider, private keyboard: Keyboard) { this.contentChanged = new EventEmitter(); } @@ -69,13 +72,17 @@ export class CoreRichTextEditorComponent { this.editorElement = this.editor.nativeElement as HTMLDivElement; this.editorElement.innerHTML = this.control.value; this.textarea.value = this.control.value; - this.control.setValue(this.control.value); this.editorElement.onchange = this.onChange.bind(this); this.editorElement.onkeyup = this.onChange.bind(this); this.editorElement.onpaste = this.onChange.bind(this); this.editorElement.oninput = this.onChange.bind(this); + // Listen for changes on the control to update the editor (if it is updated from outside of this component). + this.valueChangeSubscription = this.control.valueChanges.subscribe((param) => { + this.editorElement.innerHTML = param; + }); + // Setup button actions. const buttons = (this.decorate.nativeElement as HTMLDivElement).getElementsByTagName('button'); for (let i = 0; i < buttons.length; i++) { @@ -109,14 +116,16 @@ export class CoreRichTextEditorComponent { if (this.isNullOrWhiteSpace(this.editorElement.innerText)) { this.clearText(); } else { - this.control.setValue(this.editorElement.innerHTML); + // Don't emit event so our valueChanges doesn't get notified by this change. + this.control.setValue(this.editorElement.innerHTML, {emitEvent: false}); this.textarea.value = this.editorElement.innerHTML; } } else { if (this.isNullOrWhiteSpace(this.textarea.value)) { this.clearText(); } else { - this.control.setValue(this.textarea.value); + // Don't emit event so our valueChanges doesn't get notified by this change. + this.control.setValue(this.textarea.value, {emitEvent: false}); } } @@ -183,7 +192,8 @@ export class CoreRichTextEditorComponent { clearText(): void { this.editorElement.innerHTML = '

'; this.textarea.value = ''; - this.control.setValue(null); + // Don't emit event so our valueChanges doesn't get notified by this change. + this.control.setValue(null, {emitEvent: false}); } /** @@ -199,4 +209,11 @@ export class CoreRichTextEditorComponent { $event.stopPropagation(); document.execCommand(command, false, parameters); } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.valueChangeSubscription && this.valueChangeSubscription.unsubscribe(); + } } From 89e22c980ecb4cf08d74778daff2b56ab0ba82e6 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 28 May 2018 11:42:32 +0200 Subject: [PATCH 07/11] MOBILE-2353 wiki: Implement link handlers --- .../mod/wiki/providers/create-link-handler.ts | 136 ++++++++++++++++++ .../mod/wiki/providers/edit-link-handler.ts | 66 +++++++++ .../mod/wiki/providers/index-link-handler.ts | 29 ++++ .../providers/page-or-map-link-handler.ts | 111 ++++++++++++++ src/addon/mod/wiki/wiki.module.ts | 19 ++- 5 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 src/addon/mod/wiki/providers/create-link-handler.ts create mode 100644 src/addon/mod/wiki/providers/edit-link-handler.ts create mode 100644 src/addon/mod/wiki/providers/index-link-handler.ts create mode 100644 src/addon/mod/wiki/providers/page-or-map-link-handler.ts diff --git a/src/addon/mod/wiki/providers/create-link-handler.ts b/src/addon/mod/wiki/providers/create-link-handler.ts new file mode 100644 index 000000000..7eb5287ab --- /dev/null +++ b/src/addon/mod/wiki/providers/create-link-handler.ts @@ -0,0 +1,136 @@ +// (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 { NavController, ViewController } from 'ionic-angular'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { AddonModWikiProvider } from './wiki'; + +/** + * Handler to treat links to create a wiki page. + */ +@Injectable() +export class AddonModWikiCreateLinkHandler extends CoreContentLinksHandlerBase { + name = 'AddonModWikiCreateLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModWiki'; + pattern = /\/mod\/wiki\/create\.php.*([\&\?]swid=\d+)/; + + constructor(protected domUtils: CoreDomUtilsProvider, protected wikiProvider: AddonModWikiProvider, + protected courseHelper: CoreCourseHelperProvider, protected linkHelper: CoreContentLinksHelperProvider, + protected courseProvider: CoreCourseProvider) { + super(); + } + + /** + * Check if the current view is a wiki page of the same wiki. + * + * @param {ViewController} activeView Active view. + * @param {number} subwikiId Subwiki ID to check. + * @param {string} siteId Site ID. + * @return {Promise} Promise resolved with boolean: whether current view belongs to the same wiki. + */ + protected currentStateIsSameWiki(activeView: ViewController, subwikiId: number, siteId: string): Promise { + + if (activeView && activeView.component.name == 'AddonModWikiIndexPage') { + if (activeView.data.subwikiId == subwikiId) { + // Same subwiki, so it's same wiki. + return Promise.resolve(true); + + } else if (activeView.data.pageId) { + // Get the page contents to check the subwiki. + return this.wikiProvider.getPageContents(activeView.data.pageId, false, false, siteId).then((page) => { + return page.subwikiid == subwikiId; + }).catch(() => { + // Not found, return false. + return false; + }); + + } else if (activeView.data.wikiId) { + // Check if the subwiki belongs to this wiki. + return this.wikiProvider.wikiHasSubwiki(activeView.data.wikiId, subwikiId, false, false, siteId); + + } else if (activeView.data.courseId && activeView.data.module) { + const moduleId = activeView.data.module && activeView.data.module.id; + if (moduleId) { + // Get the wiki. + return this.wikiProvider.getWiki(activeView.data.courseId, moduleId, false, siteId).then((wiki) => { + // Check if the subwiki belongs to this wiki. + return this.wikiProvider.wikiHasSubwiki(wiki.id, subwikiId, false, false, siteId); + }).catch(() => { + // Not found, return false. + return false; + }); + } + } + } + + return Promise.resolve(false); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + + courseId = courseId || params.courseid || params.cid; + + return [{ + action: (siteId, navCtrl?: NavController): void => { + const modal = this.domUtils.showModalLoading(), + subwikiId = parseInt(params.swid, 10), + activeView = navCtrl && navCtrl.getActive(); + + // Check if the link is inside the same wiki. + this.currentStateIsSameWiki(activeView, subwikiId, siteId).then((isSameWiki) => { + if (isSameWiki) { + // User is seeing the wiki, we can get the module from the wiki params. + if (activeView && activeView.data.module && activeView.data.module.id) { + // We already have it in the params. + return activeView.data.module; + } else if (activeView && activeView.data.wikiId) { + return this.courseProvider.getModuleBasicInfoByInstance(activeView.data.wikiId, 'wiki', siteId) + .catch(() => { + // Not found. + }); + } + } + }).then((module) => { + // Return the params. + const pageParams = { + module: module, + courseId: courseId || (module && module.course) || (activeView && activeView.data.courseId), + pageTitle: params.title, + subwikiId: subwikiId + }; + + this.linkHelper.goInSite(navCtrl, 'AddonModWikiEditPage', pageParams, siteId); + }).finally(() => { + modal.dismiss(); + }); + } + }]; + } +} diff --git a/src/addon/mod/wiki/providers/edit-link-handler.ts b/src/addon/mod/wiki/providers/edit-link-handler.ts new file mode 100644 index 000000000..5c74cb4d7 --- /dev/null +++ b/src/addon/mod/wiki/providers/edit-link-handler.ts @@ -0,0 +1,66 @@ +// (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 { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; + +/** + * Handler to treat links to edit a wiki page. + */ +@Injectable() +export class AddonModWikiEditLinkHandler extends CoreContentLinksHandlerBase { + name = 'AddonModWikiEditLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModWiki'; + pattern = /\/mod\/wiki\/edit\.php.*([\&\?]pageid=\d+)/; + + constructor(protected linkHelper: CoreContentLinksHelperProvider, protected textUtils: CoreTextUtilsProvider) { + super(); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + + courseId = courseId || params.courseid || params.cid; + + return [{ + action: (siteId, navCtrl?): void => { + + let section = ''; + if (typeof params.section != 'undefined') { + section = this.textUtils.decodeURIComponent(params.section.replace(/\+/g, ' ')); + } + + const pageParams = { + courseId: courseId, + section: section, + pageId: parseInt(params.pageid, 10) + }; + + this.linkHelper.goInSite(navCtrl, 'AddonModWikiEditPage', pageParams, siteId); + } + }]; + } +} diff --git a/src/addon/mod/wiki/providers/index-link-handler.ts b/src/addon/mod/wiki/providers/index-link-handler.ts new file mode 100644 index 000000000..212cbaa64 --- /dev/null +++ b/src/addon/mod/wiki/providers/index-link-handler.ts @@ -0,0 +1,29 @@ +// (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 { CoreContentLinksModuleIndexHandler } from '@core/contentlinks/classes/module-index-handler'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; + +/** + * Handler to treat links to wiki index. + */ +@Injectable() +export class AddonModWikiIndexLinkHandler extends CoreContentLinksModuleIndexHandler { + name = 'AddonModWikiIndexLinkHandler'; + + constructor(courseHelper: CoreCourseHelperProvider) { + super(courseHelper, 'AddonModWiki', 'wiki'); + } +} diff --git a/src/addon/mod/wiki/providers/page-or-map-link-handler.ts b/src/addon/mod/wiki/providers/page-or-map-link-handler.ts new file mode 100644 index 000000000..8a5bf9e23 --- /dev/null +++ b/src/addon/mod/wiki/providers/page-or-map-link-handler.ts @@ -0,0 +1,111 @@ +// (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 { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { AddonModWikiProvider } from './wiki'; + +/** + * Handler to treat links to a wiki page or the wiki map. + */ +@Injectable() +export class AddonModWikiPageOrMapLinkHandler extends CoreContentLinksHandlerBase { + name = 'AddonModWikiPageOrMapLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModWiki'; + pattern = /\/mod\/wiki\/(view|map)\.php.*([\&\?]pageid=\d+)/; + + constructor(protected domUtils: CoreDomUtilsProvider, protected wikiProvider: AddonModWikiProvider, + protected courseHelper: CoreCourseHelperProvider, protected linkHelper: CoreContentLinksHelperProvider) { + super(); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + + courseId = courseId || params.courseid || params.cid; + + return [{ + action: (siteId, navCtrl?): void => { + const modal = this.domUtils.showModalLoading(), + pageId = parseInt(params.pageid, 10), + action = url.indexOf('mod/wiki/map.php') != -1 ? 'map' : 'page'; + + // Get the page data to obtain wikiId, subwikiId, etc. + this.wikiProvider.getPageContents(pageId, false, false, siteId).then((page) => { + let promise; + if (courseId) { + promise = Promise.resolve(courseId); + } else { + promise = this.courseHelper.getModuleCourseIdByInstance(page.wikiid, 'wiki', siteId); + } + + return promise.then((courseId) => { + const pageParams = { + courseId: courseId, + pageId: page.id, + pageTitle: page.title, + wikiId: page.wikiid, + subwikiId: page.subwikiid, + action: action + }; + + this.linkHelper.goInSite(navCtrl, 'AddonModWikiIndexPage', pageParams, siteId); + }); + }).catch((error) => { + + this.domUtils.showErrorModalDefault(error, 'addon.mod_wiki.errorloadingpage', true); + }).finally(() => { + modal.dismiss(); + }); + } + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + const isMap = url.indexOf('mod/wiki/map.php') != -1; + + if (params.id && !isMap) { + // ID param is more prioritary than pageid in index page, it's a index URL. + return false; + } else if (isMap && typeof params.option != 'undefined' && params.option != 5) { + // Map link but the option isn't "Page list", not supported. + return false; + } + + return true; + } +} diff --git a/src/addon/mod/wiki/wiki.module.ts b/src/addon/mod/wiki/wiki.module.ts index ed21a0d9d..a85ced279 100644 --- a/src/addon/mod/wiki/wiki.module.ts +++ b/src/addon/mod/wiki/wiki.module.ts @@ -16,6 +16,7 @@ import { NgModule } from '@angular/core'; import { CoreCronDelegate } from '@providers/cron'; import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; +import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; import { AddonModWikiComponentsModule } from './components/components.module'; import { AddonModWikiProvider } from './providers/wiki'; import { AddonModWikiOfflineProvider } from './providers/wiki-offline'; @@ -23,6 +24,10 @@ import { AddonModWikiSyncProvider } from './providers/wiki-sync'; import { AddonModWikiModuleHandler } from './providers/module-handler'; import { AddonModWikiPrefetchHandler } from './providers/prefetch-handler'; import { AddonModWikiSyncCronHandler } from './providers/sync-cron-handler'; +import { AddonModWikiIndexLinkHandler } from './providers/index-link-handler'; +import { AddonModWikiPageOrMapLinkHandler } from './providers/page-or-map-link-handler'; +import { AddonModWikiCreateLinkHandler } from './providers/create-link-handler'; +import { AddonModWikiEditLinkHandler } from './providers/edit-link-handler'; @NgModule({ declarations: [ @@ -36,16 +41,26 @@ import { AddonModWikiSyncCronHandler } from './providers/sync-cron-handler'; AddonModWikiSyncProvider, AddonModWikiModuleHandler, AddonModWikiPrefetchHandler, - AddonModWikiSyncCronHandler + AddonModWikiSyncCronHandler, + AddonModWikiIndexLinkHandler, + AddonModWikiPageOrMapLinkHandler, + AddonModWikiCreateLinkHandler, + AddonModWikiEditLinkHandler ] }) export class AddonModWikiModule { constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModWikiModuleHandler, prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModWikiPrefetchHandler, - cronDelegate: CoreCronDelegate, syncHandler: AddonModWikiSyncCronHandler, ) { + cronDelegate: CoreCronDelegate, syncHandler: AddonModWikiSyncCronHandler, linksDelegate: CoreContentLinksDelegate, + indexHandler: AddonModWikiIndexLinkHandler, pageOrMapHandler: AddonModWikiPageOrMapLinkHandler, + createHandler: AddonModWikiCreateLinkHandler, editHandler: AddonModWikiEditLinkHandler) { moduleDelegate.registerHandler(moduleHandler); prefetchDelegate.registerHandler(prefetchHandler); cronDelegate.register(syncHandler); + linksDelegate.registerHandler(indexHandler); + linksDelegate.registerHandler(pageOrMapHandler); + linksDelegate.registerHandler(createHandler); + linksDelegate.registerHandler(editHandler); } } From df5d519b0af9017cb93433698470686ee3c783ee Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 28 May 2018 13:36:07 +0200 Subject: [PATCH 08/11] MOBILE-2353 wiki: Allow editing offline pages --- .../mod/wiki/components/index/index.html | 2 +- src/addon/mod/wiki/pages/edit/edit.ts | 29 +++++++++++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/addon/mod/wiki/components/index/index.html b/src/addon/mod/wiki/components/index/index.html index 335b5ea75..ea3b8ab30 100644 --- a/src/addon/mod/wiki/components/index/index.html +++ b/src/addon/mod/wiki/components/index/index.html @@ -15,7 +15,7 @@ - + diff --git a/src/addon/mod/wiki/pages/edit/edit.ts b/src/addon/mod/wiki/pages/edit/edit.ts index fef89d36b..b64a0fbe5 100644 --- a/src/addon/mod/wiki/pages/edit/edit.ts +++ b/src/addon/mod/wiki/pages/edit/edit.ts @@ -25,7 +25,7 @@ import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseHelperProvider } from '@core/course/providers/helper'; import { AddonModWikiProvider } from '../../providers/wiki'; import { AddonModWikiOfflineProvider } from '../../providers/wiki-offline'; -import { AddonModWikiSyncProvider } from '../../providers/wiki-sync'; +import { AddonModWikiSyncProvider, AddonModWikiSyncSubwikiResult } from '../../providers/wiki-sync'; /** * Page that allows adding or editing a wiki page. @@ -182,14 +182,31 @@ export class AddonModWikiEditPage implements OnInit, OnDestroy { } }); } else { + const pageTitle = this.pageForm.controls.title.value; + // New page. Wait for sync to be over (if any). promise = this.wikiSync.waitForSync(this.blockId); - if (this.contentControl.value) { - // Check if there's already some offline data for this page. - promise = promise.then(() => { - return this.wikiOffline.getNewPage(this.pageForm.controls.title.value, this.subwikiId, this.wikiId, - this.userId, this.groupId); + if (pageTitle) { + // Title is set, it could be editing an offline page or creating a new page using an edit link. + promise = promise.then((result: AddonModWikiSyncSubwikiResult) => { + + // First of all, verify if this page was created in the current sync. + if (result) { + const page = result.created.find((page) => { + return page.title == pageTitle; + }); + + if (page && page.pageId > 0) { + // Page was created, now it exists in the site. + this.pageId = page.pageId; + + return this.fetchWikiPageData(); + } + } + + // Check if there's already some offline data for this page. + return this.wikiOffline.getNewPage(pageTitle, this.subwikiId, this.wikiId, this.userId, this.groupId); }).then((page) => { // Load offline content. this.contentControl.setValue(page.cachedcontent); From ea6bc89cfdd01cbf6980210f229a50dc83bb56f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Mon, 28 May 2018 16:08:48 +0200 Subject: [PATCH 09/11] MOBILE-2353 wiki: Styling --- src/addon/mod/wiki/components/index/index.html | 4 ++-- src/addon/mod/wiki/components/index/index.scss | 6 ------ .../mod/wiki/components/subwiki-picker/subwiki-picker.scss | 2 ++ src/components/empty-box/empty-box.scss | 1 - 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/addon/mod/wiki/components/index/index.html b/src/addon/mod/wiki/components/index/index.html index ea3b8ab30..268a90f4d 100644 --- a/src/addon/mod/wiki/components/index/index.html +++ b/src/addon/mod/wiki/components/index/index.html @@ -29,7 +29,7 @@ -
+
@@ -45,7 +45,7 @@ {{ pageWarning }}
-
+
diff --git a/src/addon/mod/wiki/components/index/index.scss b/src/addon/mod/wiki/components/index/index.scss index 383937d2f..72166f42a 100644 --- a/src/addon/mod/wiki/components/index/index.scss +++ b/src/addon/mod/wiki/components/index/index.scss @@ -5,12 +5,6 @@ $addon-mod-wiki-toc-border-color: $gray-dark !default; $addon-mod-wiki-toc-background-color: $gray-light !default; addon-mod-wiki-index { - background-color: $white; - - .core-tabs-content-container, .addon-mod_wiki-page-content { - background-color: $white; - } - .wiki-toc { border: 1px solid $addon-mod-wiki-toc-border-color; background: $addon-mod-wiki-toc-background-color; diff --git a/src/addon/mod/wiki/components/subwiki-picker/subwiki-picker.scss b/src/addon/mod/wiki/components/subwiki-picker/subwiki-picker.scss index d33abf979..5f78efab7 100644 --- a/src/addon/mod/wiki/components/subwiki-picker/subwiki-picker.scss +++ b/src/addon/mod/wiki/components/subwiki-picker/subwiki-picker.scss @@ -6,8 +6,10 @@ addon-mod-wiki-subwiki-picker { .item.addon-mod_wiki-subwiki-selected { background-color: $gray-light; + color: $core-color; .icon { + color: $core-color; font-size: 24px; } } diff --git a/src/components/empty-box/empty-box.scss b/src/components/empty-box/empty-box.scss index fe8163b29..1e56ae295 100644 --- a/src/components/empty-box/empty-box.scss +++ b/src/components/empty-box/empty-box.scss @@ -8,7 +8,6 @@ core-empty-box { display: table; height: 100%; width: 100%; - z-index: -1; margin: 0; padding: 0; clear: both; From c492921df174d4101b99b86528712a30bb1b1e82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Tue, 29 May 2018 16:56:47 +0200 Subject: [PATCH 10/11] MOBILE-2353 rte: Improve editor with resizing --- .../feedback/comments/component/comments.html | 3 +- .../onlinetext/component/onlinetext.html | 3 +- src/addon/mod/forum/components/post/post.html | 4 +- .../pages/new-discussion/new-discussion.html | 4 +- .../mod/wiki/components/index/index.scss | 7 ++ src/addon/mod/wiki/pages/edit/edit.html | 3 +- src/addon/qtype/essay/component/essay.html | 4 +- src/app/app.scss | 2 +- .../rich-text-editor/rich-text-editor.html | 34 +++--- .../rich-text-editor/rich-text-editor.scss | 35 +++--- .../rich-text-editor/rich-text-editor.ts | 102 ++++++++++++++++-- src/lang/en.json | 2 + src/providers/utils/dom.ts | 19 +++- 13 files changed, 169 insertions(+), 53 deletions(-) diff --git a/src/addon/mod/assign/feedback/comments/component/comments.html b/src/addon/mod/assign/feedback/comments/component/comments.html index 1a0fcbcad..3732d55bb 100644 --- a/src/addon/mod/assign/feedback/comments/component/comments.html +++ b/src/addon/mod/assign/feedback/comments/component/comments.html @@ -19,6 +19,5 @@ - - + diff --git a/src/addon/mod/assign/submission/onlinetext/component/onlinetext.html b/src/addon/mod/assign/submission/onlinetext/component/onlinetext.html index e74173388..fde89ebe0 100644 --- a/src/addon/mod/assign/submission/onlinetext/component/onlinetext.html +++ b/src/addon/mod/assign/submission/onlinetext/component/onlinetext.html @@ -15,7 +15,6 @@

{{ 'core.numwords' | translate: {'$a': words + ' / ' + configs.wordlimit} }}

- - +
diff --git a/src/addon/mod/forum/components/post/post.html b/src/addon/mod/forum/components/post/post.html index 3e89f3252..9e6fc37f1 100644 --- a/src/addon/mod/forum/components/post/post.html +++ b/src/addon/mod/forum/components/post/post.html @@ -38,9 +38,7 @@ {{ 'addon.mod_forum.message' | translate }} - - + diff --git a/src/addon/mod/forum/pages/new-discussion/new-discussion.html b/src/addon/mod/forum/pages/new-discussion/new-discussion.html index 096edd01d..228adea02 100644 --- a/src/addon/mod/forum/pages/new-discussion/new-discussion.html +++ b/src/addon/mod/forum/pages/new-discussion/new-discussion.html @@ -19,9 +19,7 @@ {{ 'addon.mod_forum.message' | translate }} - - + {{ 'addon.mod_forum.group' | translate }} diff --git a/src/addon/mod/wiki/components/index/index.scss b/src/addon/mod/wiki/components/index/index.scss index 72166f42a..e3404377f 100644 --- a/src/addon/mod/wiki/components/index/index.scss +++ b/src/addon/mod/wiki/components/index/index.scss @@ -5,6 +5,13 @@ $addon-mod-wiki-toc-border-color: $gray-dark !default; $addon-mod-wiki-toc-background-color: $gray-light !default; addon-mod-wiki-index { + background-color: $white; + + .core-tabs-content-container, + .addon-mod_wiki-page-content { + background-color: $white; + } + .wiki-toc { border: 1px solid $addon-mod-wiki-toc-border-color; background: $addon-mod-wiki-toc-background-color; diff --git a/src/addon/mod/wiki/pages/edit/edit.html b/src/addon/mod/wiki/pages/edit/edit.html index e41b3541c..b8b029fb2 100644 --- a/src/addon/mod/wiki/pages/edit/edit.html +++ b/src/addon/mod/wiki/pages/edit/edit.html @@ -17,8 +17,7 @@ - - + {{ 'addon.mod_wiki.wrongversionlock' | translate }} diff --git a/src/addon/qtype/essay/component/essay.html b/src/addon/qtype/essay/component/essay.html index cb1a94af4..f763428f2 100644 --- a/src/addon/qtype/essay/component/essay.html +++ b/src/addon/qtype/essay/component/essay.html @@ -11,9 +11,7 @@ - - + diff --git a/src/app/app.scss b/src/app/app.scss index 8d03df376..510ee0f62 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -32,7 +32,7 @@ .img-responsive { display: block; max-width: 100%; - /* height: auto; */ + height: auto; } .opacity-hide { opacity: 0; } diff --git a/src/components/rich-text-editor/rich-text-editor.html b/src/components/rich-text-editor/rich-text-editor.html index e4a71afd3..14b4c5dde 100644 --- a/src/components/rich-text-editor/rich-text-editor.html +++ b/src/components/rich-text-editor/rich-text-editor.html @@ -3,26 +3,30 @@ -
- - - - - - - - - - - - +
+
+ + + + + + + + + + + + +
-
- +
+
+ +
diff --git a/src/components/rich-text-editor/rich-text-editor.scss b/src/components/rich-text-editor/rich-text-editor.scss index a850981f3..366bfef2d 100644 --- a/src/components/rich-text-editor/rich-text-editor.scss +++ b/src/components/rich-text-editor/rich-text-editor.scss @@ -10,7 +10,6 @@ core-rich-text-editor { display: flex; flex-direction: column; } - .core-rte-editor, .core-textarea { padding: 2px; margin: 2px; @@ -51,21 +50,33 @@ core-rich-text-editor { overflow-y: auto; } - div.formatOptions { - background: $gray-dark; - margin: 5px 1px 15px 1px; + div.core-rte-toolbar { + background: $gray-darker; + margin: 0px 1px 15px 1px; text-align: center; flex-grow: 0; width: 100%; z-index: 1; - button { - background: $gray-dark; - color: $white; - font-size: 1.1em; - height: 35px; - min-width: 30px; - padding-left: 1px; - padding-right: 1px; + + .core-rte-buttons { + display: flex; + align-items: center; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-evenly; + + button { + background: $gray-darker; + color: $white; + font-size: 1.1em; + height: 35px; + min-width: 30px; + padding-left: 3px; + padding-right: 3px; + border-right: 1px solid $gray-dark; + border-bottom: 1px solid $gray-dark; + flex-grow: 1; + } } } diff --git a/src/components/rich-text-editor/rich-text-editor.ts b/src/components/rich-text-editor/rich-text-editor.ts index 4eac21804..2bc66557c 100644 --- a/src/components/rich-text-editor/rich-text-editor.ts +++ b/src/components/rich-text-editor/rich-text-editor.ts @@ -12,9 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, AfterContentInit, OnDestroy } from '@angular/core'; -import { TextInput } from 'ionic-angular'; +import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, AfterContentInit, OnDestroy, Optional } + from '@angular/core'; +import { TextInput, Content } from 'ionic-angular'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreUrlUtilsProvider } from '@providers/utils/url'; import { FormControl } from '@angular/forms'; import { Keyboard } from '@ionic-native/keyboard'; import { Subscription } from 'rxjs'; @@ -39,25 +43,32 @@ import { Subscription } from 'rxjs'; }) export class CoreRichTextEditorComponent implements AfterContentInit, OnDestroy { // Based on: https://github.com/judgewest2000/Ionic3RichText/ - // @todo: Resize, images, anchor button, fullscreen... + // @todo: Anchor button, fullscreen... @Input() placeholder = ''; // Placeholder to set in textarea. @Input() control: FormControl; // Form control. @Input() name = 'core-rich-text-editor'; // Name to set to the textarea. + @Input() component?: string; // The component to link the files to. + @Input() componentId?: number; // An ID to use in conjunction with the component. @Output() contentChanged: EventEmitter; @ViewChild('editor') editor: ElementRef; // WYSIWYG editor. @ViewChild('textarea') textarea: TextInput; // Textarea editor. @ViewChild('decorate') decorate: ElementRef; // Buttons. - rteEnabled = false; - uniqueId = `rte{Math.floor(Math.random() * 1000000)}`; - editorElement: HTMLDivElement; + protected element: HTMLDivElement; + protected editorElement: HTMLDivElement; + protected resizeFunction; protected valueChangeSubscription: Subscription; - constructor(private domUtils: CoreDomUtilsProvider, private keyboard: Keyboard) { + rteEnabled = false; + + constructor(private domUtils: CoreDomUtilsProvider, private keyboard: Keyboard, private urlUtils: CoreUrlUtilsProvider, + private sitesProvider: CoreSitesProvider, private filepoolProvider: CoreFilepoolProvider, + @Optional() private content: Content, elementRef: ElementRef) { this.contentChanged = new EventEmitter(); + this.element = elementRef.nativeElement as HTMLDivElement; } /** @@ -104,6 +115,58 @@ export class CoreRichTextEditorComponent implements AfterContentInit, OnDestroy } } } + + this.treatExternalContent(); + + this.resizeFunction = this.maximizeEditorSize.bind(this); + window.addEventListener('resize', this.resizeFunction); + setTimeout(this.resizeFunction, 1000); + } + + /** + * Resize editor to maximize the space occupied. + */ + protected maximizeEditorSize(): void { + this.content.resize(); + const contentVisibleHeight = this.content.contentHeight; + + // Editor is ready, adjust Height if needed. + if (contentVisibleHeight > 0) { + const height = this.getSurroundingHeight(this.element); + if (contentVisibleHeight > height) { + this.element.style.height = this.domUtils.formatPixelsSize(contentVisibleHeight - height); + } else { + this.element.style.height = ''; + } + } + } + + /** + * Get the height of the surrounding elements from the current to the top element. + * + * @param {any} element Directive DOM element to get surroundings elements from. + * @return {number} Surrounding height in px. + */ + protected getSurroundingHeight(element: any): number { + let height = 0; + + while (element.parentNode && element.parentNode.tagName != 'ION-CONTENT') { + const parent = element.parentNode; + if (element.tagName && element.tagName != 'CORE-LOADING') { + parent.childNodes.forEach((child) => { + if (child.tagName && child != element) { + height += this.domUtils.getElementHeight(child, false, true, true); + } + }); + } + element = parent; + } + + const cs = getComputedStyle(element); + height += this.domUtils.getComputedStyleMeasure(cs, 'paddingTop') + + this.domUtils.getComputedStyleMeasure(cs, 'paddingBottom'); + + return height; } /** @@ -171,6 +234,30 @@ export class CoreRichTextEditorComponent implements AfterContentInit, OnDestroy }, 1); } + /** + * Treat elements that can contain external content. + * We only search for images because the editor should receive unfiltered text, so the multimedia filter won't be applied. + * Treating videos and audios in here is complex, so if a user manually adds one he won't be able to play it in the editor. + */ + protected treatExternalContent(): void { + const elements = Array.from(this.editorElement.querySelectorAll('img')), + siteId = this.sitesProvider.getCurrentSiteId(), + canDownloadFiles = this.sitesProvider.getCurrentSite().canDownloadFiles(); + elements.forEach((el) => { + const url = el.src; + + if (!url || !this.urlUtils.isDownloadableUrl(url) || (!canDownloadFiles && this.urlUtils.isPluginFileUrl(url))) { + // Nothing to treat. + return; + } + + // Check if it's downloaded. + return this.filepoolProvider.getSrcByUrl(siteId, url, this.component, this.componentId).then((finalUrl) => { + el.setAttribute('src', finalUrl); + }); + }); + } + /** * Check if text is empty. * @param {string} value text @@ -215,5 +302,6 @@ export class CoreRichTextEditorComponent implements AfterContentInit, OnDestroy */ ngOnDestroy(): void { this.valueChangeSubscription && this.valueChangeSubscription.unsubscribe(); + window.removeEventListener('resize', this.resizeFunction); } } diff --git a/src/lang/en.json b/src/lang/en.json index 0a5d19c35..84768daed 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -227,6 +227,8 @@ "usernotfullysetup": "User not fully set-up", "users": "Users", "view": "View", + "viewcode": "View code", + "vieweditor": "View editor", "viewprofile": "View profile", "warningofflinedatadeleted": "Offline data of {{component}} '{{name}}' has been deleted. {{error}}", "whatisyourage": "What is your age?", diff --git a/src/providers/utils/dom.ts b/src/providers/utils/dom.ts index e52e75121..c66f47855 100644 --- a/src/providers/utils/dom.ts +++ b/src/providers/utils/dom.ts @@ -365,13 +365,16 @@ export class CoreDomUtilsProvider { let surround = 0; if (usePadding) { - surround += parseInt(computedStyle['padding' + priorSide], 10) + parseInt(computedStyle['padding' + afterSide], 10); + surround += this.getComputedStyleMeasure(computedStyle, 'padding' + priorSide) + + this.getComputedStyleMeasure(computedStyle, 'padding' + afterSide); } if (useMargin) { - surround += parseInt(computedStyle['margin' + priorSide], 10) + parseInt(computedStyle['margin' + afterSide], 10); + surround += this.getComputedStyleMeasure(computedStyle, 'margin' + priorSide) + + this.getComputedStyleMeasure(computedStyle, 'margin' + afterSide); } if (useBorder) { - surround += parseInt(computedStyle['border' + priorSide], 10) + parseInt(computedStyle['border' + afterSide], 10); + surround += this.getComputedStyleMeasure(computedStyle, 'border' + priorSide + 'Width') + + this.getComputedStyleMeasure(computedStyle, 'border' + afterSide + 'Width'); } if (innerMeasure) { measure = measure > surround ? measure - surround : 0; @@ -381,7 +384,17 @@ export class CoreDomUtilsProvider { } return measure; + } + /** + * Returns the computed style measure or 0 if not found or NaN. + * + * @param {any} style Style from getComputedStyle. + * @param {string} measure Measure to get. + * @return {number} Result of the measure. + */ + getComputedStyleMeasure(style: any, measure: string): number { + return parseInt(style[measure], 10) || 0; } /** From 9d16365e2c6fcc62c89a40924dd2e6f45bb96304 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 30 May 2018 10:01:29 +0200 Subject: [PATCH 11/11] MOBILE-2353 wiki: Clear cache on logout --- src/addon/mod/wiki/components/index/index.ts | 4 +++- src/addon/mod/wiki/providers/wiki.ts | 10 ++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/addon/mod/wiki/components/index/index.ts b/src/addon/mod/wiki/components/index/index.ts index 4abff5c7d..6ba317c27 100644 --- a/src/addon/mod/wiki/components/index/index.ts +++ b/src/addon/mod/wiki/components/index/index.ts @@ -813,7 +813,9 @@ export class AddonModWikiIndexComponent extends CoreCourseModuleMainActivityComp }); popover.onDidDismiss((subwiki) => { - this.goToSubwiki(subwiki.id, subwiki.userid, subwiki.groupid, subwiki.canedit); + if (subwiki) { + this.goToSubwiki(subwiki.id, subwiki.userid, subwiki.groupid, subwiki.canedit); + } }); popover.present({ diff --git a/src/addon/mod/wiki/providers/wiki.ts b/src/addon/mod/wiki/providers/wiki.ts index 511d245c3..ce98a5dff 100644 --- a/src/addon/mod/wiki/providers/wiki.ts +++ b/src/addon/mod/wiki/providers/wiki.ts @@ -14,6 +14,7 @@ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; +import { CoreEventsProvider } from '@providers/events'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; import { CoreUtilsProvider } from '@providers/utils/utils'; @@ -69,8 +70,13 @@ export class AddonModWikiProvider { constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private appProvider: CoreAppProvider, private filepoolProvider: CoreFilepoolProvider, private utils: CoreUtilsProvider, private translate: TranslateService, - private wikiOffline: AddonModWikiOfflineProvider) { + private wikiOffline: AddonModWikiOfflineProvider, eventsProvider: CoreEventsProvider) { this.logger = logger.getInstance('AddonModWikiProvider'); + + // Clear subwiki lists cache on logout. + eventsProvider.on(CoreEventsProvider.LOGIN, () => { + this.clearSubwikiList(); + }); } /** @@ -78,7 +84,7 @@ export class AddonModWikiProvider { * * @param {number} [wikiId] wiki Id, if not provided all will be cleared. */ - clearSubwikiList(wikiId: number): void { + clearSubwikiList(wikiId?: number): void { if (typeof wikiId == 'undefined') { this.subwikiListsCache = {}; } else {