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/mod/quiz/pages/player/player.ts b/src/addon/mod/quiz/pages/player/player.ts index 75fb54312..0ccbb17cf 100644 --- a/src/addon/mod/quiz/pages/player/player.ts +++ b/src/addon/mod/quiz/pages/player/player.ts @@ -569,7 +569,8 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { // Prepare the answers to be sent for the attempt. protected prepareAnswers(): Promise { - return this.questionHelper.prepareAnswers(this.questions, this.getAnswers(), this.offline); + return this.questionHelper.prepareAnswers(this.questions, this.getAnswers(), this.offline, this.component, + this.quiz.coursemodule); } /** @@ -612,6 +613,8 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { if (this.formElement) { this.domUtils.triggerFormSubmittedEvent(this.formElement, !this.offline, this.sitesProvider.getCurrentSiteId()); } + + return this.questionHelper.clearTmpData(this.questions, this.component, this.quiz.coursemodule); }); } diff --git a/src/addon/mod/quiz/providers/quiz-offline.ts b/src/addon/mod/quiz/providers/quiz-offline.ts index 6c1f5121e..e7bac37d5 100644 --- a/src/addon/mod/quiz/providers/quiz-offline.ts +++ b/src/addon/mod/quiz/providers/quiz-offline.ts @@ -342,8 +342,8 @@ export class AddonModQuizOfflineProvider { for (const slot in questionsWithAnswers) { const question = questionsWithAnswers[slot]; - promises.push(this.behaviourDelegate.determineNewState( - quiz.preferredbehaviour, AddonModQuizProvider.COMPONENT, attempt.id, question, siteId).then((state) => { + promises.push(this.behaviourDelegate.determineNewState(quiz.preferredbehaviour, AddonModQuizProvider.COMPONENT, + attempt.id, question, quiz.coursemodule, siteId).then((state) => { // Check if state has changed. if (state && state.name != question.state) { newStates[question.slot] = state.name; diff --git a/src/addon/mod/quiz/providers/quiz-sync.ts b/src/addon/mod/quiz/providers/quiz-sync.ts index e13a013f5..c4521591e 100644 --- a/src/addon/mod/quiz/providers/quiz-sync.ts +++ b/src/addon/mod/quiz/providers/quiz-sync.ts @@ -21,6 +21,7 @@ import { CoreSitesProvider, CoreSitesReadingStrategy } from '@providers/sites'; import { CoreSyncProvider } from '@providers/sync'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtils } from '@providers/utils/utils'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; @@ -77,25 +78,33 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider * @param quiz Quiz. * @param courseId Course ID. * @param warnings List of warnings generated by the sync. - * @param attemptId Last attempt ID. - * @param offlineAttempt Offline attempt synchronized, if any. - * @param onlineAttempt Online data for the offline attempt. - * @param removeAttempt Whether the offline data should be removed. - * @param updated Whether some data was sent to the site. + * @param options Other options. * @return Promise resolved on success. */ - protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], attemptId?: number, offlineAttempt?: any, - onlineAttempt?: any, removeAttempt?: boolean, updated?: boolean): Promise { + protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], options?: FinishSyncOptions) + : Promise { + options = options || {}; // Invalidate the data for the quiz and attempt. - return this.quizProvider.invalidateAllQuizData(quiz.id, courseId, attemptId, siteId).catch(() => { + return this.quizProvider.invalidateAllQuizData(quiz.id, courseId, options.attemptId, siteId).catch(() => { // Ignore errors. }).then(() => { - if (removeAttempt && attemptId) { - return this.quizOfflineProvider.removeAttemptAndAnswers(attemptId, siteId); + if (options.removeAttempt && options.attemptId) { + const promises = []; + + promises.push(this.quizOfflineProvider.removeAttemptAndAnswers(options.attemptId, siteId)); + + if (options.onlineQuestions) { + for (const slot in options.onlineQuestions) { + promises.push(this.questionDelegate.deleteOfflineData(options.onlineQuestions[slot], + AddonModQuizProvider.COMPONENT, quiz.coursemodule, siteId)); + } + } + + return Promise.all(promises); } }).then(() => { - if (updated) { + if (options.updated) { // Data has been sent. Update prefetched data. return this.courseProvider.getModuleBasicInfoByInstance(quiz.id, 'quiz', siteId).then((module) => { return this.prefetchAfterUpdateQuiz(module, quiz, courseId, undefined, siteId); @@ -109,14 +118,14 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider }); }).then(() => { // Check if online attempt was finished because of the sync. - if (onlineAttempt && !this.quizProvider.isAttemptFinished(onlineAttempt.state)) { + if (options.onlineAttempt && !this.quizProvider.isAttemptFinished(options.onlineAttempt.state)) { // Attempt wasn't finished at start. Check if it's finished now. return this.quizProvider.getUserAttempts(quiz.id, {cmId: quiz.coursemodule, siteId}).then((attempts) => { // Search the attempt. for (const i in attempts) { const attempt = attempts[i]; - if (attempt.id == onlineAttempt.id) { + if (attempt.id == options.onlineAttempt.id) { return this.quizProvider.isAttemptFinished(attempt.state); } } @@ -288,16 +297,6 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider syncQuiz(quiz: any, askPreflight?: boolean, siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); - const warnings = []; - const courseId = quiz.course; - const modOptions = { - cmId: quiz.coursemodule, - readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, - siteId, - }; - let syncPromise; - let preflightData; - if (this.isSyncing(quiz.id, siteId)) { // There's already a sync ongoing for this quiz, return the promise. return this.getOngoingSync(quiz.id, siteId); @@ -310,115 +309,139 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); } + return this.addOngoingSync(quiz.id, this.performSyncQuiz(quiz, askPreflight, siteId), siteId); + } + + /** + * Perform the quiz sync. + * + * @param quiz Quiz. + * @param askPreflight Whether we should ask for preflight data if needed. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success. + */ + async performSyncQuiz(quiz: any, askPreflight?: boolean, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const warnings = []; + const courseId = quiz.course; + const modOptions = { + cmId: quiz.coursemodule, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + this.logger.debug('Try to sync quiz ' + quiz.id + ' in site ' + siteId); // Sync offline logs. - syncPromise = this.logHelper.syncIfNeeded(AddonModQuizProvider.COMPONENT, quiz.id, siteId).catch(() => { - // Ignore errors. - }).then(() => { - // Get all the offline attempts for the quiz. - return this.quizOfflineProvider.getQuizAttempts(quiz.id, siteId); - }).then((attempts) => { - // Should return 0 or 1 attempt. - if (!attempts.length) { - return this.finishSync(siteId, quiz, courseId, warnings); - } + await CoreUtils.instance.ignoreErrors(this.logHelper.syncIfNeeded(AddonModQuizProvider.COMPONENT, quiz.id, siteId)); - const offlineAttempt = attempts.pop(); + // Get all the offline attempts for the quiz. It should always be 0 or 1 attempt + const offlineAttempts = await this.quizOfflineProvider.getQuizAttempts(quiz.id, siteId); - // Now get the list of online attempts to make sure this attempt exists and isn't finished. - return this.quizProvider.getUserAttempts(quiz.id, modOptions).then((attempts) => { - const lastAttemptId = attempts.length ? attempts[attempts.length - 1].id : undefined; - let onlineAttempt; + if (!offlineAttempts.length) { + // Nothing to sync, finish. + return this.finishSync(siteId, quiz, courseId, warnings); + } - // Search the attempt we retrieved from offline. - for (const i in attempts) { - const attempt = attempts[i]; + if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + throw new Error(this.translate.instant('core.cannotconnect')); + } - if (attempt.id == offlineAttempt.id) { - onlineAttempt = attempt; - break; - } - } + const offlineAttempt = offlineAttempts.pop(); - if (!onlineAttempt || this.quizProvider.isAttemptFinished(onlineAttempt.state)) { - // Attempt not found or it's finished in online. Discard it. - warnings.push(this.translate.instant('addon.mod_quiz.warningattemptfinished')); + // Now get the list of online attempts to make sure this attempt exists and isn't finished. + const onlineAttempts = await this.quizProvider.getUserAttempts(quiz.id, modOptions); - return this.finishSync(siteId, quiz, courseId, warnings, offlineAttempt.id, offlineAttempt, onlineAttempt, - true); - } - - // Get the data stored in offline. - return this.quizOfflineProvider.getAttemptAnswers(offlineAttempt.id, siteId).then((answersList) => { - - if (!answersList.length) { - // No answers stored, finish. - return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt, - true); - } - - const answers = this.questionProvider.convertAnswersArrayToObject(answersList), - offlineQuestions = this.quizOfflineProvider.classifyAnswersInQuestions(answers); - let finish; - - // We're going to need preflightData, get it. - return this.quizProvider.getQuizAccessInformation(quiz.id, modOptions).then((info) => { - - return this.prefetchHandler.getPreflightData(quiz, info, onlineAttempt, askPreflight, - 'core.settings.synchronization', siteId); - }).then((data) => { - preflightData = data; - - // Now get the online questions data. - const pages = this.quizProvider.getPagesFromLayoutAndQuestions(onlineAttempt.layout, offlineQuestions); - - return this.quizProvider.getAllQuestionsData(quiz, onlineAttempt, preflightData, { - pages, - readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, - siteId, - }); - }).then((onlineQuestions) => { - - // Validate questions, discarding the offline answers that can't be synchronized. - return this.validateQuestions(onlineAttempt.id, onlineQuestions, offlineQuestions, siteId); - }).then((discardedData) => { - - // Get the answers to send. - const answers = this.quizOfflineProvider.extractAnswersFromQuestions(offlineQuestions); - finish = offlineAttempt.finished && !discardedData; - - if (discardedData) { - if (offlineAttempt.finished) { - warnings.push(this.translate.instant('addon.mod_quiz.warningdatadiscardedfromfinished')); - } else { - warnings.push(this.translate.instant('addon.mod_quiz.warningdatadiscarded')); - } - } - - return this.quizProvider.processAttempt(quiz, onlineAttempt, answers, preflightData, finish, false, false, - siteId); - }).then(() => { - - // Answers sent, now set the current page if the attempt isn't finished. - if (!finish) { - // Don't pass the quiz instance because we don't want to trigger a Firebase event in this case. - return this.quizProvider.logViewAttempt(onlineAttempt.id, offlineAttempt.currentpage, preflightData, - false, undefined, siteId).catch(() => { - // Ignore errors. - }); - } - }).then(() => { - - // Data sent. Finish the sync. - return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt, - true, true); - }); - }); - }); + const lastAttemptId = onlineAttempts.length ? onlineAttempts[onlineAttempts.length - 1].id : undefined; + const onlineAttempt = onlineAttempts.find((attempt) => { + return attempt.id == offlineAttempt.id; }); - return this.addOngoingSync(quiz.id, syncPromise, siteId); + if (!onlineAttempt || this.quizProvider.isAttemptFinished(onlineAttempt.state)) { + // Attempt not found or it's finished in online. Discard it. + warnings.push(this.translate.instant('addon.mod_quiz.warningattemptfinished')); + + return this.finishSync(siteId, quiz, courseId, warnings, { + attemptId: offlineAttempt.id, + offlineAttempt, + onlineAttempt, + removeAttempt: true, + }); + } + + // Get the data stored in offline. + const answersList = await this.quizOfflineProvider.getAttemptAnswers(offlineAttempt.id, siteId); + + if (!answersList.length) { + // No answers stored, finish. + return this.finishSync(siteId, quiz, courseId, warnings, { + attemptId: lastAttemptId, + offlineAttempt, + onlineAttempt, + removeAttempt: true, + }); + } + + const offlineAnswers = this.questionProvider.convertAnswersArrayToObject(answersList); + const offlineQuestions = this.quizOfflineProvider.classifyAnswersInQuestions(offlineAnswers); + + // We're going to need preflightData, get it. + const info = await this.quizProvider.getQuizAccessInformation(quiz.id, modOptions); + + const preflightData = await this.prefetchHandler.getPreflightData(quiz, info, onlineAttempt, askPreflight, + 'core.settings.synchronization', siteId); + + // Now get the online questions data. + const onlineQuestions = await this.quizProvider.getAllQuestionsData(quiz, onlineAttempt, preflightData, { + pages: this.quizProvider.getPagesFromLayoutAndQuestions(onlineAttempt.layout, offlineQuestions), + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }); + + // Validate questions, discarding the offline answers that can't be synchronized. + const discardedData = await this.validateQuestions(onlineAttempt.id, onlineQuestions, offlineQuestions, siteId); + + // Let questions prepare the data to send. + await Promise.all(Object.keys(offlineQuestions).map(async (slot) => { + const onlineQuestion = onlineQuestions[slot]; + + await this.questionDelegate.prepareSyncData(onlineQuestion, offlineQuestions[slot].answers, + AddonModQuizProvider.COMPONENT, quiz.coursemodule, siteId); + })); + + // Get the answers to send. + const answers = this.quizOfflineProvider.extractAnswersFromQuestions(offlineQuestions); + const finish = offlineAttempt.finished && !discardedData; + + if (discardedData) { + if (offlineAttempt.finished) { + warnings.push(this.translate.instant('addon.mod_quiz.warningdatadiscardedfromfinished')); + } else { + warnings.push(this.translate.instant('addon.mod_quiz.warningdatadiscarded')); + } + } + + // Send the answers. + await this.quizProvider.processAttempt(quiz, onlineAttempt, answers, preflightData, finish, false, false, siteId); + + if (!finish) { + // Answers sent, now set the current page. + // Don't pass the quiz instance because we don't want to trigger a Firebase event in this case. + await CoreUtils.instance.ignoreErrors(this.quizProvider.logViewAttempt(onlineAttempt.id, offlineAttempt.currentpage, + preflightData, false, undefined, siteId)); + } + + // Data sent. Finish the sync. + return this.finishSync(siteId, quiz, courseId, warnings, { + attemptId: lastAttemptId, + offlineAttempt, + onlineAttempt, + removeAttempt: true, + updated: true, + onlineQuestions, + }); } /** @@ -466,3 +489,15 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider }); } } + +/** + * Options to pass to finish sync. + */ +type FinishSyncOptions = { + attemptId?: number; // Last attempt ID. + offlineAttempt?: any; // Offline attempt synchronized, if any. + onlineAttempt?: any; // Online data for the offline attempt. + removeAttempt?: boolean; // Whether the offline data should be removed. + updated?: boolean; // Whether the offline data should be removed. + onlineQuestions?: any; // Online questions indexed by slot. +}; diff --git a/src/addon/mod/quiz/providers/quiz.ts b/src/addon/mod/quiz/providers/quiz.ts index 6caa9148e..0c5d3599d 100644 --- a/src/addon/mod/quiz/providers/quiz.ts +++ b/src/addon/mod/quiz/providers/quiz.ts @@ -215,6 +215,8 @@ export class AddonModQuizProvider { }; return site.read('mod_quiz_get_attempt_data', params, preSets); + }).then((result) => { + return this.parseQuestions(result); }); } @@ -389,7 +391,9 @@ export class AddonModQuizProvider { ...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. }; - return site.read('mod_quiz_get_attempt_review', params, preSets); + return site.read('mod_quiz_get_attempt_review', params, preSets).then((result) => { + return this.parseQuestions(result); + }); }); } @@ -427,6 +431,8 @@ export class AddonModQuizProvider { return site.read('mod_quiz_get_attempt_summary', params, preSets).then((response) => { if (response && response.questions) { + response = this.parseQuestions(response); + if (options.loadLocal) { return this.quizOfflineProvider.loadQuestionsLocalStates(attemptId, response.questions, site.getId()); } @@ -1560,6 +1566,27 @@ export class AddonModQuizProvider { siteId); } + /** + * Parse questions of a WS response. + * + * @param result Result to parse. + * @return Parsed result. + */ + parseQuestions(result: any): any { + for (let i = 0; i < result.questions.length; i++) { + const question = result.questions[i]; + + if (!question.settings) { + // Site doesn't return settings, stop. + break; + } + + question.settings = this.textUtils.parseJSON(question.settings, null); + } + + return result; + } + /** * Process an attempt, saving its data. * diff --git a/src/addon/qbehaviour/deferredcbm/providers/handler.ts b/src/addon/qbehaviour/deferredcbm/providers/handler.ts index a32634c1f..1ba3bcdcc 100644 --- a/src/addon/qbehaviour/deferredcbm/providers/handler.ts +++ b/src/addon/qbehaviour/deferredcbm/providers/handler.ts @@ -40,13 +40,14 @@ export class AddonQbehaviourDeferredCBMHandler implements CoreQuestionBehaviourH * @param component Component the question belongs to. * @param attemptId Attempt ID the question belongs to. * @param question The question. + * @param componentId Component ID. * @param siteId Site ID. If not defined, current site. * @return New state (or promise resolved with state). */ - determineNewState(component: string, attemptId: number, question: any, siteId?: string) + determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string) : CoreQuestionState | Promise { // Depends on deferredfeedback. - return this.deferredFeedbackHandler.determineNewStateDeferred(component, attemptId, question, siteId, + return this.deferredFeedbackHandler.determineNewStateDeferred(component, attemptId, question, componentId, siteId, this.isCompleteResponse.bind(this), this.isSameResponse.bind(this)); } @@ -71,11 +72,13 @@ export class AddonQbehaviourDeferredCBMHandler implements CoreQuestionBehaviourH * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ - protected isCompleteResponse(question: any, answers: any): number { + protected isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { // First check if the question answer is complete. - const complete = this.questionDelegate.isCompleteResponse(question, answers); + const complete = this.questionDelegate.isCompleteResponse(question, answers, component, componentId); if (complete > 0) { // Answer is complete, check the user answered CBM too. return answers['-certainty'] ? 1 : 0; @@ -101,12 +104,14 @@ export class AddonQbehaviourDeferredCBMHandler implements CoreQuestionBehaviourH * @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). * @param newAnswers Object with the new question answers. * @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). + * @param component The component the question is related to. + * @param componentId Component ID. * @return Whether they're the same. */ - protected isSameResponse(question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any, newBasicAnswers: any) - : boolean { + protected isSameResponse(question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any, newBasicAnswers: any, + component: string, componentId: string | number): boolean { // First check if the question answer is the same. - const same = this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers); + const same = this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers, component, componentId); if (same) { // Same response, check the CBM is the same too. return prevAnswers['-certainty'] == newAnswers['-certainty']; diff --git a/src/addon/qbehaviour/deferredfeedback/providers/handler.ts b/src/addon/qbehaviour/deferredfeedback/providers/handler.ts index 0edf35d41..31f21252a 100644 --- a/src/addon/qbehaviour/deferredfeedback/providers/handler.ts +++ b/src/addon/qbehaviour/deferredfeedback/providers/handler.ts @@ -23,9 +23,11 @@ import { CoreQuestionProvider, CoreQuestionState } from '@core/question/provider * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ -export type isCompleteResponseFunction = (question: any, answers: any) => number; +export type isCompleteResponseFunction = (question: any, answers: any, component: string, componentId: string | number) => number; /** * Check if two responses are the same. @@ -35,10 +37,12 @@ export type isCompleteResponseFunction = (question: any, answers: any) => number * @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). * @param newAnswers Object with the new question answers. * @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). + * @param component The component the question is related to. + * @param componentId Component ID. * @return Whether they're the same. */ export type isSameResponseFunction = (question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any, - newBasicAnswers: any) => boolean; + newBasicAnswers: any, component: string, componentId: string | number) => boolean; /** * Handler to support deferred feedback question behaviour. @@ -58,12 +62,13 @@ export class AddonQbehaviourDeferredFeedbackHandler implements CoreQuestionBehav * @param component Component the question belongs to. * @param attemptId Attempt ID the question belongs to. * @param question The question. + * @param componentId Component ID. * @param siteId Site ID. If not defined, current site. * @return New state (or promise resolved with state). */ - determineNewState(component: string, attemptId: number, question: any, siteId?: string) + determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string) : CoreQuestionState | Promise { - return this.determineNewStateDeferred(component, attemptId, question, siteId); + return this.determineNewStateDeferred(component, attemptId, question, componentId, siteId); } /** @@ -72,75 +77,82 @@ export class AddonQbehaviourDeferredFeedbackHandler implements CoreQuestionBehav * @param component Component the question belongs to. * @param attemptId Attempt ID the question belongs to. * @param question The question. + * @param componentId Component ID. * @param siteId Site ID. If not defined, current site. * @param isCompleteFn Function to override the default isCompleteResponse check. * @param isSameFn Function to override the default isSameResponse check. * @return Promise resolved with state. */ - determineNewStateDeferred(component: string, attemptId: number, question: any, siteId?: string, - isCompleteFn?: isCompleteResponseFunction, isSameFn?: isSameResponseFunction): Promise { + async determineNewStateDeferred(component: string, attemptId: number, question: any, componentId: string | number, + siteId?: string, isCompleteFn?: isCompleteResponseFunction, isSameFn?: isSameResponseFunction) + : Promise { // Check if we have local data for the question. - return this.questionProvider.getQuestion(component, attemptId, question.slot, siteId).catch(() => { + let dbQuestion; + try { + dbQuestion = await this.questionProvider.getQuestion(component, attemptId, question.slot, siteId); + } catch (error) { // No entry found, use the original data. - return question; - }).then((dbQuestion) => { - const state = this.questionProvider.getState(dbQuestion.state); + dbQuestion = question; + } - if (state.finished || !state.active) { - // Question is finished, it cannot change. - return state; + const state = this.questionProvider.getState(dbQuestion.state); + + if (state.finished || !state.active) { + // Question is finished, it cannot change. + return state; + } + + const newBasicAnswers = this.questionProvider.getBasicAnswers(question.answers); + + if (dbQuestion.state) { + // Question already has a state stored. Check if answer has changed. + let prevAnswers = await this.questionProvider.getQuestionAnswers(component, attemptId, question.slot, false, siteId); + + prevAnswers = this.questionProvider.convertAnswersArrayToObject(prevAnswers, true); + const prevBasicAnswers = this.questionProvider.getBasicAnswers(prevAnswers); + + // If answers haven't changed the state is the same. + if (isSameFn) { + if (isSameFn(question, prevAnswers, prevBasicAnswers, question.answers, newBasicAnswers, + component, componentId)) { + return state; + } + } else { + if (this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers, component, componentId)) { + return state; + } } + } - // We need to check if the answers have changed. Retrieve current stored answers. - return this.questionProvider.getQuestionAnswers(component, attemptId, question.slot, false, siteId) - .then((prevAnswers) => { + // Answers have changed. Now check if the response is complete and calculate the new state. + let complete: number; + let newState: string; - const newBasicAnswers = this.questionProvider.getBasicAnswers(question.answers); + if (isCompleteFn) { + // Pass all the answers since some behaviours might need the extra data. + complete = isCompleteFn(question, question.answers, component, componentId); + } else { + // Only pass the basic answers since questions should be independent of extra data. + complete = this.questionDelegate.isCompleteResponse(question, newBasicAnswers, component, componentId); + } - prevAnswers = this.questionProvider.convertAnswersArrayToObject(prevAnswers, true); - const prevBasicAnswers = this.questionProvider.getBasicAnswers(prevAnswers); + if (complete < 0) { + newState = 'cannotdeterminestatus'; + } else if (complete > 0) { + newState = 'complete'; + } else { + const gradable = this.questionDelegate.isGradableResponse(question, newBasicAnswers, component, componentId); + if (gradable < 0) { + newState = 'cannotdeterminestatus'; + } else if (gradable > 0) { + newState = 'invalid'; + } else { + newState = 'todo'; + } + } - // If answers haven't changed the state is the same. - if (isSameFn) { - if (isSameFn(question, prevAnswers, prevBasicAnswers, question.answers, newBasicAnswers)) { - return state; - } - } else { - if (this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers)) { - return state; - } - } - - // Answers have changed. Now check if the response is complete and calculate the new state. - let complete: number, - newState: string; - if (isCompleteFn) { - // Pass all the answers since some behaviours might need the extra data. - complete = isCompleteFn(question, question.answers); - } else { - // Only pass the basic answers since questions should be independent of extra data. - complete = this.questionDelegate.isCompleteResponse(question, newBasicAnswers); - } - - if (complete < 0) { - newState = 'cannotdeterminestatus'; - } else if (complete > 0) { - newState = 'complete'; - } else { - const gradable = this.questionDelegate.isGradableResponse(question, newBasicAnswers); - if (gradable < 0) { - newState = 'cannotdeterminestatus'; - } else if (gradable > 0) { - newState = 'invalid'; - } else { - newState = 'todo'; - } - } - - return this.questionProvider.getState(newState); - }); - }); + return this.questionProvider.getState(newState); } /** diff --git a/src/addon/qbehaviour/informationitem/providers/handler.ts b/src/addon/qbehaviour/informationitem/providers/handler.ts index c30e2c800..dfe438725 100644 --- a/src/addon/qbehaviour/informationitem/providers/handler.ts +++ b/src/addon/qbehaviour/informationitem/providers/handler.ts @@ -35,10 +35,11 @@ export class AddonQbehaviourInformationItemHandler implements CoreQuestionBehavi * @param component Component the question belongs to. * @param attemptId Attempt ID the question belongs to. * @param question The question. + * @param componentId Component ID. * @param siteId Site ID. If not defined, current site. * @return New state (or promise resolved with state). */ - determineNewState(component: string, attemptId: number, question: any, siteId?: string) + determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string) : CoreQuestionState | Promise { if (question.answers['-seen']) { return this.questionProvider.getState('complete'); diff --git a/src/addon/qbehaviour/manualgraded/providers/handler.ts b/src/addon/qbehaviour/manualgraded/providers/handler.ts index 98da9c06d..9a650d9d2 100644 --- a/src/addon/qbehaviour/manualgraded/providers/handler.ts +++ b/src/addon/qbehaviour/manualgraded/providers/handler.ts @@ -17,28 +17,7 @@ import { Injectable } from '@angular/core'; import { CoreQuestionBehaviourHandler } from '@core/question/providers/behaviour-delegate'; import { CoreQuestionDelegate } from '@core/question/providers/delegate'; import { CoreQuestionProvider, CoreQuestionState } from '@core/question/providers/question'; - -/** - * Check if a response is complete. - * - * @param question The question. - * @param answers Object with the question answers (without prefix). - * @return 1 if complete, 0 if not complete, -1 if cannot determine. - */ -export type isCompleteResponseFunction = (question: any, answers: any) => number; - -/** - * Check if two responses are the same. - * - * @param question Question. - * @param prevAnswers Object with the previous question answers. - * @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). - * @param newAnswers Object with the new question answers. - * @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). - * @return Whether they're the same. - */ -export type isSameResponseFunction = (question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any, - newBasicAnswers: any) => boolean; +import { AddonQbehaviourDeferredFeedbackHandler } from '@addon/qbehaviour/deferredfeedback/providers/handler'; /** * Handler to support manual graded question behaviour. @@ -48,7 +27,9 @@ export class AddonQbehaviourManualGradedHandler implements CoreQuestionBehaviour name = 'AddonQbehaviourManualGraded'; type = 'manualgraded'; - constructor(private questionDelegate: CoreQuestionDelegate, private questionProvider: CoreQuestionProvider) { + constructor(protected questionDelegate: CoreQuestionDelegate, + protected questionProvider: CoreQuestionProvider, + protected deferredFeedbackHandler: AddonQbehaviourDeferredFeedbackHandler) { // Nothing to do. } @@ -58,82 +39,14 @@ export class AddonQbehaviourManualGradedHandler implements CoreQuestionBehaviour * @param component Component the question belongs to. * @param attemptId Attempt ID the question belongs to. * @param question The question. + * @param componentId Component ID. * @param siteId Site ID. If not defined, current site. * @return New state (or promise resolved with state). */ - determineNewState(component: string, attemptId: number, question: any, siteId?: string) + determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string) : CoreQuestionState | Promise { - return this.determineNewStateManualGraded(component, attemptId, question, siteId); - } - - /** - * Determine a question new state based on its answer(s) for manual graded question behaviour. - * - * @param component Component the question belongs to. - * @param attemptId Attempt ID the question belongs to. - * @param question The question. - * @param siteId Site ID. If not defined, current site. - * @param isCompleteFn Function to override the default isCompleteResponse check. - * @param isSameFn Function to override the default isSameResponse check. - * @return Promise resolved with state. - */ - determineNewStateManualGraded(component: string, attemptId: number, question: any, siteId?: string, - isCompleteFn?: isCompleteResponseFunction, isSameFn?: isSameResponseFunction): Promise { - - // Check if we have local data for the question. - return this.questionProvider.getQuestion(component, attemptId, question.slot, siteId).catch(() => { - // No entry found, use the original data. - return question; - }).then((dbQuestion) => { - const state = this.questionProvider.getState(dbQuestion.state); - - if (state.finished || !state.active) { - // Question is finished, it cannot change. - return state; - } - - // We need to check if the answers have changed. Retrieve current stored answers. - return this.questionProvider.getQuestionAnswers(component, attemptId, question.slot, false, siteId) - .then((prevAnswers) => { - - const newBasicAnswers = this.questionProvider.getBasicAnswers(question.answers); - - prevAnswers = this.questionProvider.convertAnswersArrayToObject(prevAnswers, true); - const prevBasicAnswers = this.questionProvider.getBasicAnswers(prevAnswers); - - // If answers haven't changed the state is the same. - if (isSameFn) { - if (isSameFn(question, prevAnswers, prevBasicAnswers, question.answers, newBasicAnswers)) { - return state; - } - } else { - if (this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers)) { - return state; - } - } - - // Answers have changed. Now check if the response is complete and calculate the new state. - let complete: number, - newState: string; - if (isCompleteFn) { - // Pass all the answers since some behaviours might need the extra data. - complete = isCompleteFn(question, question.answers); - } else { - // Only pass the basic answers since questions should be independent of extra data. - complete = this.questionDelegate.isCompleteResponse(question, newBasicAnswers); - } - - if (complete < 0) { - newState = 'cannotdeterminestatus'; - } else if (complete > 0) { - newState = 'complete'; - } else { - newState = 'todo'; - } - - return this.questionProvider.getState(newState); - }); - }); + // Same implementation as the deferred feedback. Use that function instead of replicating it. + return this.deferredFeedbackHandler.determineNewStateDeferred(component, attemptId, question, componentId, siteId); } /** diff --git a/src/addon/qtype/calculated/providers/handler.ts b/src/addon/qtype/calculated/providers/handler.ts index c7832f91c..22423edb4 100644 --- a/src/addon/qtype/calculated/providers/handler.ts +++ b/src/addon/qtype/calculated/providers/handler.ts @@ -24,6 +24,14 @@ import { AddonQtypeCalculatedComponent } from '../component/calculated'; */ @Injectable() export class AddonQtypeCalculatedHandler implements CoreQuestionHandler { + static UNITINPUT = '0'; + static UNITRADIO = '1'; + static UNITSELECT = '2'; + static UNITNONE = '3'; + + static UNITGRADED = '1'; + static UNITOPTIONAL = '0'; + name = 'AddonQtypeCalculated'; type = 'qtype_calculated'; @@ -41,23 +49,69 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler { return AddonQtypeCalculatedComponent; } + /** + * Check if the units are in a separate field for the question. + * + * @param question Question. + * @return Whether units are in a separate field. + */ + hasSeparateUnitField(question: any): boolean { + if (!question.settings) { + const element = this.domUtils.convertToElement(question.html); + + return !!(element.querySelector('select[name*=unit]') || element.querySelector('input[type="radio"]')); + } + + return question.settings.unitdisplay === AddonQtypeCalculatedHandler.UNITRADIO || + question.settings.unitdisplay === AddonQtypeCalculatedHandler.UNITSELECT; + } + /** * Check if a response is complete. * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ - isCompleteResponse(question: any, answers: any): number { - if (this.isGradableResponse(question, answers) === 0 || !this.validateUnits(answers['answer'])) { + isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { + if (!this.isGradableResponse(question, answers, component, componentId)) { return 0; } - if (this.requiresUnits(question)) { - return this.isValidValue(answers['unit']) ? 1 : 0; + const parsedAnswer = this.parseAnswer(question, answers['answer']); + if (parsedAnswer.answer === null) { + return 0; } - return -1; + if (!question.settings) { + if (this.hasSeparateUnitField(question)) { + return this.isValidValue(answers['unit']) ? 1 : 0; + } + + // We cannot know if the answer should contain units or not. + return -1; + } + + if (question.settings.unitdisplay != AddonQtypeCalculatedHandler.UNITINPUT && parsedAnswer.unit) { + // There should be no units or be outside of the input, not valid. + return 0; + } + + if (this.hasSeparateUnitField(question) && !this.isValidValue(answers['unit'])) { + // Unit not supplied as a separate field and it's required. + return 0; + } + + if (question.settings.unitdisplay == AddonQtypeCalculatedHandler.UNITINPUT && + question.settings.unitgradingtype == AddonQtypeCalculatedHandler.UNITGRADED && + !this.isValidValue(parsedAnswer.unit)) { + // Unit not supplied inside the input and it's required. + return 0; + } + + return 1; } /** @@ -75,16 +129,12 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. */ - isGradableResponse(question: any, answers: any): number { - let isGradable = this.isValidValue(answers['answer']); - if (isGradable && this.requiresUnits(question)) { - // The question requires a unit. - isGradable = this.isValidValue(answers['unit']); - } - - return isGradable ? 1 : 0; + isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { + return this.isValidValue(answers['answer']) ? 1 : 0; } /** @@ -93,9 +143,11 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler { * @param question Question. * @param prevAnswers Object with the previous question answers. * @param newAnswers Object with the new question answers. + * @param component The component the question is related to. + * @param componentId Component ID. * @return Whether they're the same. */ - isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') && this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'unit'); } @@ -111,36 +163,24 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler { } /** - * Check if a question requires units in a separate input. - * - * @param question The question. - * @return Whether the question requires units. - */ - requiresUnits(question: any): boolean { - const element = this.domUtils.convertToElement(question.html); - - return !!(element.querySelector('select[name*=unit]') || element.querySelector('input[type="radio"]')); - } - - /** - * Validate a number with units. We don't have the list of valid units and conversions, so we can't perform - * a full validation. If this function returns true it means we can't be sure it's valid. + * Parse an answer string. * + * @param question Question. * @param answer Answer. - * @return False if answer isn't valid, true if we aren't sure if it's valid. + * @return 0 if answer isn't valid, 1 if answer is valid, -1 if we aren't sure if it's valid. */ - validateUnits(answer: string): boolean { + parseAnswer(question: any, answer: string): {answer: number, unit: string} { if (!answer) { - return false; + return {answer: null, unit: null}; } - const regexString = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?'; + let regexString = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?'; // Strip spaces (which may be thousands separators) and change other forms of writing e to e. - answer = answer.replace(' ', ''); + answer = answer.replace(/ /g, ''); answer = answer.replace(/(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)/, 'e$1'); - // If a '.' is present or there are multiple ',' (i.e. 2,456,789) assume ',' is a thousands separator and stip it. + // If a '.' is present or there are multiple ',' (i.e. 2,456,789) assume ',' is a thousands separator and strip it. // Else assume it is a decimal separator, and change it to '.'. if (answer.indexOf('.') != -1 || answer.split(',').length - 1 > 1) { answer = answer.replace(',', ''); @@ -148,11 +188,31 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler { answer = answer.replace(',', '.'); } - // We don't know if units should be before or after so we check both. - if (answer.match(new RegExp('^' + regexString)) === null || answer.match(new RegExp(regexString + '$')) === null) { - return false; + let unitsLeft = false; + let match = null; + + if (!question.settings) { + // We don't know if units should be before or after so we check both. + match = answer.match(new RegExp('^' + regexString)); + if (!match) { + unitsLeft = true; + match = answer.match(new RegExp(regexString + '$')); + } + } else { + unitsLeft = question.settings.unitsleft == '1'; + regexString = unitsLeft ? regexString + '$' : '^' + regexString; + + match = answer.match(new RegExp(regexString)); } - return true; + if (!match) { + return {answer: null, unit: null}; + } + + const numberString = match[0]; + const unit = unitsLeft ? answer.substr(0, answer.length - match[0].length) : answer.substr(match[0].length); + + // No need to calculate the multiplier. + return {answer: Number(numberString), unit: unit}; } } diff --git a/src/addon/qtype/calculatedmulti/providers/handler.ts b/src/addon/qtype/calculatedmulti/providers/handler.ts index 63fcd7a3e..e2d8111ca 100644 --- a/src/addon/qtype/calculatedmulti/providers/handler.ts +++ b/src/addon/qtype/calculatedmulti/providers/handler.ts @@ -46,9 +46,11 @@ export class AddonQtypeCalculatedMultiHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ - isCompleteResponse(question: any, answers: any): number { + isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { // This question type depends on multichoice. return this.multichoiceHandler.isCompleteResponseSingle(answers); } @@ -68,9 +70,11 @@ export class AddonQtypeCalculatedMultiHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. */ - isGradableResponse(question: any, answers: any): number { + isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { // This question type depends on multichoice. return this.multichoiceHandler.isGradableResponseSingle(answers); } @@ -81,9 +85,11 @@ export class AddonQtypeCalculatedMultiHandler implements CoreQuestionHandler { * @param question Question. * @param prevAnswers Object with the previous question answers. * @param newAnswers Object with the new question answers. + * @param component The component the question is related to. + * @param componentId Component ID. * @return Whether they're the same. */ - isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { // This question type depends on multichoice. return this.multichoiceHandler.isSameResponseSingle(prevAnswers, newAnswers); } diff --git a/src/addon/qtype/calculatedsimple/providers/handler.ts b/src/addon/qtype/calculatedsimple/providers/handler.ts index e5f442b20..1301f3da5 100644 --- a/src/addon/qtype/calculatedsimple/providers/handler.ts +++ b/src/addon/qtype/calculatedsimple/providers/handler.ts @@ -46,11 +46,13 @@ export class AddonQtypeCalculatedSimpleHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ - isCompleteResponse(question: any, answers: any): number { + isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { // This question type depends on calculated. - return this.calculatedHandler.isCompleteResponse(question, answers); + return this.calculatedHandler.isCompleteResponse(question, answers, component, componentId); } /** @@ -68,11 +70,13 @@ export class AddonQtypeCalculatedSimpleHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. */ - isGradableResponse(question: any, answers: any): number { + isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { // This question type depends on calculated. - return this.calculatedHandler.isGradableResponse(question, answers); + return this.calculatedHandler.isGradableResponse(question, answers, component, componentId); } /** @@ -81,10 +85,12 @@ export class AddonQtypeCalculatedSimpleHandler implements CoreQuestionHandler { * @param question Question. * @param prevAnswers Object with the previous question answers. * @param newAnswers Object with the new question answers. + * @param component The component the question is related to. + * @param componentId Component ID. * @return Whether they're the same. */ - isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { // This question type depends on calculated. - return this.calculatedHandler.isSameResponse(question, prevAnswers, newAnswers); + return this.calculatedHandler.isSameResponse(question, prevAnswers, newAnswers, component, componentId); } } diff --git a/src/addon/qtype/ddimageortext/providers/handler.ts b/src/addon/qtype/ddimageortext/providers/handler.ts index 1774415fb..94dcebcf6 100644 --- a/src/addon/qtype/ddimageortext/providers/handler.ts +++ b/src/addon/qtype/ddimageortext/providers/handler.ts @@ -61,9 +61,11 @@ export class AddonQtypeDdImageOrTextHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ - isCompleteResponse(question: any, answers: any): number { + isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { // An answer is complete if all drop zones have an answer. // We should always receive all the drop zones with their value ('' if not answered). for (const name in answers) { @@ -91,9 +93,11 @@ export class AddonQtypeDdImageOrTextHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. */ - isGradableResponse(question: any, answers: any): number { + isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { for (const name in answers) { const value = answers[name]; if (value && value !== '0') { @@ -110,9 +114,11 @@ export class AddonQtypeDdImageOrTextHandler implements CoreQuestionHandler { * @param question Question. * @param prevAnswers Object with the previous question answers. * @param newAnswers Object with the new question answers. + * @param component The component the question is related to. + * @param componentId Component ID. * @return Whether they're the same. */ - isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); } } diff --git a/src/addon/qtype/ddmarker/providers/handler.ts b/src/addon/qtype/ddmarker/providers/handler.ts index 2e8195ff5..e4fa425b8 100644 --- a/src/addon/qtype/ddmarker/providers/handler.ts +++ b/src/addon/qtype/ddmarker/providers/handler.ts @@ -62,9 +62,11 @@ export class AddonQtypeDdMarkerHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ - isCompleteResponse(question: any, answers: any): number { + isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { // If 1 dragitem is set we assume the answer is complete (like Moodle does). for (const name in answers) { if (answers[name]) { @@ -90,10 +92,12 @@ export class AddonQtypeDdMarkerHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. */ - isGradableResponse(question: any, answers: any): number { - return this.isCompleteResponse(question, answers); + isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { + return this.isCompleteResponse(question, answers, component, componentId); } /** @@ -102,9 +106,11 @@ export class AddonQtypeDdMarkerHandler implements CoreQuestionHandler { * @param question Question. * @param prevAnswers Object with the previous question answers. * @param newAnswers Object with the new question answers. + * @param component The component the question is related to. + * @param componentId Component ID. * @return Whether they're the same. */ - isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); } diff --git a/src/addon/qtype/ddwtos/providers/handler.ts b/src/addon/qtype/ddwtos/providers/handler.ts index d310e27b2..8f740bea7 100644 --- a/src/addon/qtype/ddwtos/providers/handler.ts +++ b/src/addon/qtype/ddwtos/providers/handler.ts @@ -61,9 +61,11 @@ export class AddonQtypeDdwtosHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ - isCompleteResponse(question: any, answers: any): number { + isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { for (const name in answers) { const value = answers[name]; if (!value || value === '0') { @@ -89,9 +91,11 @@ export class AddonQtypeDdwtosHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. */ - isGradableResponse(question: any, answers: any): number { + isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { for (const name in answers) { const value = answers[name]; if (value && value !== '0') { @@ -108,9 +112,11 @@ export class AddonQtypeDdwtosHandler implements CoreQuestionHandler { * @param question Question. * @param prevAnswers Object with the previous question answers. * @param newAnswers Object with the new question answers. + * @param component The component the question is related to. + * @param componentId Component ID. * @return Whether they're the same. */ - isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); } } diff --git a/src/addon/qtype/essay/component/addon-qtype-essay.html b/src/addon/qtype/essay/component/addon-qtype-essay.html index 13462423b..bfdcf2329 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..6fc1dd19c 100644 --- a/src/addon/qtype/essay/component/essay.ts +++ b/src/addon/qtype/essay/component/essay.ts @@ -14,8 +14,12 @@ import { Component, OnInit, Injector } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; +import { CoreWSExternalFile } from '@providers/ws'; +import { CoreFileUploader } from '@core/fileuploader/providers/fileuploader'; import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component'; +import { CoreQuestion } from '@core/question/providers/question'; import { FormControl, FormBuilder } from '@angular/forms'; +import { CoreFileSession } from '@providers/file-session'; /** * Component to render an essay question. @@ -28,6 +32,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 +43,39 @@ export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implemen * Component being initialized. */ ngOnInit(): void { + this.uploadFilesSupported = typeof this.question.responsefileareas != 'undefined'; this.initEssayComponent(); this.formControl = this.fb.control(this.question.textarea && this.question.textarea.text); + + if (this.question.allowsAttachments && this.uploadFilesSupported) { + this.loadAttachments(); + } + } + + /** + * Load attachments. + * + * @return Promise resolved when done. + */ + async loadAttachments(): Promise { + if (this.offlineEnabled && this.question.localAnswers['attachments_offline']) { + + const attachmentsData = this.textUtils.parseJSON(this.question.localAnswers['attachments_offline'], {}); + let offlineFiles = []; + + if (attachmentsData.offline) { + offlineFiles = await this.questionHelper.getStoredQuestionFiles(this.question, this.component, this.componentId); + + offlineFiles = CoreFileUploader.instance.markOfflineFiles(offlineFiles); + } + + this.attachments = (attachmentsData.online || []).concat(offlineFiles); + } else { + this.attachments = Array.from(this.questionHelper.getResponseFileAreaFiles(this.question, 'attachments')); + } + + CoreFileSession.instance.setFiles(this.component, + CoreQuestion.instance.getQuestionComponentId(this.question, this.componentId), this.attachments); } } diff --git a/src/addon/qtype/essay/providers/handler.ts b/src/addon/qtype/essay/providers/handler.ts index 9206b4e4e..39d29d9a1 100644 --- a/src/addon/qtype/essay/providers/handler.ts +++ b/src/addon/qtype/essay/providers/handler.ts @@ -14,11 +14,15 @@ // limitations under the License. import { Injectable, Injector } from '@angular/core'; +import { CoreFileSession } from '@providers/file-session'; +import { CoreSites } from '@providers/sites'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreFileUploader } from '@core/fileuploader/providers/fileuploader'; import { CoreQuestionHandler } from '@core/question/providers/delegate'; import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; +import { CoreQuestion } from '@core/question/providers/question'; import { AddonQtypeEssayComponent } from '../component/essay'; /** @@ -32,6 +36,59 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler { constructor(private utils: CoreUtilsProvider, private questionHelper: CoreQuestionHelperProvider, private textUtils: CoreTextUtilsProvider, private domUtils: CoreDomUtilsProvider) { } + /** + * Clear temporary data after the data has been saved. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + */ + clearTmpData(question: any, component: string, componentId: string | number): void { + const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); + const files = CoreFileSession.instance.getFiles(component, questionComponentId); + + // Clear the files in session for this question. + CoreFileSession.instance.clearFiles(component, questionComponentId); + + // Now delete the local files from the tmp folder. + CoreFileUploader.instance.clearTmpFiles(files); + } + + /** + * Delete any stored data for the question. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + deleteOfflineData(question: any, component: string, componentId: string | number, siteId?: string): Promise { + return this.questionHelper.deleteStoredQuestionFiles(question, component, componentId, siteId); + } + + /** + * Check whether the question allows text and/or attachments. + * + * @param question Question to check. + * @return Allowed options. + */ + protected getAllowedOptions(question: any): {text: boolean, attachments: boolean} { + if (question.settings) { + return { + text: question.settings.responseformat != 'noinline', + attachments: question.settings.attachments != '0', + }; + } else { + const element = this.domUtils.convertToElement(question.html); + + return { + text: !!element.querySelector('textarea[name*=_answer]'), + attachments: !!element.querySelector('div[id*=filemanager]'), + }; + } + } + /** * Return the name of the behaviour to use for the question. * If the question should use the default behaviour you shouldn't implement this function. @@ -65,14 +122,15 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler { */ getPreventSubmitMessage(question: any): string { const element = this.domUtils.convertToElement(question.html); + const uploadFilesSupported = typeof question.responsefileareas != 'undefined'; - if (element.querySelector('div[id*=filemanager]')) { + if (!uploadFilesSupported && 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'; + if (!uploadFilesSupported && this.questionHelper.hasDraftFileUrls(element.innerHTML)) { + return 'core.question.errorinlinefilesnotsupportedinsite'; } } @@ -81,20 +139,34 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ - isCompleteResponse(question: any, answers: any): number { - const element = this.domUtils.convertToElement(question.html); + isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { - const hasInlineText = answers['answer'] && answers['answer'] !== '', - allowsAttachments = !!element.querySelector('div[id*=filemanager]'); + const hasTextAnswer = answers['answer'] && answers['answer'] !== ''; + const uploadFilesSupported = typeof question.responsefileareas != 'undefined'; + const allowedOptions = this.getAllowedOptions(question); - if (!allowsAttachments) { - return hasInlineText ? 1 : 0; + if (!allowedOptions.attachments) { + return hasTextAnswer ? 1 : 0; } - // We can't know if the attachments are required or if the user added any in web. - return -1; + if (!uploadFilesSupported) { + // We can't know if the attachments are required or if the user added any in web. + return -1; + } + + const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); + const attachments = CoreFileSession.instance.getFiles(component, questionComponentId); + + if (!allowedOptions.text) { + return attachments && attachments.length >= Number(question.settings.attachmentsrequired) ? 1 : 0; + } + + return (hasTextAnswer || question.settings.responserequired == '0') && + (attachments && attachments.length > Number(question.settings.attachmentsrequired)) ? 1 : 0; } /** @@ -112,10 +184,20 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. */ - isGradableResponse(question: any, answers: any): number { - return 0; + isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { + if (typeof question.responsefileareas == 'undefined') { + return -1; + } + + const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); + const attachments = CoreFileSession.instance.getFiles(component, questionComponentId); + + // Determine if the given response has online text or attachments. + return (answers['answer'] && answers['answer'] !== '') || (attachments && attachments.length > 0) ? 1 : 0; } /** @@ -124,30 +206,165 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler { * @param question Question. * @param prevAnswers Object with the previous question answers. * @param newAnswers Object with the new question answers. + * @param component The component the question is related to. + * @param componentId Component ID. * @return Whether they're the same. */ - isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { - return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer'); + isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { + const uploadFilesSupported = typeof question.responsefileareas != 'undefined'; + const allowedOptions = this.getAllowedOptions(question); + + // First check the inline text. + const answerIsEqual = allowedOptions.text ? this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') : true; + + if (!allowedOptions.attachments || !uploadFilesSupported || !answerIsEqual) { + // No need to check attachments. + return answerIsEqual; + } + + // Check attachments now. + const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); + const attachments = CoreFileSession.instance.getFiles(component, questionComponentId); + const originalAttachments = this.questionHelper.getResponseFileAreaFiles(question, 'attachments'); + + return !CoreFileUploader.instance.areFileListDifferent(attachments, originalAttachments); } /** - * Prepare and add to answers the data to send to server based in the input. Return promise if async. + * Prepare and add to answers the data to send to server based in the input. * * @param question Question. * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. * @param offline Whether the data should be saved in offline. + * @param component The component the question is related to. + * @param componentId Component ID. * @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, component: string, componentId: string | number, + siteId?: string): Promise { + const element = this.domUtils.convertToElement(question.html); + const attachmentsInput = element.querySelector('.attachments input[name*=_attachments]'); // Search the textarea to get its name. const textarea = element.querySelector('textarea[name*=_answer]'); if (textarea && typeof answers[textarea.name] != 'undefined') { - // Add some HTML to the text if needed. - answers[textarea.name] = this.textUtils.formatHtmlLines(answers[textarea.name]); + await this.prepareTextAnswer(question, answers, textarea, siteId); + } + + if (attachmentsInput) { + await this.prepareAttachments(question, answers, offline, component, componentId, attachmentsInput, siteId); } } + + /** + * Prepare attachments. + * + * @param question Question. + * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. + * @param offline Whether the data should be saved in offline. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param attachmentsInput The HTML input containing the draft ID for attachments. + * @param siteId Site ID. If not defined, current site. + * @return Return a promise resolved when done if async, void if sync. + */ + async prepareAttachments(question: any, answers: any, offline: boolean, component: string, componentId: string | number, + attachmentsInput: HTMLInputElement, siteId?: string): Promise { + + // Treat attachments if any. + const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); + const attachments = CoreFileSession.instance.getFiles(component, questionComponentId); + const draftId = Number(attachmentsInput.value); + + if (offline) { + // Get the folder where to store the files. + const folderPath = CoreQuestion.instance.getQuestionFolder(question.type, component, questionComponentId, siteId); + + const result = await CoreFileUploader.instance.storeFilesToUpload(folderPath, attachments); + + // Store the files in the answers. + answers[attachmentsInput.name + '_offline'] = JSON.stringify(result); + } else { + // Check if any attachment was deleted. + const originalAttachments = this.questionHelper.getResponseFileAreaFiles(question, 'attachments'); + const filesToDelete = CoreFileUploader.instance.getFilesToDelete(originalAttachments, attachments); + + if (filesToDelete.length > 0) { + // Delete files. + await CoreFileUploader.instance.deleteDraftFiles(draftId, filesToDelete, siteId); + } + + await CoreFileUploader.instance.uploadFiles(draftId, attachments, siteId); + } + } + + /** + * Prepare data to send when performing a synchronization. + * + * @param question Question. + * @param answers Answers of the question, without the prefix. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async prepareSyncData(question: any, answers: {[name: string]: any}, component: string, componentId: string | number, + siteId?: string): Promise { + + const element = this.domUtils.convertToElement(question.html); + const attachmentsInput = element.querySelector('.attachments input[name*=_attachments]'); + + if (attachmentsInput) { + // Update the draft ID, the stored one could no longer be valid. + answers.attachments = attachmentsInput.value; + } + + if (answers && answers.attachments_offline) { + const attachmentsData = this.textUtils.parseJSON(answers.attachments_offline, {}); + + // Check if any attachment was deleted. + const originalAttachments = this.questionHelper.getResponseFileAreaFiles(question, 'attachments'); + const filesToDelete = CoreFileUploader.instance.getFilesToDelete(originalAttachments, attachmentsData.online); + + if (filesToDelete.length > 0) { + // Delete files. + await CoreFileUploader.instance.deleteDraftFiles(answers.attachments, filesToDelete, siteId); + } + + if (attachmentsData.offline) { + // Upload the offline files. + const offlineFiles = await this.questionHelper.getStoredQuestionFiles(question, component, componentId, siteId); + + await CoreFileUploader.instance.uploadFiles(answers.attachments, attachmentsData.online.concat(offlineFiles), + siteId); + } + + delete answers.attachments_offline; + } + } + + /** + * Prepare the text answer. + * + * @param question Question. + * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. + * @param textarea The textarea HTML element of the question. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async prepareTextAnswer(question: any, answers: any, textarea: HTMLTextAreaElement, siteId?: string): Promise { + 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/addon/qtype/gapselect/providers/handler.ts b/src/addon/qtype/gapselect/providers/handler.ts index 50b83068e..26ee5c10e 100644 --- a/src/addon/qtype/gapselect/providers/handler.ts +++ b/src/addon/qtype/gapselect/providers/handler.ts @@ -61,9 +61,11 @@ export class AddonQtypeGapSelectHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ - isCompleteResponse(question: any, answers: any): number { + isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { // We should always get a value for each select so we can assume we receive all the possible answers. for (const name in answers) { const value = answers[name]; @@ -90,9 +92,11 @@ export class AddonQtypeGapSelectHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. */ - isGradableResponse(question: any, answers: any): number { + isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { // We should always get a value for each select so we can assume we receive all the possible answers. for (const name in answers) { const value = answers[name]; @@ -110,9 +114,11 @@ export class AddonQtypeGapSelectHandler implements CoreQuestionHandler { * @param question Question. * @param prevAnswers Object with the previous question answers. * @param newAnswers Object with the new question answers. + * @param component The component the question is related to. + * @param componentId Component ID. * @return Whether they're the same. */ - isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); } } diff --git a/src/addon/qtype/match/providers/handler.ts b/src/addon/qtype/match/providers/handler.ts index 90d87d1d1..dcc287984 100644 --- a/src/addon/qtype/match/providers/handler.ts +++ b/src/addon/qtype/match/providers/handler.ts @@ -61,9 +61,11 @@ export class AddonQtypeMatchHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ - isCompleteResponse(question: any, answers: any): number { + isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { // We should always get a value for each select so we can assume we receive all the possible answers. for (const name in answers) { const value = answers[name]; @@ -90,9 +92,11 @@ export class AddonQtypeMatchHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. */ - isGradableResponse(question: any, answers: any): number { + isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { // We should always get a value for each select so we can assume we receive all the possible answers. for (const name in answers) { const value = answers[name]; @@ -110,9 +114,11 @@ export class AddonQtypeMatchHandler implements CoreQuestionHandler { * @param question Question. * @param prevAnswers Object with the previous question answers. * @param newAnswers Object with the new question answers. + * @param component The component the question is related to. + * @param componentId Component ID. * @return Whether they're the same. */ - isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); } } diff --git a/src/addon/qtype/multianswer/providers/handler.ts b/src/addon/qtype/multianswer/providers/handler.ts index 798dcf572..5cdf85a99 100644 --- a/src/addon/qtype/multianswer/providers/handler.ts +++ b/src/addon/qtype/multianswer/providers/handler.ts @@ -62,9 +62,11 @@ export class AddonQtypeMultiAnswerHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ - isCompleteResponse(question: any, answers: any): number { + isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { // Get all the inputs in the question to check if they've all been answered. const names = this.questionProvider.getBasicAnswers(this.questionHelper.getAllInputNamesFromHtml(question.html)); for (const name in names) { @@ -92,9 +94,11 @@ export class AddonQtypeMultiAnswerHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. */ - isGradableResponse(question: any, answers: any): number { + isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { // We should always get a value for each select so we can assume we receive all the possible answers. for (const name in answers) { const value = answers[name]; @@ -112,9 +116,11 @@ export class AddonQtypeMultiAnswerHandler implements CoreQuestionHandler { * @param question Question. * @param prevAnswers Object with the previous question answers. * @param newAnswers Object with the new question answers. + * @param component The component the question is related to. + * @param componentId Component ID. * @return Whether they're the same. */ - isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); } diff --git a/src/addon/qtype/multichoice/providers/handler.ts b/src/addon/qtype/multichoice/providers/handler.ts index 92424fbd2..1bd43f76b 100644 --- a/src/addon/qtype/multichoice/providers/handler.ts +++ b/src/addon/qtype/multichoice/providers/handler.ts @@ -45,9 +45,11 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ - isCompleteResponse(question: any, answers: any): number { + isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { let isSingle = true, isMultiComplete = false; @@ -95,10 +97,12 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. */ - isGradableResponse(question: any, answers: any): number { - return this.isCompleteResponse(question, answers); + isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { + return this.isCompleteResponse(question, answers, component, componentId); } /** @@ -118,9 +122,11 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler { * @param question Question. * @param prevAnswers Object with the previous question answers. * @param newAnswers Object with the new question answers. + * @param component The component the question is related to. + * @param componentId Component ID. * @return Whether they're the same. */ - isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { let isSingle = true, isMultiSame = true; @@ -158,10 +164,13 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler { * @param question Question. * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. * @param offline Whether the data should be saved in offline. + * @param component The component the question is related to. + * @param componentId Component ID. * @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 { + prepareAnswers(question: any, answers: any, offline: boolean, component: string, componentId: string | number, siteId?: string) + : void | Promise { if (question && !question.multi && typeof answers[question.optionsName] != 'undefined' && !answers[question.optionsName]) { /* It's a single choice and the user hasn't answered. Delete the answer because sending an empty string (default value) will mark the first option as selected. */ diff --git a/src/addon/qtype/randomsamatch/providers/handler.ts b/src/addon/qtype/randomsamatch/providers/handler.ts index a0eede940..8c48923f2 100644 --- a/src/addon/qtype/randomsamatch/providers/handler.ts +++ b/src/addon/qtype/randomsamatch/providers/handler.ts @@ -46,11 +46,13 @@ export class AddonQtypeRandomSaMatchHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ - isCompleteResponse(question: any, answers: any): number { + isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { // This question behaves like a match question. - return this.matchHandler.isCompleteResponse(question, answers); + return this.matchHandler.isCompleteResponse(question, answers, component, componentId); } /** @@ -68,11 +70,13 @@ export class AddonQtypeRandomSaMatchHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. */ - isGradableResponse(question: any, answers: any): number { + isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { // This question behaves like a match question. - return this.matchHandler.isGradableResponse(question, answers); + return this.matchHandler.isGradableResponse(question, answers, component, componentId); } /** @@ -81,10 +85,12 @@ export class AddonQtypeRandomSaMatchHandler implements CoreQuestionHandler { * @param question Question. * @param prevAnswers Object with the previous question answers. * @param newAnswers Object with the new question answers. + * @param component The component the question is related to. + * @param componentId Component ID. * @return Whether they're the same. */ - isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { // This question behaves like a match question. - return this.matchHandler.isSameResponse(question, prevAnswers, newAnswers); + return this.matchHandler.isSameResponse(question, prevAnswers, newAnswers, component, componentId); } } diff --git a/src/addon/qtype/shortanswer/providers/handler.ts b/src/addon/qtype/shortanswer/providers/handler.ts index f9629fa75..14abddb04 100644 --- a/src/addon/qtype/shortanswer/providers/handler.ts +++ b/src/addon/qtype/shortanswer/providers/handler.ts @@ -45,9 +45,11 @@ export class AddonQtypeShortAnswerHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ - isCompleteResponse(question: any, answers: any): number { + isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { return (answers['answer'] || answers['answer'] === 0) ? 1 : 0; } @@ -66,10 +68,12 @@ export class AddonQtypeShortAnswerHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. */ - isGradableResponse(question: any, answers: any): number { - return this.isCompleteResponse(question, answers); + isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { + return this.isCompleteResponse(question, answers, null, null); } /** @@ -78,9 +82,11 @@ export class AddonQtypeShortAnswerHandler implements CoreQuestionHandler { * @param question Question. * @param prevAnswers Object with the previous question answers. * @param newAnswers Object with the new question answers. + * @param component The component the question is related to. + * @param componentId Component ID. * @return Whether they're the same. */ - isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer'); } } diff --git a/src/addon/qtype/truefalse/providers/handler.ts b/src/addon/qtype/truefalse/providers/handler.ts index f811dfa65..a8ef44aac 100644 --- a/src/addon/qtype/truefalse/providers/handler.ts +++ b/src/addon/qtype/truefalse/providers/handler.ts @@ -46,9 +46,11 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ - isCompleteResponse(question: any, answers: any): number { + isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { return answers['answer'] ? 1 : 0; } @@ -67,10 +69,12 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. */ - isGradableResponse(question: any, answers: any): number { - return this.isCompleteResponse(question, answers); + isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { + return this.isCompleteResponse(question, answers, null, null); } /** @@ -79,9 +83,11 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler { * @param question Question. * @param prevAnswers Object with the previous question answers. * @param newAnswers Object with the new question answers. + * @param component The component the question is related to. + * @param componentId Component ID. * @return Whether they're the same. */ - isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer'); } @@ -91,10 +97,13 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler { * @param question Question. * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. * @param offline Whether the data should be saved in offline. + * @param component The component the question is related to. + * @param componentId Component ID. * @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 { + prepareAnswers(question: any, answers: any, offline: boolean, component: string, componentId: string | number, siteId?: string) + : void | Promise { if (question && typeof answers[question.optionsName] != 'undefined' && !answers[question.optionsName]) { // The user hasn't answered. Delete the answer to prevent marking one of the answers automatically. delete answers[question.optionsName]; diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 391cb9b50..8657003fb 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -1861,6 +1861,7 @@ "core.mainmenu.help": "Help", "core.mainmenu.logout": "Log out", "core.mainmenu.website": "Website", + "core.maxfilesize": "Maximum size for new files: {{$a}}", "core.maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}", "core.min": "min", "core.mins": "mins", @@ -1944,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.errorembeddedfilesnotsupportedinsite": "Your site doesn't support editing embedded 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/components/attachments/attachments.ts b/src/components/attachments/attachments.ts index 810165c39..e327db8a6 100644 --- a/src/components/attachments/attachments.ts +++ b/src/components/attachments/attachments.ts @@ -39,8 +39,8 @@ import { CoreFileUploaderHelperProvider } from '@core/fileuploader/providers/hel }) export class CoreAttachmentsComponent implements OnInit { @Input() files: any[]; // List of attachments. New attachments will be added to this array. - @Input() maxSize: number; // Max size for attachments. If not defined, 0 or -1, unknown size. - @Input() maxSubmissions: number; // Max number of attachments. If -1 or not defined, unknown limit. + @Input() maxSize: number; // Max size for attachments. -1 means unlimited, not defined or 0 means unknown limit. + @Input() maxSubmissions: number; // Max number of attachments. -1 means unlimited, not defined means unknown limit. @Input() component: string; // Component the downloaded files will be linked to. @Input() componentId: string | number; // Component ID. @Input() allowOffline: boolean | string; // Whether to allow selecting files in offline. @@ -61,17 +61,18 @@ export class CoreAttachmentsComponent implements OnInit { * Component being initialized. */ ngOnInit(): void { - this.maxSize = Number(this.maxSize); // Make sure it's defined and it's a number. - this.maxSize = !isNaN(this.maxSize) && this.maxSize > 0 ? this.maxSize : -1; + this.maxSize = Number(this.maxSize) || 0; // Make sure it's defined and it's a number. - if (this.maxSize == -1) { + if (this.maxSize === 0) { this.maxSizeReadable = this.translate.instant('core.unknown'); - } else { + } else if (this.maxSize > 0) { this.maxSizeReadable = this.textUtils.bytesToSize(this.maxSize, 2); + } else { + this.maxSizeReadable = this.translate.instant('core.unlimited'); } if (typeof this.maxSubmissions == 'undefined' || this.maxSubmissions < 0) { - this.maxSubmissionsReadable = this.translate.instant('core.unknown'); + this.maxSubmissionsReadable = this.maxSubmissions < 0 ? undefined : this.translate.instant('core.unknown'); this.unlimitedFiles = true; } else { this.maxSubmissionsReadable = String(this.maxSubmissions); diff --git a/src/components/attachments/core-attachments.html b/src/components/attachments/core-attachments.html index 7a1abcda8..f57780bad 100644 --- a/src/components/attachments/core-attachments.html +++ b/src/components/attachments/core-attachments.html @@ -1,5 +1,6 @@ - {{ 'core.maxsizeandattachments' | translate:{$a: {size: maxSizeReadable, attachments: maxSubmissionsReadable} } }} + {{ 'core.maxsizeandattachments' | translate:{$a: {size: maxSizeReadable, attachments: maxSubmissionsReadable} } }} + {{ 'core.maxfilesize' | translate:{$a: maxSizeReadable} }} 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/fileuploader/providers/fileuploader.ts b/src/core/fileuploader/providers/fileuploader.ts index 2caac3c0c..43bbc021b 100644 --- a/src/core/fileuploader/providers/fileuploader.ts +++ b/src/core/fileuploader/providers/fileuploader.ts @@ -15,6 +15,7 @@ import { Injectable } from '@angular/core'; import { ModalController } from 'ionic-angular'; import { Camera, CameraOptions } from '@ionic-native/camera'; +import { FileEntry } from '@ionic-native/file'; import { MediaCapture, MediaFile, CaptureError, CaptureAudioOptions, CaptureVideoOptions } from '@ionic-native/media-capture'; import { TranslateService } from '@ngx-translate/core'; import { CoreFileProvider } from '@providers/file'; @@ -25,9 +26,11 @@ import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreUtilsProvider } from '@providers/utils/utils'; -import { CoreWSFileUploadOptions } from '@providers/ws'; +import { CoreWSFileUploadOptions, CoreWSExternalFile } from '@providers/ws'; import { Subject } from 'rxjs'; import { CoreApp } from '@providers/app'; +import { CoreSite } from '@classes/site'; +import { makeSingleton } from '@singletons/core.singletons'; /** * File upload options. @@ -96,7 +99,7 @@ export class CoreFileUploaderProvider { // Currently we are going to compare the order of the files as well. // This function can be improved comparing more fields or not comparing the order. for (let i = 0; i < a.length; i++) { - if ((a[i].name || a[i].filename) != (b[i].name || b[i].filename)) { + if (a[i].name != b[i].name || a[i].filename != b[i].filename) { return true; } } @@ -104,6 +107,36 @@ export class CoreFileUploaderProvider { return false; } + /** + * Check if a certain site allows deleting draft files. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if can delete. + * @since 3.10 + */ + async canDeleteDraftFiles(siteId?: string): Promise { + try { + const site = await this.sitesProvider.getSite(siteId); + + return this.canDeleteDraftFilesInSite(site); + } catch (error) { + return false; + } + } + + /** + * Check if a certain site allows deleting draft files. + * + * @param site Site. If not defined, use current site. + * @return Whether draft files can be deleted. + * @since 3.10 + */ + canDeleteDraftFilesInSite(site?: CoreSite): boolean { + site = site || this.sitesProvider.getCurrentSite(); + + return site.wsAvailable('core_files_delete_draft_files'); + } + /** * Start the audio recorder application and return information about captured audio clip files. * @@ -173,6 +206,25 @@ export class CoreFileUploaderProvider { }); } + /** + * Delete draft files. + * + * @param draftId Draft ID. + * @param files Files to delete. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteDraftFiles(draftId: number, files: {filepath: string, filename: string}[], siteId?: string): Promise { + const site = await this.sitesProvider.getSite(siteId); + + const params = { + draftitemid: draftId, + files: files, + }; + + return site.write('core_files_delete_draft_files', params); + } + /** * Get the upload options for a file taken with the Camera Cordova plugin. * @@ -215,6 +267,35 @@ export class CoreFileUploaderProvider { return options; } + /** + * Given a list of original files and a list of current files, return the list of files to delete. + * + * @param originalFiles Original files. + * @param currentFiles Current files. + * @return List of files to delete. + */ + getFilesToDelete(originalFiles: CoreWSExternalFile[], currentFiles: (CoreWSExternalFile | FileEntry)[]) + : {filepath: string, filename: string}[] { + + const filesToDelete: {filepath: string, filename: string}[] = []; + currentFiles = currentFiles || []; + + originalFiles.forEach((file) => { + const stillInList = currentFiles.some((currentFile) => { + return ( currentFile).fileurl == file.fileurl; + }); + + if (!stillInList) { + filesToDelete.push({ + filepath: file.filepath, + filename: file.filename, + }); + } + }); + + return filesToDelete; + } + /** * Get the upload options for a file of any type. * @@ -534,6 +615,47 @@ export class CoreFileUploaderProvider { }); } + /** + * Given a list of files (either online files or local files), upload the local files to the draft area. + * Local files are not deleted from the device after upload. + * + * @param itemId Draft ID. + * @param files List of files. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the itemId. + */ + async uploadFiles(itemId: number, files: (CoreWSExternalFile | FileEntry)[], siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (!files || !files.length) { + return; + } + + // Index the online files by name. + const usedNames: {[name: string]: (CoreWSExternalFile | FileEntry)} = {}; + const filesToUpload: FileEntry[] = []; + files.forEach((file) => { + const isOnlineFile = ( file).filename && !( file).name; + + if (isOnlineFile) { + usedNames[( file).filename.toLowerCase()] = file; + } else { + filesToUpload.push( file); + } + }); + + await Promise.all(filesToUpload.map(async (file) => { + // Make sure the file name is unique in the area. + const name = this.fileProvider.calculateUniqueName(usedNames, file.name); + usedNames[name] = file; + + // Now upload the file. + const options = this.getFileUploadOptions(file.toURL(), name, undefined, false, 'draft', itemId); + + await this.uploadFile(file.toURL(), options, undefined, siteId); + })); + } + /** * Upload a file to a draft area and return the draft ID. * @@ -615,3 +737,5 @@ export class CoreFileUploaderProvider { }); } } + +export class CoreFileUploader extends makeSingleton(CoreFileUploaderProvider) {} 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-behaviour-handler.ts b/src/core/question/classes/base-behaviour-handler.ts index 852d7dfc5..50e3c7229 100644 --- a/src/core/question/classes/base-behaviour-handler.ts +++ b/src/core/question/classes/base-behaviour-handler.ts @@ -34,10 +34,11 @@ export class CoreQuestionBehaviourBaseHandler implements CoreQuestionBehaviourHa * @param component Component the question belongs to. * @param attemptId Attempt ID the question belongs to. * @param question The question. + * @param componentId Component ID. * @param siteId Site ID. If not defined, current site. * @return New state (or promise resolved with state). */ - determineNewState(component: string, attemptId: number, question: any, siteId?: string) + determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string) : CoreQuestionState | Promise { // Return the current state. return this.questionProvider.getState(question.state); diff --git a/src/core/question/classes/base-question-component.ts b/src/core/question/classes/base-question-component.ts index f968ec0b9..92820067c 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'; /** @@ -105,9 +107,13 @@ export class CoreQuestionBaseComponent { this.question.select = selectModel; // Check which one should be displayed first: the select or the input. - const input = questionEl.querySelector('input[type="text"][name*=answer]'); - this.question.selectFirst = - questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(select.outerHTML); + if (this.question.settings) { + this.question.selectFirst = this.question.settings.unitsleft == '1'; + } else { + const input = questionEl.querySelector('input[type="text"][name*=answer]'); + this.question.selectFirst = + questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(select.outerHTML); + } return questionEl; } @@ -159,9 +165,15 @@ export class CoreQuestionBaseComponent { } // Check which one should be displayed first: the options or the input. - const input = questionEl.querySelector('input[type="text"][name*=answer]'); - this.question.optionsFirst = - questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(radios[0].outerHTML); + if (this.question.settings) { + this.question.optionsFirst = this.question.settings.unitsleft == '1'; + } else { + 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 +213,46 @@ export class CoreQuestionBaseComponent { const questionEl = this.initComponent(); if (questionEl) { - // First search the textarea. const textarea = questionEl.querySelector('textarea[name*=_answer]'); - this.question.allowsAttachments = !!questionEl.querySelector('div[id*=filemanager]'); - 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); + const answerDraftIdInput = questionEl.querySelector('input[name*="_answer:itemid"]'); - if (!textarea) { - // Textarea not found, we might be in review. Search the answer and the attachments. + if (this.question.settings) { + this.question.allowsAttachments = this.question.settings.attachments != '0'; + this.question.allowsAnswerFiles = this.question.settings.responseformat == 'editorfilepicker'; + this.question.isMonospaced = this.question.settings.responseformat == 'monospaced'; + this.question.isPlainText = this.question.isMonospaced || this.question.settings.responseformat == 'plain'; + } else { + 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.question.allowsAnswerFiles && + this.questionHelper.hasDraftFileUrls(questionEl.innerHTML); + + 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 +262,43 @@ 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 (this.question.settings) { + this.question.attachmentsMaxFiles = Number(this.question.settings.attachments); + this.question.attachmentsAcceptedTypes = this.question.settings.filetypeslist && + this.question.settings.filetypeslist.join(','); + } + + if (fileManagerUrl) { + const params = CoreUrlUtils.instance.extractUrlParams(fileManagerUrl); + const maxBytes = Number(params.maxbytes); + const areaMaxBytes = Number(params.areamaxbytes); + + this.question.attachmentsMaxBytes = maxBytes === -1 || areaMaxBytes === -1 ? + Math.max(maxBytes, areaMaxBytes) : Math.min(maxBytes, areaMaxBytes); + } + } + + return questionEl; } } @@ -270,6 +338,8 @@ export class CoreQuestionBaseComponent { // Set the question text. this.question.text = content.innerHTML; + + return element; } /** diff --git a/src/core/question/classes/base-question-handler.ts b/src/core/question/classes/base-question-handler.ts index 745536c5a..afaa35fa4 100644 --- a/src/core/question/classes/base-question-handler.ts +++ b/src/core/question/classes/base-question-handler.ts @@ -79,9 +79,11 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ - isCompleteResponse(question: any, answers: any): number { + isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { return -1; } @@ -91,9 +93,11 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. */ - isGradableResponse(question: any, answers: any): number { + isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { return -1; } @@ -103,9 +107,11 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler { * @param question Question. * @param prevAnswers Object with the previous question answers. * @param newAnswers Object with the new question answers. + * @param component The component the question is related to. + * @param componentId Component ID. * @return Whether they're the same. */ - isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { return false; } @@ -115,10 +121,13 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler { * @param question Question. * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. * @param offline Whether the data should be saved in offline. + * @param component The component the question is related to. + * @param componentId Component ID. * @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 { + prepareAnswers(question: any, answers: any, offline: boolean, component: string, componentId: string | number, siteId?: string) + : void | Promise { // Nothing to do. } diff --git a/src/core/question/lang/en.json b/src/core/question/lang/en.json index 513c436d2..ee7018835 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.", + "errorembeddedfilesnotsupportedinsite": "Your site doesn't support editing embedded 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/behaviour-delegate.ts b/src/core/question/providers/behaviour-delegate.ts index db0d052de..c0c9dd717 100644 --- a/src/core/question/providers/behaviour-delegate.ts +++ b/src/core/question/providers/behaviour-delegate.ts @@ -36,10 +36,11 @@ export interface CoreQuestionBehaviourHandler extends CoreDelegateHandler { * @param component Component the question belongs to. * @param attemptId Attempt ID the question belongs to. * @param question The question. + * @param componentId Component ID. * @param siteId Site ID. If not defined, current site. * @return State (or promise resolved with state). */ - determineNewState?(component: string, attemptId: number, question: any, siteId?: string) + determineNewState?(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string) : CoreQuestionState | Promise; /** @@ -74,15 +75,16 @@ export class CoreQuestionBehaviourDelegate extends CoreDelegate { * @param component Component the question belongs to. * @param attemptId Attempt ID the question belongs to. * @param question The question. + * @param componentId Component ID. * @param siteId Site ID. If not defined, current site. * @return Promise resolved with state. */ - determineNewState(behaviour: string, component: string, attemptId: number, question: any, siteId?: string) - : Promise { + determineNewState(behaviour: string, component: string, attemptId: number, question: any, componentId: string | number, + siteId?: string): Promise { behaviour = this.questionDelegate.getBehaviourForQuestion(question, behaviour); return Promise.resolve(this.executeFunctionOnEnabled(behaviour, 'determineNewState', - [component, attemptId, question, siteId])); + [component, attemptId, question, componentId, siteId])); } /** diff --git a/src/core/question/providers/delegate.ts b/src/core/question/providers/delegate.ts index 3d53a059c..6caa7eb2b 100644 --- a/src/core/question/providers/delegate.ts +++ b/src/core/question/providers/delegate.ts @@ -62,9 +62,11 @@ export interface CoreQuestionHandler extends CoreDelegateHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ - isCompleteResponse?(question: any, answers: any): number; + isCompleteResponse?(question: any, answers: any, component: string, componentId: string | number): number; /** * Check if a student has provided enough of an answer for the question to be graded automatically, @@ -72,9 +74,11 @@ export interface CoreQuestionHandler extends CoreDelegateHandler { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. */ - isGradableResponse?(question: any, answers: any): number; + isGradableResponse?(question: any, answers: any, component: string, componentId: string | number): number; /** * Check if two responses are the same. @@ -84,7 +88,7 @@ export interface CoreQuestionHandler extends CoreDelegateHandler { * @param newAnswers Object with the new question answers. * @return Whether they're the same. */ - isSameResponse?(question: any, prevAnswers: any, newAnswers: any): boolean; + isSameResponse?(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean; /** * Prepare and add to answers the data to send to server based in the input. Return promise if async. @@ -92,10 +96,13 @@ export interface CoreQuestionHandler extends CoreDelegateHandler { * @param question Question. * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. * @param offline Whether the data should be saved in offline. + * @param component The component the question is related to. + * @param componentId Component ID. * @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; + prepareAnswers?(question: any, answers: any, offline: boolean, component: string, componentId: string | number, + siteId?: string): void | Promise; /** * Validate if an offline sequencecheck is valid compared with the online one. @@ -115,6 +122,40 @@ export interface CoreQuestionHandler extends CoreDelegateHandler { * @return List of URLs. */ getAdditionalDownloadableFiles?(question: any, usageId: number): string[]; + + /** + * Clear temporary data after the data has been saved. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return If async, promise resolved when done. + */ + clearTmpData?(question: any, component: string, componentId: string | number): void | Promise; + + /** + * Delete any stored data for the question. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return If async, promise resolved when done. + */ + deleteOfflineData?(question: any, component: string, componentId: string | number, siteId?: string): void | Promise; + + /** + * Prepare data to send when performing a synchronization. + * + * @param question Question. + * @param answers Answers of the question, without the prefix. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return If async, promise resolved when done. + */ + prepareSyncData?(question: any, answers: {[name: string]: any}, component: string, componentId: string | number, + siteId?: string): void | Promise; } /** @@ -196,12 +237,14 @@ export class CoreQuestionDelegate extends CoreDelegate { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if complete, 0 if not complete, -1 if cannot determine. */ - isCompleteResponse(question: any, answers: any): number { + isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { const type = this.getTypeName(question); - return this.executeFunctionOnEnabled(type, 'isCompleteResponse', [question, answers]); + return this.executeFunctionOnEnabled(type, 'isCompleteResponse', [question, answers, component, componentId]); } /** @@ -210,12 +253,14 @@ export class CoreQuestionDelegate extends CoreDelegate { * * @param question The question. * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. */ - isGradableResponse(question: any, answers: any): number { + isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { const type = this.getTypeName(question); - return this.executeFunctionOnEnabled(type, 'isGradableResponse', [question, answers]); + return this.executeFunctionOnEnabled(type, 'isGradableResponse', [question, answers, component, componentId]); } /** @@ -226,10 +271,10 @@ export class CoreQuestionDelegate extends CoreDelegate { * @param newAnswers Object with the new question answers. * @return Whether they're the same. */ - isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { const type = this.getTypeName(question); - return this.executeFunctionOnEnabled(type, 'isSameResponse', [question, prevAnswers, newAnswers]); + return this.executeFunctionOnEnabled(type, 'isSameResponse', [question, prevAnswers, newAnswers, component, componentId]); } /** @@ -248,13 +293,17 @@ export class CoreQuestionDelegate extends CoreDelegate { * @param question Question. * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. * @param offline Whether the data should be saved in offline. + * @param component The component the question is related to. + * @param componentId Component ID. * @param siteId Site ID. If not defined, current site. * @return Promise resolved when data has been prepared. */ - prepareAnswersForQuestion(question: any, answers: any, offline: boolean, siteId?: string): Promise { + prepareAnswersForQuestion(question: any, answers: any, offline: boolean, component: string, componentId: string | number, + siteId?: string): Promise { const type = this.getTypeName(question); - return Promise.resolve(this.executeFunctionOnEnabled(type, 'prepareAnswers', [question, answers, offline, siteId])); + return Promise.resolve(this.executeFunctionOnEnabled(type, 'prepareAnswers', + [question, answers, offline, component, componentId, siteId])); } /** @@ -282,4 +331,50 @@ export class CoreQuestionDelegate extends CoreDelegate { return this.executeFunctionOnEnabled(type, 'getAdditionalDownloadableFiles', [question, usageId]) || []; } + + /** + * Clear temporary data after the data has been saved. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return If async, promise resolved when done. + */ + clearTmpData(question: any, component: string, componentId: string | number): void | Promise { + const type = this.getTypeName(question); + + return this.executeFunctionOnEnabled(type, 'clearTmpData', [question, component, componentId]); + } + + /** + * Clear temporary data after the data has been saved. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return If async, promise resolved when done. + */ + deleteOfflineData(question: any, component: string, componentId: string | number, siteId?: string): void | Promise { + const type = this.getTypeName(question); + + return this.executeFunctionOnEnabled(type, 'deleteOfflineData', [question, component, componentId, siteId]); + } + + /** + * Prepare data to send when performing a synchronization. + * + * @param question Question. + * @param answers Answers of the question, without the prefix. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return If async, promise resolved when done. + */ + prepareSyncData?(question: any, answers: {[name: string]: any}, component: string, componentId: string | number, + siteId?: string): void | Promise { + const type = this.getTypeName(question); + + return this.executeFunctionOnEnabled(type, 'prepareSyncData', [question, answers, component, componentId, siteId]); + } } diff --git a/src/core/question/providers/helper.ts b/src/core/question/providers/helper.ts index edaf17390..9c6570858 100644 --- a/src/core/question/providers/helper.ts +++ b/src/core/question/providers/helper.ts @@ -13,9 +13,12 @@ // limitations under the License. import { Injectable, EventEmitter } from '@angular/core'; +import { FileEntry } from '@ionic-native/file'; import { TranslateService } from '@ngx-translate/core'; +import { CoreFile } from '@providers/file'; 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'; @@ -59,6 +62,41 @@ export class CoreQuestionHelperProvider { }); } + /** + * Clear questions temporary data after the data has been saved. + * + * @param questions The list of questions. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return Promise resolved when done. + */ + async clearTmpData(questions: any[], component: string, componentId: string | number): Promise { + questions = questions || []; + + await Promise.all(questions.map(async (question) => { + await this.questionDelegate.clearTmpData(question, component, componentId); + })); + } + + /** + * Delete files stored for a question. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteStoredQuestionFiles(question: any, component: string, componentId: string | number, siteId?: string) + : Promise { + + const questionComponentId = this.questionProvider.getQuestionComponentId(question, componentId); + const folderPath = this.questionProvider.getQuestionFolder(question.type, component, questionComponentId, siteId); + + // Ignore errors, maybe the folder doesn't exist. + await this.utils.ignoreErrors(CoreFile.instance.removeDir(folderPath)); + } + /** * Extract question behaviour submit buttons from the question's HTML and add them to "behaviourButtons" property. * The buttons aren't deleted from the content because all the im-controls block will be removed afterwards. @@ -421,6 +459,41 @@ 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 files stored for a question. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + getStoredQuestionFiles(question: any, component: string, componentId: string | number, siteId?: string): Promise { + const questionComponentId = this.questionProvider.getQuestionComponentId(question, componentId); + const folderPath = this.questionProvider.getQuestionFolder(question.type, component, questionComponentId, siteId); + + return CoreFile.instance.getDirectoryContents(folderPath); + } + /** * Get the validation error message from a question HTML if it's there. * @@ -521,7 +594,7 @@ export class CoreQuestionHelperProvider { if (!component) { component = CoreQuestionProvider.COMPONENT; - componentId = question.id; + componentId = question.number; } urls.push(...this.questionDelegate.getAdditionalDownloadableFiles(question, usageId)); @@ -552,15 +625,19 @@ export class CoreQuestionHelperProvider { * @param questions The list of questions. * @param answers The input data. * @param offline True if data should be saved in offline. + * @param component The component the question is related to. + * @param componentId Component ID. * @param siteId Site ID. If not defined, current site. * @return Promise resolved with answers to send to server. */ - prepareAnswers(questions: any[], answers: any, offline?: boolean, siteId?: string): Promise { + prepareAnswers(questions: any[], answers: any, offline: boolean, component: string, componentId: string | number, + siteId?: string): Promise { const promises = []; questions = questions || []; questions.forEach((question) => { - promises.push(this.questionDelegate.prepareAnswersForQuestion(question, answers, offline, siteId)); + promises.push(this.questionDelegate.prepareAnswersForQuestion(question, answers, offline, component, componentId, + siteId)); }); return this.utils.allPromises(promises).then(() => { diff --git a/src/core/question/providers/question.ts b/src/core/question/providers/question.ts index 30e2ef73b..0a9945fda 100644 --- a/src/core/question/providers/question.ts +++ b/src/core/question/providers/question.ts @@ -13,10 +13,13 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { CoreFile } from '@providers/file'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; +import { CoreTextUtils } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreUtilsProvider } from '@providers/utils/utils'; +import { makeSingleton } from '@singletons/core.singletons'; /** * An object to represent a question state. @@ -413,6 +416,35 @@ export class CoreQuestionProvider { }); } + /** + * Given a question and a componentId, return a componentId that is unique for the question. + * + * @param question Question. + * @param componentId Component ID. + * @return Question component ID. + */ + getQuestionComponentId(question: any, componentId: string | number): string { + return componentId + '_' + question.number; + } + + /** + * Get the path to the folder where to store files for an offline question. + * + * @param type Question type. + * @param component Component the question is related to. + * @param componentId Question component ID, returned by getQuestionComponentId. + * @param siteId Site ID. If not defined, current site. + * @return Folder path. + */ + getQuestionFolder(type: string, component: string, componentId: string, siteId?: string): string { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const siteFolderPath = CoreFile.instance.getSiteFolder(siteId); + const questionFolderPath = 'offlinequestion/' + type + '/' + component + '/' + componentId; + + return CoreTextUtils.instance.concatenatePaths(siteFolderPath, questionFolderPath); + } + /** * Extract the question slot from a question name. * @@ -612,3 +644,5 @@ export class CoreQuestionProvider { }); } } + +export class CoreQuestion extends makeSingleton(CoreQuestionProvider) {} diff --git a/src/lang/en.json b/src/lang/en.json index c6c46dc48..2a1dc13f5 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -143,6 +143,7 @@ "loadmore": "Load more", "location": "Location", "lostconnection": "Your authentication token is invalid or has expired. You will have to reconnect to the site.", + "maxfilesize": "Maximum size for new files: {{$a}}", "maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}", "min": "min", "mins": "mins", diff --git a/src/providers/file.ts b/src/providers/file.ts index 98df61923..7c555e71e 100644 --- a/src/providers/file.ts +++ b/src/providers/file.ts @@ -1114,10 +1114,8 @@ export class CoreFileProvider { // Get existing files in the folder. return this.getDirectoryContents(dirPath).then((entries) => { const files = {}; - let num = 1, - fileNameWithoutExtension = this.mimeUtils.removeExtension(fileName), - extension = this.mimeUtils.getFileExtension(fileName) || defaultExt, - newName; + let fileNameWithoutExtension = this.mimeUtils.removeExtension(fileName); + let extension = this.mimeUtils.getFileExtension(fileName) || defaultExt; // Clean the file name. fileNameWithoutExtension = this.textUtils.removeSpecialCharactersForFiles( @@ -1135,26 +1133,40 @@ export class CoreFileProvider { extension = ''; } - newName = fileNameWithoutExtension + extension; - if (typeof files[newName.toLowerCase()] == 'undefined') { - // No file with the same name. - return newName; - } else { - // Repeated name. Add a number until we find a free name. - do { - newName = fileNameWithoutExtension + '(' + num + ')' + extension; - num++; - } while (typeof files[newName.toLowerCase()] != 'undefined'); - - // Ask the user what he wants to do. - return newName; - } + return this.calculateUniqueName(files, fileNameWithoutExtension + extension); }).catch(() => { // Folder doesn't exist, name is unique. Clean it and return it. return this.textUtils.removeSpecialCharactersForFiles(this.textUtils.decodeURIComponent(fileName)); }); } + /** + * Given a file name and a set of already used names, calculate a unique name. + * + * @param usedNames Object with names already used as keys. + * @param name Name to check. + * @return Unique name. + */ + calculateUniqueName(usedNames: {[name: string]: any}, name: string): string { + if (typeof usedNames[name.toLowerCase()] == 'undefined') { + // No file with the same name. + return name; + } else { + // Repeated name. Add a number until we find a free name. + const nameWithoutExtension = this.mimeUtils.removeExtension(name); + let extension = this.mimeUtils.getFileExtension(name); + let num = 1; + extension = extension ? '.' + extension : ''; + + do { + name = nameWithoutExtension + '(' + num + ')' + extension; + num++; + } while (typeof usedNames[name.toLowerCase()] != 'undefined'); + + return name; + } + } + /** * Remove app temporary folder. * 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; diff --git a/upgrade.txt b/upgrade.txt index f66263f6a..1f2e73f6f 100644 --- a/upgrade.txt +++ b/upgrade.txt @@ -1,6 +1,10 @@ This files describes API changes in the Moodle Mobile app, information provided here is intended especially for developers. +=== 3.9.3 === + +- In the core-attachments component, passing a -1 as maxSize or maxSubmissions used to mean "unknown limit". Now -1 means unlimited. + === 3.8.3 === - CoreFileProvider.writeFileDataInFile has been deprecated. Please use CoreFileHelperProvider.writeFileDataInFile instead.