forked from EVOgeek/Vmeda.Online
		
	
						commit
						0cf2b3c74e
					
				| @ -1861,6 +1861,7 @@ | |||||||
|   "core.mainmenu.help": "moodle", |   "core.mainmenu.help": "moodle", | ||||||
|   "core.mainmenu.logout": "moodle", |   "core.mainmenu.logout": "moodle", | ||||||
|   "core.mainmenu.website": "local_moodlemobileapp", |   "core.mainmenu.website": "local_moodlemobileapp", | ||||||
|  |   "core.maxfilesize": "moodle", | ||||||
|   "core.maxsizeandattachments": "moodle", |   "core.maxsizeandattachments": "moodle", | ||||||
|   "core.min": "moodle", |   "core.min": "moodle", | ||||||
|   "core.mins": "moodle", |   "core.mins": "moodle", | ||||||
| @ -1944,8 +1945,8 @@ | |||||||
|   "core.question.certainty": "qbehaviour_deferredcbm", |   "core.question.certainty": "qbehaviour_deferredcbm", | ||||||
|   "core.question.complete": "question", |   "core.question.complete": "question", | ||||||
|   "core.question.correct": "question", |   "core.question.correct": "question", | ||||||
|   "core.question.errorattachmentsnotsupported": "local_moodlemobileapp", |   "core.question.errorattachmentsnotsupportedinsite": "local_moodlemobileapp", | ||||||
|   "core.question.errorinlinefilesnotsupported": "local_moodlemobileapp", |   "core.question.errorembeddedfilesnotsupportedinsite": "local_moodlemobileapp", | ||||||
|   "core.question.errorquestionnotsupported": "local_moodlemobileapp", |   "core.question.errorquestionnotsupported": "local_moodlemobileapp", | ||||||
|   "core.question.feedback": "question", |   "core.question.feedback": "question", | ||||||
|   "core.question.howtodraganddrop": "local_moodlemobileapp", |   "core.question.howtodraganddrop": "local_moodlemobileapp", | ||||||
|  | |||||||
| @ -411,7 +411,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid | |||||||
|                                 const params = this.urlUtils.extractUrlParams(response.data.reviewlesson.value); |                                 const params = this.urlUtils.extractUrlParams(response.data.reviewlesson.value); | ||||||
|                                 if (params && params.pageid) { |                                 if (params && params.pageid) { | ||||||
|                                     // The retake can be reviewed, mark it as finished. Don't block the user for this.
 |                                     // 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); | ||||||
|                                 } |                                 } | ||||||
|                             } |                             } | ||||||
|                         } |                         } | ||||||
|  | |||||||
| @ -569,7 +569,8 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { | |||||||
| 
 | 
 | ||||||
|     // Prepare the answers to be sent for the attempt.
 |     // Prepare the answers to be sent for the attempt.
 | ||||||
|     protected prepareAnswers(): Promise<any> { |     protected prepareAnswers(): Promise<any> { | ||||||
|         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) { |             if (this.formElement) { | ||||||
|                 this.domUtils.triggerFormSubmittedEvent(this.formElement, !this.offline, this.sitesProvider.getCurrentSiteId()); |                 this.domUtils.triggerFormSubmittedEvent(this.formElement, !this.offline, this.sitesProvider.getCurrentSiteId()); | ||||||
|             } |             } | ||||||
|  | 
 | ||||||
|  |             return this.questionHelper.clearTmpData(this.questions, this.component, this.quiz.coursemodule); | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -342,8 +342,8 @@ export class AddonModQuizOfflineProvider { | |||||||
|         for (const slot in questionsWithAnswers) { |         for (const slot in questionsWithAnswers) { | ||||||
|             const question = questionsWithAnswers[slot]; |             const question = questionsWithAnswers[slot]; | ||||||
| 
 | 
 | ||||||
|             promises.push(this.behaviourDelegate.determineNewState( |             promises.push(this.behaviourDelegate.determineNewState(quiz.preferredbehaviour, AddonModQuizProvider.COMPONENT, | ||||||
|                         quiz.preferredbehaviour, AddonModQuizProvider.COMPONENT, attempt.id, question, siteId).then((state) => { |                     attempt.id, question, quiz.coursemodule, siteId).then((state) => { | ||||||
|                 // Check if state has changed.
 |                 // Check if state has changed.
 | ||||||
|                 if (state && state.name != question.state) { |                 if (state && state.name != question.state) { | ||||||
|                     newStates[question.slot] = state.name; |                     newStates[question.slot] = state.name; | ||||||
|  | |||||||
| @ -21,6 +21,7 @@ import { CoreSitesProvider, CoreSitesReadingStrategy } from '@providers/sites'; | |||||||
| import { CoreSyncProvider } from '@providers/sync'; | import { CoreSyncProvider } from '@providers/sync'; | ||||||
| import { CoreTextUtilsProvider } from '@providers/utils/text'; | import { CoreTextUtilsProvider } from '@providers/utils/text'; | ||||||
| import { CoreTimeUtilsProvider } from '@providers/utils/time'; | import { CoreTimeUtilsProvider } from '@providers/utils/time'; | ||||||
|  | import { CoreUtils } from '@providers/utils/utils'; | ||||||
| import { CoreCourseProvider } from '@core/course/providers/course'; | import { CoreCourseProvider } from '@core/course/providers/course'; | ||||||
| import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; | import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; | ||||||
| import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; | import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; | ||||||
| @ -77,25 +78,33 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider | |||||||
|      * @param quiz Quiz. |      * @param quiz Quiz. | ||||||
|      * @param courseId Course ID. |      * @param courseId Course ID. | ||||||
|      * @param warnings List of warnings generated by the sync. |      * @param warnings List of warnings generated by the sync. | ||||||
|      * @param attemptId Last attempt ID. |      * @param options Other options. | ||||||
|      * @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. |  | ||||||
|      * @return Promise resolved on success. |      * @return Promise resolved on success. | ||||||
|      */ |      */ | ||||||
|     protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], attemptId?: number, offlineAttempt?: any, |     protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], options?: FinishSyncOptions) | ||||||
|             onlineAttempt?: any, removeAttempt?: boolean, updated?: boolean): Promise<AddonModQuizSyncResult> { |             : Promise<AddonModQuizSyncResult> { | ||||||
|  |         options = options || {}; | ||||||
| 
 | 
 | ||||||
|         // Invalidate the data for the quiz and attempt.
 |         // 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.
 |             // Ignore errors.
 | ||||||
|         }).then(() => { |         }).then(() => { | ||||||
|             if (removeAttempt && attemptId) { |             if (options.removeAttempt && options.attemptId) { | ||||||
|                 return this.quizOfflineProvider.removeAttemptAndAnswers(attemptId, siteId); |                 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(() => { |         }).then(() => { | ||||||
|             if (updated) { |             if (options.updated) { | ||||||
|                 // Data has been sent. Update prefetched data.
 |                 // Data has been sent. Update prefetched data.
 | ||||||
|                 return this.courseProvider.getModuleBasicInfoByInstance(quiz.id, 'quiz', siteId).then((module) => { |                 return this.courseProvider.getModuleBasicInfoByInstance(quiz.id, 'quiz', siteId).then((module) => { | ||||||
|                     return this.prefetchAfterUpdateQuiz(module, quiz, courseId, undefined, siteId); |                     return this.prefetchAfterUpdateQuiz(module, quiz, courseId, undefined, siteId); | ||||||
| @ -109,14 +118,14 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider | |||||||
|             }); |             }); | ||||||
|         }).then(() => { |         }).then(() => { | ||||||
|             // Check if online attempt was finished because of the sync.
 |             // 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.
 |                 // Attempt wasn't finished at start. Check if it's finished now.
 | ||||||
|                 return this.quizProvider.getUserAttempts(quiz.id, {cmId: quiz.coursemodule, siteId}).then((attempts) => { |                 return this.quizProvider.getUserAttempts(quiz.id, {cmId: quiz.coursemodule, siteId}).then((attempts) => { | ||||||
|                     // Search the attempt.
 |                     // Search the attempt.
 | ||||||
|                     for (const i in attempts) { |                     for (const i in attempts) { | ||||||
|                         const attempt = attempts[i]; |                         const attempt = attempts[i]; | ||||||
| 
 | 
 | ||||||
|                         if (attempt.id == onlineAttempt.id) { |                         if (attempt.id == options.onlineAttempt.id) { | ||||||
|                             return this.quizProvider.isAttemptFinished(attempt.state); |                             return this.quizProvider.isAttemptFinished(attempt.state); | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
| @ -288,16 +297,6 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider | |||||||
|     syncQuiz(quiz: any, askPreflight?: boolean, siteId?: string): Promise<AddonModQuizSyncResult> { |     syncQuiz(quiz: any, askPreflight?: boolean, siteId?: string): Promise<AddonModQuizSyncResult> { | ||||||
|         siteId = siteId || this.sitesProvider.getCurrentSiteId(); |         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)) { |         if (this.isSyncing(quiz.id, siteId)) { | ||||||
|             // There's already a sync ongoing for this quiz, return the promise.
 |             // There's already a sync ongoing for this quiz, return the promise.
 | ||||||
|             return this.getOngoingSync(quiz.id, siteId); |             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 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<AddonModQuizSyncResult> { | ||||||
|  |         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); |         this.logger.debug('Try to sync quiz ' + quiz.id + ' in site ' + siteId); | ||||||
| 
 | 
 | ||||||
|         // Sync offline logs.
 |         // Sync offline logs.
 | ||||||
|         syncPromise = this.logHelper.syncIfNeeded(AddonModQuizProvider.COMPONENT, quiz.id, siteId).catch(() => { |         await CoreUtils.instance.ignoreErrors(this.logHelper.syncIfNeeded(AddonModQuizProvider.COMPONENT, quiz.id, siteId)); | ||||||
|             // 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); |  | ||||||
|             } |  | ||||||
| 
 | 
 | ||||||
|             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.
 |         if (!offlineAttempts.length) { | ||||||
|             return this.quizProvider.getUserAttempts(quiz.id, modOptions).then((attempts) => { |             // Nothing to sync, finish.
 | ||||||
|                 const lastAttemptId = attempts.length ? attempts[attempts.length - 1].id : undefined; |             return this.finishSync(siteId, quiz, courseId, warnings); | ||||||
|                 let onlineAttempt; |         } | ||||||
| 
 | 
 | ||||||
|                 // Search the attempt we retrieved from offline.
 |         if (!this.appProvider.isOnline()) { | ||||||
|                 for (const i in attempts) { |             // Cannot sync in offline.
 | ||||||
|                     const attempt = attempts[i]; |             throw new Error(this.translate.instant('core.cannotconnect')); | ||||||
|  |         } | ||||||
| 
 | 
 | ||||||
|                     if (attempt.id == offlineAttempt.id) { |         const offlineAttempt = offlineAttempts.pop(); | ||||||
|                         onlineAttempt = attempt; |  | ||||||
|                         break; |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
| 
 | 
 | ||||||
|                 if (!onlineAttempt || this.quizProvider.isAttemptFinished(onlineAttempt.state)) { |         // Now get the list of online attempts to make sure this attempt exists and isn't finished.
 | ||||||
|                     // Attempt not found or it's finished in online. Discard it.
 |         const onlineAttempts = await this.quizProvider.getUserAttempts(quiz.id, modOptions); | ||||||
|                     warnings.push(this.translate.instant('addon.mod_quiz.warningattemptfinished')); |  | ||||||
| 
 | 
 | ||||||
|                     return this.finishSync(siteId, quiz, courseId, warnings, offlineAttempt.id, offlineAttempt, onlineAttempt, |         const lastAttemptId = onlineAttempts.length ? onlineAttempts[onlineAttempts.length - 1].id : undefined; | ||||||
|                             true); |         const onlineAttempt = onlineAttempts.find((attempt) => { | ||||||
|                 } |             return attempt.id == offlineAttempt.id; | ||||||
| 
 |  | ||||||
|                 // 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); |  | ||||||
|                     }); |  | ||||||
|                 }); |  | ||||||
|             }); |  | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|         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.
 | ||||||
|  | }; | ||||||
|  | |||||||
| @ -215,6 +215,8 @@ export class AddonModQuizProvider { | |||||||
|             }; |             }; | ||||||
| 
 | 
 | ||||||
|             return site.read('mod_quiz_get_attempt_data', params, preSets); |             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.
 |                 ...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) => { |             return site.read('mod_quiz_get_attempt_summary', params, preSets).then((response) => { | ||||||
|                 if (response && response.questions) { |                 if (response && response.questions) { | ||||||
|  |                     response = this.parseQuestions(response); | ||||||
|  | 
 | ||||||
|                     if (options.loadLocal) { |                     if (options.loadLocal) { | ||||||
|                         return this.quizOfflineProvider.loadQuestionsLocalStates(attemptId, response.questions, site.getId()); |                         return this.quizOfflineProvider.loadQuestionsLocalStates(attemptId, response.questions, site.getId()); | ||||||
|                     } |                     } | ||||||
| @ -1560,6 +1566,27 @@ export class AddonModQuizProvider { | |||||||
|                 siteId); |                 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. |      * Process an attempt, saving its data. | ||||||
|      * |      * | ||||||
|  | |||||||
| @ -40,13 +40,14 @@ export class AddonQbehaviourDeferredCBMHandler implements CoreQuestionBehaviourH | |||||||
|      * @param component Component the question belongs to. |      * @param component Component the question belongs to. | ||||||
|      * @param attemptId Attempt ID the question belongs to. |      * @param attemptId Attempt ID the question belongs to. | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|  |      * @param componentId Component ID. | ||||||
|      * @param siteId Site ID. If not defined, current site. |      * @param siteId Site ID. If not defined, current site. | ||||||
|      * @return New state (or promise resolved with state). |      * @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<CoreQuestionState> { |             : CoreQuestionState | Promise<CoreQuestionState> { | ||||||
|         // Depends on deferredfeedback.
 |         // 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)); |             this.isCompleteResponse.bind(this), this.isSameResponse.bind(this)); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -71,11 +72,13 @@ export class AddonQbehaviourDeferredCBMHandler implements CoreQuestionBehaviourH | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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.
 |         // 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) { |         if (complete > 0) { | ||||||
|             // Answer is complete, check the user answered CBM too.
 |             // Answer is complete, check the user answered CBM too.
 | ||||||
|             return answers['-certainty'] ? 1 : 0; |             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 prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). | ||||||
|      * @param newAnswers Object with the new question answers. |      * @param newAnswers Object with the new question answers. | ||||||
|      * @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). |      * @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. |      * @return Whether they're the same. | ||||||
|      */ |      */ | ||||||
|     protected isSameResponse(question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any, newBasicAnswers: any) |     protected isSameResponse(question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any, newBasicAnswers: any, | ||||||
|             : boolean { |             component: string, componentId: string | number): boolean { | ||||||
|         // First check if the question answer is the same.
 |         // 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) { |         if (same) { | ||||||
|             // Same response, check the CBM is the same too.
 |             // Same response, check the CBM is the same too.
 | ||||||
|             return prevAnswers['-certainty'] == newAnswers['-certainty']; |             return prevAnswers['-certainty'] == newAnswers['-certainty']; | ||||||
|  | |||||||
| @ -23,9 +23,11 @@ import { CoreQuestionProvider, CoreQuestionState } from '@core/question/provider | |||||||
|  * |  * | ||||||
|  * @param question The question. |  * @param question The question. | ||||||
|  * @param answers Object with the question answers (without prefix). |  * @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. |  * @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. |  * 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 prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). | ||||||
|  * @param newAnswers Object with the new question answers. |  * @param newAnswers Object with the new question answers. | ||||||
|  * @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). |  * @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. |  * @return Whether they're the same. | ||||||
|  */ |  */ | ||||||
| export type isSameResponseFunction = (question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any, | 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. |  * Handler to support deferred feedback question behaviour. | ||||||
| @ -58,12 +62,13 @@ export class AddonQbehaviourDeferredFeedbackHandler implements CoreQuestionBehav | |||||||
|      * @param component Component the question belongs to. |      * @param component Component the question belongs to. | ||||||
|      * @param attemptId Attempt ID the question belongs to. |      * @param attemptId Attempt ID the question belongs to. | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|  |      * @param componentId Component ID. | ||||||
|      * @param siteId Site ID. If not defined, current site. |      * @param siteId Site ID. If not defined, current site. | ||||||
|      * @return New state (or promise resolved with state). |      * @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<CoreQuestionState> { |             : CoreQuestionState | Promise<CoreQuestionState> { | ||||||
|         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 component Component the question belongs to. | ||||||
|      * @param attemptId Attempt ID the question belongs to. |      * @param attemptId Attempt ID the question belongs to. | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|  |      * @param componentId Component ID. | ||||||
|      * @param siteId Site ID. If not defined, current site. |      * @param siteId Site ID. If not defined, current site. | ||||||
|      * @param isCompleteFn Function to override the default isCompleteResponse check. |      * @param isCompleteFn Function to override the default isCompleteResponse check. | ||||||
|      * @param isSameFn Function to override the default isSameResponse check. |      * @param isSameFn Function to override the default isSameResponse check. | ||||||
|      * @return Promise resolved with state. |      * @return Promise resolved with state. | ||||||
|      */ |      */ | ||||||
|     determineNewStateDeferred(component: string, attemptId: number, question: any, siteId?: string, |     async determineNewStateDeferred(component: string, attemptId: number, question: any, componentId: string | number, | ||||||
|             isCompleteFn?: isCompleteResponseFunction, isSameFn?: isSameResponseFunction): Promise<CoreQuestionState> { |             siteId?: string, isCompleteFn?: isCompleteResponseFunction, isSameFn?: isSameResponseFunction) | ||||||
|  |             : Promise<CoreQuestionState> { | ||||||
| 
 | 
 | ||||||
|         // Check if we have local data for the question.
 |         // 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.
 |             // No entry found, use the original data.
 | ||||||
|             return question; |             dbQuestion = question; | ||||||
|         }).then((dbQuestion) => { |         } | ||||||
|             const state = this.questionProvider.getState(dbQuestion.state); |  | ||||||
| 
 | 
 | ||||||
|             if (state.finished || !state.active) { |         const state = this.questionProvider.getState(dbQuestion.state); | ||||||
|                 // Question is finished, it cannot change.
 | 
 | ||||||
|                 return 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.
 |         // Answers have changed. Now check if the response is complete and calculate the new state.
 | ||||||
|             return this.questionProvider.getQuestionAnswers(component, attemptId, question.slot, false, siteId) |         let complete: number; | ||||||
|                     .then((prevAnswers) => { |         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); |         if (complete < 0) { | ||||||
|                 const prevBasicAnswers = this.questionProvider.getBasicAnswers(prevAnswers); |             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.
 |         return this.questionProvider.getState(newState); | ||||||
|                 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); |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  | |||||||
| @ -35,10 +35,11 @@ export class AddonQbehaviourInformationItemHandler implements CoreQuestionBehavi | |||||||
|      * @param component Component the question belongs to. |      * @param component Component the question belongs to. | ||||||
|      * @param attemptId Attempt ID the question belongs to. |      * @param attemptId Attempt ID the question belongs to. | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|  |      * @param componentId Component ID. | ||||||
|      * @param siteId Site ID. If not defined, current site. |      * @param siteId Site ID. If not defined, current site. | ||||||
|      * @return New state (or promise resolved with state). |      * @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<CoreQuestionState> { |             : CoreQuestionState | Promise<CoreQuestionState> { | ||||||
|         if (question.answers['-seen']) { |         if (question.answers['-seen']) { | ||||||
|             return this.questionProvider.getState('complete'); |             return this.questionProvider.getState('complete'); | ||||||
|  | |||||||
| @ -17,28 +17,7 @@ import { Injectable } from '@angular/core'; | |||||||
| import { CoreQuestionBehaviourHandler } from '@core/question/providers/behaviour-delegate'; | import { CoreQuestionBehaviourHandler } from '@core/question/providers/behaviour-delegate'; | ||||||
| import { CoreQuestionDelegate } from '@core/question/providers/delegate'; | import { CoreQuestionDelegate } from '@core/question/providers/delegate'; | ||||||
| import { CoreQuestionProvider, CoreQuestionState } from '@core/question/providers/question'; | import { CoreQuestionProvider, CoreQuestionState } from '@core/question/providers/question'; | ||||||
| 
 | import { AddonQbehaviourDeferredFeedbackHandler } from '@addon/qbehaviour/deferredfeedback/providers/handler'; | ||||||
| /** |  | ||||||
|  * 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; |  | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Handler to support manual graded question behaviour. |  * Handler to support manual graded question behaviour. | ||||||
| @ -48,7 +27,9 @@ export class AddonQbehaviourManualGradedHandler implements CoreQuestionBehaviour | |||||||
|     name = 'AddonQbehaviourManualGraded'; |     name = 'AddonQbehaviourManualGraded'; | ||||||
|     type = 'manualgraded'; |     type = 'manualgraded'; | ||||||
| 
 | 
 | ||||||
|     constructor(private questionDelegate: CoreQuestionDelegate, private questionProvider: CoreQuestionProvider) { |     constructor(protected questionDelegate: CoreQuestionDelegate, | ||||||
|  |             protected questionProvider: CoreQuestionProvider, | ||||||
|  |             protected deferredFeedbackHandler: AddonQbehaviourDeferredFeedbackHandler) { | ||||||
|         // Nothing to do.
 |         // Nothing to do.
 | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -58,82 +39,14 @@ export class AddonQbehaviourManualGradedHandler implements CoreQuestionBehaviour | |||||||
|      * @param component Component the question belongs to. |      * @param component Component the question belongs to. | ||||||
|      * @param attemptId Attempt ID the question belongs to. |      * @param attemptId Attempt ID the question belongs to. | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|  |      * @param componentId Component ID. | ||||||
|      * @param siteId Site ID. If not defined, current site. |      * @param siteId Site ID. If not defined, current site. | ||||||
|      * @return New state (or promise resolved with state). |      * @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<CoreQuestionState> { |             : CoreQuestionState | Promise<CoreQuestionState> { | ||||||
|         return this.determineNewStateManualGraded(component, attemptId, question, siteId); |         // Same implementation as the deferred feedback. Use that function instead of replicating it.
 | ||||||
|     } |         return this.deferredFeedbackHandler.determineNewStateDeferred(component, attemptId, question, componentId, 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<CoreQuestionState> { |  | ||||||
| 
 |  | ||||||
|         // 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); |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  | |||||||
| @ -24,6 +24,14 @@ import { AddonQtypeCalculatedComponent } from '../component/calculated'; | |||||||
|  */ |  */ | ||||||
| @Injectable() | @Injectable() | ||||||
| export class AddonQtypeCalculatedHandler implements CoreQuestionHandler { | 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'; |     name = 'AddonQtypeCalculated'; | ||||||
|     type = 'qtype_calculated'; |     type = 'qtype_calculated'; | ||||||
| 
 | 
 | ||||||
| @ -41,23 +49,69 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler { | |||||||
|         return AddonQtypeCalculatedComponent; |         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. |      * Check if a response is complete. | ||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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 (this.isGradableResponse(question, answers) === 0 || !this.validateUnits(answers['answer'])) { |         if (!this.isGradableResponse(question, answers, component, componentId)) { | ||||||
|             return 0; |             return 0; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (this.requiresUnits(question)) { |         const parsedAnswer = this.parseAnswer(question, answers['answer']); | ||||||
|             return this.isValidValue(answers['unit']) ? 1 : 0; |         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 question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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 { | ||||||
|         let isGradable = this.isValidValue(answers['answer']); |         return this.isValidValue(answers['answer']) ? 1 : 0; | ||||||
|         if (isGradable && this.requiresUnits(question)) { |  | ||||||
|             // The question requires a unit.
 |  | ||||||
|             isGradable = this.isValidValue(answers['unit']); |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         return isGradable ? 1 : 0; |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -93,9 +143,11 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler { | |||||||
|      * @param question Question. |      * @param question Question. | ||||||
|      * @param prevAnswers Object with the previous question answers. |      * @param prevAnswers Object with the previous question answers. | ||||||
|      * @param newAnswers Object with the new 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. |      * @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') && |         return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') && | ||||||
|             this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'unit'); |             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. |      * Parse an answer string. | ||||||
|      * |  | ||||||
|      * @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. |  | ||||||
|      * |      * | ||||||
|  |      * @param question Question. | ||||||
|      * @param answer Answer. |      * @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) { |         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.
 |         // 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'); |         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 '.'.
 |         // Else assume it is a decimal separator, and change it to '.'.
 | ||||||
|         if (answer.indexOf('.') != -1 || answer.split(',').length - 1 > 1) { |         if (answer.indexOf('.') != -1 || answer.split(',').length - 1 > 1) { | ||||||
|             answer = answer.replace(',', ''); |             answer = answer.replace(',', ''); | ||||||
| @ -148,11 +188,31 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler { | |||||||
|             answer = answer.replace(',', '.'); |             answer = answer.replace(',', '.'); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         // We don't know if units should be before or after so we check both.
 |         let unitsLeft = false; | ||||||
|         if (answer.match(new RegExp('^' + regexString)) === null || answer.match(new RegExp(regexString + '$')) === null) { |         let match = null; | ||||||
|             return false; | 
 | ||||||
|  |         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}; | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -46,9 +46,11 @@ export class AddonQtypeCalculatedMultiHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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.
 |         // This question type depends on multichoice.
 | ||||||
|         return this.multichoiceHandler.isCompleteResponseSingle(answers); |         return this.multichoiceHandler.isCompleteResponseSingle(answers); | ||||||
|     } |     } | ||||||
| @ -68,9 +70,11 @@ export class AddonQtypeCalculatedMultiHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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.
 |         // This question type depends on multichoice.
 | ||||||
|         return this.multichoiceHandler.isGradableResponseSingle(answers); |         return this.multichoiceHandler.isGradableResponseSingle(answers); | ||||||
|     } |     } | ||||||
| @ -81,9 +85,11 @@ export class AddonQtypeCalculatedMultiHandler implements CoreQuestionHandler { | |||||||
|      * @param question Question. |      * @param question Question. | ||||||
|      * @param prevAnswers Object with the previous question answers. |      * @param prevAnswers Object with the previous question answers. | ||||||
|      * @param newAnswers Object with the new 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. |      * @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.
 |         // This question type depends on multichoice.
 | ||||||
|         return this.multichoiceHandler.isSameResponseSingle(prevAnswers, newAnswers); |         return this.multichoiceHandler.isSameResponseSingle(prevAnswers, newAnswers); | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -46,11 +46,13 @@ export class AddonQtypeCalculatedSimpleHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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.
 |         // 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 question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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.
 |         // 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 question Question. | ||||||
|      * @param prevAnswers Object with the previous question answers. |      * @param prevAnswers Object with the previous question answers. | ||||||
|      * @param newAnswers Object with the new 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. |      * @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.
 |         // This question type depends on calculated.
 | ||||||
|         return this.calculatedHandler.isSameResponse(question, prevAnswers, newAnswers); |         return this.calculatedHandler.isSameResponse(question, prevAnswers, newAnswers, component, componentId); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -61,9 +61,11 @@ export class AddonQtypeDdImageOrTextHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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.
 |         // 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).
 |         // We should always receive all the drop zones with their value ('' if not answered).
 | ||||||
|         for (const name in answers) { |         for (const name in answers) { | ||||||
| @ -91,9 +93,11 @@ export class AddonQtypeDdImageOrTextHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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) { |         for (const name in answers) { | ||||||
|             const value = answers[name]; |             const value = answers[name]; | ||||||
|             if (value && value !== '0') { |             if (value && value !== '0') { | ||||||
| @ -110,9 +114,11 @@ export class AddonQtypeDdImageOrTextHandler implements CoreQuestionHandler { | |||||||
|      * @param question Question. |      * @param question Question. | ||||||
|      * @param prevAnswers Object with the previous question answers. |      * @param prevAnswers Object with the previous question answers. | ||||||
|      * @param newAnswers Object with the new 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. |      * @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); |         return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -62,9 +62,11 @@ export class AddonQtypeDdMarkerHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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).
 |         // If 1 dragitem is set we assume the answer is complete (like Moodle does).
 | ||||||
|         for (const name in answers) { |         for (const name in answers) { | ||||||
|             if (answers[name]) { |             if (answers[name]) { | ||||||
| @ -90,10 +92,12 @@ export class AddonQtypeDdMarkerHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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 this.isCompleteResponse(question, answers); |         return this.isCompleteResponse(question, answers, component, componentId); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -102,9 +106,11 @@ export class AddonQtypeDdMarkerHandler implements CoreQuestionHandler { | |||||||
|      * @param question Question. |      * @param question Question. | ||||||
|      * @param prevAnswers Object with the previous question answers. |      * @param prevAnswers Object with the previous question answers. | ||||||
|      * @param newAnswers Object with the new 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. |      * @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); |         return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -61,9 +61,11 @@ export class AddonQtypeDdwtosHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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) { |         for (const name in answers) { | ||||||
|             const value = answers[name]; |             const value = answers[name]; | ||||||
|             if (!value || value === '0') { |             if (!value || value === '0') { | ||||||
| @ -89,9 +91,11 @@ export class AddonQtypeDdwtosHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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) { |         for (const name in answers) { | ||||||
|             const value = answers[name]; |             const value = answers[name]; | ||||||
|             if (value && value !== '0') { |             if (value && value !== '0') { | ||||||
| @ -108,9 +112,11 @@ export class AddonQtypeDdwtosHandler implements CoreQuestionHandler { | |||||||
|      * @param question Question. |      * @param question Question. | ||||||
|      * @param prevAnswers Object with the previous question answers. |      * @param prevAnswers Object with the previous question answers. | ||||||
|      * @param newAnswers Object with the new 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. |      * @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); |         return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -5,9 +5,10 @@ | |||||||
|     </ion-item> |     </ion-item> | ||||||
| 
 | 
 | ||||||
|     <!-- Textarea. --> |     <!-- Textarea. --> | ||||||
|     <ion-item *ngIf="question.textarea && !question.hasDraftFiles"> |     <ion-item *ngIf="question.textarea && (!question.hasDraftFiles || uploadFilesSupported)"> | ||||||
|         <!-- "Format" hidden input --> |         <!-- "Format" and draftid hidden inputs --> | ||||||
|         <input item-content *ngIf="question.formatInput" type="hidden" [name]="question.formatInput.name" [value]="question.formatInput.value" > |         <input item-content *ngIf="question.formatInput" type="hidden" [name]="question.formatInput.name" [value]="question.formatInput.value" > | ||||||
|  |         <input item-content *ngIf="question.answerDraftIdInput" type="hidden" [name]="question.answerDraftIdInput.name" [value]="question.answerDraftIdInput.value" > | ||||||
|         <!-- Plain text textarea. --> |         <!-- Plain text textarea. --> | ||||||
|         <ion-textarea *ngIf="question.isPlainText" class="core-question-textarea" [ngClass]='{"core-monospaced": question.isMonospaced}' placeholder="{{ 'core.question.answer' | translate }}" [attr.name]="question.textarea.name" aria-multiline="true" [ngModel]="question.textarea.text"></ion-textarea> |         <ion-textarea *ngIf="question.isPlainText" class="core-question-textarea" [ngClass]='{"core-monospaced": question.isMonospaced}' placeholder="{{ 'core.question.answer' | translate }}" [attr.name]="question.textarea.name" aria-multiline="true" [ngModel]="question.textarea.text"></ion-textarea> | ||||||
|         <!-- Rich text editor. --> |         <!-- Rich text editor. --> | ||||||
| @ -15,22 +16,29 @@ | |||||||
|     </ion-item> |     </ion-item> | ||||||
| 
 | 
 | ||||||
|     <!-- Draft files not supported. --> |     <!-- Draft files not supported. --> | ||||||
|     <ng-container *ngIf="question.textarea && question.hasDraftFiles"> |     <ng-container *ngIf="question.textarea && question.hasDraftFiles && !uploadFilesSupported"> | ||||||
|         <ion-item text-wrap class="core-danger-item"> |         <ion-item text-wrap class="core-danger-item"> | ||||||
|             <p class="core-question-warning">{{ 'core.question.errorinlinefilesnotsupported' | translate }}</p> |             <p class="core-question-warning">{{ 'core.question.errorinlinefilesnotsupportedinsite' | translate }}</p> | ||||||
|         </ion-item> |         </ion-item> | ||||||
|         <ion-item text-wrap> |         <ion-item text-wrap> | ||||||
|             <p><core-format-text [component]="component" [componentId]="componentId" [text]="question.textarea.text" [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId"></core-format-text></p> |             <p><core-format-text [component]="component" [componentId]="componentId" [text]="question.textarea.text" [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId"></core-format-text></p> | ||||||
|         </ion-item> |         </ion-item> | ||||||
|     </ng-container> |     </ng-container> | ||||||
| 
 | 
 | ||||||
|     <!-- Attachments not supported in the app yet. --> |     <!-- Attachments. --> | ||||||
|     <ion-item text-wrap *ngIf="question.allowsAttachments" class="core-danger-item"> |     <ng-container *ngIf="question.allowsAttachments"> | ||||||
|         <p class="core-question-warning">{{ 'core.question.errorattachmentsnotsupported' | translate }}</p> |         <core-attachments *ngIf="uploadFilesSupported" [files]="attachments" [component]="component" [componentId]="componentId" [maxSize]="question.attachmentsMaxBytes" [maxSubmissions]="question.attachmentsMaxFiles" [allowOffline]="offlineEnabled" [acceptedTypes]="question.attachmentsAcceptedTypes"></core-attachments> | ||||||
|     </ion-item> | 
 | ||||||
|  |         <input item-content *ngIf="uploadFilesSupported" type="hidden" [name]="question.attachmentsDraftIdInput.name" [value]="question.attachmentsDraftIdInput.value" > | ||||||
|  | 
 | ||||||
|  |         <!-- Attachments not supported in this site. --> | ||||||
|  |         <ion-item text-wrap *ngIf="!uploadFilesSupported" class="core-danger-item"> | ||||||
|  |             <p class="core-question-warning">{{ 'core.question.errorattachmentsnotsupportedinsite' | translate }}</p> | ||||||
|  |         </ion-item> | ||||||
|  |     </ng-container> | ||||||
| 
 | 
 | ||||||
|     <!-- Answer to the question and attachments (reviewing). --> |     <!-- Answer to the question and attachments (reviewing). --> | ||||||
|     <ion-item text-wrap *ngIf="!question.textarea && (question.answer || (!question.attachments.length && !question.allowsAttachments))"> |     <ion-item text-wrap *ngIf="!question.textarea && (question.answer || question.answer == '')"> | ||||||
|         <p><core-format-text [ngClass]='{"core-monospaced": question.isMonospaced}' [component]="component" [componentId]="componentId" [text]="question.answer" [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId"></core-format-text></p> |         <p><core-format-text [ngClass]='{"core-monospaced": question.isMonospaced}' [component]="component" [componentId]="componentId" [text]="question.answer" [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId"></core-format-text></p> | ||||||
|     </ion-item> |     </ion-item> | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -14,8 +14,12 @@ | |||||||
| 
 | 
 | ||||||
| import { Component, OnInit, Injector } from '@angular/core'; | import { Component, OnInit, Injector } from '@angular/core'; | ||||||
| import { CoreLoggerProvider } from '@providers/logger'; | 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 { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component'; | ||||||
|  | import { CoreQuestion } from '@core/question/providers/question'; | ||||||
| import { FormControl, FormBuilder } from '@angular/forms'; | import { FormControl, FormBuilder } from '@angular/forms'; | ||||||
|  | import { CoreFileSession } from '@providers/file-session'; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Component to render an essay question. |  * Component to render an essay question. | ||||||
| @ -28,6 +32,9 @@ export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implemen | |||||||
| 
 | 
 | ||||||
|     protected formControl: FormControl; |     protected formControl: FormControl; | ||||||
| 
 | 
 | ||||||
|  |     attachments: CoreWSExternalFile[]; | ||||||
|  |     uploadFilesSupported: boolean; | ||||||
|  | 
 | ||||||
|     constructor(logger: CoreLoggerProvider, injector: Injector, protected fb: FormBuilder) { |     constructor(logger: CoreLoggerProvider, injector: Injector, protected fb: FormBuilder) { | ||||||
|         super(logger, 'AddonQtypeEssayComponent', injector); |         super(logger, 'AddonQtypeEssayComponent', injector); | ||||||
|     } |     } | ||||||
| @ -36,8 +43,39 @@ export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implemen | |||||||
|      * Component being initialized. |      * Component being initialized. | ||||||
|      */ |      */ | ||||||
|     ngOnInit(): void { |     ngOnInit(): void { | ||||||
|  |         this.uploadFilesSupported = typeof this.question.responsefileareas != 'undefined'; | ||||||
|         this.initEssayComponent(); |         this.initEssayComponent(); | ||||||
| 
 | 
 | ||||||
|         this.formControl = this.fb.control(this.question.textarea && this.question.textarea.text); |         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<void> { | ||||||
|  |         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); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -14,11 +14,15 @@ | |||||||
| // limitations under the License.
 | // limitations under the License.
 | ||||||
| 
 | 
 | ||||||
| import { Injectable, Injector } from '@angular/core'; | import { Injectable, Injector } from '@angular/core'; | ||||||
|  | import { CoreFileSession } from '@providers/file-session'; | ||||||
|  | import { CoreSites } from '@providers/sites'; | ||||||
| import { CoreTextUtilsProvider } from '@providers/utils/text'; | import { CoreTextUtilsProvider } from '@providers/utils/text'; | ||||||
| import { CoreDomUtilsProvider } from '@providers/utils/dom'; | import { CoreDomUtilsProvider } from '@providers/utils/dom'; | ||||||
| import { CoreUtilsProvider } from '@providers/utils/utils'; | import { CoreUtilsProvider } from '@providers/utils/utils'; | ||||||
|  | import { CoreFileUploader } from '@core/fileuploader/providers/fileuploader'; | ||||||
| import { CoreQuestionHandler } from '@core/question/providers/delegate'; | import { CoreQuestionHandler } from '@core/question/providers/delegate'; | ||||||
| import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; | import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; | ||||||
|  | import { CoreQuestion } from '@core/question/providers/question'; | ||||||
| import { AddonQtypeEssayComponent } from '../component/essay'; | import { AddonQtypeEssayComponent } from '../component/essay'; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
| @ -32,6 +36,59 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler { | |||||||
|     constructor(private utils: CoreUtilsProvider, private questionHelper: CoreQuestionHelperProvider, |     constructor(private utils: CoreUtilsProvider, private questionHelper: CoreQuestionHelperProvider, | ||||||
|             private textUtils: CoreTextUtilsProvider, private domUtils: CoreDomUtilsProvider) { } |             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<void> { | ||||||
|  |         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. |      * 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. |      * 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 { |     getPreventSubmitMessage(question: any): string { | ||||||
|         const element = this.domUtils.convertToElement(question.html); |         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.
 |             // 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)) { |         if (!uploadFilesSupported && this.questionHelper.hasDraftFileUrls(element.innerHTML)) { | ||||||
|             return 'core.question.errorinlinefilesnotsupported'; |             return 'core.question.errorinlinefilesnotsupportedinsite'; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -81,20 +139,34 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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 element = this.domUtils.convertToElement(question.html); |  | ||||||
| 
 | 
 | ||||||
|         const hasInlineText = answers['answer'] && answers['answer'] !== '', |         const hasTextAnswer = answers['answer'] && answers['answer'] !== ''; | ||||||
|             allowsAttachments = !!element.querySelector('div[id*=filemanager]'); |         const uploadFilesSupported = typeof question.responsefileareas != 'undefined'; | ||||||
|  |         const allowedOptions = this.getAllowedOptions(question); | ||||||
| 
 | 
 | ||||||
|         if (!allowsAttachments) { |         if (!allowedOptions.attachments) { | ||||||
|             return hasInlineText ? 1 : 0; |             return hasTextAnswer ? 1 : 0; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         // We can't know if the attachments are required or if the user added any in web.
 |         if (!uploadFilesSupported) { | ||||||
|         return -1; |             // 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 question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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 0; |         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 question Question. | ||||||
|      * @param prevAnswers Object with the previous question answers. |      * @param prevAnswers Object with the previous question answers. | ||||||
|      * @param newAnswers Object with the new 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. |      * @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'); |         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 question Question. | ||||||
|      * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. |      * @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 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. |      * @param siteId Site ID. If not defined, current site. | ||||||
|      * @return Return a promise resolved when done if async, void if sync. |      * @return Return a promise resolved when done if async, void if sync. | ||||||
|      */ |      */ | ||||||
|     prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): void | Promise<any> { |     async prepareAnswers(question: any, answers: any, offline: boolean, component: string, componentId: string | number, | ||||||
|  |             siteId?: string): Promise<void> { | ||||||
|  | 
 | ||||||
|         const element = this.domUtils.convertToElement(question.html); |         const element = this.domUtils.convertToElement(question.html); | ||||||
|  |         const attachmentsInput = <HTMLInputElement> element.querySelector('.attachments input[name*=_attachments]'); | ||||||
| 
 | 
 | ||||||
|         // Search the textarea to get its name.
 |         // Search the textarea to get its name.
 | ||||||
|         const textarea = <HTMLTextAreaElement> element.querySelector('textarea[name*=_answer]'); |         const textarea = <HTMLTextAreaElement> element.querySelector('textarea[name*=_answer]'); | ||||||
| 
 | 
 | ||||||
|         if (textarea && typeof answers[textarea.name] != 'undefined') { |         if (textarea && typeof answers[textarea.name] != 'undefined') { | ||||||
|             // Add some HTML to the text if needed.
 |             await this.prepareTextAnswer(question, answers, textarea, siteId); | ||||||
|             answers[textarea.name] = this.textUtils.formatHtmlLines(answers[textarea.name]); |         } | ||||||
|  | 
 | ||||||
|  |         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<void> { | ||||||
|  | 
 | ||||||
|  |         // 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<void> { | ||||||
|  | 
 | ||||||
|  |         const element = this.domUtils.convertToElement(question.html); | ||||||
|  |         const attachmentsInput = <HTMLInputElement> 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<void> { | ||||||
|  |         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]); | ||||||
|  |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -61,9 +61,11 @@ export class AddonQtypeGapSelectHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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.
 |         // We should always get a value for each select so we can assume we receive all the possible answers.
 | ||||||
|         for (const name in answers) { |         for (const name in answers) { | ||||||
|             const value = answers[name]; |             const value = answers[name]; | ||||||
| @ -90,9 +92,11 @@ export class AddonQtypeGapSelectHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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.
 |         // We should always get a value for each select so we can assume we receive all the possible answers.
 | ||||||
|         for (const name in answers) { |         for (const name in answers) { | ||||||
|             const value = answers[name]; |             const value = answers[name]; | ||||||
| @ -110,9 +114,11 @@ export class AddonQtypeGapSelectHandler implements CoreQuestionHandler { | |||||||
|      * @param question Question. |      * @param question Question. | ||||||
|      * @param prevAnswers Object with the previous question answers. |      * @param prevAnswers Object with the previous question answers. | ||||||
|      * @param newAnswers Object with the new 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. |      * @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); |         return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -61,9 +61,11 @@ export class AddonQtypeMatchHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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.
 |         // We should always get a value for each select so we can assume we receive all the possible answers.
 | ||||||
|         for (const name in answers) { |         for (const name in answers) { | ||||||
|             const value = answers[name]; |             const value = answers[name]; | ||||||
| @ -90,9 +92,11 @@ export class AddonQtypeMatchHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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.
 |         // We should always get a value for each select so we can assume we receive all the possible answers.
 | ||||||
|         for (const name in answers) { |         for (const name in answers) { | ||||||
|             const value = answers[name]; |             const value = answers[name]; | ||||||
| @ -110,9 +114,11 @@ export class AddonQtypeMatchHandler implements CoreQuestionHandler { | |||||||
|      * @param question Question. |      * @param question Question. | ||||||
|      * @param prevAnswers Object with the previous question answers. |      * @param prevAnswers Object with the previous question answers. | ||||||
|      * @param newAnswers Object with the new 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. |      * @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); |         return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -62,9 +62,11 @@ export class AddonQtypeMultiAnswerHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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.
 |         // 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)); |         const names = this.questionProvider.getBasicAnswers(this.questionHelper.getAllInputNamesFromHtml(question.html)); | ||||||
|         for (const name in names) { |         for (const name in names) { | ||||||
| @ -92,9 +94,11 @@ export class AddonQtypeMultiAnswerHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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.
 |         // We should always get a value for each select so we can assume we receive all the possible answers.
 | ||||||
|         for (const name in answers) { |         for (const name in answers) { | ||||||
|             const value = answers[name]; |             const value = answers[name]; | ||||||
| @ -112,9 +116,11 @@ export class AddonQtypeMultiAnswerHandler implements CoreQuestionHandler { | |||||||
|      * @param question Question. |      * @param question Question. | ||||||
|      * @param prevAnswers Object with the previous question answers. |      * @param prevAnswers Object with the previous question answers. | ||||||
|      * @param newAnswers Object with the new 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. |      * @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); |         return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -45,9 +45,11 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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, |         let isSingle = true, | ||||||
|             isMultiComplete = false; |             isMultiComplete = false; | ||||||
| 
 | 
 | ||||||
| @ -95,10 +97,12 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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 this.isCompleteResponse(question, answers); |         return this.isCompleteResponse(question, answers, component, componentId); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -118,9 +122,11 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler { | |||||||
|      * @param question Question. |      * @param question Question. | ||||||
|      * @param prevAnswers Object with the previous question answers. |      * @param prevAnswers Object with the previous question answers. | ||||||
|      * @param newAnswers Object with the new 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. |      * @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, |         let isSingle = true, | ||||||
|             isMultiSame = true; |             isMultiSame = true; | ||||||
| 
 | 
 | ||||||
| @ -158,10 +164,13 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler { | |||||||
|      * @param question Question. |      * @param question Question. | ||||||
|      * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. |      * @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 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. |      * @param siteId Site ID. If not defined, current site. | ||||||
|      * @return Return a promise resolved when done if async, void if sync. |      * @return Return a promise resolved when done if async, void if sync. | ||||||
|      */ |      */ | ||||||
|     prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): void | Promise<any> { |     prepareAnswers(question: any, answers: any, offline: boolean, component: string, componentId: string | number, siteId?: string) | ||||||
|  |             : void | Promise<any> { | ||||||
|         if (question && !question.multi && typeof answers[question.optionsName] != 'undefined' && !answers[question.optionsName]) { |         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 |             /* 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. */ |                sending an empty string (default value) will mark the first option as selected. */ | ||||||
|  | |||||||
| @ -46,11 +46,13 @@ export class AddonQtypeRandomSaMatchHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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.
 |         // 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 question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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.
 |         // 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 question Question. | ||||||
|      * @param prevAnswers Object with the previous question answers. |      * @param prevAnswers Object with the previous question answers. | ||||||
|      * @param newAnswers Object with the new 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. |      * @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.
 |         // This question behaves like a match question.
 | ||||||
|         return this.matchHandler.isSameResponse(question, prevAnswers, newAnswers); |         return this.matchHandler.isSameResponse(question, prevAnswers, newAnswers, component, componentId); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -45,9 +45,11 @@ export class AddonQtypeShortAnswerHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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; |         return (answers['answer'] || answers['answer'] === 0) ? 1 : 0; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -66,10 +68,12 @@ export class AddonQtypeShortAnswerHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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 this.isCompleteResponse(question, answers); |         return this.isCompleteResponse(question, answers, null, null); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -78,9 +82,11 @@ export class AddonQtypeShortAnswerHandler implements CoreQuestionHandler { | |||||||
|      * @param question Question. |      * @param question Question. | ||||||
|      * @param prevAnswers Object with the previous question answers. |      * @param prevAnswers Object with the previous question answers. | ||||||
|      * @param newAnswers Object with the new 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. |      * @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'); |         return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer'); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -46,9 +46,11 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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; |         return answers['answer'] ? 1 : 0; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -67,10 +69,12 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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 this.isCompleteResponse(question, answers); |         return this.isCompleteResponse(question, answers, null, null); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -79,9 +83,11 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler { | |||||||
|      * @param question Question. |      * @param question Question. | ||||||
|      * @param prevAnswers Object with the previous question answers. |      * @param prevAnswers Object with the previous question answers. | ||||||
|      * @param newAnswers Object with the new 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. |      * @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'); |         return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer'); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -91,10 +97,13 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler { | |||||||
|      * @param question Question. |      * @param question Question. | ||||||
|      * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. |      * @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 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. |      * @param siteId Site ID. If not defined, current site. | ||||||
|      * @return Return a promise resolved when done if async, void if sync. |      * @return Return a promise resolved when done if async, void if sync. | ||||||
|      */ |      */ | ||||||
|     prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): void | Promise<any> { |     prepareAnswers(question: any, answers: any, offline: boolean, component: string, componentId: string | number, siteId?: string) | ||||||
|  |             : void | Promise<any> { | ||||||
|         if (question && typeof answers[question.optionsName] != 'undefined' && !answers[question.optionsName]) { |         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.
 |             // The user hasn't answered. Delete the answer to prevent marking one of the answers automatically.
 | ||||||
|             delete answers[question.optionsName]; |             delete answers[question.optionsName]; | ||||||
|  | |||||||
| @ -1861,6 +1861,7 @@ | |||||||
|     "core.mainmenu.help": "Help", |     "core.mainmenu.help": "Help", | ||||||
|     "core.mainmenu.logout": "Log out", |     "core.mainmenu.logout": "Log out", | ||||||
|     "core.mainmenu.website": "Website", |     "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.maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}", | ||||||
|     "core.min": "min", |     "core.min": "min", | ||||||
|     "core.mins": "mins", |     "core.mins": "mins", | ||||||
| @ -1944,8 +1945,8 @@ | |||||||
|     "core.question.certainty": "Certainty", |     "core.question.certainty": "Certainty", | ||||||
|     "core.question.complete": "Complete", |     "core.question.complete": "Complete", | ||||||
|     "core.question.correct": "Correct", |     "core.question.correct": "Correct", | ||||||
|     "core.question.errorattachmentsnotsupported": "The application doesn't support attaching files to answers yet.", |     "core.question.errorattachmentsnotsupportedinsite": "Your site doesn't support attaching files to answers yet.", | ||||||
|     "core.question.errorinlinefilesnotsupported": "The application doesn't support editing inline files 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.errorquestionnotsupported": "This question type is not supported by the app: {{$a}}.", | ||||||
|     "core.question.feedback": "Feedback", |     "core.question.feedback": "Feedback", | ||||||
|     "core.question.howtodraganddrop": "Tap to select then tap to drop.", |     "core.question.howtodraganddrop": "Tap to select then tap to drop.", | ||||||
|  | |||||||
| @ -221,13 +221,16 @@ export class CoreSite { | |||||||
| 
 | 
 | ||||||
|     // Versions of Moodle releases.
 |     // Versions of Moodle releases.
 | ||||||
|     protected MOODLE_RELEASES = { |     protected MOODLE_RELEASES = { | ||||||
|         3.1: 2016052300, |         '3.1': 2016052300, | ||||||
|         3.2: 2016120500, |         '3.2': 2016120500, | ||||||
|         3.3: 2017051503, |         '3.3': 2017051503, | ||||||
|         3.4: 2017111300, |         '3.4': 2017111300, | ||||||
|         3.5: 2018051700, |         '3.5': 2018051700, | ||||||
|         3.6: 2018120300, |         '3.6': 2018120300, | ||||||
|         3.7: 2019052000 |         '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'; |     static MINIMUM_MOODLE_VERSION = '3.1'; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -39,8 +39,8 @@ import { CoreFileUploaderHelperProvider } from '@core/fileuploader/providers/hel | |||||||
| }) | }) | ||||||
| export class CoreAttachmentsComponent implements OnInit { | export class CoreAttachmentsComponent implements OnInit { | ||||||
|     @Input() files: any[]; // List of attachments. New attachments will be added to this array.
 |     @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() maxSize: number; // Max size for attachments. -1 means unlimited, not defined or 0 means unknown limit.
 | ||||||
|     @Input() maxSubmissions: number; // Max number of attachments. If -1 or not defined, 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() component: string; // Component the downloaded files will be linked to.
 | ||||||
|     @Input() componentId: string | number; // Component ID.
 |     @Input() componentId: string | number; // Component ID.
 | ||||||
|     @Input() allowOffline: boolean | string; // Whether to allow selecting files in offline.
 |     @Input() allowOffline: boolean | string; // Whether to allow selecting files in offline.
 | ||||||
| @ -61,17 +61,18 @@ export class CoreAttachmentsComponent implements OnInit { | |||||||
|      * Component being initialized. |      * Component being initialized. | ||||||
|      */ |      */ | ||||||
|     ngOnInit(): void { |     ngOnInit(): void { | ||||||
|         this.maxSize = Number(this.maxSize); // Make sure it's defined and it's a number.
 |         this.maxSize = Number(this.maxSize) || 0; // Make sure it's defined and it's a number.
 | ||||||
|         this.maxSize = !isNaN(this.maxSize) && this.maxSize > 0 ? this.maxSize : -1; |  | ||||||
| 
 | 
 | ||||||
|         if (this.maxSize == -1) { |         if (this.maxSize === 0) { | ||||||
|             this.maxSizeReadable = this.translate.instant('core.unknown'); |             this.maxSizeReadable = this.translate.instant('core.unknown'); | ||||||
|         } else { |         } else if (this.maxSize > 0) { | ||||||
|             this.maxSizeReadable = this.textUtils.bytesToSize(this.maxSize, 2); |             this.maxSizeReadable = this.textUtils.bytesToSize(this.maxSize, 2); | ||||||
|  |         } else { | ||||||
|  |             this.maxSizeReadable = this.translate.instant('core.unlimited'); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (typeof this.maxSubmissions == 'undefined' || this.maxSubmissions < 0) { |         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; |             this.unlimitedFiles = true; | ||||||
|         } else { |         } else { | ||||||
|             this.maxSubmissionsReadable = String(this.maxSubmissions); |             this.maxSubmissionsReadable = String(this.maxSubmissions); | ||||||
|  | |||||||
| @ -1,5 +1,6 @@ | |||||||
| <ion-item text-wrap> | <ion-item text-wrap> | ||||||
|     {{ 'core.maxsizeandattachments' | translate:{$a: {size: maxSizeReadable, attachments: maxSubmissionsReadable} } }} |     <span *ngIf="maxSubmissionsReadable">{{ 'core.maxsizeandattachments' | translate:{$a: {size: maxSizeReadable, attachments: maxSubmissionsReadable} } }}</span> | ||||||
|  |     <span *ngIf="!maxSubmissionsReadable">{{ 'core.maxfilesize' | translate:{$a: maxSizeReadable} }}</span> | ||||||
|     <span [core-mark-required]="required" class="core-mark-required"></span> |     <span [core-mark-required]="required" class="core-mark-required"></span> | ||||||
| </ion-item> | </ion-item> | ||||||
| <ion-item text-wrap *ngIf="fileTypes && fileTypes.mimetypes && fileTypes.mimetypes.length"> | <ion-item text-wrap *ngIf="fileTypes && fileTypes.mimetypes && fileTypes.mimetypes.length"> | ||||||
|  | |||||||
| @ -56,6 +56,7 @@ ion-app.app-root core-rich-text-editor { | |||||||
|         img { |         img { | ||||||
|             @include padding(null, null, null, 2px); |             @include padding(null, null, null, 2px); | ||||||
|             max-width: 95%; |             max-width: 95%; | ||||||
|  |             width: auto; | ||||||
|         } |         } | ||||||
|         &:empty:before { |         &:empty:before { | ||||||
|             content: attr(data-placeholder-text); |             content: attr(data-placeholder-text); | ||||||
|  | |||||||
| @ -15,6 +15,7 @@ | |||||||
| import { Injectable } from '@angular/core'; | import { Injectable } from '@angular/core'; | ||||||
| import { ModalController } from 'ionic-angular'; | import { ModalController } from 'ionic-angular'; | ||||||
| import { Camera, CameraOptions } from '@ionic-native/camera'; | 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 { MediaCapture, MediaFile, CaptureError, CaptureAudioOptions, CaptureVideoOptions } from '@ionic-native/media-capture'; | ||||||
| import { TranslateService } from '@ngx-translate/core'; | import { TranslateService } from '@ngx-translate/core'; | ||||||
| import { CoreFileProvider } from '@providers/file'; | import { CoreFileProvider } from '@providers/file'; | ||||||
| @ -25,9 +26,11 @@ import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; | |||||||
| import { CoreTextUtilsProvider } from '@providers/utils/text'; | import { CoreTextUtilsProvider } from '@providers/utils/text'; | ||||||
| import { CoreTimeUtilsProvider } from '@providers/utils/time'; | import { CoreTimeUtilsProvider } from '@providers/utils/time'; | ||||||
| import { CoreUtilsProvider } from '@providers/utils/utils'; | import { CoreUtilsProvider } from '@providers/utils/utils'; | ||||||
| import { CoreWSFileUploadOptions } from '@providers/ws'; | import { CoreWSFileUploadOptions, CoreWSExternalFile } from '@providers/ws'; | ||||||
| import { Subject } from 'rxjs'; | import { Subject } from 'rxjs'; | ||||||
| import { CoreApp } from '@providers/app'; | import { CoreApp } from '@providers/app'; | ||||||
|  | import { CoreSite } from '@classes/site'; | ||||||
|  | import { makeSingleton } from '@singletons/core.singletons'; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * File upload options. |  * File upload options. | ||||||
| @ -96,7 +99,7 @@ export class CoreFileUploaderProvider { | |||||||
|         // Currently we are going to compare the order of the files as well.
 |         // 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.
 |         // This function can be improved comparing more fields or not comparing the order.
 | ||||||
|         for (let i = 0; i < a.length; i++) { |         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; |                 return true; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
| @ -104,6 +107,36 @@ export class CoreFileUploaderProvider { | |||||||
|         return false; |         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<boolean> { | ||||||
|  |         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. |      * 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<void> { | ||||||
|  |         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. |      * Get the upload options for a file taken with the Camera Cordova plugin. | ||||||
|      * |      * | ||||||
| @ -215,6 +267,35 @@ export class CoreFileUploaderProvider { | |||||||
|         return options; |         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 (<CoreWSExternalFile> 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. |      * 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<void> { | ||||||
|  |         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 = (<CoreWSExternalFile> file).filename && !(<FileEntry> file).name; | ||||||
|  | 
 | ||||||
|  |             if (isOnlineFile) { | ||||||
|  |                 usedNames[(<CoreWSExternalFile> file).filename.toLowerCase()] = file; | ||||||
|  |             } else { | ||||||
|  |                 filesToUpload.push(<FileEntry> 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. |      * 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) {} | ||||||
|  | |||||||
| @ -407,7 +407,7 @@ export class CoreGradesHelperProvider { | |||||||
|                     if (matches && matches.length) { |                     if (matches && matches.length) { | ||||||
|                         const hrefParams = this.urlUtils.extractUrlParams(matches[1]); |                         const hrefParams = this.urlUtils.extractUrlParams(matches[1]); | ||||||
| 
 | 
 | ||||||
|                         return hrefParams && hrefParams.id == moduleId; |                         return hrefParams && Number(hrefParams.id) == moduleId; | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1152,7 +1152,7 @@ export class CoreLoginHelperProvider { | |||||||
|                     const providerToUse = identityProviders.find((provider) => { |                     const providerToUse = identityProviders.find((provider) => { | ||||||
|                         const params = this.urlUtils.extractUrlParams(provider.url); |                         const params = this.urlUtils.extractUrlParams(provider.url); | ||||||
| 
 | 
 | ||||||
|                         return params.id == currentSite.getOAuthId(); |                         return Number(params.id) == currentSite.getOAuthId(); | ||||||
|                     }); |                     }); | ||||||
| 
 | 
 | ||||||
|                     if (providerToUse) { |                     if (providerToUse) { | ||||||
|  | |||||||
| @ -34,10 +34,11 @@ export class CoreQuestionBehaviourBaseHandler implements CoreQuestionBehaviourHa | |||||||
|      * @param component Component the question belongs to. |      * @param component Component the question belongs to. | ||||||
|      * @param attemptId Attempt ID the question belongs to. |      * @param attemptId Attempt ID the question belongs to. | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|  |      * @param componentId Component ID. | ||||||
|      * @param siteId Site ID. If not defined, current site. |      * @param siteId Site ID. If not defined, current site. | ||||||
|      * @return New state (or promise resolved with state). |      * @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<CoreQuestionState> { |             : CoreQuestionState | Promise<CoreQuestionState> { | ||||||
|         // Return the current state.
 |         // Return the current state.
 | ||||||
|         return this.questionProvider.getState(question.state); |         return this.questionProvider.getState(question.state); | ||||||
|  | |||||||
| @ -14,8 +14,10 @@ | |||||||
| 
 | 
 | ||||||
| import { Input, Output, EventEmitter, Injector, ElementRef } from '@angular/core'; | import { Input, Output, EventEmitter, Injector, ElementRef } from '@angular/core'; | ||||||
| import { CoreLoggerProvider } from '@providers/logger'; | import { CoreLoggerProvider } from '@providers/logger'; | ||||||
|  | import { CoreSites } from '@providers/sites'; | ||||||
| import { CoreDomUtilsProvider } from '@providers/utils/dom'; | import { CoreDomUtilsProvider } from '@providers/utils/dom'; | ||||||
| import { CoreTextUtilsProvider } from '@providers/utils/text'; | import { CoreTextUtilsProvider } from '@providers/utils/text'; | ||||||
|  | import { CoreUrlUtils } from '@providers/utils/url'; | ||||||
| import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; | import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
| @ -105,9 +107,13 @@ export class CoreQuestionBaseComponent { | |||||||
|                 this.question.select = selectModel; |                 this.question.select = selectModel; | ||||||
| 
 | 
 | ||||||
|                 // Check which one should be displayed first: the select or the input.
 |                 // Check which one should be displayed first: the select or the input.
 | ||||||
|                 const input = questionEl.querySelector('input[type="text"][name*=answer]'); |                 if (this.question.settings) { | ||||||
|                 this.question.selectFirst = |                     this.question.selectFirst = this.question.settings.unitsleft == '1'; | ||||||
|                         questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(select.outerHTML); |                 } 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; |                 return questionEl; | ||||||
|             } |             } | ||||||
| @ -159,9 +165,15 @@ export class CoreQuestionBaseComponent { | |||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             // Check which one should be displayed first: the options or the input.
 |             // Check which one should be displayed first: the options or the input.
 | ||||||
|             const input = questionEl.querySelector('input[type="text"][name*=answer]'); |             if (this.question.settings) { | ||||||
|             this.question.optionsFirst = |                 this.question.optionsFirst = this.question.settings.unitsleft == '1'; | ||||||
|                     questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(radios[0].outerHTML); |             } 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(); |         const questionEl = this.initComponent(); | ||||||
| 
 | 
 | ||||||
|         if (questionEl) { |         if (questionEl) { | ||||||
|             // First search the textarea.
 |  | ||||||
|             const textarea = <HTMLTextAreaElement> questionEl.querySelector('textarea[name*=_answer]'); |             const textarea = <HTMLTextAreaElement> questionEl.querySelector('textarea[name*=_answer]'); | ||||||
|             this.question.allowsAttachments = !!questionEl.querySelector('div[id*=filemanager]'); |             const answerDraftIdInput = <HTMLInputElement> questionEl.querySelector('input[name*="_answer:itemid"]'); | ||||||
|             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); |  | ||||||
| 
 | 
 | ||||||
|             if (!textarea) { |             if (this.question.settings) { | ||||||
|                 // Textarea not found, we might be in review. Search the answer and the attachments.
 |                 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.answer = this.domUtils.getContentsOfElement(questionEl, '.qtype_essay_response'); | ||||||
|                 this.question.attachments = this.questionHelper.getQuestionAttachmentsFromHtml( |                 this.question.attachments = this.questionHelper.getQuestionAttachmentsFromHtml( | ||||||
|                         this.domUtils.getContentsOfElement(questionEl, '.attachments')); |                         this.domUtils.getContentsOfElement(questionEl, '.attachments')); | ||||||
|             } else { | 
 | ||||||
|                 // Textarea found.
 |                 return questionEl; | ||||||
|                 const input = <HTMLInputElement> questionEl.querySelector('input[type="hidden"][name*=answerformat]'), |             } | ||||||
|                     content = textarea.innerHTML; | 
 | ||||||
|  |             if (textarea) { | ||||||
|  |                 const input = <HTMLInputElement> 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 = { |                 this.question.textarea = { | ||||||
|                     id: textarea.id, |                     id: textarea.id, | ||||||
|                     name: textarea.name, |                     name: textarea.name, | ||||||
|                     text: content ? this.textUtils.decodeHTML(content) : '' |                     text: content, | ||||||
|                 }; |                 }; | ||||||
| 
 | 
 | ||||||
|                 if (input) { |                 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 = <HTMLInputElement> questionEl.querySelector('.attachments input[name*=_attachments]'); | ||||||
|  |                 const objectElement = <HTMLObjectElement> 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.
 |         // Set the question text.
 | ||||||
|         this.question.text = content.innerHTML; |         this.question.text = content.innerHTML; | ||||||
|  | 
 | ||||||
|  |         return element; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  | |||||||
| @ -79,9 +79,11 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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; |         return -1; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -91,9 +93,11 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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; |         return -1; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -103,9 +107,11 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler { | |||||||
|      * @param question Question. |      * @param question Question. | ||||||
|      * @param prevAnswers Object with the previous question answers. |      * @param prevAnswers Object with the previous question answers. | ||||||
|      * @param newAnswers Object with the new 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. |      * @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; |         return false; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -115,10 +121,13 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler { | |||||||
|      * @param question Question. |      * @param question Question. | ||||||
|      * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. |      * @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 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. |      * @param siteId Site ID. If not defined, current site. | ||||||
|      * @return Return a promise resolved when done if async, void if sync. |      * @return Return a promise resolved when done if async, void if sync. | ||||||
|      */ |      */ | ||||||
|     prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): void | Promise<any> { |     prepareAnswers(question: any, answers: any, offline: boolean, component: string, componentId: string | number, siteId?: string) | ||||||
|  |             : void | Promise<any> { | ||||||
|         // Nothing to do.
 |         // Nothing to do.
 | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -5,8 +5,8 @@ | |||||||
|     "certainty": "Certainty", |     "certainty": "Certainty", | ||||||
|     "complete": "Complete", |     "complete": "Complete", | ||||||
|     "correct": "Correct", |     "correct": "Correct", | ||||||
|     "errorattachmentsnotsupported": "The application doesn't support attaching files to answers yet.", |     "errorattachmentsnotsupportedinsite": "Your site doesn't support attaching files to answers yet.", | ||||||
|     "errorinlinefilesnotsupported": "The application doesn't support editing inline files yet.", |     "errorembeddedfilesnotsupportedinsite": "Your site doesn't support editing embedded files yet.", | ||||||
|     "errorquestionnotsupported": "This question type is not supported by the app: {{$a}}.", |     "errorquestionnotsupported": "This question type is not supported by the app: {{$a}}.", | ||||||
|     "feedback": "Feedback", |     "feedback": "Feedback", | ||||||
|     "howtodraganddrop": "Tap to select then tap to drop.", |     "howtodraganddrop": "Tap to select then tap to drop.", | ||||||
|  | |||||||
| @ -36,10 +36,11 @@ export interface CoreQuestionBehaviourHandler extends CoreDelegateHandler { | |||||||
|      * @param component Component the question belongs to. |      * @param component Component the question belongs to. | ||||||
|      * @param attemptId Attempt ID the question belongs to. |      * @param attemptId Attempt ID the question belongs to. | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|  |      * @param componentId Component ID. | ||||||
|      * @param siteId Site ID. If not defined, current site. |      * @param siteId Site ID. If not defined, current site. | ||||||
|      * @return State (or promise resolved with state). |      * @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<CoreQuestionState>; |         : CoreQuestionState | Promise<CoreQuestionState>; | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -74,15 +75,16 @@ export class CoreQuestionBehaviourDelegate extends CoreDelegate { | |||||||
|      * @param component Component the question belongs to. |      * @param component Component the question belongs to. | ||||||
|      * @param attemptId Attempt ID the question belongs to. |      * @param attemptId Attempt ID the question belongs to. | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|  |      * @param componentId Component ID. | ||||||
|      * @param siteId Site ID. If not defined, current site. |      * @param siteId Site ID. If not defined, current site. | ||||||
|      * @return Promise resolved with state. |      * @return Promise resolved with state. | ||||||
|      */ |      */ | ||||||
|     determineNewState(behaviour: string, component: string, attemptId: number, question: any, siteId?: string) |     determineNewState(behaviour: string, component: string, attemptId: number, question: any, componentId: string | number, | ||||||
|             : Promise<CoreQuestionState> { |             siteId?: string): Promise<CoreQuestionState> { | ||||||
|         behaviour = this.questionDelegate.getBehaviourForQuestion(question, behaviour); |         behaviour = this.questionDelegate.getBehaviourForQuestion(question, behaviour); | ||||||
| 
 | 
 | ||||||
|         return Promise.resolve(this.executeFunctionOnEnabled(behaviour, 'determineNewState', |         return Promise.resolve(this.executeFunctionOnEnabled(behaviour, 'determineNewState', | ||||||
|                 [component, attemptId, question, siteId])); |                 [component, attemptId, question, componentId, siteId])); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  | |||||||
| @ -62,9 +62,11 @@ export interface CoreQuestionHandler extends CoreDelegateHandler { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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, |      * 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 question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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. |      * Check if two responses are the same. | ||||||
| @ -84,7 +88,7 @@ export interface CoreQuestionHandler extends CoreDelegateHandler { | |||||||
|      * @param newAnswers Object with the new question answers. |      * @param newAnswers Object with the new question answers. | ||||||
|      * @return Whether they're the same. |      * @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. |      * 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 question Question. | ||||||
|      * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. |      * @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 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. |      * @param siteId Site ID. If not defined, current site. | ||||||
|      * @return Return a promise resolved when done if async, void if sync. |      * @return Return a promise resolved when done if async, void if sync. | ||||||
|      */ |      */ | ||||||
|     prepareAnswers?(question: any, answers: any, offline: boolean, siteId?: string): void | Promise<any>; |     prepareAnswers?(question: any, answers: any, offline: boolean, component: string, componentId: string | number, | ||||||
|  |             siteId?: string): void | Promise<any>; | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Validate if an offline sequencecheck is valid compared with the online one. |      * 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. |      * @return List of URLs. | ||||||
|      */ |      */ | ||||||
|     getAdditionalDownloadableFiles?(question: any, usageId: number): string[]; |     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<void>; | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * 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<void>; | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * 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<void>; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
| @ -196,12 +237,14 @@ export class CoreQuestionDelegate extends CoreDelegate { | |||||||
|      * |      * | ||||||
|      * @param question The question. |      * @param question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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); |         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 question The question. | ||||||
|      * @param answers Object with the question answers (without prefix). |      * @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. |      * @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); |         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. |      * @param newAnswers Object with the new question answers. | ||||||
|      * @return Whether they're the same. |      * @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); |         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 question Question. | ||||||
|      * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. |      * @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 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. |      * @param siteId Site ID. If not defined, current site. | ||||||
|      * @return Promise resolved when data has been prepared. |      * @return Promise resolved when data has been prepared. | ||||||
|      */ |      */ | ||||||
|     prepareAnswersForQuestion(question: any, answers: any, offline: boolean, siteId?: string): Promise<any> { |     prepareAnswersForQuestion(question: any, answers: any, offline: boolean, component: string, componentId: string | number, | ||||||
|  |             siteId?: string): Promise<any> { | ||||||
|         const type = this.getTypeName(question); |         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]) || []; |         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<void> { | ||||||
|  |         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<void> { | ||||||
|  |         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<void> { | ||||||
|  |         const type = this.getTypeName(question); | ||||||
|  | 
 | ||||||
|  |         return this.executeFunctionOnEnabled(type, 'prepareSyncData', [question, answers, component, componentId, siteId]); | ||||||
|  |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -13,9 +13,12 @@ | |||||||
| // limitations under the License.
 | // limitations under the License.
 | ||||||
| 
 | 
 | ||||||
| import { Injectable, EventEmitter } from '@angular/core'; | import { Injectable, EventEmitter } from '@angular/core'; | ||||||
|  | import { FileEntry } from '@ionic-native/file'; | ||||||
| import { TranslateService } from '@ngx-translate/core'; | import { TranslateService } from '@ngx-translate/core'; | ||||||
|  | import { CoreFile } from '@providers/file'; | ||||||
| import { CoreFilepoolProvider } from '@providers/filepool'; | import { CoreFilepoolProvider } from '@providers/filepool'; | ||||||
| import { CoreSitesProvider } from '@providers/sites'; | import { CoreSitesProvider } from '@providers/sites'; | ||||||
|  | import { CoreWSExternalFile } from '@providers/ws'; | ||||||
| import { CoreDomUtilsProvider } from '@providers/utils/dom'; | import { CoreDomUtilsProvider } from '@providers/utils/dom'; | ||||||
| import { CoreTextUtilsProvider } from '@providers/utils/text'; | import { CoreTextUtilsProvider } from '@providers/utils/text'; | ||||||
| import { CoreUrlUtilsProvider } from '@providers/utils/url'; | 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<void> { | ||||||
|  |         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<void> { | ||||||
|  | 
 | ||||||
|  |         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. |      * 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. |      * 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 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<FileEntry[]> { | ||||||
|  |         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. |      * Get the validation error message from a question HTML if it's there. | ||||||
|      * |      * | ||||||
| @ -521,7 +594,7 @@ export class CoreQuestionHelperProvider { | |||||||
| 
 | 
 | ||||||
|         if (!component) { |         if (!component) { | ||||||
|             component = CoreQuestionProvider.COMPONENT; |             component = CoreQuestionProvider.COMPONENT; | ||||||
|             componentId = question.id; |             componentId = question.number; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         urls.push(...this.questionDelegate.getAdditionalDownloadableFiles(question, usageId)); |         urls.push(...this.questionDelegate.getAdditionalDownloadableFiles(question, usageId)); | ||||||
| @ -552,15 +625,19 @@ export class CoreQuestionHelperProvider { | |||||||
|      * @param questions The list of questions. |      * @param questions The list of questions. | ||||||
|      * @param answers The input data. |      * @param answers The input data. | ||||||
|      * @param offline True if data should be saved in offline. |      * @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. |      * @param siteId Site ID. If not defined, current site. | ||||||
|      * @return Promise resolved with answers to send to server. |      * @return Promise resolved with answers to send to server. | ||||||
|      */ |      */ | ||||||
|     prepareAnswers(questions: any[], answers: any, offline?: boolean, siteId?: string): Promise<any> { |     prepareAnswers(questions: any[], answers: any, offline: boolean, component: string, componentId: string | number, | ||||||
|  |             siteId?: string): Promise<any> { | ||||||
|         const promises = []; |         const promises = []; | ||||||
| 
 | 
 | ||||||
|         questions = questions || []; |         questions = questions || []; | ||||||
|         questions.forEach((question) => { |         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(() => { |         return this.utils.allPromises(promises).then(() => { | ||||||
|  | |||||||
| @ -13,10 +13,13 @@ | |||||||
| // limitations under the License.
 | // limitations under the License.
 | ||||||
| 
 | 
 | ||||||
| import { Injectable } from '@angular/core'; | import { Injectable } from '@angular/core'; | ||||||
|  | import { CoreFile } from '@providers/file'; | ||||||
| import { CoreLoggerProvider } from '@providers/logger'; | import { CoreLoggerProvider } from '@providers/logger'; | ||||||
| import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; | import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; | ||||||
|  | import { CoreTextUtils } from '@providers/utils/text'; | ||||||
| import { CoreTimeUtilsProvider } from '@providers/utils/time'; | import { CoreTimeUtilsProvider } from '@providers/utils/time'; | ||||||
| import { CoreUtilsProvider } from '@providers/utils/utils'; | import { CoreUtilsProvider } from '@providers/utils/utils'; | ||||||
|  | import { makeSingleton } from '@singletons/core.singletons'; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * An object to represent a question state. |  * 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. |      * Extract the question slot from a question name. | ||||||
|      * |      * | ||||||
| @ -612,3 +644,5 @@ export class CoreQuestionProvider { | |||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | export class CoreQuestion extends makeSingleton(CoreQuestionProvider) {} | ||||||
|  | |||||||
| @ -143,6 +143,7 @@ | |||||||
|     "loadmore": "Load more", |     "loadmore": "Load more", | ||||||
|     "location": "Location", |     "location": "Location", | ||||||
|     "lostconnection": "Your authentication token is invalid or has expired. You will have to reconnect to the site.", |     "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}}", |     "maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}", | ||||||
|     "min": "min", |     "min": "min", | ||||||
|     "mins": "mins", |     "mins": "mins", | ||||||
|  | |||||||
| @ -1114,10 +1114,8 @@ export class CoreFileProvider { | |||||||
|         // Get existing files in the folder.
 |         // Get existing files in the folder.
 | ||||||
|         return this.getDirectoryContents(dirPath).then((entries) => { |         return this.getDirectoryContents(dirPath).then((entries) => { | ||||||
|             const files = {}; |             const files = {}; | ||||||
|             let num = 1, |             let fileNameWithoutExtension = this.mimeUtils.removeExtension(fileName); | ||||||
|                 fileNameWithoutExtension = this.mimeUtils.removeExtension(fileName), |             let extension = this.mimeUtils.getFileExtension(fileName) || defaultExt; | ||||||
|                 extension = this.mimeUtils.getFileExtension(fileName) || defaultExt, |  | ||||||
|                 newName; |  | ||||||
| 
 | 
 | ||||||
|             // Clean the file name.
 |             // Clean the file name.
 | ||||||
|             fileNameWithoutExtension = this.textUtils.removeSpecialCharactersForFiles( |             fileNameWithoutExtension = this.textUtils.removeSpecialCharactersForFiles( | ||||||
| @ -1135,26 +1133,40 @@ export class CoreFileProvider { | |||||||
|                 extension = ''; |                 extension = ''; | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             newName = fileNameWithoutExtension + extension; |             return this.calculateUniqueName(files, 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; |  | ||||||
|             } |  | ||||||
|         }).catch(() => { |         }).catch(() => { | ||||||
|             // Folder doesn't exist, name is unique. Clean it and return it.
 |             // Folder doesn't exist, name is unique. Clean it and return it.
 | ||||||
|             return this.textUtils.removeSpecialCharactersForFiles(this.textUtils.decodeURIComponent(fileName)); |             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. |      * Remove app temporary folder. | ||||||
|      * |      * | ||||||
|  | |||||||
| @ -19,6 +19,7 @@ import { TranslateService } from '@ngx-translate/core'; | |||||||
| import { CoreLangProvider } from '../lang'; | import { CoreLangProvider } from '../lang'; | ||||||
| import { makeSingleton } from '@singletons/core.singletons'; | import { makeSingleton } from '@singletons/core.singletons'; | ||||||
| import { CoreApp } from '../app'; | import { CoreApp } from '../app'; | ||||||
|  | import { CoreWSExternalFile } from '../ws'; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Different type of errors the app can treat. |  * Different type of errors the app can treat. | ||||||
| @ -699,6 +700,60 @@ export class CoreTextUtilsProvider { | |||||||
|         return text.replace(/(?:\r\n|\r|\n)/g, newValue); |         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. |      * Replace @@PLUGINFILE@@ wildcards with the real URL in a text. | ||||||
|      * |      * | ||||||
| @ -717,6 +772,36 @@ export class CoreTextUtilsProvider { | |||||||
|         return text; |         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. |      * Replace pluginfile URLs with @@PLUGINFILE@@ wildcards. | ||||||
|      * |      * | ||||||
|  | |||||||
| @ -114,10 +114,10 @@ export class CoreUrlUtilsProvider { | |||||||
|      * @param url URL to treat. |      * @param url URL to treat. | ||||||
|      * @return Object with the params. |      * @return Object with the params. | ||||||
|      */ |      */ | ||||||
|     extractUrlParams(url: string): any { |     extractUrlParams(url: string): {[name: string]: string} { | ||||||
|         const regex = /[?&]+([^=&]+)=?([^&]*)?/gi, |         const regex = /[?&]+([^=&]+)=?([^&]*)?/gi, | ||||||
|             subParamsPlaceholder = '@@@SUBPARAMS@@@', |             subParamsPlaceholder = '@@@SUBPARAMS@@@', | ||||||
|             params: any = {}, |             params: {[name: string]: string} = {}, | ||||||
|             urlAndHash = url.split('#'), |             urlAndHash = url.split('#'), | ||||||
|             questionMarkSplit = urlAndHash[0].split('?'); |             questionMarkSplit = urlAndHash[0].split('?'); | ||||||
|         let subParams; |         let subParams; | ||||||
|  | |||||||
| @ -1,6 +1,10 @@ | |||||||
| This files describes API changes in the Moodle Mobile app, | This files describes API changes in the Moodle Mobile app, | ||||||
| information provided here is intended especially for developers. | 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 === | === 3.8.3 === | ||||||
| 
 | 
 | ||||||
| - CoreFileProvider.writeFileDataInFile has been deprecated. Please use CoreFileHelperProvider.writeFileDataInFile instead. | - CoreFileProvider.writeFileDataInFile has been deprecated. Please use CoreFileHelperProvider.writeFileDataInFile instead. | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user