diff --git a/scripts/langindex.json b/scripts/langindex.json index 1e1606b32..0a3a21d16 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1861,6 +1861,7 @@ "core.mainmenu.help": "moodle", "core.mainmenu.logout": "moodle", "core.mainmenu.website": "local_moodlemobileapp", + "core.maxfilesize": "moodle", "core.maxsizeandattachments": "moodle", "core.min": "moodle", "core.mins": "moodle", @@ -1944,8 +1945,8 @@ "core.question.certainty": "qbehaviour_deferredcbm", "core.question.complete": "question", "core.question.correct": "question", - "core.question.errorattachmentsnotsupported": "local_moodlemobileapp", - "core.question.errorinlinefilesnotsupported": "local_moodlemobileapp", + "core.question.errorattachmentsnotsupportedinsite": "local_moodlemobileapp", + "core.question.errorembeddedfilesnotsupportedinsite": "local_moodlemobileapp", "core.question.errorquestionnotsupported": "local_moodlemobileapp", "core.question.feedback": "question", "core.question.howtodraganddrop": "local_moodlemobileapp", diff --git a/src/addon/mod/lesson/providers/lesson-sync.ts b/src/addon/mod/lesson/providers/lesson-sync.ts index a5375d352..b7e4c86db 100644 --- a/src/addon/mod/lesson/providers/lesson-sync.ts +++ b/src/addon/mod/lesson/providers/lesson-sync.ts @@ -411,7 +411,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid const params = this.urlUtils.extractUrlParams(response.data.reviewlesson.value); if (params && params.pageid) { // The retake can be reviewed, mark it as finished. Don't block the user for this. - this.setRetakeFinishedInSync(lessonId, retake.retake, params.pageid, siteId); + this.setRetakeFinishedInSync(lessonId, retake.retake, Number(params.pageid), siteId); } } } diff --git a/src/addon/qtype/essay/component/addon-qtype-essay.html b/src/addon/qtype/essay/component/addon-qtype-essay.html index 13462423b..677a3149c 100644 --- a/src/addon/qtype/essay/component/addon-qtype-essay.html +++ b/src/addon/qtype/essay/component/addon-qtype-essay.html @@ -5,9 +5,10 @@ - - + + + @@ -15,22 +16,29 @@ - + -

{{ 'core.question.errorinlinefilesnotsupported' | translate }}

+

{{ 'core.question.errorinlinefilesnotsupportedinsite' | translate }}

- - -

{{ 'core.question.errorattachmentsnotsupported' | translate }}

-
+ + + + + + + + +

{{ 'core.question.errorattachmentsnotsupportedinsite' | translate }}

+
+
- +

diff --git a/src/addon/qtype/essay/component/essay.ts b/src/addon/qtype/essay/component/essay.ts index 8cd380933..3419ca125 100644 --- a/src/addon/qtype/essay/component/essay.ts +++ b/src/addon/qtype/essay/component/essay.ts @@ -14,8 +14,11 @@ import { Component, OnInit, Injector } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSites } from '@providers/sites'; +import { CoreWSExternalFile } from '@providers/ws'; import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component'; import { FormControl, FormBuilder } from '@angular/forms'; +import { CoreFileSession } from '@providers/file-session'; /** * Component to render an essay question. @@ -28,6 +31,9 @@ export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implemen protected formControl: FormControl; + attachments: CoreWSExternalFile[]; + uploadFilesSupported: boolean; + constructor(logger: CoreLoggerProvider, injector: Injector, protected fb: FormBuilder) { super(logger, 'AddonQtypeEssayComponent', injector); } @@ -36,8 +42,14 @@ export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implemen * Component being initialized. */ ngOnInit(): void { + this.uploadFilesSupported = CoreSites.instance.getCurrentSite().isVersionGreaterEqualThan('3.10'); this.initEssayComponent(); this.formControl = this.fb.control(this.question.textarea && this.question.textarea.text); + + if (this.question.allowsAttachments && this.uploadFilesSupported) { + this.attachments = Array.from(this.questionHelper.getResponseFileAreaFiles(this.question, 'attachments')); + CoreFileSession.instance.setFiles(this.component, this.componentId + '_' + this.question.id, this.attachments); + } } } diff --git a/src/addon/qtype/essay/providers/handler.ts b/src/addon/qtype/essay/providers/handler.ts index 9206b4e4e..fc9466973 100644 --- a/src/addon/qtype/essay/providers/handler.ts +++ b/src/addon/qtype/essay/providers/handler.ts @@ -14,6 +14,7 @@ // limitations under the License. import { Injectable, Injector } from '@angular/core'; +import { CoreSites } from '@providers/sites'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreUtilsProvider } from '@providers/utils/utils'; @@ -68,11 +69,11 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler { if (element.querySelector('div[id*=filemanager]')) { // The question allows attachments. Since the app cannot attach files yet we will prevent submitting the question. - return 'core.question.errorattachmentsnotsupported'; + return 'core.question.errorattachmentsnotsupportedinsite'; } if (this.questionHelper.hasDraftFileUrls(element.innerHTML)) { - return 'core.question.errorinlinefilesnotsupported'; + return 'core.question.errorinlinefilesnotsupportedinsite'; } } @@ -139,13 +140,21 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler { * @param siteId Site ID. If not defined, current site. * @return Return a promise resolved when done if async, void if sync. */ - prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): void | Promise { + async prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): Promise { const element = this.domUtils.convertToElement(question.html); // Search the textarea to get its name. const textarea = element.querySelector('textarea[name*=_answer]'); if (textarea && typeof answers[textarea.name] != 'undefined') { + if (this.questionHelper.hasDraftFileUrls(question.html) && question.responsefileareas) { + // Restore draftfile URLs. + const site = await CoreSites.instance.getSite(siteId); + + answers[textarea.name] = this.textUtils.restoreDraftfileUrls(site.getURL(), answers[textarea.name], + question.html, this.questionHelper.getResponseFileAreaFiles(question, 'answer')); + } + // Add some HTML to the text if needed. answers[textarea.name] = this.textUtils.formatHtmlLines(answers[textarea.name]); } diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 70be9851c..7f1c3160f 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -1945,8 +1945,8 @@ "core.question.certainty": "Certainty", "core.question.complete": "Complete", "core.question.correct": "Correct", - "core.question.errorattachmentsnotsupported": "The application doesn't support attaching files to answers yet.", - "core.question.errorinlinefilesnotsupported": "The application doesn't support editing inline files yet.", + "core.question.errorattachmentsnotsupportedinsite": "Your site doesn't support attaching files to answers yet.", + "core.question.errorinlinefilesnotsupportedinsite": "Your site doesn't support editing inline files yet.", "core.question.errorquestionnotsupported": "This question type is not supported by the app: {{$a}}.", "core.question.feedback": "Feedback", "core.question.howtodraganddrop": "Tap to select then tap to drop.", diff --git a/src/classes/site.ts b/src/classes/site.ts index b3285842f..0502b505c 100644 --- a/src/classes/site.ts +++ b/src/classes/site.ts @@ -221,13 +221,16 @@ export class CoreSite { // Versions of Moodle releases. protected MOODLE_RELEASES = { - 3.1: 2016052300, - 3.2: 2016120500, - 3.3: 2017051503, - 3.4: 2017111300, - 3.5: 2018051700, - 3.6: 2018120300, - 3.7: 2019052000 + '3.1': 2016052300, + '3.2': 2016120500, + '3.3': 2017051503, + '3.4': 2017111300, + '3.5': 2018051700, + '3.6': 2018120300, + '3.7': 2019052000, + '3.8': 2019111800, + '3.9': 2020061500, + '3.10': 2020092400, // @todo: Replace with the right value once 3.10 is released. }; static MINIMUM_MOODLE_VERSION = '3.1'; diff --git a/src/core/editor/components/rich-text-editor/rich-text-editor.scss b/src/core/editor/components/rich-text-editor/rich-text-editor.scss index ae01ced74..512b960b7 100644 --- a/src/core/editor/components/rich-text-editor/rich-text-editor.scss +++ b/src/core/editor/components/rich-text-editor/rich-text-editor.scss @@ -56,6 +56,7 @@ ion-app.app-root core-rich-text-editor { img { @include padding(null, null, null, 2px); max-width: 95%; + width: auto; } &:empty:before { content: attr(data-placeholder-text); diff --git a/src/core/grades/providers/helper.ts b/src/core/grades/providers/helper.ts index 7bdfc7b67..65f0fd4c1 100644 --- a/src/core/grades/providers/helper.ts +++ b/src/core/grades/providers/helper.ts @@ -407,7 +407,7 @@ export class CoreGradesHelperProvider { if (matches && matches.length) { const hrefParams = this.urlUtils.extractUrlParams(matches[1]); - return hrefParams && hrefParams.id == moduleId; + return hrefParams && Number(hrefParams.id) == moduleId; } } diff --git a/src/core/login/providers/helper.ts b/src/core/login/providers/helper.ts index cf9546050..05c517d98 100644 --- a/src/core/login/providers/helper.ts +++ b/src/core/login/providers/helper.ts @@ -1152,7 +1152,7 @@ export class CoreLoginHelperProvider { const providerToUse = identityProviders.find((provider) => { const params = this.urlUtils.extractUrlParams(provider.url); - return params.id == currentSite.getOAuthId(); + return Number(params.id) == currentSite.getOAuthId(); }); if (providerToUse) { diff --git a/src/core/question/classes/base-question-component.ts b/src/core/question/classes/base-question-component.ts index f968ec0b9..f51f53eee 100644 --- a/src/core/question/classes/base-question-component.ts +++ b/src/core/question/classes/base-question-component.ts @@ -14,8 +14,10 @@ import { Input, Output, EventEmitter, Injector, ElementRef } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSites } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreUrlUtils } from '@providers/utils/url'; import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; /** @@ -162,6 +164,8 @@ export class CoreQuestionBaseComponent { const input = questionEl.querySelector('input[type="text"][name*=answer]'); this.question.optionsFirst = questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(radios[0].outerHTML); + + return questionEl; } } @@ -201,27 +205,38 @@ export class CoreQuestionBaseComponent { const questionEl = this.initComponent(); if (questionEl) { - // First search the textarea. const textarea = questionEl.querySelector('textarea[name*=_answer]'); + const answerDraftIdInput = questionEl.querySelector('input[name*="_answer:itemid"]'); + this.question.allowsAttachments = !!questionEl.querySelector('div[id*=filemanager]'); + this.question.allowsAnswerFiles = !!answerDraftIdInput; this.question.isMonospaced = !!questionEl.querySelector('.qtype_essay_monospaced'); this.question.isPlainText = this.question.isMonospaced || !!questionEl.querySelector('.qtype_essay_plain'); - this.question.hasDraftFiles = this.questionHelper.hasDraftFileUrls(questionEl.innerHTML); + this.question.hasDraftFiles = this.question.allowsAnswerFiles && + this.questionHelper.hasDraftFileUrls(questionEl.innerHTML); - if (!textarea) { - // Textarea not found, we might be in review. Search the answer and the attachments. + if (!textarea && !this.question.allowsAttachments) { + // Textarea and filemanager not found, we might be in review. Search the answer and the attachments. this.question.answer = this.domUtils.getContentsOfElement(questionEl, '.qtype_essay_response'); this.question.attachments = this.questionHelper.getQuestionAttachmentsFromHtml( this.domUtils.getContentsOfElement(questionEl, '.attachments')); - } else { - // Textarea found. - const input = questionEl.querySelector('input[type="hidden"][name*=answerformat]'), - content = textarea.innerHTML; + + return questionEl; + } + + if (textarea) { + const input = questionEl.querySelector('input[type="hidden"][name*=answerformat]'); + let content = this.textUtils.decodeHTML(textarea.innerHTML || ''); + + if (this.question.hasDraftFiles && this.question.responsefileareas) { + content = this.textUtils.replaceDraftfileUrls(CoreSites.instance.getCurrentSite().getURL(), content, + this.questionHelper.getResponseFileAreaFiles(this.question, 'answer')).text; + } this.question.textarea = { id: textarea.id, name: textarea.name, - text: content ? this.textUtils.decodeHTML(content) : '' + text: content, }; if (input) { @@ -231,6 +246,38 @@ export class CoreQuestionBaseComponent { }; } } + + if (answerDraftIdInput) { + this.question.answerDraftIdInput = { + name: answerDraftIdInput.name, + value: Number(answerDraftIdInput.value), + }; + } + + if (this.question.allowsAttachments) { + const attachmentsInput = questionEl.querySelector('.attachments input[name*=_attachments]'); + const objectElement = questionEl.querySelector('.attachments object'); + const fileManagerUrl = objectElement && objectElement.data; + + if (attachmentsInput) { + this.question.attachmentsDraftIdInput = { + name: attachmentsInput.name, + value: Number(attachmentsInput.value), + }; + } + + if (fileManagerUrl) { + const params = CoreUrlUtils.instance.extractUrlParams(fileManagerUrl); + const maxBytes = Number(params.maxbytes); + const areaMaxBytes = Number(params.areamaxbytes); + + this.question.attachmentsMaxFiles = Number(params.maxfiles); + this.question.attachmentsMaxBytes = maxBytes === -1 || areaMaxBytes === -1 ? + Math.max(maxBytes, areaMaxBytes) : Math.min(maxBytes, areaMaxBytes); + } + } + + return questionEl; } } @@ -270,6 +317,8 @@ export class CoreQuestionBaseComponent { // Set the question text. this.question.text = content.innerHTML; + + return element; } /** diff --git a/src/core/question/lang/en.json b/src/core/question/lang/en.json index 513c436d2..1f06d28f9 100644 --- a/src/core/question/lang/en.json +++ b/src/core/question/lang/en.json @@ -5,8 +5,8 @@ "certainty": "Certainty", "complete": "Complete", "correct": "Correct", - "errorattachmentsnotsupported": "The application doesn't support attaching files to answers yet.", - "errorinlinefilesnotsupported": "The application doesn't support editing inline files yet.", + "errorattachmentsnotsupportedinsite": "Your site doesn't support attaching files to answers yet.", + "errorinlinefilesnotsupportedinsite": "Your site doesn't support editing inline files yet.", "errorquestionnotsupported": "This question type is not supported by the app: {{$a}}.", "feedback": "Feedback", "howtodraganddrop": "Tap to select then tap to drop.", diff --git a/src/core/question/providers/helper.ts b/src/core/question/providers/helper.ts index edaf17390..de5c9111e 100644 --- a/src/core/question/providers/helper.ts +++ b/src/core/question/providers/helper.ts @@ -16,6 +16,7 @@ import { Injectable, EventEmitter } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreSitesProvider } from '@providers/sites'; +import { CoreWSExternalFile } from '@providers/ws'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreUrlUtilsProvider } from '@providers/utils/url'; @@ -421,6 +422,25 @@ export class CoreQuestionHelperProvider { return state ? state.class : ''; } + /** + * Return the files of a certain response file area. + * + * @param question Question. + * @param areaName Name of the area, e.g. 'attachments'. + * @return List of files. + */ + getResponseFileAreaFiles(question: any, areaName: string): CoreWSExternalFile[] { + if (!question.responsefileareas) { + return []; + } + + const area = question.responsefileareas.find((area) => { + return area.area == areaName; + }); + + return area && area.files || []; + } + /** * Get the validation error message from a question HTML if it's there. * diff --git a/src/providers/utils/text.ts b/src/providers/utils/text.ts index ddac24c1b..6a26e27c8 100644 --- a/src/providers/utils/text.ts +++ b/src/providers/utils/text.ts @@ -19,6 +19,7 @@ import { TranslateService } from '@ngx-translate/core'; import { CoreLangProvider } from '../lang'; import { makeSingleton } from '@singletons/core.singletons'; import { CoreApp } from '../app'; +import { CoreWSExternalFile } from '../ws'; /** * Different type of errors the app can treat. @@ -699,6 +700,60 @@ export class CoreTextUtilsProvider { return text.replace(/(?:\r\n|\r|\n)/g, newValue); } + /** + * Replace draftfile URLs with the equivalent pluginfile URL. + * + * @param siteUrl URL of the site. + * @param text Text to treat, including draftfile URLs. + * @param files List of files of the area, using pluginfile URLs. + * @return Treated text and map with the replacements. + */ + replaceDraftfileUrls(siteUrl: string, text: string, files: CoreWSExternalFile[]) + : {text: string, replaceMap?: {[url: string]: string}} { + + if (!text || !files || !files.length) { + return {text}; + } + + const draftfileUrl = this.concatenatePaths(siteUrl, 'draftfile.php'); + const matches = text.match(new RegExp(this.escapeForRegex(draftfileUrl) + '[^\'" ]+', 'ig')); + + if (!matches || !matches.length) { + return {text}; + } + + // Index the pluginfile URLs by file name. + const pluginfileMap: {[name: string]: string} = {}; + files.forEach((file) => { + pluginfileMap[file.filename] = file.fileurl; + }); + + // Replace each draftfile with the corresponding pluginfile URL. + const replaceMap: {[url: string]: string} = {}; + matches.forEach((url) => { + if (replaceMap[url]) { + // URL already treated, same file embedded more than once. + return; + } + + // Get the filename from the URL. + let filename = url.substr(url.lastIndexOf('/') + 1); + if (filename.indexOf('?') != -1) { + filename = filename.substr(0, filename.indexOf('?')); + } + + if (pluginfileMap[filename]) { + replaceMap[url] = pluginfileMap[filename]; + text = text.replace(new RegExp(this.escapeForRegex(url), 'g'), pluginfileMap[filename]); + } + }); + + return { + text, + replaceMap, + }; + } + /** * Replace @@PLUGINFILE@@ wildcards with the real URL in a text. * @@ -717,6 +772,36 @@ export class CoreTextUtilsProvider { return text; } + /** + * Restore original draftfile URLs. + * + * @param text Text to treat, including pluginfile URLs. + * @param replaceMap Map of the replacements that were done. + * @return Treated text. + */ + restoreDraftfileUrls(siteUrl: string, treatedText: string, originalText: string, files: CoreWSExternalFile[]): string { + if (!treatedText || !files || !files.length) { + return treatedText; + } + + const draftfileUrl = this.concatenatePaths(siteUrl, 'draftfile.php'); + const draftfileUrlRegexPrefix = this.escapeForRegex(draftfileUrl) + '/[^/]+/[^/]+/[^/]+/[^/]+/'; + + files.forEach((file) => { + // Search the draftfile URL in the original text. + const matches = originalText.match(new RegExp( + draftfileUrlRegexPrefix + this.escapeForRegex(file.filename) + '[^\'" ]*', 'i')); + + if (!matches || !matches[0]) { + return; // Original URL not found, skip. + } + + treatedText = treatedText.replace(new RegExp(this.escapeForRegex(file.fileurl), 'g'), matches[0]); + }); + + return treatedText; + } + /** * Replace pluginfile URLs with @@PLUGINFILE@@ wildcards. * diff --git a/src/providers/utils/url.ts b/src/providers/utils/url.ts index 18297a0d8..cd287a9da 100644 --- a/src/providers/utils/url.ts +++ b/src/providers/utils/url.ts @@ -114,10 +114,10 @@ export class CoreUrlUtilsProvider { * @param url URL to treat. * @return Object with the params. */ - extractUrlParams(url: string): any { + extractUrlParams(url: string): {[name: string]: string} { const regex = /[?&]+([^=&]+)=?([^&]*)?/gi, subParamsPlaceholder = '@@@SUBPARAMS@@@', - params: any = {}, + params: {[name: string]: string} = {}, urlAndHash = url.split('#'), questionMarkSplit = urlAndHash[0].split('?'); let subParams;