// (C) Copyright 2015 Moodle Pty Ltd. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { ContextLevel } from '@/core/constants'; import { Injectable } from '@angular/core'; import { CoreCourse } from '@features/course/services/course'; import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; import { CoreRatingOffline } from '@features/rating/services/rating-offline'; import { FileEntry } from '@ionic-native/file/ngx'; import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreFormFields } from '@singletons/form'; import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils } from '@services/utils/utils'; import { makeSingleton, Translate } from '@singletons'; import { CoreEvents } from '@singletons/events'; import { AddonModDataEntry, AddonModData, AddonModDataProvider, AddonModDataSearchEntriesOptions, AddonModDataEntries, AddonModDataEntryFields, AddonModDataAction, AddonModDataGetEntryFormatted, AddonModDataData, AddonModDataTemplateType, AddonModDataGetDataAccessInformationWSResponse, AddonModDataTemplateMode, AddonModDataField, AddonModDataEntryWSField, } from './data'; import { AddonModDataFieldsDelegate } from './data-fields-delegate'; import { AddonModDataOffline, AddonModDataOfflineAction } from './data-offline'; import { CoreFileEntry } from '@services/file-helper'; /** * Service that provides helper functions for datas. */ @Injectable({ providedIn: 'root' }) export class AddonModDataHelperProvider { /** * Returns the record with the offline actions applied. * * @param record Entry to modify. * @param offlineActions Offline data with the actions done. * @param fields Entry defined fields indexed by fieldid. * @return Promise resolved when done. */ protected async applyOfflineActions( record: AddonModDataEntry, offlineActions: AddonModDataOfflineAction[], fields: AddonModDataField[], ): Promise { const promises: Promise[] = []; offlineActions.forEach((action) => { record.timemodified = action.timemodified; record.hasOffline = true; const offlineContents: Record = {}; switch (action.action) { case AddonModDataAction.APPROVE: record.approved = true; break; case AddonModDataAction.DISAPPROVE: record.approved = false; break; case AddonModDataAction.DELETE: record.deleted = true; break; case AddonModDataAction.ADD: case AddonModDataAction.EDIT: record.groupid = action.groupid; action.fields.forEach((offlineContent) => { if (offlineContents[offlineContent.fieldid] === undefined) { offlineContents[offlineContent.fieldid] = {}; } if (offlineContent.subfield) { offlineContents[offlineContent.fieldid][offlineContent.subfield] = CoreTextUtils.parseJSON(offlineContent.value, ''); } else { offlineContents[offlineContent.fieldid][''] = CoreTextUtils.parseJSON(offlineContent.value, ''); } }); // Override field contents. fields.forEach((field) => { if (AddonModDataFieldsDelegate.hasFiles(field)) { promises.push(this.getStoredFiles(record.dataid, record.id, field.id).then((offlineFiles) => { record.contents[field.id] = AddonModDataFieldsDelegate.overrideData( field, record.contents[field.id], offlineContents[field.id], offlineFiles, ); record.contents[field.id].fieldid = field.id; return; })); } else { record.contents[field.id] = AddonModDataFieldsDelegate.overrideData( field, record.contents[field.id], offlineContents[field.id], ); record.contents[field.id].fieldid = field.id; } }); break; default: break; } }); await Promise.all(promises); return record; } /** * Approve or disapprove a database entry. * * @param dataId Database ID. * @param entryId Entry ID. * @param approve True to approve, false to disapprove. * @param courseId Course ID. It not defined, it will be fetched. * @param siteId Site ID. If not defined, current site. */ async approveOrDisapproveEntry( dataId: number, entryId: number, approve: boolean, courseId?: number, siteId?: string, ): Promise { siteId = siteId || CoreSites.getCurrentSiteId(); const modal = await CoreDomUtils.showModalLoading('core.sending', true); try { courseId = await this.getActivityCourseIdIfNotSet(dataId, courseId, siteId); try { // Approve/disapprove entry. await AddonModData.approveEntry(dataId, entryId, approve, courseId, siteId); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.mod_data.errorapproving', true); throw error; } const promises: Promise[] = []; promises.push(AddonModData.invalidateEntryData(dataId, entryId, siteId)); promises.push(AddonModData.invalidateEntriesData(dataId, siteId)); await CoreUtils.ignoreErrors(Promise.all(promises)); CoreEvents.trigger(AddonModDataProvider.ENTRY_CHANGED, { dataId: dataId, entryId: entryId }, siteId); CoreDomUtils.showToast(approve ? 'addon.mod_data.recordapproved' : 'addon.mod_data.recorddisapproved', true, 3000); } catch { // Ignore error, it was already displayed. } finally { modal.dismiss(); } } /** * Displays fields for being shown. * * @param template Template HMTL. * @param fields Fields that defines every content in the entry. * @param entry Entry. * @param offset Entry offset. * @param mode Mode list or show. * @param actions Actions that can be performed to the record. * @return Generated HTML. */ displayShowFields( template: string, fields: AddonModDataField[], entry: AddonModDataEntry, offset = 0, mode: AddonModDataTemplateMode, actions: Record, ): string { if (!template) { return ''; } // Replace the fields found on template. fields.forEach((field) => { let replace = '[[' + field.name + ']]'; replace = replace.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); const replaceRegex = new RegExp(replace, 'gi'); // Replace field by a generic directive. const render = ''; template = template.replace(replaceRegex, render); }); for (const action in actions) { const replaceRegex = new RegExp('##' + action + '##', 'gi'); // Is enabled? if (actions[action]) { let render = ''; if (action == AddonModDataAction.MOREURL) { // Render more url directly because it can be part of an HTML attribute. render = CoreSites.getRequiredCurrentSite().getURL() + '/mod/data/view.php?d={{database.id}}&rid=' + entry.id; } else if (action == 'approvalstatus') { render = Translate.instant('addon.mod_data.' + (entry.approved ? 'approved' : 'notapproved')); } else { render = ''; } template = template.replace(replaceRegex, render); } else { template = template.replace(replaceRegex, ''); } } return template; } /** * Get online and offline entries, or search entries. * * @param database Database object. * @param fields The fields that define the contents. * @param options Other options. * @return Promise resolved when the database is retrieved. */ async fetchEntries( database: AddonModDataData, fields: AddonModDataField[], options: AddonModDataSearchEntriesOptions = {}, ): Promise { const site = await CoreSites.getSite(options.siteId); options.groupId = options.groupId || 0; options.page = options.page || 0; const offlineActions: Record = {}; const result: AddonModDataEntries = { entries: [], totalcount: 0, offlineEntries: [], }; options.siteId = site.id; const offlinePromise = AddonModDataOffline.getDatabaseEntries(database.id, site.id).then((actions) => { result.hasOfflineActions = !!actions.length; actions.forEach((action) => { if (offlineActions[action.entryid] === undefined) { offlineActions[action.entryid] = []; } offlineActions[action.entryid].push(action); // We only display new entries in the first page when not searching. if (action.action == AddonModDataAction.ADD && options.page == 0 && !options.search && !options.advSearch && (!action.groupid || !options.groupId || action.groupid == options.groupId)) { result.offlineEntries!.push({ id: action.entryid, canmanageentry: true, approved: !database.approval || database.manageapproved, dataid: database.id, groupid: action.groupid, timecreated: -action.entryid, timemodified: -action.entryid, userid: site.getUserId(), fullname: site.getInfo()?.fullname, contents: {}, }); } }); // Sort offline entries by creation time. result.offlineEntries!.sort((a, b) => b.timecreated - a.timecreated); return; }); const ratingsPromise = CoreRatingOffline.hasRatings('mod_data', 'entry', ContextLevel.MODULE, database.coursemodule) .then((hasRatings) => { result.hasOfflineRatings = hasRatings; return; }); let fetchPromise: Promise; if (options.search || options.advSearch) { fetchPromise = AddonModData.searchEntries(database.id, options).then((searchResult) => { result.entries = searchResult.entries; result.totalcount = searchResult.totalcount; result.maxcount = searchResult.maxcount; return; }); } else { fetchPromise = AddonModData.getEntries(database.id, options).then((entriesResult) => { result.entries = entriesResult.entries; result.totalcount = entriesResult.totalcount; return; }); } await Promise.all([offlinePromise, ratingsPromise, fetchPromise]); // Apply offline actions to online and offline entries. const promises: Promise[] = []; result.entries.forEach((entry) => { promises.push(this.applyOfflineActions(entry, offlineActions[entry.id] || [], fields)); }); result.offlineEntries!.forEach((entry) => { promises.push(this.applyOfflineActions(entry, offlineActions[entry.id] || [], fields)); }); await Promise.all(promises); return result; } /** * Fetch an online or offline entry. * * @param database Database. * @param fields List of database fields. * @param entryId Entry ID. * @param siteId Site ID. If not defined, current site. * @return Promise resolved with the entry. */ async fetchEntry( database: AddonModDataData, fields: AddonModDataField[], entryId: number, siteId?: string, ): Promise { const site = await CoreSites.getSite(siteId); const offlineActions = await AddonModDataOffline.getEntryActions(database.id, entryId, site.id); let response: AddonModDataGetEntryFormatted; if (entryId > 0) { // Online entry. response = await AddonModData.getEntry(database.id, entryId, { cmId: database.coursemodule, siteId: site.id }); } else { // Offline entry or new entry. response = { entry: { id: entryId, userid: site.getUserId(), groupid: 0, dataid: database.id, timecreated: -entryId, timemodified: -entryId, approved: !database.approval || database.manageapproved, canmanageentry: true, fullname: site.getInfo()?.fullname, contents: {}, }, }; } await this.applyOfflineActions(response.entry, offlineActions, fields); return response; } /** * Returns an object with all the actions that the user can do over the record. * * @param database Database activity. * @param accessInfo Access info to the activity. * @param entry Entry or record where the actions will be performed. * @return Keyed with the action names and boolean to evalute if it can or cannot be done. */ getActions( database: AddonModDataData, accessInfo: AddonModDataGetDataAccessInformationWSResponse, entry: AddonModDataEntry, ): Record { return { add: false, // Not directly used on entries. more: true, moreurl: true, user: true, userpicture: true, timeadded: true, timemodified: true, tags: true, edit: entry.canmanageentry && !entry.deleted, // This already checks capabilities and readonly period. delete: entry.canmanageentry, approve: database.approval && accessInfo.canapprove && !entry.approved && !entry.deleted, disapprove: database.approval && accessInfo.canapprove && entry.approved && !entry.deleted, approvalstatus: database.approval, comments: database.comments, // Unsupported actions. delcheck: false, export: false, }; } /** * Convenience function to get the course id of the database. * * @param dataId Database id. * @param courseId Course id, if known. * @param siteId Site id, if not set, current site will be used. * @return Resolved with course Id when done. */ protected async getActivityCourseIdIfNotSet(dataId: number, courseId?: number, siteId?: string): Promise { if (courseId) { return courseId; } const module = await CoreCourse.getModuleBasicInfoByInstance( dataId, 'data', { siteId, readingStrategy: CoreSitesReadingStrategy.PREFER_CACHE }, ); return module.course; } /** * Returns the default template of a certain type. * * Based on Moodle function data_generate_default_template. * * @param type Type of template. * @param fields List of database fields. * @return Template HTML. */ getDefaultTemplate(type: AddonModDataTemplateType, fields: AddonModDataField[]): string { if (type == AddonModDataTemplateType.LIST_HEADER || type == AddonModDataTemplateType.LIST_FOOTER) { return ''; } const html: string[] = []; if (type == AddonModDataTemplateType.LIST) { html.push('##delcheck##
'); } html.push( '
', '', '', ); fields.forEach((field) => { html.push( '', '', '', '', ); }); if (type == AddonModDataTemplateType.LIST) { html.push( '', '', '', ); } else if (type == AddonModDataTemplateType.SINGLE) { html.push( '', '', '', ); } else if (type == AddonModDataTemplateType.SEARCH) { html.push( '', '', '', '', '', '', '', '', ); } html.push( '', '
', field.name, ': [[', field.name, ']]
', '##edit## ##more## ##delete## ##approve## ##disapprove## ##export##', '
', '##edit## ##delete## ##approve## ##disapprove## ##export##', '
Author first name: ##firstname##
Author surname: ##lastname##
', '
', ); if (type == AddonModDataTemplateType.LIST) { html.push('
'); } return html.join(''); } /** * Retrieve the entered data in the edit form. * We don't use ng-model because it doesn't detect changes done by JavaScript. * * @param inputData Array with the entered form values. * @param fields Fields that defines every content in the entry. * @param dataId Database Id. If set, files will be uploaded and itemId set. * @param entryId Entry Id. * @param entryContents Original entry contents. * @param offline True to prepare the data for an offline uploading, false otherwise. * @param siteId Site ID. If not defined, current site. * @return That contains object with the answers. */ async getEditDataFromForm( inputData: CoreFormFields, fields: AddonModDataField[], dataId: number, entryId: number, entryContents: AddonModDataEntryFields, offline = false, siteId?: string, ): Promise { if (!inputData) { return []; } siteId = siteId || CoreSites.getCurrentSiteId(); // Filter and translate fields to each field plugin. const entryFieldDataToSend: AddonModDataEntryWSField[] = []; const promises = fields.map(async (field) => { const fieldData = AddonModDataFieldsDelegate.getFieldEditData(field, inputData, entryContents[field.id]); if (!fieldData) { return; } const proms = fieldData.map(async (fieldSubdata) => { let value = fieldSubdata.value; // Upload Files if asked. if (dataId && fieldSubdata.files) { value = await this.uploadOrStoreFiles( dataId, 0, entryId, fieldSubdata.fieldid, fieldSubdata.files, offline, siteId, ); } // WS wants values in JSON format. entryFieldDataToSend.push({ fieldid: fieldSubdata.fieldid, subfield: fieldSubdata.subfield || '', value: value ? JSON.stringify(value) : '', }); return; }); await Promise.all(proms); }); await Promise.all(promises); return entryFieldDataToSend; } /** * Retrieve the temp files to be updated. * * @param inputData Array with the entered form values. * @param fields Fields that defines every content in the entry. * @param entryContents Original entry contents indexed by field id. * @return That contains object with the files. */ async getEditTmpFiles( inputData: CoreFormFields, fields: AddonModDataField[], entryContents: AddonModDataEntryFields, ): Promise { if (!inputData) { return []; } // Filter and translate fields to each field plugin. const promises = fields.map((field) => AddonModDataFieldsDelegate.getFieldEditFiles(field, inputData, entryContents[field.id])); const fieldsFiles = await Promise.all(promises); return fieldsFiles.reduce((files, fieldFiles) => files.concat(fieldFiles), []); } /** * Get a list of stored attachment files for a new entry. See $mmaModDataHelper#storeFiles. * * @param dataId Database ID. * @param entryId Entry ID or, if creating, timemodified. * @param fieldId Field ID. * @param siteId Site ID. If not defined, current site. * @return Promise resolved with the files. */ async getStoredFiles(dataId: number, entryId: number, fieldId: number, siteId?: string): Promise { const folderPath = await AddonModDataOffline.getEntryFieldFolder(dataId, entryId, fieldId, siteId); try { return await CoreFileUploader.getStoredFiles(folderPath); } catch { // Ignore not found files. return []; } } /** * Returns the template of a certain type. * * @param data Database object. * @param type Type of template. * @param fields List of database fields. * @return Template HTML. */ getTemplate(data: AddonModDataData, type: AddonModDataTemplateType, fields: AddonModDataField[]): string { let template = data[type] || this.getDefaultTemplate(type, fields); if (type != AddonModDataTemplateType.LIST_HEADER && type != AddonModDataTemplateType.LIST_FOOTER) { // Try to fix syntax errors so the template can be parsed by Angular. template = CoreDomUtils.fixHtml(template); } // Add core-link directive to links. template = template.replace( /]*href="[^>]*)>/ig, (match, attributes) => '', ); return template; } /** * Check if data has been changed by the user. * * @param inputData Object with the entered form values. * @param fields Fields that defines every content in the entry. * @param dataId Database Id. If set, fils will be uploaded and itemId set. * @param entryContents Original entry contents indexed by field id. * @return True if changed, false if not. */ hasEditDataChanged( inputData: CoreFormFields, fields: AddonModDataField[], entryContents: AddonModDataEntryFields, ): boolean { return fields.some((field) => AddonModDataFieldsDelegate.hasFieldDataChanged(field, inputData, entryContents[field.id])); } /** * Displays a confirmation modal for deleting an entry. * * @param dataId Database ID. * @param entryId Entry ID. * @param courseId Course ID. It not defined, it will be fetched. * @param siteId Site ID. If not defined, current site. */ async showDeleteEntryModal(dataId: number, entryId: number, courseId?: number, siteId?: string): Promise { siteId = siteId || CoreSites.getCurrentSiteId(); try { await CoreDomUtils.showDeleteConfirm('addon.mod_data.confirmdeleterecord'); const modal = await CoreDomUtils.showModalLoading(); try { if (entryId > 0) { courseId = await this.getActivityCourseIdIfNotSet(dataId, courseId, siteId); } AddonModData.deleteEntry(dataId, entryId, courseId!, siteId); } catch (message) { CoreDomUtils.showErrorModalDefault(message, 'addon.mod_data.errordeleting', true); modal.dismiss(); return; } try { await AddonModData.invalidateEntryData(dataId, entryId, siteId); await AddonModData.invalidateEntriesData(dataId, siteId); } catch (error) { // Ignore errors. } CoreEvents.trigger(AddonModDataProvider.ENTRY_CHANGED, { dataId, entryId, deleted: true }, siteId); CoreDomUtils.showToast('addon.mod_data.recorddeleted', true, 3000); modal.dismiss(); } catch { // Ignore error, it was already displayed. } } /** * Given a list of files (either online files or local files), store the local files in a local folder * to be submitted later. * * @param dataId Database ID. * @param entryId Entry ID or, if creating, timemodified. * @param fieldId Field ID. * @param files List of files. * @param siteId Site ID. If not defined, current site. * @return Promise resolved if success, rejected otherwise. */ async storeFiles( dataId: number, entryId: number, fieldId: number, files: CoreFileEntry[], siteId?: string, ): Promise { // Get the folder where to store the files. const folderPath = await AddonModDataOffline.getEntryFieldFolder(dataId, entryId, fieldId, siteId); return CoreFileUploader.storeFilesToUpload(folderPath, files); } /** * Upload or store some files, depending if the user is offline or not. * * @param dataId Database ID. * @param itemId Draft ID to use. Undefined or 0 to create a new draft ID. * @param entryId Entry ID or, if creating, timemodified. * @param fieldId Field ID. * @param files List of files. * @param offline True if files sould be stored for offline, false to upload them. * @param siteId Site ID. If not defined, current site. * @return Promise resolved with the itemId for the uploaded file/s. */ async uploadOrStoreFiles( dataId: number, itemId: number, entryId: number, fieldId: number, files: CoreFileEntry[], offline: true, siteId?: string, ): Promise; async uploadOrStoreFiles( dataId: number, itemId: number, entryId: number, fieldId: number, files: CoreFileEntry[], offline: false, siteId?: string, ): Promise; async uploadOrStoreFiles( dataId: number, itemId: number, entryId: number, fieldId: number, files: CoreFileEntry[], offline: boolean, siteId?: string, ): Promise; async uploadOrStoreFiles( dataId: number, itemId: number = 0, entryId: number, fieldId: number, files: CoreFileEntry[], offline: boolean, siteId?: string, ): Promise { if (offline) { return this.storeFiles(dataId, entryId, fieldId, files, siteId); } if (!files.length) { return 0; } return CoreFileUploader.uploadOrReuploadFiles(files, AddonModDataProvider.COMPONENT, itemId, siteId); } } export const AddonModDataHelper = makeSingleton(AddonModDataHelperProvider);