From 5106e11e29e8ddf684c996a675b1d5081f8a568a Mon Sep 17 00:00:00 2001 From: Albert Gasset Date: Fri, 25 May 2018 15:35:03 +0200 Subject: [PATCH] MOBILE-2342 glossary: Implement glossary and offline providers --- src/addon/mod/glossary/glossary.module.ts | 30 + src/addon/mod/glossary/lang/en.json | 29 + src/addon/mod/glossary/providers/glossary.ts | 886 +++++++++++++++++++ src/addon/mod/glossary/providers/offline.ts | 280 ++++++ src/app/app.module.ts | 2 + 5 files changed, 1227 insertions(+) create mode 100644 src/addon/mod/glossary/glossary.module.ts create mode 100644 src/addon/mod/glossary/lang/en.json create mode 100644 src/addon/mod/glossary/providers/glossary.ts create mode 100644 src/addon/mod/glossary/providers/offline.ts diff --git a/src/addon/mod/glossary/glossary.module.ts b/src/addon/mod/glossary/glossary.module.ts new file mode 100644 index 000000000..0c12b9b7e --- /dev/null +++ b/src/addon/mod/glossary/glossary.module.ts @@ -0,0 +1,30 @@ +// (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 { AddonModGlossaryProvider } from './providers/glossary'; +import { AddonModGlossaryOfflineProvider } from './providers/offline'; + +@NgModule({ + declarations: [ + ], + imports: [ + ], + providers: [ + AddonModGlossaryProvider, + AddonModGlossaryOfflineProvider, + ] +}) +export class AddonModGlossaryModule { +} diff --git a/src/addon/mod/glossary/lang/en.json b/src/addon/mod/glossary/lang/en.json new file mode 100644 index 000000000..28a0078f5 --- /dev/null +++ b/src/addon/mod/glossary/lang/en.json @@ -0,0 +1,29 @@ +{ + "addentry": "Add a new entry", + "aliases": "Keyword(s)", + "attachment": "Attachment", + "browsemode": "Browse entries", + "byalphabet": "Alphabetically", + "byauthor": "Group by author", + "bycategory": "Group by category", + "bynewestfirst": "Newest first", + "byrecentlyupdated": "Recently updated", + "bysearch": "Search", + "cannoteditentry": "Cannot edit entry", + "casesensitive": "This entry is case sensitive", + "categories": "Categories", + "concept": "Concept", + "definition": "Definition", + "entriestobesynced": "Entries to be synced", + "entrypendingapproval": "This entry is pending approval.", + "entryusedynalink": "This entry should be automatically linked", + "errconceptalreadyexists": "This concept already exists. No duplicates allowed in this glossary.", + "errorloadingentries": "An error occurred while loading entries.", + "errorloadingentry": "An error occurred while loading the entry.", + "errorloadingglossary": "An error occurred while loading the glossary.", + "fillfields": "Concept and definition are mandatory fields.", + "fullmatch": "Match whole words only", + "linking": "Auto-linking", + "noentriesfound": "No entries were found.", + "searchquery": "Search query" +} diff --git a/src/addon/mod/glossary/providers/glossary.ts b/src/addon/mod/glossary/providers/glossary.ts new file mode 100644 index 000000000..02482f0e9 --- /dev/null +++ b/src/addon/mod/glossary/providers/glossary.ts @@ -0,0 +1,886 @@ +// (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 { CoreSite } from '@classes/site'; +import { CoreAppProvider } from '@providers/app'; +import { CoreFilepoolProvider } from '@providers/filepool'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { AddonModGlossaryOfflineProvider } from './offline'; + +/** + * Service that provides some features for glossaries. + */ +@Injectable() +export class AddonModGlossaryProvider { + static COMPONENT = 'mmaModGlossary'; + static LIMIT_ENTRIES = 25; + static LIMIT_CATEGORIES = 10; + static SHOW_ALL_CATERGORIES = 0; + static SHOW_NOT_CATEGORISED = -1; + + static ADD_ENTRY_EVENT = 'addon_mod_glossary_add_entry'; + + protected ROOT_CACHE_KEY = 'mmaModGlossary:'; + + constructor(private appProvider: CoreAppProvider, + private sitesProvider: CoreSitesProvider, + private filepoolProvider: CoreFilepoolProvider, + private translate: TranslateService, + private textUtils: CoreTextUtilsProvider, + private utils: CoreUtilsProvider, + private glossaryOffline: AddonModGlossaryOfflineProvider) {} + + /** + * Get the course glossary cache key. + * + * @param {number} courseId Course Id. + * @return {string} Cache key. + */ + protected getCourseGlossariesCacheKey(courseId: number): string { + return this.ROOT_CACHE_KEY + 'courseGlossaries:' + courseId; + } + + /** + * Get all the glossaries in a course. + * + * @param {number} courseId Course Id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved with the glossaries. + */ + getCourseGlossaries(courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + courseids: [courseId] + }; + const preSets = { + cacheKey: this.getCourseGlossariesCacheKey(courseId) + }; + + return site.read('mod_glossary_get_glossaries_by_courses', params, preSets).then((result) => { + return result.glossaries; + }); + }); + } + + /** + * Invalidate all glossaries in a course. + * + * @param {number} courseId Course Id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved when data is invalidated. + */ + invalidateCourseGlossaries(courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const key = this.getCourseGlossariesCacheKey(courseId); + + return site.invalidateWsCacheForKey(key); + }); + } + + /** + * Get the entries by author cache key. + * + * @param {number} glossaryId Glossary Id. + * @param {string} letter First letter of firstname or lastname, or either keywords: ALL or SPECIAL. + * @param {string} field Search and order using: FIRSTNAME or LASTNAME + * @param {string} sort The direction of the order: ASC or DESC + * @return {string} Cache key. + */ + protected getEntriesByAuthorCacheKey(glossaryId: number, letter: string, field: string, sort: string): string { + return this.ROOT_CACHE_KEY + 'entriesByAuthor:' + glossaryId + ':' + letter + ':' + field + ':' + sort; + } + + /** + * Get entries by author. + * + * @param {number} glossaryId Glossary Id. + * @param {string} letter First letter of firstname or lastname, or either keywords: ALL or SPECIAL. + * @param {string} field Search and order using: FIRSTNAME or LASTNAME + * @param {string} sort The direction of the order: ASC or DESC + * @param {number} from Start returning records from here. + * @param {number} limit Number of records to return. + * @param {boolean} forceCache True to always get the value from cache, false otherwise. Default false. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved with the entries. + */ + getEntriesByAuthor(glossaryId: number, letter: string, field: string, sort: string, from: number, limit: number, + forceCache: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + id: glossaryId, + letter: letter, + field: field, + sort: sort, + from: from, + limit: limit + }; + const preSets = { + cacheKey: this.getEntriesByAuthorCacheKey(glossaryId, letter, field, sort), + omitExpires: forceCache + }; + + return site.read('mod_glossary_get_entries_by_author', params, preSets); + }); + } + + /** + * Invalidate cache of entries by author. + * + * @param {number} glossaryId Glossary Id. + * @param {string} letter First letter of firstname or lastname, or either keywords: ALL or SPECIAL. + * @param {string} field Search and order using: FIRSTNAME or LASTNAME + * @param {string} sort The direction of the order: ASC or DESC + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved when data is invalidated. + */ + invalidateEntriesByAuthor(glossaryId: number, letter: string, field: string, sort: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const key = this.getEntriesByAuthorCacheKey(glossaryId, letter, field, sort); + + return site.invalidateWsCacheForKey(key); + }); + } + + /** + * Get entries by category. + * + * @param {number} glossaryId Glossary Id. + * @param {string} categoryId The category ID. Use constant SHOW_ALL_CATERGORIES for all categories, or + * constant SHOW_NOT_CATEGORISED for uncategorised entries. + * @param {number} from Start returning records from here. + * @param {number} limit Number of records to return. + * @param {boolean} forceCache True to always get the value from cache, false otherwise. Default false. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved with the entries. + */ + getEntriesByCategory(glossaryId: number, categoryId: number, from: number, limit: number, forceCache: boolean, + siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + id: glossaryId, + categoryid: categoryId, + from: from, + limit: limit + }; + const preSets = { + cacheKey: this.getEntriesByCategoryCacheKey(glossaryId, categoryId), + omitExpires: forceCache + }; + + return site.read('mod_glossary_get_entries_by_category', params, preSets); + }); + } + + /** + * Invalidate cache of entries by category. + * + * @param {number} glossaryId Glossary Id. + * @param {string} categoryId The category ID. Use constant SHOW_ALL_CATERGORIES for all categories, or + * constant SHOW_NOT_CATEGORISED for uncategorised entries. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved when data is invalidated. + */ + invalidateEntriesByCategory(glossaryId: number, categoryId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const key = this.getEntriesByCategoryCacheKey(glossaryId, categoryId); + + return site.invalidateWsCacheForKey(key); + }); + } + + /** + * Get the entries by category cache key. + * + * @param {number} glossaryId Glossary Id. + * @param {string} categoryId The category ID. Use constant SHOW_ALL_CATERGORIES for all categories, or + * constant SHOW_NOT_CATEGORISED for uncategorised entries. + * @return {string} Cache key. + */ + getEntriesByCategoryCacheKey(glossaryId: number, categoryId: number): string { + return this.ROOT_CACHE_KEY + 'entriesByCategory:' + glossaryId + ':' + categoryId; + } + + /** + * Get the entries by date cache key. + * + * @param {number} glossaryId Glossary Id. + * @param {string} order The way to order the records. + * @param {string} sort The direction of the order. + * @return {string} Cache key. + */ + getEntriesByDateCacheKey(glossaryId: number, order: string, sort: string): string { + return this.ROOT_CACHE_KEY + 'entriesByDate:' + glossaryId + ':' + order + ':' + sort; + } + + /** + * Get entries by date. + * + * @param {number} glossaryId Glossary Id. + * @param {string} order The way to order the records. + * @param {string} sort The direction of the order. + * @param {number} from Start returning records from here. + * @param {number} limit Number of records to return. + * @param {boolean} forceCache True to always get the value from cache, false otherwise. Default false. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved with the entries. + */ + getEntriesByDate(glossaryId: number, order: string, sort: string, from: number, limit: number, forceCache: boolean, + siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + id: glossaryId, + order: order, + sort: sort, + from: from, + limit: limit + }; + const preSets = { + cacheKey: this.getEntriesByDateCacheKey(glossaryId, order, sort), + omitExpires: forceCache + }; + + return site.read('mod_glossary_get_entries_by_date', params, preSets); + }); + } + + /** + * Invalidate cache of entries by date. + * + * @param {number} glossaryId Glossary Id. + * @param {string} order The way to order the records. + * @param {string} sort The direction of the order. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved when data is invalidated. + */ + invalidateEntriesByDate(glossaryId: number, order: string, sort: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const key = this.getEntriesByDateCacheKey(glossaryId, order, sort); + + return site.invalidateWsCacheForKey(key); + }); + } + + /** + * Get the entries by letter cache key. + * + * @param {number} glossaryId Glossary Id. + * @param {string} letter A letter, or a special keyword. + * @return {string} Cache key. + */ + protected getEntriesByLetterCacheKey(glossaryId: number, letter: string): string { + return this.ROOT_CACHE_KEY + 'entriesByLetter:' + glossaryId + ':' + letter; + } + + /** + * Get entries by letter. + * + * @param {number} glossaryId Glossary Id. + * @param {string} letter A letter, or a special keyword. + * @param {number} from Start returning records from here. + * @param {number} limit Number of records to return. + * @param {boolean} forceCache True to always get the value from cache, false otherwise. Default false. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved with the entries. + */ + getEntriesByLetter(glossaryId: number, letter: string, from: number, limit: number, forceCache: boolean, siteId?: string): + Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + id: glossaryId, + letter: letter, + from: from, + limit: limit + }; + const preSets = { + cacheKey: this.getEntriesByLetterCacheKey(glossaryId, letter), + omitExpires: forceCache + }; + + return site.read('mod_glossary_get_entries_by_letter', params, preSets); + }); + } + + /** + * Invalidate cache of entries by letter. + * + * @param {number} glossaryId Glossary Id. + * @param {string} letter A letter, or a special keyword. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved when data is invalidated. + */ + invalidateEntriesByLetter(glossaryId: number, letter: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const key = this.getEntriesByLetterCacheKey(glossaryId, letter); + + return site.invalidateWsCacheForKey(key); + }); + } + + /** + * Get the entries by search cache key. + * + * @param {number} glossaryId Glossary Id. + * @param {string} query The search query. + * @param {boolean} fullSearch Whether or not full search is required. + * @param {string} order The way to order the results. + * @param {string} sort The direction of the order. + * @return {string} Cache key. + */ + protected getEntriesBySearchCacheKey(glossaryId: number, query: string, fullSearch: boolean, order: string, sort: string): + string { + return this.ROOT_CACHE_KEY + 'entriesBySearch:' + glossaryId + ':' + fullSearch + ':' + order + ':' + sort + ':' + query; + } + + /** + * Get entries by search. + * + * @param {number} glossaryId Glossary Id. + * @param {string} query The search query. + * @param {boolean} fullSearch Whether or not full search is required. + * @param {string} order The way to order the results. + * @param {string} sort The direction of the order. + * @param {number} from Start returning records from here. + * @param {number} limit Number of records to return. + * @param {boolean} forceCache True to always get the value from cache, false otherwise. Default false. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved with the entries. + */ + getEntriesBySearch(glossaryId: number, query: string, fullSearch: boolean, order: string, sort: string, from: number, + limit: number, forceCache: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + id: glossaryId, + query: query, + fullsearch: fullSearch, + order: order, + sort: sort, + from: from, + limit: limit + }; + const preSets = { + cacheKey: this.getEntriesBySearchCacheKey(glossaryId, query, fullSearch, order, sort), + omitExpires: forceCache, + }; + + return site.read('mod_glossary_get_entries_by_search', params, preSets); + }); + } + + /** + * Invalidate cache of entries by search. + * + * @param {number} glossaryId Glossary Id. + * @param {string} query The search query. + * @param {boolean} fullSearch Whether or not full search is required. + * @param {string} order The way to order the results. + * @param {string} sort The direction of the order. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved when data is invalidated. + */ + invalidateEntriesBySearch(glossaryId: number, query: string, fullSearch: boolean, order: string, sort: string, siteId?: string): + Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const key = this.getEntriesBySearchCacheKey(glossaryId, query, fullSearch, order, sort); + + return site.invalidateWsCacheForKey(key); + }); + } + + /** + * Get the glossary categories cache key. + * + * @param {number} glossaryId Glossary Id. + * @return {string} The cache key. + */ + protected getCategoriesCacheKey(glossaryId: number): string { + return this.ROOT_CACHE_KEY + 'categories:' + glossaryId; + } + + /** + * Get all the categories related to the glossary. + * + * @param {number} glossaryId Glossary Id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the categories if supported or empty array if not. + */ + getAllCategories(glossaryId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.getCategories(glossaryId, 0, AddonModGlossaryProvider.LIMIT_CATEGORIES, [], site); + }); + } + + /** + * Get the categories related to the glossary by sections. It's a recursive function see initial call values. + * + * @param {number} glossaryId Glossary Id. + * @param {number} from Number of categories already fetched, so fetch will be done from this number. Initial value 0. + * @param {number} limit Number of categories to fetch. Initial value LIMIT_CATEGORIES. + * @param {any[]} categories Already fetched categories where to append the fetch. Initial value []. + * @param {any} site Site object. + * @return {Promise} Promise resolved with the categories. + */ + protected getCategories(glossaryId: number, from: number, limit: number, categories: any[], site: CoreSite): Promise { + const params = { + id: glossaryId, + from: from, + limit: limit + }; + const preSets = { + cacheKey: this.getCategoriesCacheKey(glossaryId) + }; + + return site.read('mod_glossary_get_categories', params, preSets).then((response) => { + categories = categories.concat(response.categories); + const canLoadMore = (from + limit) < response.count; + if (canLoadMore) { + from += limit; + + return this.getCategories(glossaryId, from, limit, categories, site); + } + + return categories; + }); + } + + /** + * Invalidate cache of categories by glossary id. + * + * @param {number} glossaryId Glossary Id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when categories data has been invalidated, + */ + invalidateCategories(glossaryId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getCategoriesCacheKey(glossaryId)); + }); + } + + /** + * Get an entry by ID cache key. + * + * @param {number} entryId Entry Id. + * @return {string} Cache key. + */ + protected getEntryCacheKey(entryId: number): string { + return this.ROOT_CACHE_KEY + 'getEntry:' + entryId; + } + + /** + * Get one entry by ID. + * + * @param {number} entryId Entry ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the entry. + */ + getEntry(entryId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + id: entryId + }; + const preSets = { + cacheKey: this.getEntryCacheKey(entryId) + }; + + return site.read('mod_glossary_get_entry_by_id', params, preSets).then((response) => { + if (response && response.entry) { + return response.entry; + } else { + return Promise.reject(null); + } + }); + }); + } + + /** + * Performs the fetch of the entries using the propper function and arguments. + * + * @param {Function} fetchFunction Function to fetch. + * @param {any[]} fetchArguments Arguments to call the fetching. + * @param {number} [limitFrom=0] Number of entries already fetched, so fetch will be done from this number. + * @param {number} [limitNum] Number of records to return. Defaults to LIMIT_ENTRIES. + * @param {boolean} [forceCache=false] True to always get the value from cache, false otherwise. Default false. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the response. + */ + fetchEntries(fetchFunction: Function, fetchArguments: any[], limitFrom: number = 0, limitNum?: number, + forceCache: boolean = false, siteId?: string): Promise { + limitNum = limitNum || AddonModGlossaryProvider.LIMIT_ENTRIES; + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const args = fetchArguments.slice(); + args.push(limitFrom); + args.push(limitNum); + args.push(forceCache); + args.push(siteId); + + return fetchFunction.apply(this, args); + } + + /** + * Performs the whole fetch of the entries using the propper function and arguments. + * + * @param {Function} fetchFunction Function to fetch. + * @param {any[]} fetchArguments Arguments to call the fetching. + * @param {boolean} [forceCache=false] True to always get the value from cache, false otherwise. Default false. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with all entrries. + */ + fetchAllEntries(fetchFunction: Function, fetchArguments: any[], forceCache: boolean = false, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const entries = []; + const limitNum = AddonModGlossaryProvider.LIMIT_ENTRIES; + + const fetchMoreEntries = (): Promise => { + return this.fetchEntries(fetchFunction, fetchArguments, entries.length, limitNum, forceCache, siteId).then((result) => { + Array.prototype.push.apply(entries, result.entries); + + return entries.length < result.count ? fetchMoreEntries() : entries; + }); + }; + + return fetchMoreEntries(); + } + + /** + * Invalidate cache of entry by ID. + * + * @param {number} entryId Entry Id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved when data is invalidated. + */ + invalidateEntry(entryId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getEntryCacheKey(entryId)); + }); + } + + /** + * Invalidate cache of all entries in the array. + * + * @param {any[]} entries Entry objects to invalidate. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved when data is invalidated. + */ + protected invalidateEntries(entries: any[], siteId?: string): Promise { + const keys = []; + entries.forEach((entry) => { + keys.push(this.getEntryCacheKey(entry.id)); + }); + + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateMultipleWsCacheForKey(keys); + }); + } + + /** + * Invalidate the prefetched content except files. + * To invalidate files, use AddonModGlossary#invalidateFiles. + * + * @param {number} moduleId The module ID. + * @param {number} courseId Course ID. + * @return {Promise} Promise resolved when data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number): Promise { + return this.getGlossary(courseId, moduleId).then((glossary) => { + return this.invalidateGlossaryEntries(glossary).finally(() => { + return this.utils.allPromises([ + this.invalidateCourseGlossaries(courseId), + this.invalidateCategories(glossary.id) + ]); + }); + }); + } + + /** + * Invalidate the prefetched content for a given glossary, except files. + * To invalidate files, use $mmaModGlossary#invalidateFiles. + * + * @param {any} glossary The glossary object. + * @param {boolean} [onlyEntriesList] If true, entries won't be invalidated. + * @return {Promise} Promise resolved when data is invalidated. + */ + invalidateGlossaryEntries(glossary: any, onlyEntriesList?: boolean): Promise { + const promises = []; + + if (!onlyEntriesList) { + promises.push(this.fetchAllEntries(this.getEntriesByLetter, [glossary.id, 'ALL'], true).then((entries) => { + return this.invalidateEntries(entries); + })); + } + + glossary.browsemodes.forEach((mode) => { + switch (mode) { + case 'letter': + promises.push(this.invalidateEntriesByLetter(glossary.id, 'ALL')); + break; + case 'cat': + promises.push(this.invalidateEntriesByCategory(glossary.id, AddonModGlossaryProvider.SHOW_ALL_CATERGORIES)); + break; + case 'date': + promises.push(this.invalidateEntriesByDate(glossary.id, 'CREATION', 'DESC')); + promises.push(this.invalidateEntriesByDate(glossary.id, 'UPDATE', 'DESC')); + break; + case 'author': + promises.push(this.invalidateEntriesByAuthor(glossary.id, 'ALL', 'LASTNAME', 'ASC')); + break; + default: + } + }); + + return this.utils.allPromises(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. + */ + protected invalidateFiles(moduleId: number, siteId?: string): Promise { + return this.filepoolProvider.invalidateFilesByComponent(siteId, AddonModGlossaryProvider.COMPONENT, moduleId); + } + + /** + * Get one glossary by cmid. + * + * @param {number} courseId Course Id. + * @param {number} cmId Course Module Id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the glossary. + */ + getGlossary(courseId: number, cmId: number, siteId?: string): Promise { + return this.getCourseGlossaries(courseId, siteId).then((glossaries) => { + const glossary = glossaries.find((glossary) => glossary.coursemodule == cmId); + + if (glossary) { + return glossary; + } + + return Promise.reject(null); + }); + } + + /** + * Get one glossary by glossary ID. + * + * @param {number} courseId Course Id. + * @param {number} glossaryId Glossary Id. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the glossary. + */ + getGlossaryById(courseId: number, glossaryId: number, siteId?: string): Promise { + return this.getCourseGlossaries(courseId, siteId).then((glossaries) => { + const glossary = glossaries.find((glossary) => glossary.id == glossaryId); + + if (glossary) { + return glossary; + } + + return Promise.reject(null); + }); + } + + /** + * Create a new entry on a glossary + * + * @param {number} glossaryId Glossary ID. + * @param {string} concept Glossary entry concept. + * @param {string} definition Glossary entry concept definition. + * @param {number} courseId Course ID of the glossary. + * @param {any} [options] Array of options for the entry. + * @param {any} [attach] Attachments ID if sending online, result of $mmFileUploader#storeFilesToUpload otherwise. + * @param {number} [timeCreated] The time the entry was created. If not defined, current time. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {any} [discardEntry] The entry provided will be discarded if found. + * @param {boolean} [allowOffline] True if it can be stored in offline, false otherwise. + * @param {boolean} [checkDuplicates] Check for duplicates before storing offline. Only used if allowOffline is true. + * @return {Promise} Promise resolved with entry ID if entry was created in server, false if stored in device. + */ + addEntry(glossaryId: number, concept: string, definition: string, courseId: number, options: any, attach: any, + timeCreated: number, siteId?: string, discardEntry?: any, allowOffline?: boolean, checkDuplicates?: boolean): + Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Convenience function to store a new entry to be synchronized later. + const storeOffline = (): Promise => { + const discardTime = discardEntry && discardEntry.timecreated; + + let duplicatesPromise; + if (checkDuplicates) { + duplicatesPromise = this.isConceptUsed(glossaryId, concept, discardTime, siteId); + } else { + duplicatesPromise = Promise.resolve(false); + } + + // Check if the entry is duplicated in online or offline mode. + return duplicatesPromise.then((used) => { + if (used) { + return Promise.reject(this.translate.instant('addon.mod_glossary.errconceptalreadyexists')); + } + + return this.glossaryOffline.addNewEntry(glossaryId, concept, definition, courseId, attach, options, timeCreated, + siteId, undefined, discardEntry).then(() => { + return false; + }); + }); + }; + + if (!this.appProvider.isOnline() && allowOffline) { + // App is offline, store the action. + return storeOffline(); + } + + // If we are editing an offline entry, discard previous first. + let discardPromise; + if (discardEntry) { + discardPromise = this.glossaryOffline.deleteNewEntry( + glossaryId, discardEntry.concept, discardEntry.timecreated, siteId); + } else { + discardPromise = Promise.resolve(); + } + + return discardPromise.then(() => { + // Try to add it in online. + return this.addEntryOnline(glossaryId, concept, definition, options, attach, siteId).then((entryId) => { + return entryId; + }).catch((error) => { + if (allowOffline && !this.utils.isWebServiceError(error)) { + // Couldn't connect to server, store in offline. + return storeOffline(); + } else { + // The WebService has thrown an error or offline not supported, reject. + return Promise.reject(error); + } + }); + }); + } + + /** + * Create a new entry on a glossary. It does not cache calls. It will fail if offline or cannot connect. + * + * @param {number} glossaryId Glossary ID. + * @param {string} concept Glossary entry concept. + * @param {string} definition Glossary entry concept definition. + * @param {any} [options] Array of options for the entry. + * @param {number} [attachId] Attachments ID (if any attachment). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the entry ID if created, rejected otherwise. + */ + addEntryOnline(glossaryId: number, concept: string, definition: string, options?: any, attachId?: number, siteId?: string): + Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + glossaryid: glossaryId, + concept: concept, + definition: definition, + definitionformat: 1, + options: this.utils.objectToArrayOfObjects(options || {}, 'name', 'value') + }; + + if (attachId) { + params.options.push({ + name: 'attachmentsid', + value: attachId + }); + } + + // Workaround for bug MDL-57737. + if (!site.isVersionGreaterEqualThan('3.2.2')) { + params.definition = this.textUtils.cleanTags(params.definition); + } + + return site.write('mod_glossary_add_entry', params).then((response) => { + if (response && response.entryid) { + return response.entryid; + } + + return this.utils.createFakeWSError(''); + }); + }); + } + + /** + * Check if a entry concept is already used. + * + * @param {number} glossaryId Glossary ID. + * @param {string} concept Concept to check. + * @param {number} [timeCreated] Timecreated to check that is not the timecreated we are editing. + * @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 error. + */ + isConceptUsed(glossaryId: number, concept: string, timeCreated?: number, siteId?: string): Promise { + // Check offline first. + return this.glossaryOffline.isConceptUsed(glossaryId, concept, timeCreated, siteId).then((exists) => { + if (exists) { + return true; + } + + // If we get here, there's no offline entry with this name, check online. + // Get entries from the cache. + return this.fetchAllEntries(this.getEntriesByLetter, [glossaryId, 'ALL'], true, siteId).then((entries) => { + // Check if there's any entry with the same concept. + return entries.some((entry) => entry.concept == concept); + }); + }).catch(() => { + // Error, assume not used. + return false; + }); + } + + /** + * Return whether or not the plugin is enabled for editing in the current site. Plugin is enabled if the glossary WS are + * available. + * + * @return {boolean} Whether the glossary editing is available or not. + */ + isPluginEnabledForEditing(): boolean { + return this.sitesProvider.getCurrentSite().wsAvailable('mod_glossary_add_entry'); + } + + /** + * Report a glossary as being viewed. + * + * @param {number} glossaryId Glossary ID. + * @param {string} mode The mode in which the glossary was viewed. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logView(glossaryId: number, mode: string): Promise { + const params = { + id: glossaryId, + mode: mode + }; + + return this.sitesProvider.getCurrentSite().write('mod_glossary_view_glossary', params); + } + + /** + * Report a glossary entry as being viewed. + * + * @param {number} entryId Entry ID. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logEntryView(entryId: number): Promise { + const params = { + id: entryId + }; + + return this.sitesProvider.getCurrentSite().write('mod_glossary_view_entry', params); + } +} diff --git a/src/addon/mod/glossary/providers/offline.ts b/src/addon/mod/glossary/providers/offline.ts new file mode 100644 index 000000000..d33af7131 --- /dev/null +++ b/src/addon/mod/glossary/providers/offline.ts @@ -0,0 +1,280 @@ +// (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 { CoreFileProvider } from '@providers/file'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; + +/** + * Service to handle offline glossary. + */ +@Injectable() +export class AddonModGlossaryOfflineProvider { + + // Variables for database. + protected ENTRIES_TABLE = 'addon_mod_glossary_entrues'; + + protected tablesSchema = [ + { + name: this.ENTRIES_TABLE, + columns: [ + { + name: 'glossaryid', + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'concept', + type: 'TEXT', + }, + { + name: 'definition', + type: 'TEXT', + }, + { + name: 'definitionformat', + type: 'TEXT', + }, + { + name: 'userid', + type: 'INTEGER', + }, + { + name: 'timecreated', + type: 'INTEGER', + }, + { + name: 'options', + type: 'TEXT', + }, + { + name: 'attachments', + type: 'TEXT', + }, + ], + primaryKeys: ['glossaryid', 'concept', 'timecreated'] + } + ]; + + constructor(private fileProvider: CoreFileProvider, + private sitesProvider: CoreSitesProvider, + private textUtils: CoreTextUtilsProvider, + private utils: CoreUtilsProvider) { + this.sitesProvider.createTablesFromSchema(this.tablesSchema); + } + + /** + * Delete a new entry. + * + * @param {number} glossaryId Glossary ID. + * @param {string} concept Glossary entry concept. + * @param {number} timeCreated The time the entry was created. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if deleted, rejected if failure. + */ + deleteNewEntry(glossaryId: number, concept: string, timeCreated: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions = { + glossaryid: glossaryId, + concept: concept, + timecreated: timeCreated, + }; + + return site.getDb().deleteRecords(this.ENTRIES_TABLE, conditions); + }); + } + + /** + * Get all the stored new entries from all the glossaries. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with entries. + */ + getAllNewEntries(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecords(this.ENTRIES_TABLE).then((records: any[]) => { + return records.map(this.parseRecord.bind(this)); + }); + }); + } + + /** + * Get a stored new entry. + * + * @param {number} glossaryId Glossary ID. + * @param {string} concept Glossary entry concept. + * @param {number} timeCreated The time the entry was created. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with entry. + */ + getNewEntry(glossaryId: number, concept: string, timeCreated: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions = { + glossaryid: glossaryId, + concept: concept, + timecreated: timeCreated, + }; + + return site.getDb().getRecord(this.ENTRIES_TABLE, conditions).then(this.parseRecord.bind(this)); + }); + } + + /** + * Get all the stored add entry data from a certain glossary. + * + * @param {number} glossaryId Glossary ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the entries belong to. If not defined, current user in site. + * @return {Promise} Promise resolved with entries. + */ + getGlossaryNewEntries(glossaryId: number, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions = { + glossaryid: glossaryId, + userId: userId || site.getUserId(), + }; + + return site.getDb().getRecords(this.ENTRIES_TABLE, conditions).then((records: any[]) => { + return records.map(this.parseRecord.bind(this)); + }); + }); + } + + /** + * Check if a concept is used offline. + * + * @param {number} glossaryId Glossary ID. + * @param {string} concept Concept to check. + * @param {number} [timeCreated] Time of the entry we are editing. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with true if concept is found, false otherwise. + */ + isConceptUsed(glossaryId: number, concept: string, timeCreated?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions = { + glossaryid: glossaryId, + concept: concept, + }; + + return site.getDb().getRecords(this.ENTRIES_TABLE, conditions).then((entries) => { + if (!entries.length) { + return false; + } + + if (entries.length > 1 || !timeCreated) { + return true; + } + + // If there's only one entry, check that is not the one we are editing. + return this.utils.promiseFails(this.getNewEntry(glossaryId, concept, timeCreated, siteId)); + }); + }).catch(() => { + // No offline data found, return false. + return false; + }); + } + + /** + * Save a new entry to be sent later. + * + * @param {number} glossaryId Glossary ID. + * @param {string} concept Glossary entry concept. + * @param {string} definition Glossary entry concept definition. + * @param {number} courseId Course ID of the glossary. + * @param {any} [options] Options for the entry. + * @param {any} [attachments] Result of CoreFileUploaderProvider#storeFilesToUpload for attachments. + * @param {number} [timeCreated] The time the entry was created. If not defined, current time. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User the entry belong to. If not defined, current user in site. + * @param {any} [discardEntry] The entry provided will be discarded if found. + * @return {Promise} Promise resolved if stored, rejected if failure. + */ + addNewEntry(glossaryId: number, concept: string, definition: string, courseId: number, options?: any, attachments?: any, + timeCreated?: number, siteId?: string, userId?: number, discardEntry?: any): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const entry = { + glossaryid: glossaryId, + courseid: courseId, + concept: concept, + definition: definition, + definitionformat: 'html', + options: JSON.stringify(options), + attachments: JSON.stringify(attachments), + userid: userId || site.getUserId(), + timecreated: timeCreated || new Date().getTime() + }; + + // If editing an offline entry, delete previous first. + let discardPromise; + if (discardEntry) { + discardPromise = this.deleteNewEntry(glossaryId, discardEntry.concept, discardEntry.timecreated, site.getId()); + } else { + discardPromise = Promise.resolve(); + } + + return discardPromise.then(() => { + return site.getDb().insertRecord(this.ENTRIES_TABLE, entry).then(() => false); + }); + }); + } + + /** + * Get the path to the folder where to store files for offline attachments in a glossary. + * + * @param {number} glossaryId Glossary ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the path. + */ + getGlossaryFolder(glossaryId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const siteFolderPath = this.fileProvider.getSiteFolder(site.getId()); + const folderPath = 'offlineglossary/' + glossaryId; + + return this.textUtils.concatenatePaths(siteFolderPath, folderPath); + }); + } + + /** + * Get the path to the folder where to store files for a new offline entry. + * + * @param {number} glossaryId Glossary ID. + * @param {string} concept The name of the entry. + * @param {number} timeCreated Time to allow duplicated entries. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the path. + */ + getEntryFolder(glossaryId: number, concept: string, timeCreated: number, siteId?: string): Promise { + return this.getGlossaryFolder(glossaryId, siteId).then((folderPath) => { + return this.textUtils.concatenatePaths(folderPath, 'newentry_' + concept + '_' + timeCreated); + }); + } + + /** + * Parse "options" and "attachments" columns of a fetched record. + * + * @param {any} records Record object + * @return {any} Record object with columns parsed. + */ + protected parseRecord(record: any): any { + record.options = this.textUtils.parseJSON(record.options); + record.attachments = this.textUtils.parseJSON(record.attachments); + + return record; + } +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 7c565ae77..a58fa3881 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -87,6 +87,7 @@ import { AddonModResourceModule } from '@addon/mod/resource/resource.module'; import { AddonModFeedbackModule } from '@addon/mod/feedback/feedback.module'; import { AddonModFolderModule } from '@addon/mod/folder/folder.module'; import { AddonModForumModule } from '@addon/mod/forum/forum.module'; +import { AddonModGlossaryModule } from '@addon/mod/glossary/glossary.module'; import { AddonModPageModule } from '@addon/mod/page/page.module'; import { AddonModQuizModule } from '@addon/mod/quiz/quiz.module'; import { AddonModScormModule } from '@addon/mod/scorm/scorm.module'; @@ -188,6 +189,7 @@ export const CORE_PROVIDERS: any[] = [ AddonModFeedbackModule, AddonModFolderModule, AddonModForumModule, + AddonModGlossaryModule, AddonModLtiModule, AddonModPageModule, AddonModQuizModule,