forked from CIT/Vmeda.Online
		
	
						commit
						0cf2b3c74e
					
				| @ -1861,6 +1861,7 @@ | ||||
|   "core.mainmenu.help": "moodle", | ||||
|   "core.mainmenu.logout": "moodle", | ||||
|   "core.mainmenu.website": "local_moodlemobileapp", | ||||
|   "core.maxfilesize": "moodle", | ||||
|   "core.maxsizeandattachments": "moodle", | ||||
|   "core.min": "moodle", | ||||
|   "core.mins": "moodle", | ||||
| @ -1944,8 +1945,8 @@ | ||||
|   "core.question.certainty": "qbehaviour_deferredcbm", | ||||
|   "core.question.complete": "question", | ||||
|   "core.question.correct": "question", | ||||
|   "core.question.errorattachmentsnotsupported": "local_moodlemobileapp", | ||||
|   "core.question.errorinlinefilesnotsupported": "local_moodlemobileapp", | ||||
|   "core.question.errorattachmentsnotsupportedinsite": "local_moodlemobileapp", | ||||
|   "core.question.errorembeddedfilesnotsupportedinsite": "local_moodlemobileapp", | ||||
|   "core.question.errorquestionnotsupported": "local_moodlemobileapp", | ||||
|   "core.question.feedback": "question", | ||||
|   "core.question.howtodraganddrop": "local_moodlemobileapp", | ||||
|  | ||||
| @ -411,7 +411,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid | ||||
|                                 const params = this.urlUtils.extractUrlParams(response.data.reviewlesson.value); | ||||
|                                 if (params && params.pageid) { | ||||
|                                     // The retake can be reviewed, mark it as finished. Don't block the user for this.
 | ||||
|                                     this.setRetakeFinishedInSync(lessonId, retake.retake, params.pageid, siteId); | ||||
|                                     this.setRetakeFinishedInSync(lessonId, retake.retake, Number(params.pageid), siteId); | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|  | ||||
| @ -569,7 +569,8 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { | ||||
| 
 | ||||
|     // Prepare the answers to be sent for the attempt.
 | ||||
|     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) { | ||||
|                 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) { | ||||
|             const question = questionsWithAnswers[slot]; | ||||
| 
 | ||||
|             promises.push(this.behaviourDelegate.determineNewState( | ||||
|                         quiz.preferredbehaviour, AddonModQuizProvider.COMPONENT, attempt.id, question, siteId).then((state) => { | ||||
|             promises.push(this.behaviourDelegate.determineNewState(quiz.preferredbehaviour, AddonModQuizProvider.COMPONENT, | ||||
|                     attempt.id, question, quiz.coursemodule, siteId).then((state) => { | ||||
|                 // Check if state has changed.
 | ||||
|                 if (state && state.name != question.state) { | ||||
|                     newStates[question.slot] = state.name; | ||||
|  | ||||
| @ -21,6 +21,7 @@ import { CoreSitesProvider, CoreSitesReadingStrategy } from '@providers/sites'; | ||||
| import { CoreSyncProvider } from '@providers/sync'; | ||||
| import { CoreTextUtilsProvider } from '@providers/utils/text'; | ||||
| import { CoreTimeUtilsProvider } from '@providers/utils/time'; | ||||
| import { CoreUtils } from '@providers/utils/utils'; | ||||
| import { CoreCourseProvider } from '@core/course/providers/course'; | ||||
| import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; | ||||
| import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; | ||||
| @ -77,25 +78,33 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider | ||||
|      * @param quiz Quiz. | ||||
|      * @param courseId Course ID. | ||||
|      * @param warnings List of warnings generated by the sync. | ||||
|      * @param attemptId Last attempt ID. | ||||
|      * @param offlineAttempt Offline attempt synchronized, if any. | ||||
|      * @param onlineAttempt Online data for the offline attempt. | ||||
|      * @param removeAttempt Whether the offline data should be removed. | ||||
|      * @param updated Whether some data was sent to the site. | ||||
|      * @param options Other options. | ||||
|      * @return Promise resolved on success. | ||||
|      */ | ||||
|     protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], attemptId?: number, offlineAttempt?: any, | ||||
|             onlineAttempt?: any, removeAttempt?: boolean, updated?: boolean): Promise<AddonModQuizSyncResult> { | ||||
|     protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], options?: FinishSyncOptions) | ||||
|             : Promise<AddonModQuizSyncResult> { | ||||
|         options = options || {}; | ||||
| 
 | ||||
|         // Invalidate the data for the quiz and attempt.
 | ||||
|         return this.quizProvider.invalidateAllQuizData(quiz.id, courseId, attemptId, siteId).catch(() => { | ||||
|         return this.quizProvider.invalidateAllQuizData(quiz.id, courseId, options.attemptId, siteId).catch(() => { | ||||
|             // Ignore errors.
 | ||||
|         }).then(() => { | ||||
|             if (removeAttempt && attemptId) { | ||||
|                 return this.quizOfflineProvider.removeAttemptAndAnswers(attemptId, siteId); | ||||
|             if (options.removeAttempt && options.attemptId) { | ||||
|                 const promises = []; | ||||
| 
 | ||||
|                 promises.push(this.quizOfflineProvider.removeAttemptAndAnswers(options.attemptId, siteId)); | ||||
| 
 | ||||
|                 if (options.onlineQuestions) { | ||||
|                     for (const slot in options.onlineQuestions) { | ||||
|                         promises.push(this.questionDelegate.deleteOfflineData(options.onlineQuestions[slot], | ||||
|                                 AddonModQuizProvider.COMPONENT, quiz.coursemodule, siteId)); | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 return Promise.all(promises); | ||||
|             } | ||||
|         }).then(() => { | ||||
|             if (updated) { | ||||
|             if (options.updated) { | ||||
|                 // Data has been sent. Update prefetched data.
 | ||||
|                 return this.courseProvider.getModuleBasicInfoByInstance(quiz.id, 'quiz', siteId).then((module) => { | ||||
|                     return this.prefetchAfterUpdateQuiz(module, quiz, courseId, undefined, siteId); | ||||
| @ -109,14 +118,14 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider | ||||
|             }); | ||||
|         }).then(() => { | ||||
|             // Check if online attempt was finished because of the sync.
 | ||||
|             if (onlineAttempt && !this.quizProvider.isAttemptFinished(onlineAttempt.state)) { | ||||
|             if (options.onlineAttempt && !this.quizProvider.isAttemptFinished(options.onlineAttempt.state)) { | ||||
|                 // Attempt wasn't finished at start. Check if it's finished now.
 | ||||
|                 return this.quizProvider.getUserAttempts(quiz.id, {cmId: quiz.coursemodule, siteId}).then((attempts) => { | ||||
|                     // Search the attempt.
 | ||||
|                     for (const i in attempts) { | ||||
|                         const attempt = attempts[i]; | ||||
| 
 | ||||
|                         if (attempt.id == onlineAttempt.id) { | ||||
|                         if (attempt.id == options.onlineAttempt.id) { | ||||
|                             return this.quizProvider.isAttemptFinished(attempt.state); | ||||
|                         } | ||||
|                     } | ||||
| @ -288,16 +297,6 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider | ||||
|     syncQuiz(quiz: any, askPreflight?: boolean, siteId?: string): Promise<AddonModQuizSyncResult> { | ||||
|         siteId = siteId || this.sitesProvider.getCurrentSiteId(); | ||||
| 
 | ||||
|         const warnings = []; | ||||
|         const courseId = quiz.course; | ||||
|         const modOptions = { | ||||
|             cmId: quiz.coursemodule, | ||||
|             readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, | ||||
|             siteId, | ||||
|         }; | ||||
|         let syncPromise; | ||||
|         let preflightData; | ||||
| 
 | ||||
|         if (this.isSyncing(quiz.id, siteId)) { | ||||
|             // There's already a sync ongoing for this quiz, return the promise.
 | ||||
|             return this.getOngoingSync(quiz.id, siteId); | ||||
| @ -310,115 +309,139 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider | ||||
|             return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); | ||||
|         } | ||||
| 
 | ||||
|         return this.addOngoingSync(quiz.id, this.performSyncQuiz(quiz, askPreflight, siteId), siteId); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Perform the quiz sync. | ||||
|      * | ||||
|      * @param quiz Quiz. | ||||
|      * @param askPreflight Whether we should ask for preflight data if needed. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return Promise resolved in success. | ||||
|      */ | ||||
|     async performSyncQuiz(quiz: any, askPreflight?: boolean, siteId?: string): Promise<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); | ||||
| 
 | ||||
|         // Sync offline logs.
 | ||||
|         syncPromise = this.logHelper.syncIfNeeded(AddonModQuizProvider.COMPONENT, quiz.id, siteId).catch(() => { | ||||
|             // Ignore errors.
 | ||||
|         }).then(() => { | ||||
|             // Get all the offline attempts for the quiz.
 | ||||
|             return this.quizOfflineProvider.getQuizAttempts(quiz.id, siteId); | ||||
|         }).then((attempts) => { | ||||
|             // Should return 0 or 1 attempt.
 | ||||
|             if (!attempts.length) { | ||||
|                 return this.finishSync(siteId, quiz, courseId, warnings); | ||||
|             } | ||||
|         await CoreUtils.instance.ignoreErrors(this.logHelper.syncIfNeeded(AddonModQuizProvider.COMPONENT, quiz.id, siteId)); | ||||
| 
 | ||||
|             const offlineAttempt = attempts.pop(); | ||||
|         // Get all the offline attempts for the quiz. It should always be 0 or 1 attempt
 | ||||
|         const offlineAttempts = await this.quizOfflineProvider.getQuizAttempts(quiz.id, siteId); | ||||
| 
 | ||||
|             // Now get the list of online attempts to make sure this attempt exists and isn't finished.
 | ||||
|             return this.quizProvider.getUserAttempts(quiz.id, modOptions).then((attempts) => { | ||||
|                 const lastAttemptId = attempts.length ? attempts[attempts.length - 1].id : undefined; | ||||
|                 let onlineAttempt; | ||||
|         if (!offlineAttempts.length) { | ||||
|             // Nothing to sync, finish.
 | ||||
|             return this.finishSync(siteId, quiz, courseId, warnings); | ||||
|         } | ||||
| 
 | ||||
|                 // Search the attempt we retrieved from offline.
 | ||||
|                 for (const i in attempts) { | ||||
|                     const attempt = attempts[i]; | ||||
|         if (!this.appProvider.isOnline()) { | ||||
|             // Cannot sync in offline.
 | ||||
|             throw new Error(this.translate.instant('core.cannotconnect')); | ||||
|         } | ||||
| 
 | ||||
|                     if (attempt.id == offlineAttempt.id) { | ||||
|                         onlineAttempt = attempt; | ||||
|                         break; | ||||
|                     } | ||||
|                 } | ||||
|         const offlineAttempt = offlineAttempts.pop(); | ||||
| 
 | ||||
|                 if (!onlineAttempt || this.quizProvider.isAttemptFinished(onlineAttempt.state)) { | ||||
|                     // Attempt not found or it's finished in online. Discard it.
 | ||||
|                     warnings.push(this.translate.instant('addon.mod_quiz.warningattemptfinished')); | ||||
|         // Now get the list of online attempts to make sure this attempt exists and isn't finished.
 | ||||
|         const onlineAttempts = await this.quizProvider.getUserAttempts(quiz.id, modOptions); | ||||
| 
 | ||||
|                     return this.finishSync(siteId, quiz, courseId, warnings, offlineAttempt.id, offlineAttempt, onlineAttempt, | ||||
|                             true); | ||||
|                 } | ||||
| 
 | ||||
|                 // Get the data stored in offline.
 | ||||
|                 return this.quizOfflineProvider.getAttemptAnswers(offlineAttempt.id, siteId).then((answersList) => { | ||||
| 
 | ||||
|                     if (!answersList.length) { | ||||
|                         // No answers stored, finish.
 | ||||
|                         return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt, | ||||
|                                 true); | ||||
|                     } | ||||
| 
 | ||||
|                     const answers = this.questionProvider.convertAnswersArrayToObject(answersList), | ||||
|                         offlineQuestions = this.quizOfflineProvider.classifyAnswersInQuestions(answers); | ||||
|                     let finish; | ||||
| 
 | ||||
|                     // We're going to need preflightData, get it.
 | ||||
|                     return this.quizProvider.getQuizAccessInformation(quiz.id, modOptions).then((info) => { | ||||
| 
 | ||||
|                         return this.prefetchHandler.getPreflightData(quiz, info, onlineAttempt, askPreflight, | ||||
|                                 'core.settings.synchronization', siteId); | ||||
|                     }).then((data) => { | ||||
|                         preflightData = data; | ||||
| 
 | ||||
|                         // Now get the online questions data.
 | ||||
|                         const pages = this.quizProvider.getPagesFromLayoutAndQuestions(onlineAttempt.layout, offlineQuestions); | ||||
| 
 | ||||
|                         return this.quizProvider.getAllQuestionsData(quiz, onlineAttempt, preflightData, { | ||||
|                             pages, | ||||
|                             readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, | ||||
|                             siteId, | ||||
|                         }); | ||||
|                     }).then((onlineQuestions) => { | ||||
| 
 | ||||
|                         // Validate questions, discarding the offline answers that can't be synchronized.
 | ||||
|                         return this.validateQuestions(onlineAttempt.id, onlineQuestions, offlineQuestions, siteId); | ||||
|                     }).then((discardedData) => { | ||||
| 
 | ||||
|                         // Get the answers to send.
 | ||||
|                         const answers = this.quizOfflineProvider.extractAnswersFromQuestions(offlineQuestions); | ||||
|                         finish = offlineAttempt.finished && !discardedData; | ||||
| 
 | ||||
|                         if (discardedData) { | ||||
|                             if (offlineAttempt.finished) { | ||||
|                                 warnings.push(this.translate.instant('addon.mod_quiz.warningdatadiscardedfromfinished')); | ||||
|                             } else { | ||||
|                                 warnings.push(this.translate.instant('addon.mod_quiz.warningdatadiscarded')); | ||||
|                             } | ||||
|                         } | ||||
| 
 | ||||
|                         return this.quizProvider.processAttempt(quiz, onlineAttempt, answers, preflightData, finish, false, false, | ||||
|                                 siteId); | ||||
|                     }).then(() => { | ||||
| 
 | ||||
|                         // Answers sent, now set the current page if the attempt isn't finished.
 | ||||
|                         if (!finish) { | ||||
|                             // Don't pass the quiz instance because we don't want to trigger a Firebase event in this case.
 | ||||
|                             return this.quizProvider.logViewAttempt(onlineAttempt.id, offlineAttempt.currentpage, preflightData, | ||||
|                                     false, undefined, siteId).catch(() => { | ||||
|                                 // Ignore errors.
 | ||||
|                             }); | ||||
|                         } | ||||
|                     }).then(() => { | ||||
| 
 | ||||
|                         // Data sent. Finish the sync.
 | ||||
|                         return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt, | ||||
|                                 true, true); | ||||
|                     }); | ||||
|                 }); | ||||
|             }); | ||||
|         const lastAttemptId = onlineAttempts.length ? onlineAttempts[onlineAttempts.length - 1].id : undefined; | ||||
|         const onlineAttempt = onlineAttempts.find((attempt) => { | ||||
|             return attempt.id == offlineAttempt.id; | ||||
|         }); | ||||
| 
 | ||||
|         return this.addOngoingSync(quiz.id, syncPromise, siteId); | ||||
|         if (!onlineAttempt || this.quizProvider.isAttemptFinished(onlineAttempt.state)) { | ||||
|             // Attempt not found or it's finished in online. Discard it.
 | ||||
|             warnings.push(this.translate.instant('addon.mod_quiz.warningattemptfinished')); | ||||
| 
 | ||||
|             return this.finishSync(siteId, quiz, courseId, warnings, { | ||||
|                 attemptId: offlineAttempt.id, | ||||
|                 offlineAttempt, | ||||
|                 onlineAttempt, | ||||
|                 removeAttempt: true, | ||||
|             }); | ||||
|         } | ||||
| 
 | ||||
|         // Get the data stored in offline.
 | ||||
|         const answersList = await this.quizOfflineProvider.getAttemptAnswers(offlineAttempt.id, siteId); | ||||
| 
 | ||||
|         if (!answersList.length) { | ||||
|             // No answers stored, finish.
 | ||||
|             return this.finishSync(siteId, quiz, courseId, warnings, { | ||||
|                 attemptId: lastAttemptId, | ||||
|                 offlineAttempt, | ||||
|                 onlineAttempt, | ||||
|                 removeAttempt: true, | ||||
|             }); | ||||
|         } | ||||
| 
 | ||||
|         const offlineAnswers = this.questionProvider.convertAnswersArrayToObject(answersList); | ||||
|         const offlineQuestions = this.quizOfflineProvider.classifyAnswersInQuestions(offlineAnswers); | ||||
| 
 | ||||
|         // We're going to need preflightData, get it.
 | ||||
|         const info = await this.quizProvider.getQuizAccessInformation(quiz.id, modOptions); | ||||
| 
 | ||||
|         const preflightData = await this.prefetchHandler.getPreflightData(quiz, info, onlineAttempt, askPreflight, | ||||
|                     'core.settings.synchronization', siteId); | ||||
| 
 | ||||
|         // Now get the online questions data.
 | ||||
|         const onlineQuestions = await this.quizProvider.getAllQuestionsData(quiz, onlineAttempt, preflightData, { | ||||
|             pages: this.quizProvider.getPagesFromLayoutAndQuestions(onlineAttempt.layout, offlineQuestions), | ||||
|             readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, | ||||
|             siteId, | ||||
|         }); | ||||
| 
 | ||||
|         // Validate questions, discarding the offline answers that can't be synchronized.
 | ||||
|         const discardedData = await this.validateQuestions(onlineAttempt.id, onlineQuestions, offlineQuestions, siteId); | ||||
| 
 | ||||
|         // Let questions prepare the data to send.
 | ||||
|         await Promise.all(Object.keys(offlineQuestions).map(async (slot) => { | ||||
|             const onlineQuestion = onlineQuestions[slot]; | ||||
| 
 | ||||
|             await this.questionDelegate.prepareSyncData(onlineQuestion, offlineQuestions[slot].answers, | ||||
|                     AddonModQuizProvider.COMPONENT, quiz.coursemodule, siteId); | ||||
|         })); | ||||
| 
 | ||||
|         // Get the answers to send.
 | ||||
|         const answers = this.quizOfflineProvider.extractAnswersFromQuestions(offlineQuestions); | ||||
|         const finish = offlineAttempt.finished && !discardedData; | ||||
| 
 | ||||
|         if (discardedData) { | ||||
|             if (offlineAttempt.finished) { | ||||
|                 warnings.push(this.translate.instant('addon.mod_quiz.warningdatadiscardedfromfinished')); | ||||
|             } else { | ||||
|                 warnings.push(this.translate.instant('addon.mod_quiz.warningdatadiscarded')); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // Send the answers.
 | ||||
|         await this.quizProvider.processAttempt(quiz, onlineAttempt, answers, preflightData, finish, false, false, siteId); | ||||
| 
 | ||||
|         if (!finish) { | ||||
|             // Answers sent, now set the current page.
 | ||||
|             // Don't pass the quiz instance because we don't want to trigger a Firebase event in this case.
 | ||||
|             await CoreUtils.instance.ignoreErrors(this.quizProvider.logViewAttempt(onlineAttempt.id, offlineAttempt.currentpage, | ||||
|                     preflightData, false, undefined, siteId)); | ||||
|         } | ||||
| 
 | ||||
|         // Data sent. Finish the sync.
 | ||||
|         return this.finishSync(siteId, quiz, courseId, warnings, { | ||||
|             attemptId: lastAttemptId, | ||||
|             offlineAttempt, | ||||
|             onlineAttempt, | ||||
|             removeAttempt: true, | ||||
|             updated: true, | ||||
|             onlineQuestions, | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -466,3 +489,15 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider | ||||
|         }); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Options to pass to finish sync. | ||||
|  */ | ||||
| type FinishSyncOptions = { | ||||
|     attemptId?: number; // Last attempt ID.
 | ||||
|     offlineAttempt?: any; // Offline attempt synchronized, if any.
 | ||||
|     onlineAttempt?: any; // Online data for the offline attempt.
 | ||||
|     removeAttempt?: boolean; // Whether the offline data should be removed.
 | ||||
|     updated?: boolean; // Whether the offline data should be removed.
 | ||||
|     onlineQuestions?: any; // Online questions indexed by slot.
 | ||||
| }; | ||||
|  | ||||
| @ -215,6 +215,8 @@ export class AddonModQuizProvider { | ||||
|             }; | ||||
| 
 | ||||
|             return site.read('mod_quiz_get_attempt_data', params, preSets); | ||||
|         }).then((result) => { | ||||
|             return this.parseQuestions(result); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
| @ -389,7 +391,9 @@ export class AddonModQuizProvider { | ||||
|                 ...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
 | ||||
|             }; | ||||
| 
 | ||||
|             return site.read('mod_quiz_get_attempt_review', params, preSets); | ||||
|             return site.read('mod_quiz_get_attempt_review', params, preSets).then((result) => { | ||||
|                 return this.parseQuestions(result); | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
| @ -427,6 +431,8 @@ export class AddonModQuizProvider { | ||||
| 
 | ||||
|             return site.read('mod_quiz_get_attempt_summary', params, preSets).then((response) => { | ||||
|                 if (response && response.questions) { | ||||
|                     response = this.parseQuestions(response); | ||||
| 
 | ||||
|                     if (options.loadLocal) { | ||||
|                         return this.quizOfflineProvider.loadQuestionsLocalStates(attemptId, response.questions, site.getId()); | ||||
|                     } | ||||
| @ -1560,6 +1566,27 @@ export class AddonModQuizProvider { | ||||
|                 siteId); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Parse questions of a WS response. | ||||
|      * | ||||
|      * @param result Result to parse. | ||||
|      * @return Parsed result. | ||||
|      */ | ||||
|     parseQuestions(result: any): any { | ||||
|         for (let i = 0; i < result.questions.length; i++) { | ||||
|             const question = result.questions[i]; | ||||
| 
 | ||||
|             if (!question.settings) { | ||||
|                 // Site doesn't return settings, stop.
 | ||||
|                 break; | ||||
|             } | ||||
| 
 | ||||
|             question.settings = this.textUtils.parseJSON(question.settings, null); | ||||
|         } | ||||
| 
 | ||||
|         return result; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Process an attempt, saving its data. | ||||
|      * | ||||
|  | ||||
| @ -40,13 +40,14 @@ export class AddonQbehaviourDeferredCBMHandler implements CoreQuestionBehaviourH | ||||
|      * @param component Component the question belongs to. | ||||
|      * @param attemptId Attempt ID the question belongs to. | ||||
|      * @param question The question. | ||||
|      * @param componentId Component ID. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return New state (or promise resolved with state). | ||||
|      */ | ||||
|     determineNewState(component: string, attemptId: number, question: any, siteId?: string) | ||||
|     determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string) | ||||
|             : CoreQuestionState | Promise<CoreQuestionState> { | ||||
|         // Depends on deferredfeedback.
 | ||||
|         return this.deferredFeedbackHandler.determineNewStateDeferred(component, attemptId, question, siteId, | ||||
|         return this.deferredFeedbackHandler.determineNewStateDeferred(component, attemptId, question, componentId, siteId, | ||||
|             this.isCompleteResponse.bind(this), this.isSameResponse.bind(this)); | ||||
|     } | ||||
| 
 | ||||
| @ -71,11 +72,13 @@ export class AddonQbehaviourDeferredCBMHandler implements CoreQuestionBehaviourH | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|      */ | ||||
|     protected isCompleteResponse(question: any, answers: any): number { | ||||
|     protected isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         // First check if the question answer is complete.
 | ||||
|         const complete = this.questionDelegate.isCompleteResponse(question, answers); | ||||
|         const complete = this.questionDelegate.isCompleteResponse(question, answers, component, componentId); | ||||
|         if (complete > 0) { | ||||
|             // Answer is complete, check the user answered CBM too.
 | ||||
|             return answers['-certainty'] ? 1 : 0; | ||||
| @ -101,12 +104,14 @@ export class AddonQbehaviourDeferredCBMHandler implements CoreQuestionBehaviourH | ||||
|      * @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). | ||||
|      * @param newAnswers Object with the new question answers. | ||||
|      * @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return Whether they're the same. | ||||
|      */ | ||||
|     protected isSameResponse(question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any, newBasicAnswers: any) | ||||
|             : boolean { | ||||
|     protected isSameResponse(question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any, newBasicAnswers: any, | ||||
|             component: string, componentId: string | number): boolean { | ||||
|         // First check if the question answer is the same.
 | ||||
|         const same = this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers); | ||||
|         const same = this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers, component, componentId); | ||||
|         if (same) { | ||||
|             // Same response, check the CBM is the same too.
 | ||||
|             return prevAnswers['-certainty'] == newAnswers['-certainty']; | ||||
|  | ||||
| @ -23,9 +23,11 @@ import { CoreQuestionProvider, CoreQuestionState } from '@core/question/provider | ||||
|  * | ||||
|  * @param question The question. | ||||
|  * @param answers Object with the question answers (without prefix). | ||||
|  * @param component The component the question is related to. | ||||
|  * @param componentId Component ID. | ||||
|  * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|  */ | ||||
| export type isCompleteResponseFunction = (question: any, answers: any) => number; | ||||
| export type isCompleteResponseFunction = (question: any, answers: any, component: string, componentId: string | number) => number; | ||||
| 
 | ||||
| /** | ||||
|  * Check if two responses are the same. | ||||
| @ -35,10 +37,12 @@ export type isCompleteResponseFunction = (question: any, answers: any) => number | ||||
|  * @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). | ||||
|  * @param newAnswers Object with the new question answers. | ||||
|  * @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). | ||||
|  * @param component The component the question is related to. | ||||
|  * @param componentId Component ID. | ||||
|  * @return Whether they're the same. | ||||
|  */ | ||||
| export type isSameResponseFunction = (question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any, | ||||
|         newBasicAnswers: any) => boolean; | ||||
|         newBasicAnswers: any, component: string, componentId: string | number) => boolean; | ||||
| 
 | ||||
| /** | ||||
|  * Handler to support deferred feedback question behaviour. | ||||
| @ -58,12 +62,13 @@ export class AddonQbehaviourDeferredFeedbackHandler implements CoreQuestionBehav | ||||
|      * @param component Component the question belongs to. | ||||
|      * @param attemptId Attempt ID the question belongs to. | ||||
|      * @param question The question. | ||||
|      * @param componentId Component ID. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return New state (or promise resolved with state). | ||||
|      */ | ||||
|     determineNewState(component: string, attemptId: number, question: any, siteId?: string) | ||||
|     determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string) | ||||
|             : CoreQuestionState | Promise<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 attemptId Attempt ID the question belongs to. | ||||
|      * @param question The question. | ||||
|      * @param componentId Component ID. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @param isCompleteFn Function to override the default isCompleteResponse check. | ||||
|      * @param isSameFn Function to override the default isSameResponse check. | ||||
|      * @return Promise resolved with state. | ||||
|      */ | ||||
|     determineNewStateDeferred(component: string, attemptId: number, question: any, siteId?: string, | ||||
|             isCompleteFn?: isCompleteResponseFunction, isSameFn?: isSameResponseFunction): Promise<CoreQuestionState> { | ||||
|     async determineNewStateDeferred(component: string, attemptId: number, question: any, componentId: string | number, | ||||
|             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(() => { | ||||
|         let dbQuestion; | ||||
|         try { | ||||
|             dbQuestion = await this.questionProvider.getQuestion(component, attemptId, question.slot, siteId); | ||||
|         } catch (error) { | ||||
|             // No entry found, use the original data.
 | ||||
|             return question; | ||||
|         }).then((dbQuestion) => { | ||||
|             const state = this.questionProvider.getState(dbQuestion.state); | ||||
|             dbQuestion = question; | ||||
|         } | ||||
| 
 | ||||
|             if (state.finished || !state.active) { | ||||
|                 // Question is finished, it cannot change.
 | ||||
|                 return state; | ||||
|         const state = this.questionProvider.getState(dbQuestion.state); | ||||
| 
 | ||||
|         if (state.finished || !state.active) { | ||||
|             // Question is finished, it cannot change.
 | ||||
|             return state; | ||||
|         } | ||||
| 
 | ||||
|         const newBasicAnswers = this.questionProvider.getBasicAnswers(question.answers); | ||||
| 
 | ||||
|         if (dbQuestion.state) { | ||||
|             // Question already has a state stored. Check if answer has changed.
 | ||||
|             let prevAnswers = await this.questionProvider.getQuestionAnswers(component, attemptId, question.slot, false, siteId); | ||||
| 
 | ||||
|             prevAnswers = this.questionProvider.convertAnswersArrayToObject(prevAnswers, true); | ||||
|             const prevBasicAnswers = this.questionProvider.getBasicAnswers(prevAnswers); | ||||
| 
 | ||||
|             // If answers haven't changed the state is the same.
 | ||||
|             if (isSameFn) { | ||||
|                 if (isSameFn(question, prevAnswers, prevBasicAnswers, question.answers, newBasicAnswers, | ||||
|                         component, componentId)) { | ||||
|                     return state; | ||||
|                 } | ||||
|             } else { | ||||
|                 if (this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers, component, componentId)) { | ||||
|                     return state; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|             // We need to check if the answers have changed. Retrieve current stored answers.
 | ||||
|             return this.questionProvider.getQuestionAnswers(component, attemptId, question.slot, false, siteId) | ||||
|                     .then((prevAnswers) => { | ||||
|         // Answers have changed. Now check if the response is complete and calculate the new state.
 | ||||
|         let complete: number; | ||||
|         let newState: string; | ||||
| 
 | ||||
|                 const newBasicAnswers = this.questionProvider.getBasicAnswers(question.answers); | ||||
|         if (isCompleteFn) { | ||||
|             // Pass all the answers since some behaviours might need the extra data.
 | ||||
|             complete = isCompleteFn(question, question.answers, component, componentId); | ||||
|         } else { | ||||
|             // Only pass the basic answers since questions should be independent of extra data.
 | ||||
|             complete = this.questionDelegate.isCompleteResponse(question, newBasicAnswers, component, componentId); | ||||
|         } | ||||
| 
 | ||||
|                 prevAnswers = this.questionProvider.convertAnswersArrayToObject(prevAnswers, true); | ||||
|                 const prevBasicAnswers = this.questionProvider.getBasicAnswers(prevAnswers); | ||||
|         if (complete < 0) { | ||||
|             newState = 'cannotdeterminestatus'; | ||||
|         } else if (complete > 0) { | ||||
|             newState = 'complete'; | ||||
|         } else { | ||||
|             const gradable = this.questionDelegate.isGradableResponse(question, newBasicAnswers, component, componentId); | ||||
|             if (gradable < 0) { | ||||
|                 newState = 'cannotdeterminestatus'; | ||||
|             } else if (gradable > 0) { | ||||
|                 newState = 'invalid'; | ||||
|             } else { | ||||
|                 newState = 'todo'; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|                 // If answers haven't changed the state is the same.
 | ||||
|                 if (isSameFn) { | ||||
|                     if (isSameFn(question, prevAnswers, prevBasicAnswers, question.answers, newBasicAnswers)) { | ||||
|                         return state; | ||||
|                     } | ||||
|                 } else { | ||||
|                     if (this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers)) { | ||||
|                         return state; | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 // Answers have changed. Now check if the response is complete and calculate the new state.
 | ||||
|                 let complete: number, | ||||
|                     newState: string; | ||||
|                 if (isCompleteFn) { | ||||
|                     // Pass all the answers since some behaviours might need the extra data.
 | ||||
|                     complete = isCompleteFn(question, question.answers); | ||||
|                 } else { | ||||
|                     // Only pass the basic answers since questions should be independent of extra data.
 | ||||
|                     complete = this.questionDelegate.isCompleteResponse(question, newBasicAnswers); | ||||
|                 } | ||||
| 
 | ||||
|                 if (complete < 0) { | ||||
|                     newState = 'cannotdeterminestatus'; | ||||
|                 } else if (complete > 0) { | ||||
|                     newState = 'complete'; | ||||
|                 } else { | ||||
|                     const gradable = this.questionDelegate.isGradableResponse(question, newBasicAnswers); | ||||
|                     if (gradable < 0) { | ||||
|                         newState = 'cannotdeterminestatus'; | ||||
|                     } else if (gradable > 0) { | ||||
|                         newState = 'invalid'; | ||||
|                     } else { | ||||
|                         newState = 'todo'; | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 return this.questionProvider.getState(newState); | ||||
|             }); | ||||
|         }); | ||||
|         return this.questionProvider.getState(newState); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
| @ -35,10 +35,11 @@ export class AddonQbehaviourInformationItemHandler implements CoreQuestionBehavi | ||||
|      * @param component Component the question belongs to. | ||||
|      * @param attemptId Attempt ID the question belongs to. | ||||
|      * @param question The question. | ||||
|      * @param componentId Component ID. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return New state (or promise resolved with state). | ||||
|      */ | ||||
|     determineNewState(component: string, attemptId: number, question: any, siteId?: string) | ||||
|     determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string) | ||||
|             : CoreQuestionState | Promise<CoreQuestionState> { | ||||
|         if (question.answers['-seen']) { | ||||
|             return this.questionProvider.getState('complete'); | ||||
|  | ||||
| @ -17,28 +17,7 @@ import { Injectable } from '@angular/core'; | ||||
| import { CoreQuestionBehaviourHandler } from '@core/question/providers/behaviour-delegate'; | ||||
| import { CoreQuestionDelegate } from '@core/question/providers/delegate'; | ||||
| import { CoreQuestionProvider, CoreQuestionState } from '@core/question/providers/question'; | ||||
| 
 | ||||
| /** | ||||
|  * Check if a response is complete. | ||||
|  * | ||||
|  * @param question The question. | ||||
|  * @param answers Object with the question answers (without prefix). | ||||
|  * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|  */ | ||||
| export type isCompleteResponseFunction = (question: any, answers: any) => number; | ||||
| 
 | ||||
| /** | ||||
|  * Check if two responses are the same. | ||||
|  * | ||||
|  * @param question Question. | ||||
|  * @param prevAnswers Object with the previous question answers. | ||||
|  * @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). | ||||
|  * @param newAnswers Object with the new question answers. | ||||
|  * @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). | ||||
|  * @return Whether they're the same. | ||||
|  */ | ||||
| export type isSameResponseFunction = (question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any, | ||||
|         newBasicAnswers: any) => boolean; | ||||
| import { AddonQbehaviourDeferredFeedbackHandler } from '@addon/qbehaviour/deferredfeedback/providers/handler'; | ||||
| 
 | ||||
| /** | ||||
|  * Handler to support manual graded question behaviour. | ||||
| @ -48,7 +27,9 @@ export class AddonQbehaviourManualGradedHandler implements CoreQuestionBehaviour | ||||
|     name = 'AddonQbehaviourManualGraded'; | ||||
|     type = 'manualgraded'; | ||||
| 
 | ||||
|     constructor(private questionDelegate: CoreQuestionDelegate, private questionProvider: CoreQuestionProvider) { | ||||
|     constructor(protected questionDelegate: CoreQuestionDelegate, | ||||
|             protected questionProvider: CoreQuestionProvider, | ||||
|             protected deferredFeedbackHandler: AddonQbehaviourDeferredFeedbackHandler) { | ||||
|         // Nothing to do.
 | ||||
|     } | ||||
| 
 | ||||
| @ -58,82 +39,14 @@ export class AddonQbehaviourManualGradedHandler implements CoreQuestionBehaviour | ||||
|      * @param component Component the question belongs to. | ||||
|      * @param attemptId Attempt ID the question belongs to. | ||||
|      * @param question The question. | ||||
|      * @param componentId Component ID. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return New state (or promise resolved with state). | ||||
|      */ | ||||
|     determineNewState(component: string, attemptId: number, question: any, siteId?: string) | ||||
|     determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string) | ||||
|             : CoreQuestionState | Promise<CoreQuestionState> { | ||||
|         return this.determineNewStateManualGraded(component, attemptId, question, siteId); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Determine a question new state based on its answer(s) for manual graded question behaviour. | ||||
|      * | ||||
|      * @param component Component the question belongs to. | ||||
|      * @param attemptId Attempt ID the question belongs to. | ||||
|      * @param question The question. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @param isCompleteFn Function to override the default isCompleteResponse check. | ||||
|      * @param isSameFn Function to override the default isSameResponse check. | ||||
|      * @return Promise resolved with state. | ||||
|      */ | ||||
|     determineNewStateManualGraded(component: string, attemptId: number, question: any, siteId?: string, | ||||
|             isCompleteFn?: isCompleteResponseFunction, isSameFn?: isSameResponseFunction): Promise<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); | ||||
|             }); | ||||
|         }); | ||||
|         // Same implementation as the deferred feedback. Use that function instead of replicating it.
 | ||||
|         return this.deferredFeedbackHandler.determineNewStateDeferred(component, attemptId, question, componentId, siteId); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
| @ -24,6 +24,14 @@ import { AddonQtypeCalculatedComponent } from '../component/calculated'; | ||||
|  */ | ||||
| @Injectable() | ||||
| export class AddonQtypeCalculatedHandler implements CoreQuestionHandler { | ||||
|     static UNITINPUT = '0'; | ||||
|     static UNITRADIO = '1'; | ||||
|     static UNITSELECT = '2'; | ||||
|     static UNITNONE = '3'; | ||||
| 
 | ||||
|     static UNITGRADED = '1'; | ||||
|     static UNITOPTIONAL = '0'; | ||||
| 
 | ||||
|     name = 'AddonQtypeCalculated'; | ||||
|     type = 'qtype_calculated'; | ||||
| 
 | ||||
| @ -41,23 +49,69 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler { | ||||
|         return AddonQtypeCalculatedComponent; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check if the units are in a separate field for the question. | ||||
|      * | ||||
|      * @param question Question. | ||||
|      * @return Whether units are in a separate field. | ||||
|      */ | ||||
|     hasSeparateUnitField(question: any): boolean { | ||||
|         if (!question.settings) { | ||||
|             const element = this.domUtils.convertToElement(question.html); | ||||
| 
 | ||||
|             return !!(element.querySelector('select[name*=unit]') || element.querySelector('input[type="radio"]')); | ||||
|         } | ||||
| 
 | ||||
|         return question.settings.unitdisplay === AddonQtypeCalculatedHandler.UNITRADIO || | ||||
|                 question.settings.unitdisplay === AddonQtypeCalculatedHandler.UNITSELECT; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check if a response is complete. | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|      */ | ||||
|     isCompleteResponse(question: any, answers: any): number { | ||||
|         if (this.isGradableResponse(question, answers) === 0 || !this.validateUnits(answers['answer'])) { | ||||
|     isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         if (!this.isGradableResponse(question, answers, component, componentId)) { | ||||
|             return 0; | ||||
|         } | ||||
| 
 | ||||
|         if (this.requiresUnits(question)) { | ||||
|             return this.isValidValue(answers['unit']) ? 1 : 0; | ||||
|         const parsedAnswer = this.parseAnswer(question, answers['answer']); | ||||
|         if (parsedAnswer.answer === null) { | ||||
|             return 0; | ||||
|         } | ||||
| 
 | ||||
|         return -1; | ||||
|         if (!question.settings) { | ||||
|             if (this.hasSeparateUnitField(question)) { | ||||
|                 return this.isValidValue(answers['unit']) ? 1 : 0; | ||||
|             } | ||||
| 
 | ||||
|             // We cannot know if the answer should contain units or not.
 | ||||
|             return -1; | ||||
|         } | ||||
| 
 | ||||
|         if (question.settings.unitdisplay != AddonQtypeCalculatedHandler.UNITINPUT && parsedAnswer.unit) { | ||||
|             // There should be no units or be outside of the input, not valid.
 | ||||
|             return 0; | ||||
|         } | ||||
| 
 | ||||
|         if (this.hasSeparateUnitField(question) && !this.isValidValue(answers['unit'])) { | ||||
|             // Unit not supplied as a separate field and it's required.
 | ||||
|             return 0; | ||||
|         } | ||||
| 
 | ||||
|         if (question.settings.unitdisplay == AddonQtypeCalculatedHandler.UNITINPUT && | ||||
|                 question.settings.unitgradingtype == AddonQtypeCalculatedHandler.UNITGRADED && | ||||
|                 !this.isValidValue(parsedAnswer.unit)) { | ||||
|             // Unit not supplied inside the input and it's required.
 | ||||
|             return 0; | ||||
|         } | ||||
| 
 | ||||
|         return 1; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -75,16 +129,12 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. | ||||
|      */ | ||||
|     isGradableResponse(question: any, answers: any): number { | ||||
|         let isGradable = this.isValidValue(answers['answer']); | ||||
|         if (isGradable && this.requiresUnits(question)) { | ||||
|             // The question requires a unit.
 | ||||
|             isGradable = this.isValidValue(answers['unit']); | ||||
|         } | ||||
| 
 | ||||
|         return isGradable ? 1 : 0; | ||||
|     isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         return this.isValidValue(answers['answer']) ? 1 : 0; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -93,9 +143,11 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler { | ||||
|      * @param question Question. | ||||
|      * @param prevAnswers Object with the previous question answers. | ||||
|      * @param newAnswers Object with the new question answers. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return Whether they're the same. | ||||
|      */ | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { | ||||
|         return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') && | ||||
|             this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'unit'); | ||||
|     } | ||||
| @ -111,36 +163,24 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler { | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check if a question requires units in a separate input. | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @return Whether the question requires units. | ||||
|      */ | ||||
|     requiresUnits(question: any): boolean { | ||||
|         const element = this.domUtils.convertToElement(question.html); | ||||
| 
 | ||||
|         return !!(element.querySelector('select[name*=unit]') || element.querySelector('input[type="radio"]')); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Validate a number with units. We don't have the list of valid units and conversions, so we can't perform | ||||
|      * a full validation. If this function returns true it means we can't be sure it's valid. | ||||
|      * Parse an answer string. | ||||
|      * | ||||
|      * @param question Question. | ||||
|      * @param answer Answer. | ||||
|      * @return False if answer isn't valid, true if we aren't sure if it's valid. | ||||
|      * @return 0 if answer isn't valid, 1 if answer is valid, -1 if we aren't sure if it's valid. | ||||
|      */ | ||||
|     validateUnits(answer: string): boolean { | ||||
|     parseAnswer(question: any, answer: string): {answer: number, unit: string} { | ||||
|         if (!answer) { | ||||
|             return false; | ||||
|             return {answer: null, unit: null}; | ||||
|         } | ||||
| 
 | ||||
|         const regexString = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?'; | ||||
|         let regexString = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?'; | ||||
| 
 | ||||
|         // Strip spaces (which may be thousands separators) and change other forms of writing e to e.
 | ||||
|         answer = answer.replace(' ', ''); | ||||
|         answer = answer.replace(/ /g, ''); | ||||
|         answer = answer.replace(/(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)/, 'e$1'); | ||||
| 
 | ||||
|         // If a '.' is present or there are multiple ',' (i.e. 2,456,789) assume ',' is a thousands separator and stip it.
 | ||||
|         // If a '.' is present or there are multiple ',' (i.e. 2,456,789) assume ',' is a thousands separator and strip it.
 | ||||
|         // Else assume it is a decimal separator, and change it to '.'.
 | ||||
|         if (answer.indexOf('.') != -1 || answer.split(',').length - 1 > 1) { | ||||
|             answer = answer.replace(',', ''); | ||||
| @ -148,11 +188,31 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler { | ||||
|             answer = answer.replace(',', '.'); | ||||
|         } | ||||
| 
 | ||||
|         // We don't know if units should be before or after so we check both.
 | ||||
|         if (answer.match(new RegExp('^' + regexString)) === null || answer.match(new RegExp(regexString + '$')) === null) { | ||||
|             return false; | ||||
|         let unitsLeft = false; | ||||
|         let match = null; | ||||
| 
 | ||||
|         if (!question.settings) { | ||||
|             // We don't know if units should be before or after so we check both.
 | ||||
|             match = answer.match(new RegExp('^' + regexString)); | ||||
|             if (!match) { | ||||
|                 unitsLeft = true; | ||||
|                 match = answer.match(new RegExp(regexString + '$')); | ||||
|             } | ||||
|         } else { | ||||
|             unitsLeft = question.settings.unitsleft == '1'; | ||||
|             regexString = unitsLeft ? regexString + '$' : '^' + regexString; | ||||
| 
 | ||||
|             match = answer.match(new RegExp(regexString)); | ||||
|         } | ||||
| 
 | ||||
|         return true; | ||||
|         if (!match) { | ||||
|             return {answer: null, unit: null}; | ||||
|         } | ||||
| 
 | ||||
|         const numberString = match[0]; | ||||
|         const unit = unitsLeft ? answer.substr(0, answer.length - match[0].length) : answer.substr(match[0].length); | ||||
| 
 | ||||
|         // No need to calculate the multiplier.
 | ||||
|         return {answer: Number(numberString), unit: unit}; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -46,9 +46,11 @@ export class AddonQtypeCalculatedMultiHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|      */ | ||||
|     isCompleteResponse(question: any, answers: any): number { | ||||
|     isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         // This question type depends on multichoice.
 | ||||
|         return this.multichoiceHandler.isCompleteResponseSingle(answers); | ||||
|     } | ||||
| @ -68,9 +70,11 @@ export class AddonQtypeCalculatedMultiHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. | ||||
|      */ | ||||
|     isGradableResponse(question: any, answers: any): number { | ||||
|     isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         // This question type depends on multichoice.
 | ||||
|         return this.multichoiceHandler.isGradableResponseSingle(answers); | ||||
|     } | ||||
| @ -81,9 +85,11 @@ export class AddonQtypeCalculatedMultiHandler implements CoreQuestionHandler { | ||||
|      * @param question Question. | ||||
|      * @param prevAnswers Object with the previous question answers. | ||||
|      * @param newAnswers Object with the new question answers. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return Whether they're the same. | ||||
|      */ | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { | ||||
|         // This question type depends on multichoice.
 | ||||
|         return this.multichoiceHandler.isSameResponseSingle(prevAnswers, newAnswers); | ||||
|     } | ||||
|  | ||||
| @ -46,11 +46,13 @@ export class AddonQtypeCalculatedSimpleHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|      */ | ||||
|     isCompleteResponse(question: any, answers: any): number { | ||||
|     isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         // This question type depends on calculated.
 | ||||
|         return this.calculatedHandler.isCompleteResponse(question, answers); | ||||
|         return this.calculatedHandler.isCompleteResponse(question, answers, component, componentId); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -68,11 +70,13 @@ export class AddonQtypeCalculatedSimpleHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. | ||||
|      */ | ||||
|     isGradableResponse(question: any, answers: any): number { | ||||
|     isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         // This question type depends on calculated.
 | ||||
|         return this.calculatedHandler.isGradableResponse(question, answers); | ||||
|         return this.calculatedHandler.isGradableResponse(question, answers, component, componentId); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -81,10 +85,12 @@ export class AddonQtypeCalculatedSimpleHandler implements CoreQuestionHandler { | ||||
|      * @param question Question. | ||||
|      * @param prevAnswers Object with the previous question answers. | ||||
|      * @param newAnswers Object with the new question answers. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return Whether they're the same. | ||||
|      */ | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { | ||||
|         // This question type depends on calculated.
 | ||||
|         return this.calculatedHandler.isSameResponse(question, prevAnswers, newAnswers); | ||||
|         return this.calculatedHandler.isSameResponse(question, prevAnswers, newAnswers, component, componentId); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -61,9 +61,11 @@ export class AddonQtypeDdImageOrTextHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|      */ | ||||
|     isCompleteResponse(question: any, answers: any): number { | ||||
|     isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         // An answer is complete if all drop zones have an answer.
 | ||||
|         // We should always receive all the drop zones with their value ('' if not answered).
 | ||||
|         for (const name in answers) { | ||||
| @ -91,9 +93,11 @@ export class AddonQtypeDdImageOrTextHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. | ||||
|      */ | ||||
|     isGradableResponse(question: any, answers: any): number { | ||||
|     isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         for (const name in answers) { | ||||
|             const value = answers[name]; | ||||
|             if (value && value !== '0') { | ||||
| @ -110,9 +114,11 @@ export class AddonQtypeDdImageOrTextHandler implements CoreQuestionHandler { | ||||
|      * @param question Question. | ||||
|      * @param prevAnswers Object with the previous question answers. | ||||
|      * @param newAnswers Object with the new question answers. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return Whether they're the same. | ||||
|      */ | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { | ||||
|         return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -62,9 +62,11 @@ export class AddonQtypeDdMarkerHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|      */ | ||||
|     isCompleteResponse(question: any, answers: any): number { | ||||
|     isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         // If 1 dragitem is set we assume the answer is complete (like Moodle does).
 | ||||
|         for (const name in answers) { | ||||
|             if (answers[name]) { | ||||
| @ -90,10 +92,12 @@ export class AddonQtypeDdMarkerHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. | ||||
|      */ | ||||
|     isGradableResponse(question: any, answers: any): number { | ||||
|         return this.isCompleteResponse(question, answers); | ||||
|     isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         return this.isCompleteResponse(question, answers, component, componentId); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -102,9 +106,11 @@ export class AddonQtypeDdMarkerHandler implements CoreQuestionHandler { | ||||
|      * @param question Question. | ||||
|      * @param prevAnswers Object with the previous question answers. | ||||
|      * @param newAnswers Object with the new question answers. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return Whether they're the same. | ||||
|      */ | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { | ||||
|         return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -61,9 +61,11 @@ export class AddonQtypeDdwtosHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|      */ | ||||
|     isCompleteResponse(question: any, answers: any): number { | ||||
|     isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         for (const name in answers) { | ||||
|             const value = answers[name]; | ||||
|             if (!value || value === '0') { | ||||
| @ -89,9 +91,11 @@ export class AddonQtypeDdwtosHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. | ||||
|      */ | ||||
|     isGradableResponse(question: any, answers: any): number { | ||||
|     isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         for (const name in answers) { | ||||
|             const value = answers[name]; | ||||
|             if (value && value !== '0') { | ||||
| @ -108,9 +112,11 @@ export class AddonQtypeDdwtosHandler implements CoreQuestionHandler { | ||||
|      * @param question Question. | ||||
|      * @param prevAnswers Object with the previous question answers. | ||||
|      * @param newAnswers Object with the new question answers. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return Whether they're the same. | ||||
|      */ | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { | ||||
|         return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -5,9 +5,10 @@ | ||||
|     </ion-item> | ||||
| 
 | ||||
|     <!-- Textarea. --> | ||||
|     <ion-item *ngIf="question.textarea && !question.hasDraftFiles"> | ||||
|         <!-- "Format" hidden input --> | ||||
|     <ion-item *ngIf="question.textarea && (!question.hasDraftFiles || uploadFilesSupported)"> | ||||
|         <!-- "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.answerDraftIdInput" type="hidden" [name]="question.answerDraftIdInput.name" [value]="question.answerDraftIdInput.value" > | ||||
|         <!-- 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> | ||||
|         <!-- Rich text editor. --> | ||||
| @ -15,22 +16,29 @@ | ||||
|     </ion-item> | ||||
| 
 | ||||
|     <!-- 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"> | ||||
|             <p class="core-question-warning">{{ 'core.question.errorinlinefilesnotsupported' | translate }}</p> | ||||
|             <p class="core-question-warning">{{ 'core.question.errorinlinefilesnotsupportedinsite' | translate }}</p> | ||||
|         </ion-item> | ||||
|         <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> | ||||
|         </ion-item> | ||||
|     </ng-container> | ||||
| 
 | ||||
|     <!-- Attachments not supported in the app yet. --> | ||||
|     <ion-item text-wrap *ngIf="question.allowsAttachments" class="core-danger-item"> | ||||
|         <p class="core-question-warning">{{ 'core.question.errorattachmentsnotsupported' | translate }}</p> | ||||
|     </ion-item> | ||||
|     <!-- Attachments. --> | ||||
|     <ng-container *ngIf="question.allowsAttachments"> | ||||
|         <core-attachments *ngIf="uploadFilesSupported" [files]="attachments" [component]="component" [componentId]="componentId" [maxSize]="question.attachmentsMaxBytes" [maxSubmissions]="question.attachmentsMaxFiles" [allowOffline]="offlineEnabled" [acceptedTypes]="question.attachmentsAcceptedTypes"></core-attachments> | ||||
| 
 | ||||
|         <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). --> | ||||
|     <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> | ||||
|     </ion-item> | ||||
| 
 | ||||
|  | ||||
| @ -14,8 +14,12 @@ | ||||
| 
 | ||||
| import { Component, OnInit, Injector } from '@angular/core'; | ||||
| import { CoreLoggerProvider } from '@providers/logger'; | ||||
| import { CoreWSExternalFile } from '@providers/ws'; | ||||
| import { CoreFileUploader } from '@core/fileuploader/providers/fileuploader'; | ||||
| import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component'; | ||||
| import { CoreQuestion } from '@core/question/providers/question'; | ||||
| import { FormControl, FormBuilder } from '@angular/forms'; | ||||
| import { CoreFileSession } from '@providers/file-session'; | ||||
| 
 | ||||
| /** | ||||
|  * Component to render an essay question. | ||||
| @ -28,6 +32,9 @@ export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implemen | ||||
| 
 | ||||
|     protected formControl: FormControl; | ||||
| 
 | ||||
|     attachments: CoreWSExternalFile[]; | ||||
|     uploadFilesSupported: boolean; | ||||
| 
 | ||||
|     constructor(logger: CoreLoggerProvider, injector: Injector, protected fb: FormBuilder) { | ||||
|         super(logger, 'AddonQtypeEssayComponent', injector); | ||||
|     } | ||||
| @ -36,8 +43,39 @@ export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implemen | ||||
|      * Component being initialized. | ||||
|      */ | ||||
|     ngOnInit(): void { | ||||
|         this.uploadFilesSupported = typeof this.question.responsefileareas != 'undefined'; | ||||
|         this.initEssayComponent(); | ||||
| 
 | ||||
|         this.formControl = this.fb.control(this.question.textarea && this.question.textarea.text); | ||||
| 
 | ||||
|         if (this.question.allowsAttachments && this.uploadFilesSupported) { | ||||
|             this.loadAttachments(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load attachments. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async loadAttachments(): Promise<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.
 | ||||
| 
 | ||||
| import { Injectable, Injector } from '@angular/core'; | ||||
| import { CoreFileSession } from '@providers/file-session'; | ||||
| import { CoreSites } from '@providers/sites'; | ||||
| import { CoreTextUtilsProvider } from '@providers/utils/text'; | ||||
| import { CoreDomUtilsProvider } from '@providers/utils/dom'; | ||||
| import { CoreUtilsProvider } from '@providers/utils/utils'; | ||||
| import { CoreFileUploader } from '@core/fileuploader/providers/fileuploader'; | ||||
| import { CoreQuestionHandler } from '@core/question/providers/delegate'; | ||||
| import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; | ||||
| import { CoreQuestion } from '@core/question/providers/question'; | ||||
| import { AddonQtypeEssayComponent } from '../component/essay'; | ||||
| 
 | ||||
| /** | ||||
| @ -32,6 +36,59 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler { | ||||
|     constructor(private utils: CoreUtilsProvider, private questionHelper: CoreQuestionHelperProvider, | ||||
|             private textUtils: CoreTextUtilsProvider, private domUtils: CoreDomUtilsProvider) { } | ||||
| 
 | ||||
|     /** | ||||
|      * Clear temporary data after the data has been saved. | ||||
|      * | ||||
|      * @param question Question. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      */ | ||||
|     clearTmpData(question: any, component: string, componentId: string | number): void { | ||||
|         const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); | ||||
|         const files = CoreFileSession.instance.getFiles(component, questionComponentId); | ||||
| 
 | ||||
|         // Clear the files in session for this question.
 | ||||
|         CoreFileSession.instance.clearFiles(component, questionComponentId); | ||||
| 
 | ||||
|         // Now delete the local files from the tmp folder.
 | ||||
|         CoreFileUploader.instance.clearTmpFiles(files); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Delete any stored data for the question. | ||||
|      * | ||||
|      * @param question Question. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     deleteOfflineData(question: any, component: string, componentId: string | number, siteId?: string): Promise<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. | ||||
|      * If the question should use the default behaviour you shouldn't implement this function. | ||||
| @ -65,14 +122,15 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler { | ||||
|      */ | ||||
|     getPreventSubmitMessage(question: any): string { | ||||
|         const element = this.domUtils.convertToElement(question.html); | ||||
|         const uploadFilesSupported = typeof question.responsefileareas != 'undefined'; | ||||
| 
 | ||||
|         if (element.querySelector('div[id*=filemanager]')) { | ||||
|         if (!uploadFilesSupported && element.querySelector('div[id*=filemanager]')) { | ||||
|             // The question allows attachments. Since the app cannot attach files yet we will prevent submitting the question.
 | ||||
|             return 'core.question.errorattachmentsnotsupported'; | ||||
|             return 'core.question.errorattachmentsnotsupportedinsite'; | ||||
|         } | ||||
| 
 | ||||
|         if (this.questionHelper.hasDraftFileUrls(element.innerHTML)) { | ||||
|             return 'core.question.errorinlinefilesnotsupported'; | ||||
|         if (!uploadFilesSupported && this.questionHelper.hasDraftFileUrls(element.innerHTML)) { | ||||
|             return 'core.question.errorinlinefilesnotsupportedinsite'; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| @ -81,20 +139,34 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|      */ | ||||
|     isCompleteResponse(question: any, answers: any): number { | ||||
|         const element = this.domUtils.convertToElement(question.html); | ||||
|     isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
| 
 | ||||
|         const hasInlineText = answers['answer'] && answers['answer'] !== '', | ||||
|             allowsAttachments = !!element.querySelector('div[id*=filemanager]'); | ||||
|         const hasTextAnswer = answers['answer'] && answers['answer'] !== ''; | ||||
|         const uploadFilesSupported = typeof question.responsefileareas != 'undefined'; | ||||
|         const allowedOptions = this.getAllowedOptions(question); | ||||
| 
 | ||||
|         if (!allowsAttachments) { | ||||
|             return hasInlineText ? 1 : 0; | ||||
|         if (!allowedOptions.attachments) { | ||||
|             return hasTextAnswer ? 1 : 0; | ||||
|         } | ||||
| 
 | ||||
|         // We can't know if the attachments are required or if the user added any in web.
 | ||||
|         return -1; | ||||
|         if (!uploadFilesSupported) { | ||||
|             // We can't know if the attachments are required or if the user added any in web.
 | ||||
|             return -1; | ||||
|         } | ||||
| 
 | ||||
|         const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); | ||||
|         const attachments = CoreFileSession.instance.getFiles(component, questionComponentId); | ||||
| 
 | ||||
|         if (!allowedOptions.text) { | ||||
|             return attachments && attachments.length >= Number(question.settings.attachmentsrequired) ? 1 : 0; | ||||
|         } | ||||
| 
 | ||||
|         return (hasTextAnswer || question.settings.responserequired == '0') && | ||||
|                 (attachments && attachments.length > Number(question.settings.attachmentsrequired)) ? 1 : 0; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -112,10 +184,20 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. | ||||
|      */ | ||||
|     isGradableResponse(question: any, answers: any): number { | ||||
|         return 0; | ||||
|     isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         if (typeof question.responsefileareas == 'undefined') { | ||||
|             return -1; | ||||
|         } | ||||
| 
 | ||||
|         const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); | ||||
|         const attachments = CoreFileSession.instance.getFiles(component, questionComponentId); | ||||
| 
 | ||||
|         // Determine if the given response has online text or attachments.
 | ||||
|         return (answers['answer'] && answers['answer'] !== '') || (attachments && attachments.length > 0) ? 1 : 0; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -124,30 +206,165 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler { | ||||
|      * @param question Question. | ||||
|      * @param prevAnswers Object with the previous question answers. | ||||
|      * @param newAnswers Object with the new question answers. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return Whether they're the same. | ||||
|      */ | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { | ||||
|         return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer'); | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { | ||||
|         const uploadFilesSupported = typeof question.responsefileareas != 'undefined'; | ||||
|         const allowedOptions = this.getAllowedOptions(question); | ||||
| 
 | ||||
|         // First check the inline text.
 | ||||
|         const answerIsEqual = allowedOptions.text ? this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') : true; | ||||
| 
 | ||||
|         if (!allowedOptions.attachments || !uploadFilesSupported || !answerIsEqual) { | ||||
|             // No need to check attachments.
 | ||||
|             return answerIsEqual; | ||||
|         } | ||||
| 
 | ||||
|         // Check attachments now.
 | ||||
|         const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); | ||||
|         const attachments = CoreFileSession.instance.getFiles(component, questionComponentId); | ||||
|         const originalAttachments = this.questionHelper.getResponseFileAreaFiles(question, 'attachments'); | ||||
| 
 | ||||
|         return !CoreFileUploader.instance.areFileListDifferent(attachments, originalAttachments); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Prepare and add to answers the data to send to server based in the input. Return promise if async. | ||||
|      * Prepare and add to answers the data to send to server based in the input. | ||||
|      * | ||||
|      * @param question Question. | ||||
|      * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. | ||||
|      * @param offline Whether the data should be saved in offline. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return Return a promise resolved when done if async, void if sync. | ||||
|      */ | ||||
|     prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): void | Promise<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 attachmentsInput = <HTMLInputElement> element.querySelector('.attachments input[name*=_attachments]'); | ||||
| 
 | ||||
|         // Search the textarea to get its name.
 | ||||
|         const textarea = <HTMLTextAreaElement> element.querySelector('textarea[name*=_answer]'); | ||||
| 
 | ||||
|         if (textarea && typeof answers[textarea.name] != 'undefined') { | ||||
|             // Add some HTML to the text if needed.
 | ||||
|             answers[textarea.name] = this.textUtils.formatHtmlLines(answers[textarea.name]); | ||||
|             await this.prepareTextAnswer(question, answers, textarea, siteId); | ||||
|         } | ||||
| 
 | ||||
|         if (attachmentsInput) { | ||||
|             await this.prepareAttachments(question, answers, offline, component, componentId, attachmentsInput, siteId); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Prepare attachments. | ||||
|      * | ||||
|      * @param question Question. | ||||
|      * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. | ||||
|      * @param offline Whether the data should be saved in offline. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @param attachmentsInput The HTML input containing the draft ID for attachments. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return Return a promise resolved when done if async, void if sync. | ||||
|      */ | ||||
|     async prepareAttachments(question: any, answers: any, offline: boolean, component: string, componentId: string | number, | ||||
|             attachmentsInput: HTMLInputElement, siteId?: string): Promise<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 answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|      */ | ||||
|     isCompleteResponse(question: any, answers: any): number { | ||||
|     isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         // We should always get a value for each select so we can assume we receive all the possible answers.
 | ||||
|         for (const name in answers) { | ||||
|             const value = answers[name]; | ||||
| @ -90,9 +92,11 @@ export class AddonQtypeGapSelectHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. | ||||
|      */ | ||||
|     isGradableResponse(question: any, answers: any): number { | ||||
|     isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         // We should always get a value for each select so we can assume we receive all the possible answers.
 | ||||
|         for (const name in answers) { | ||||
|             const value = answers[name]; | ||||
| @ -110,9 +114,11 @@ export class AddonQtypeGapSelectHandler implements CoreQuestionHandler { | ||||
|      * @param question Question. | ||||
|      * @param prevAnswers Object with the previous question answers. | ||||
|      * @param newAnswers Object with the new question answers. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return Whether they're the same. | ||||
|      */ | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { | ||||
|         return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -61,9 +61,11 @@ export class AddonQtypeMatchHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|      */ | ||||
|     isCompleteResponse(question: any, answers: any): number { | ||||
|     isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         // We should always get a value for each select so we can assume we receive all the possible answers.
 | ||||
|         for (const name in answers) { | ||||
|             const value = answers[name]; | ||||
| @ -90,9 +92,11 @@ export class AddonQtypeMatchHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. | ||||
|      */ | ||||
|     isGradableResponse(question: any, answers: any): number { | ||||
|     isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         // We should always get a value for each select so we can assume we receive all the possible answers.
 | ||||
|         for (const name in answers) { | ||||
|             const value = answers[name]; | ||||
| @ -110,9 +114,11 @@ export class AddonQtypeMatchHandler implements CoreQuestionHandler { | ||||
|      * @param question Question. | ||||
|      * @param prevAnswers Object with the previous question answers. | ||||
|      * @param newAnswers Object with the new question answers. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return Whether they're the same. | ||||
|      */ | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { | ||||
|         return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -62,9 +62,11 @@ export class AddonQtypeMultiAnswerHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|      */ | ||||
|     isCompleteResponse(question: any, answers: any): number { | ||||
|     isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         // Get all the inputs in the question to check if they've all been answered.
 | ||||
|         const names = this.questionProvider.getBasicAnswers(this.questionHelper.getAllInputNamesFromHtml(question.html)); | ||||
|         for (const name in names) { | ||||
| @ -92,9 +94,11 @@ export class AddonQtypeMultiAnswerHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. | ||||
|      */ | ||||
|     isGradableResponse(question: any, answers: any): number { | ||||
|     isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         // We should always get a value for each select so we can assume we receive all the possible answers.
 | ||||
|         for (const name in answers) { | ||||
|             const value = answers[name]; | ||||
| @ -112,9 +116,11 @@ export class AddonQtypeMultiAnswerHandler implements CoreQuestionHandler { | ||||
|      * @param question Question. | ||||
|      * @param prevAnswers Object with the previous question answers. | ||||
|      * @param newAnswers Object with the new question answers. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return Whether they're the same. | ||||
|      */ | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { | ||||
|         return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -45,9 +45,11 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|      */ | ||||
|     isCompleteResponse(question: any, answers: any): number { | ||||
|     isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         let isSingle = true, | ||||
|             isMultiComplete = false; | ||||
| 
 | ||||
| @ -95,10 +97,12 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. | ||||
|      */ | ||||
|     isGradableResponse(question: any, answers: any): number { | ||||
|         return this.isCompleteResponse(question, answers); | ||||
|     isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         return this.isCompleteResponse(question, answers, component, componentId); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -118,9 +122,11 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler { | ||||
|      * @param question Question. | ||||
|      * @param prevAnswers Object with the previous question answers. | ||||
|      * @param newAnswers Object with the new question answers. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return Whether they're the same. | ||||
|      */ | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { | ||||
|         let isSingle = true, | ||||
|             isMultiSame = true; | ||||
| 
 | ||||
| @ -158,10 +164,13 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler { | ||||
|      * @param question Question. | ||||
|      * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. | ||||
|      * @param offline Whether the data should be saved in offline. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return Return a promise resolved when done if async, void if sync. | ||||
|      */ | ||||
|     prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): void | Promise<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]) { | ||||
|             /* 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. */ | ||||
|  | ||||
| @ -46,11 +46,13 @@ export class AddonQtypeRandomSaMatchHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|      */ | ||||
|     isCompleteResponse(question: any, answers: any): number { | ||||
|     isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         // This question behaves like a match question.
 | ||||
|         return this.matchHandler.isCompleteResponse(question, answers); | ||||
|         return this.matchHandler.isCompleteResponse(question, answers, component, componentId); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -68,11 +70,13 @@ export class AddonQtypeRandomSaMatchHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. | ||||
|      */ | ||||
|     isGradableResponse(question: any, answers: any): number { | ||||
|     isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         // This question behaves like a match question.
 | ||||
|         return this.matchHandler.isGradableResponse(question, answers); | ||||
|         return this.matchHandler.isGradableResponse(question, answers, component, componentId); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -81,10 +85,12 @@ export class AddonQtypeRandomSaMatchHandler implements CoreQuestionHandler { | ||||
|      * @param question Question. | ||||
|      * @param prevAnswers Object with the previous question answers. | ||||
|      * @param newAnswers Object with the new question answers. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return Whether they're the same. | ||||
|      */ | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { | ||||
|         // This question behaves like a match question.
 | ||||
|         return this.matchHandler.isSameResponse(question, prevAnswers, newAnswers); | ||||
|         return this.matchHandler.isSameResponse(question, prevAnswers, newAnswers, component, componentId); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -45,9 +45,11 @@ export class AddonQtypeShortAnswerHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|      */ | ||||
|     isCompleteResponse(question: any, answers: any): number { | ||||
|     isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         return (answers['answer'] || answers['answer'] === 0) ? 1 : 0; | ||||
|     } | ||||
| 
 | ||||
| @ -66,10 +68,12 @@ export class AddonQtypeShortAnswerHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. | ||||
|      */ | ||||
|     isGradableResponse(question: any, answers: any): number { | ||||
|         return this.isCompleteResponse(question, answers); | ||||
|     isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         return this.isCompleteResponse(question, answers, null, null); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -78,9 +82,11 @@ export class AddonQtypeShortAnswerHandler implements CoreQuestionHandler { | ||||
|      * @param question Question. | ||||
|      * @param prevAnswers Object with the previous question answers. | ||||
|      * @param newAnswers Object with the new question answers. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return Whether they're the same. | ||||
|      */ | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { | ||||
|         return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer'); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -46,9 +46,11 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|      */ | ||||
|     isCompleteResponse(question: any, answers: any): number { | ||||
|     isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         return answers['answer'] ? 1 : 0; | ||||
|     } | ||||
| 
 | ||||
| @ -67,10 +69,12 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. | ||||
|      */ | ||||
|     isGradableResponse(question: any, answers: any): number { | ||||
|         return this.isCompleteResponse(question, answers); | ||||
|     isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         return this.isCompleteResponse(question, answers, null, null); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -79,9 +83,11 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler { | ||||
|      * @param question Question. | ||||
|      * @param prevAnswers Object with the previous question answers. | ||||
|      * @param newAnswers Object with the new question answers. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return Whether they're the same. | ||||
|      */ | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { | ||||
|         return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer'); | ||||
|     } | ||||
| 
 | ||||
| @ -91,10 +97,13 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler { | ||||
|      * @param question Question. | ||||
|      * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. | ||||
|      * @param offline Whether the data should be saved in offline. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return Return a promise resolved when done if async, void if sync. | ||||
|      */ | ||||
|     prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): void | Promise<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]) { | ||||
|             // The user hasn't answered. Delete the answer to prevent marking one of the answers automatically.
 | ||||
|             delete answers[question.optionsName]; | ||||
|  | ||||
| @ -1861,6 +1861,7 @@ | ||||
|     "core.mainmenu.help": "Help", | ||||
|     "core.mainmenu.logout": "Log out", | ||||
|     "core.mainmenu.website": "Website", | ||||
|     "core.maxfilesize": "Maximum size for new files: {{$a}}", | ||||
|     "core.maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}", | ||||
|     "core.min": "min", | ||||
|     "core.mins": "mins", | ||||
| @ -1944,8 +1945,8 @@ | ||||
|     "core.question.certainty": "Certainty", | ||||
|     "core.question.complete": "Complete", | ||||
|     "core.question.correct": "Correct", | ||||
|     "core.question.errorattachmentsnotsupported": "The application doesn't support attaching files to answers yet.", | ||||
|     "core.question.errorinlinefilesnotsupported": "The application doesn't support editing inline files yet.", | ||||
|     "core.question.errorattachmentsnotsupportedinsite": "Your site doesn't support attaching files to answers yet.", | ||||
|     "core.question.errorembeddedfilesnotsupportedinsite": "Your site doesn't support editing embedded files yet.", | ||||
|     "core.question.errorquestionnotsupported": "This question type is not supported by the app: {{$a}}.", | ||||
|     "core.question.feedback": "Feedback", | ||||
|     "core.question.howtodraganddrop": "Tap to select then tap to drop.", | ||||
|  | ||||
| @ -221,13 +221,16 @@ export class CoreSite { | ||||
| 
 | ||||
|     // Versions of Moodle releases.
 | ||||
|     protected MOODLE_RELEASES = { | ||||
|         3.1: 2016052300, | ||||
|         3.2: 2016120500, | ||||
|         3.3: 2017051503, | ||||
|         3.4: 2017111300, | ||||
|         3.5: 2018051700, | ||||
|         3.6: 2018120300, | ||||
|         3.7: 2019052000 | ||||
|         '3.1': 2016052300, | ||||
|         '3.2': 2016120500, | ||||
|         '3.3': 2017051503, | ||||
|         '3.4': 2017111300, | ||||
|         '3.5': 2018051700, | ||||
|         '3.6': 2018120300, | ||||
|         '3.7': 2019052000, | ||||
|         '3.8': 2019111800, | ||||
|         '3.9': 2020061500, | ||||
|         '3.10': 2020092400, // @todo: Replace with the right value once 3.10 is released.
 | ||||
|     }; | ||||
|     static MINIMUM_MOODLE_VERSION = '3.1'; | ||||
| 
 | ||||
|  | ||||
| @ -39,8 +39,8 @@ import { CoreFileUploaderHelperProvider } from '@core/fileuploader/providers/hel | ||||
| }) | ||||
| export class CoreAttachmentsComponent implements OnInit { | ||||
|     @Input() files: any[]; // List of attachments. New attachments will be added to this array.
 | ||||
|     @Input() maxSize: number; // Max size for attachments. If not defined, 0 or -1, unknown size.
 | ||||
|     @Input() maxSubmissions: number; // Max number of attachments. If -1 or not defined, unknown limit.
 | ||||
|     @Input() maxSize: number; // Max size for attachments. -1 means unlimited, not defined or 0 means unknown limit.
 | ||||
|     @Input() maxSubmissions: number; // Max number of attachments. -1 means unlimited, not defined means unknown limit.
 | ||||
|     @Input() component: string; // Component the downloaded files will be linked to.
 | ||||
|     @Input() componentId: string | number; // Component ID.
 | ||||
|     @Input() allowOffline: boolean | string; // Whether to allow selecting files in offline.
 | ||||
| @ -61,17 +61,18 @@ export class CoreAttachmentsComponent implements OnInit { | ||||
|      * Component being initialized. | ||||
|      */ | ||||
|     ngOnInit(): void { | ||||
|         this.maxSize = Number(this.maxSize); // Make sure it's defined and it's a number.
 | ||||
|         this.maxSize = !isNaN(this.maxSize) && this.maxSize > 0 ? this.maxSize : -1; | ||||
|         this.maxSize = Number(this.maxSize) || 0; // Make sure it's defined and it's a number.
 | ||||
| 
 | ||||
|         if (this.maxSize == -1) { | ||||
|         if (this.maxSize === 0) { | ||||
|             this.maxSizeReadable = this.translate.instant('core.unknown'); | ||||
|         } else { | ||||
|         } else if (this.maxSize > 0) { | ||||
|             this.maxSizeReadable = this.textUtils.bytesToSize(this.maxSize, 2); | ||||
|         } else { | ||||
|             this.maxSizeReadable = this.translate.instant('core.unlimited'); | ||||
|         } | ||||
| 
 | ||||
|         if (typeof this.maxSubmissions == 'undefined' || this.maxSubmissions < 0) { | ||||
|             this.maxSubmissionsReadable = this.translate.instant('core.unknown'); | ||||
|             this.maxSubmissionsReadable = this.maxSubmissions < 0 ? undefined : this.translate.instant('core.unknown'); | ||||
|             this.unlimitedFiles = true; | ||||
|         } else { | ||||
|             this.maxSubmissionsReadable = String(this.maxSubmissions); | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| <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> | ||||
| </ion-item> | ||||
| <ion-item text-wrap *ngIf="fileTypes && fileTypes.mimetypes && fileTypes.mimetypes.length"> | ||||
|  | ||||
| @ -56,6 +56,7 @@ ion-app.app-root core-rich-text-editor { | ||||
|         img { | ||||
|             @include padding(null, null, null, 2px); | ||||
|             max-width: 95%; | ||||
|             width: auto; | ||||
|         } | ||||
|         &:empty:before { | ||||
|             content: attr(data-placeholder-text); | ||||
|  | ||||
| @ -15,6 +15,7 @@ | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { ModalController } from 'ionic-angular'; | ||||
| import { Camera, CameraOptions } from '@ionic-native/camera'; | ||||
| import { FileEntry } from '@ionic-native/file'; | ||||
| import { MediaCapture, MediaFile, CaptureError, CaptureAudioOptions, CaptureVideoOptions } from '@ionic-native/media-capture'; | ||||
| import { TranslateService } from '@ngx-translate/core'; | ||||
| import { CoreFileProvider } from '@providers/file'; | ||||
| @ -25,9 +26,11 @@ import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; | ||||
| import { CoreTextUtilsProvider } from '@providers/utils/text'; | ||||
| import { CoreTimeUtilsProvider } from '@providers/utils/time'; | ||||
| import { CoreUtilsProvider } from '@providers/utils/utils'; | ||||
| import { CoreWSFileUploadOptions } from '@providers/ws'; | ||||
| import { CoreWSFileUploadOptions, CoreWSExternalFile } from '@providers/ws'; | ||||
| import { Subject } from 'rxjs'; | ||||
| import { CoreApp } from '@providers/app'; | ||||
| import { CoreSite } from '@classes/site'; | ||||
| import { makeSingleton } from '@singletons/core.singletons'; | ||||
| 
 | ||||
| /** | ||||
|  * File upload options. | ||||
| @ -96,7 +99,7 @@ export class CoreFileUploaderProvider { | ||||
|         // Currently we are going to compare the order of the files as well.
 | ||||
|         // This function can be improved comparing more fields or not comparing the order.
 | ||||
|         for (let i = 0; i < a.length; i++) { | ||||
|             if ((a[i].name || a[i].filename) != (b[i].name || b[i].filename)) { | ||||
|             if (a[i].name != b[i].name || a[i].filename != b[i].filename) { | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
| @ -104,6 +107,36 @@ export class CoreFileUploaderProvider { | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check if a certain site allows deleting draft files. | ||||
|      * | ||||
|      * @param siteId Site Id. If not defined, use current site. | ||||
|      * @return Promise resolved with true if can delete. | ||||
|      * @since 3.10 | ||||
|      */ | ||||
|     async canDeleteDraftFiles(siteId?: string): Promise<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. | ||||
|      * | ||||
| @ -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. | ||||
|      * | ||||
| @ -215,6 +267,35 @@ export class CoreFileUploaderProvider { | ||||
|         return options; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Given a list of original files and a list of current files, return the list of files to delete. | ||||
|      * | ||||
|      * @param originalFiles Original files. | ||||
|      * @param currentFiles Current files. | ||||
|      * @return List of files to delete. | ||||
|      */ | ||||
|     getFilesToDelete(originalFiles: CoreWSExternalFile[], currentFiles: (CoreWSExternalFile | FileEntry)[]) | ||||
|             : {filepath: string, filename: string}[] { | ||||
| 
 | ||||
|         const filesToDelete: {filepath: string, filename: string}[] = []; | ||||
|         currentFiles = currentFiles || []; | ||||
| 
 | ||||
|         originalFiles.forEach((file) => { | ||||
|             const stillInList = currentFiles.some((currentFile) => { | ||||
|                 return (<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. | ||||
|      * | ||||
| @ -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. | ||||
|      * | ||||
| @ -615,3 +737,5 @@ export class CoreFileUploaderProvider { | ||||
|         }); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| export class CoreFileUploader extends makeSingleton(CoreFileUploaderProvider) {} | ||||
|  | ||||
| @ -407,7 +407,7 @@ export class CoreGradesHelperProvider { | ||||
|                     if (matches && matches.length) { | ||||
|                         const hrefParams = this.urlUtils.extractUrlParams(matches[1]); | ||||
| 
 | ||||
|                         return hrefParams && hrefParams.id == moduleId; | ||||
|                         return hrefParams && Number(hrefParams.id) == moduleId; | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|  | ||||
| @ -1152,7 +1152,7 @@ export class CoreLoginHelperProvider { | ||||
|                     const providerToUse = identityProviders.find((provider) => { | ||||
|                         const params = this.urlUtils.extractUrlParams(provider.url); | ||||
| 
 | ||||
|                         return params.id == currentSite.getOAuthId(); | ||||
|                         return Number(params.id) == currentSite.getOAuthId(); | ||||
|                     }); | ||||
| 
 | ||||
|                     if (providerToUse) { | ||||
|  | ||||
| @ -34,10 +34,11 @@ export class CoreQuestionBehaviourBaseHandler implements CoreQuestionBehaviourHa | ||||
|      * @param component Component the question belongs to. | ||||
|      * @param attemptId Attempt ID the question belongs to. | ||||
|      * @param question The question. | ||||
|      * @param componentId Component ID. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return New state (or promise resolved with state). | ||||
|      */ | ||||
|     determineNewState(component: string, attemptId: number, question: any, siteId?: string) | ||||
|     determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string) | ||||
|             : CoreQuestionState | Promise<CoreQuestionState> { | ||||
|         // Return the current state.
 | ||||
|         return this.questionProvider.getState(question.state); | ||||
|  | ||||
| @ -14,8 +14,10 @@ | ||||
| 
 | ||||
| import { Input, Output, EventEmitter, Injector, ElementRef } from '@angular/core'; | ||||
| import { CoreLoggerProvider } from '@providers/logger'; | ||||
| import { CoreSites } from '@providers/sites'; | ||||
| import { CoreDomUtilsProvider } from '@providers/utils/dom'; | ||||
| import { CoreTextUtilsProvider } from '@providers/utils/text'; | ||||
| import { CoreUrlUtils } from '@providers/utils/url'; | ||||
| import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; | ||||
| 
 | ||||
| /** | ||||
| @ -105,9 +107,13 @@ export class CoreQuestionBaseComponent { | ||||
|                 this.question.select = selectModel; | ||||
| 
 | ||||
|                 // Check which one should be displayed first: the select or the input.
 | ||||
|                 const input = questionEl.querySelector('input[type="text"][name*=answer]'); | ||||
|                 this.question.selectFirst = | ||||
|                         questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(select.outerHTML); | ||||
|                 if (this.question.settings) { | ||||
|                     this.question.selectFirst = this.question.settings.unitsleft == '1'; | ||||
|                 } else { | ||||
|                     const input = questionEl.querySelector('input[type="text"][name*=answer]'); | ||||
|                     this.question.selectFirst = | ||||
|                             questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(select.outerHTML); | ||||
|                 } | ||||
| 
 | ||||
|                 return questionEl; | ||||
|             } | ||||
| @ -159,9 +165,15 @@ export class CoreQuestionBaseComponent { | ||||
|             } | ||||
| 
 | ||||
|             // Check which one should be displayed first: the options or the input.
 | ||||
|             const input = questionEl.querySelector('input[type="text"][name*=answer]'); | ||||
|             this.question.optionsFirst = | ||||
|                     questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(radios[0].outerHTML); | ||||
|             if (this.question.settings) { | ||||
|                 this.question.optionsFirst = this.question.settings.unitsleft == '1'; | ||||
|             } else { | ||||
|                 const input = questionEl.querySelector('input[type="text"][name*=answer]'); | ||||
|                 this.question.optionsFirst = | ||||
|                         questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(radios[0].outerHTML); | ||||
|             } | ||||
| 
 | ||||
|             return questionEl; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| @ -201,27 +213,46 @@ export class CoreQuestionBaseComponent { | ||||
|         const questionEl = this.initComponent(); | ||||
| 
 | ||||
|         if (questionEl) { | ||||
|             // First search the textarea.
 | ||||
|             const textarea = <HTMLTextAreaElement> questionEl.querySelector('textarea[name*=_answer]'); | ||||
|             this.question.allowsAttachments = !!questionEl.querySelector('div[id*=filemanager]'); | ||||
|             this.question.isMonospaced = !!questionEl.querySelector('.qtype_essay_monospaced'); | ||||
|             this.question.isPlainText = this.question.isMonospaced || !!questionEl.querySelector('.qtype_essay_plain'); | ||||
|             this.question.hasDraftFiles = this.questionHelper.hasDraftFileUrls(questionEl.innerHTML); | ||||
|             const answerDraftIdInput = <HTMLInputElement> questionEl.querySelector('input[name*="_answer:itemid"]'); | ||||
| 
 | ||||
|             if (!textarea) { | ||||
|                 // Textarea not found, we might be in review. Search the answer and the attachments.
 | ||||
|             if (this.question.settings) { | ||||
|                 this.question.allowsAttachments = this.question.settings.attachments != '0'; | ||||
|                 this.question.allowsAnswerFiles = this.question.settings.responseformat == 'editorfilepicker'; | ||||
|                 this.question.isMonospaced = this.question.settings.responseformat == 'monospaced'; | ||||
|                 this.question.isPlainText = this.question.isMonospaced || this.question.settings.responseformat == 'plain'; | ||||
|             } else { | ||||
|                 this.question.allowsAttachments = !!questionEl.querySelector('div[id*=filemanager]'); | ||||
|                 this.question.allowsAnswerFiles = !!answerDraftIdInput; | ||||
|                 this.question.isMonospaced = !!questionEl.querySelector('.qtype_essay_monospaced'); | ||||
|                 this.question.isPlainText = this.question.isMonospaced || !!questionEl.querySelector('.qtype_essay_plain'); | ||||
|             } | ||||
| 
 | ||||
|             this.question.hasDraftFiles = this.question.allowsAnswerFiles && | ||||
|                     this.questionHelper.hasDraftFileUrls(questionEl.innerHTML); | ||||
| 
 | ||||
|             if (!textarea && !this.question.allowsAttachments) { | ||||
|                 // Textarea and filemanager not found, we might be in review. Search the answer and the attachments.
 | ||||
|                 this.question.answer = this.domUtils.getContentsOfElement(questionEl, '.qtype_essay_response'); | ||||
|                 this.question.attachments = this.questionHelper.getQuestionAttachmentsFromHtml( | ||||
|                         this.domUtils.getContentsOfElement(questionEl, '.attachments')); | ||||
|             } else { | ||||
|                 // Textarea found.
 | ||||
|                 const input = <HTMLInputElement> questionEl.querySelector('input[type="hidden"][name*=answerformat]'), | ||||
|                     content = textarea.innerHTML; | ||||
| 
 | ||||
|                 return questionEl; | ||||
|             } | ||||
| 
 | ||||
|             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 = { | ||||
|                     id: textarea.id, | ||||
|                     name: textarea.name, | ||||
|                     text: content ? this.textUtils.decodeHTML(content) : '' | ||||
|                     text: content, | ||||
|                 }; | ||||
| 
 | ||||
|                 if (input) { | ||||
| @ -231,6 +262,43 @@ export class CoreQuestionBaseComponent { | ||||
|                     }; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             if (answerDraftIdInput) { | ||||
|                 this.question.answerDraftIdInput = { | ||||
|                     name: answerDraftIdInput.name, | ||||
|                     value: Number(answerDraftIdInput.value), | ||||
|                 }; | ||||
|             } | ||||
| 
 | ||||
|             if (this.question.allowsAttachments) { | ||||
|                 const attachmentsInput = <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.
 | ||||
|         this.question.text = content.innerHTML; | ||||
| 
 | ||||
|         return element; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
| @ -79,9 +79,11 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|      */ | ||||
|     isCompleteResponse(question: any, answers: any): number { | ||||
|     isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         return -1; | ||||
|     } | ||||
| 
 | ||||
| @ -91,9 +93,11 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. | ||||
|      */ | ||||
|     isGradableResponse(question: any, answers: any): number { | ||||
|     isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         return -1; | ||||
|     } | ||||
| 
 | ||||
| @ -103,9 +107,11 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler { | ||||
|      * @param question Question. | ||||
|      * @param prevAnswers Object with the previous question answers. | ||||
|      * @param newAnswers Object with the new question answers. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return Whether they're the same. | ||||
|      */ | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
| @ -115,10 +121,13 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler { | ||||
|      * @param question Question. | ||||
|      * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. | ||||
|      * @param offline Whether the data should be saved in offline. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return Return a promise resolved when done if async, void if sync. | ||||
|      */ | ||||
|     prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): void | Promise<any> { | ||||
|     prepareAnswers(question: any, answers: any, offline: boolean, component: string, componentId: string | number, siteId?: string) | ||||
|             : void | Promise<any> { | ||||
|         // Nothing to do.
 | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -5,8 +5,8 @@ | ||||
|     "certainty": "Certainty", | ||||
|     "complete": "Complete", | ||||
|     "correct": "Correct", | ||||
|     "errorattachmentsnotsupported": "The application doesn't support attaching files to answers yet.", | ||||
|     "errorinlinefilesnotsupported": "The application doesn't support editing inline files yet.", | ||||
|     "errorattachmentsnotsupportedinsite": "Your site doesn't support attaching files to answers yet.", | ||||
|     "errorembeddedfilesnotsupportedinsite": "Your site doesn't support editing embedded files yet.", | ||||
|     "errorquestionnotsupported": "This question type is not supported by the app: {{$a}}.", | ||||
|     "feedback": "Feedback", | ||||
|     "howtodraganddrop": "Tap to select then tap to drop.", | ||||
|  | ||||
| @ -36,10 +36,11 @@ export interface CoreQuestionBehaviourHandler extends CoreDelegateHandler { | ||||
|      * @param component Component the question belongs to. | ||||
|      * @param attemptId Attempt ID the question belongs to. | ||||
|      * @param question The question. | ||||
|      * @param componentId Component ID. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return State (or promise resolved with state). | ||||
|      */ | ||||
|     determineNewState?(component: string, attemptId: number, question: any, siteId?: string) | ||||
|     determineNewState?(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string) | ||||
|         : CoreQuestionState | Promise<CoreQuestionState>; | ||||
| 
 | ||||
|     /** | ||||
| @ -74,15 +75,16 @@ export class CoreQuestionBehaviourDelegate extends CoreDelegate { | ||||
|      * @param component Component the question belongs to. | ||||
|      * @param attemptId Attempt ID the question belongs to. | ||||
|      * @param question The question. | ||||
|      * @param componentId Component ID. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return Promise resolved with state. | ||||
|      */ | ||||
|     determineNewState(behaviour: string, component: string, attemptId: number, question: any, siteId?: string) | ||||
|             : Promise<CoreQuestionState> { | ||||
|     determineNewState(behaviour: string, component: string, attemptId: number, question: any, componentId: string | number, | ||||
|             siteId?: string): Promise<CoreQuestionState> { | ||||
|         behaviour = this.questionDelegate.getBehaviourForQuestion(question, behaviour); | ||||
| 
 | ||||
|         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 answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|      */ | ||||
|     isCompleteResponse?(question: any, answers: any): number; | ||||
|     isCompleteResponse?(question: any, answers: any, component: string, componentId: string | number): number; | ||||
| 
 | ||||
|     /** | ||||
|      * Check if a student has provided enough of an answer for the question to be graded automatically, | ||||
| @ -72,9 +74,11 @@ export interface CoreQuestionHandler extends CoreDelegateHandler { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. | ||||
|      */ | ||||
|     isGradableResponse?(question: any, answers: any): number; | ||||
|     isGradableResponse?(question: any, answers: any, component: string, componentId: string | number): number; | ||||
| 
 | ||||
|     /** | ||||
|      * Check if two responses are the same. | ||||
| @ -84,7 +88,7 @@ export interface CoreQuestionHandler extends CoreDelegateHandler { | ||||
|      * @param newAnswers Object with the new question answers. | ||||
|      * @return Whether they're the same. | ||||
|      */ | ||||
|     isSameResponse?(question: any, prevAnswers: any, newAnswers: any): boolean; | ||||
|     isSameResponse?(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean; | ||||
| 
 | ||||
|     /** | ||||
|      * Prepare and add to answers the data to send to server based in the input. Return promise if async. | ||||
| @ -92,10 +96,13 @@ export interface CoreQuestionHandler extends CoreDelegateHandler { | ||||
|      * @param question Question. | ||||
|      * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. | ||||
|      * @param offline Whether the data should be saved in offline. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return Return a promise resolved when done if async, void if sync. | ||||
|      */ | ||||
|     prepareAnswers?(question: any, answers: any, offline: boolean, siteId?: string): void | Promise<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. | ||||
| @ -115,6 +122,40 @@ export interface CoreQuestionHandler extends CoreDelegateHandler { | ||||
|      * @return List of URLs. | ||||
|      */ | ||||
|     getAdditionalDownloadableFiles?(question: any, usageId: number): string[]; | ||||
| 
 | ||||
|     /** | ||||
|      * Clear temporary data after the data has been saved. | ||||
|      * | ||||
|      * @param question Question. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return If async, promise resolved when done. | ||||
|      */ | ||||
|     clearTmpData?(question: any, component: string, componentId: string | number): void | Promise<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 answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if complete, 0 if not complete, -1 if cannot determine. | ||||
|      */ | ||||
|     isCompleteResponse(question: any, answers: any): number { | ||||
|     isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         const type = this.getTypeName(question); | ||||
| 
 | ||||
|         return this.executeFunctionOnEnabled(type, 'isCompleteResponse', [question, answers]); | ||||
|         return this.executeFunctionOnEnabled(type, 'isCompleteResponse', [question, answers, component, componentId]); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -210,12 +253,14 @@ export class CoreQuestionDelegate extends CoreDelegate { | ||||
|      * | ||||
|      * @param question The question. | ||||
|      * @param answers Object with the question answers (without prefix). | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. | ||||
|      */ | ||||
|     isGradableResponse(question: any, answers: any): number { | ||||
|     isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number { | ||||
|         const type = this.getTypeName(question); | ||||
| 
 | ||||
|         return this.executeFunctionOnEnabled(type, 'isGradableResponse', [question, answers]); | ||||
|         return this.executeFunctionOnEnabled(type, 'isGradableResponse', [question, answers, component, componentId]); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -226,10 +271,10 @@ export class CoreQuestionDelegate extends CoreDelegate { | ||||
|      * @param newAnswers Object with the new question answers. | ||||
|      * @return Whether they're the same. | ||||
|      */ | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { | ||||
|     isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean { | ||||
|         const type = this.getTypeName(question); | ||||
| 
 | ||||
|         return this.executeFunctionOnEnabled(type, 'isSameResponse', [question, prevAnswers, newAnswers]); | ||||
|         return this.executeFunctionOnEnabled(type, 'isSameResponse', [question, prevAnswers, newAnswers, component, componentId]); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -248,13 +293,17 @@ export class CoreQuestionDelegate extends CoreDelegate { | ||||
|      * @param question Question. | ||||
|      * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. | ||||
|      * @param offline Whether the data should be saved in offline. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return Promise resolved when data has been prepared. | ||||
|      */ | ||||
|     prepareAnswersForQuestion(question: any, answers: any, offline: boolean, siteId?: string): Promise<any> { | ||||
|     prepareAnswersForQuestion(question: any, answers: any, offline: boolean, component: string, componentId: string | number, | ||||
|             siteId?: string): Promise<any> { | ||||
|         const type = this.getTypeName(question); | ||||
| 
 | ||||
|         return Promise.resolve(this.executeFunctionOnEnabled(type, 'prepareAnswers', [question, answers, offline, siteId])); | ||||
|         return Promise.resolve(this.executeFunctionOnEnabled(type, 'prepareAnswers', | ||||
|                 [question, answers, offline, component, componentId, siteId])); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -282,4 +331,50 @@ export class CoreQuestionDelegate extends CoreDelegate { | ||||
| 
 | ||||
|         return this.executeFunctionOnEnabled(type, 'getAdditionalDownloadableFiles', [question, usageId]) || []; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Clear temporary data after the data has been saved. | ||||
|      * | ||||
|      * @param question Question. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return If async, promise resolved when done. | ||||
|      */ | ||||
|     clearTmpData(question: any, component: string, componentId: string | number): void | Promise<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.
 | ||||
| 
 | ||||
| import { Injectable, EventEmitter } from '@angular/core'; | ||||
| import { FileEntry } from '@ionic-native/file'; | ||||
| import { TranslateService } from '@ngx-translate/core'; | ||||
| import { CoreFile } from '@providers/file'; | ||||
| import { CoreFilepoolProvider } from '@providers/filepool'; | ||||
| import { CoreSitesProvider } from '@providers/sites'; | ||||
| import { CoreWSExternalFile } from '@providers/ws'; | ||||
| import { CoreDomUtilsProvider } from '@providers/utils/dom'; | ||||
| import { CoreTextUtilsProvider } from '@providers/utils/text'; | ||||
| import { CoreUrlUtilsProvider } from '@providers/utils/url'; | ||||
| @ -59,6 +62,41 @@ export class CoreQuestionHelperProvider { | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Clear questions temporary data after the data has been saved. | ||||
|      * | ||||
|      * @param questions The list of questions. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async clearTmpData(questions: any[], component: string, componentId: string | number): Promise<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. | ||||
|      * The buttons aren't deleted from the content because all the im-controls block will be removed afterwards. | ||||
| @ -421,6 +459,41 @@ export class CoreQuestionHelperProvider { | ||||
|         return state ? state.class : ''; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Return the files of a certain response file area. | ||||
|      * | ||||
|      * @param question Question. | ||||
|      * @param areaName Name of the area, e.g. 'attachments'. | ||||
|      * @return List of files. | ||||
|      */ | ||||
|     getResponseFileAreaFiles(question: any, areaName: string): CoreWSExternalFile[] { | ||||
|         if (!question.responsefileareas) { | ||||
|             return []; | ||||
|         } | ||||
| 
 | ||||
|         const area = question.responsefileareas.find((area) => { | ||||
|             return area.area == areaName; | ||||
|         }); | ||||
| 
 | ||||
|         return area && area.files || []; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get files stored for a question. | ||||
|      * | ||||
|      * @param question Question. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return Promise resolved with the files. | ||||
|      */ | ||||
|     getStoredQuestionFiles(question: any, component: string, componentId: string | number, siteId?: string): Promise<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. | ||||
|      * | ||||
| @ -521,7 +594,7 @@ export class CoreQuestionHelperProvider { | ||||
| 
 | ||||
|         if (!component) { | ||||
|             component = CoreQuestionProvider.COMPONENT; | ||||
|             componentId = question.id; | ||||
|             componentId = question.number; | ||||
|         } | ||||
| 
 | ||||
|         urls.push(...this.questionDelegate.getAdditionalDownloadableFiles(question, usageId)); | ||||
| @ -552,15 +625,19 @@ export class CoreQuestionHelperProvider { | ||||
|      * @param questions The list of questions. | ||||
|      * @param answers The input data. | ||||
|      * @param offline True if data should be saved in offline. | ||||
|      * @param component The component the question is related to. | ||||
|      * @param componentId Component ID. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return Promise resolved with answers to send to server. | ||||
|      */ | ||||
|     prepareAnswers(questions: any[], answers: any, offline?: boolean, siteId?: string): Promise<any> { | ||||
|     prepareAnswers(questions: any[], answers: any, offline: boolean, component: string, componentId: string | number, | ||||
|             siteId?: string): Promise<any> { | ||||
|         const promises = []; | ||||
| 
 | ||||
|         questions = questions || []; | ||||
|         questions.forEach((question) => { | ||||
|             promises.push(this.questionDelegate.prepareAnswersForQuestion(question, answers, offline, siteId)); | ||||
|             promises.push(this.questionDelegate.prepareAnswersForQuestion(question, answers, offline, component, componentId, | ||||
|                     siteId)); | ||||
|         }); | ||||
| 
 | ||||
|         return this.utils.allPromises(promises).then(() => { | ||||
|  | ||||
| @ -13,10 +13,13 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { CoreFile } from '@providers/file'; | ||||
| import { CoreLoggerProvider } from '@providers/logger'; | ||||
| import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; | ||||
| import { CoreTextUtils } from '@providers/utils/text'; | ||||
| import { CoreTimeUtilsProvider } from '@providers/utils/time'; | ||||
| import { CoreUtilsProvider } from '@providers/utils/utils'; | ||||
| import { makeSingleton } from '@singletons/core.singletons'; | ||||
| 
 | ||||
| /** | ||||
|  * An object to represent a question state. | ||||
| @ -413,6 +416,35 @@ export class CoreQuestionProvider { | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Given a question and a componentId, return a componentId that is unique for the question. | ||||
|      * | ||||
|      * @param question Question. | ||||
|      * @param componentId Component ID. | ||||
|      * @return Question component ID. | ||||
|      */ | ||||
|     getQuestionComponentId(question: any, componentId: string | number): string { | ||||
|         return componentId + '_' + question.number; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the path to the folder where to store files for an offline question. | ||||
|      * | ||||
|      * @param type Question type. | ||||
|      * @param component Component the question is related to. | ||||
|      * @param componentId Question component ID, returned by getQuestionComponentId. | ||||
|      * @param siteId Site ID. If not defined, current site. | ||||
|      * @return Folder path. | ||||
|      */ | ||||
|     getQuestionFolder(type: string, component: string, componentId: string, siteId?: string): string { | ||||
|         siteId = siteId || this.sitesProvider.getCurrentSiteId(); | ||||
| 
 | ||||
|         const siteFolderPath = CoreFile.instance.getSiteFolder(siteId); | ||||
|         const questionFolderPath = 'offlinequestion/' + type + '/' + component + '/' + componentId; | ||||
| 
 | ||||
|         return CoreTextUtils.instance.concatenatePaths(siteFolderPath, questionFolderPath); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Extract the question slot from a question name. | ||||
|      * | ||||
| @ -612,3 +644,5 @@ export class CoreQuestionProvider { | ||||
|         }); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| export class CoreQuestion extends makeSingleton(CoreQuestionProvider) {} | ||||
|  | ||||
| @ -143,6 +143,7 @@ | ||||
|     "loadmore": "Load more", | ||||
|     "location": "Location", | ||||
|     "lostconnection": "Your authentication token is invalid or has expired. You will have to reconnect to the site.", | ||||
|     "maxfilesize": "Maximum size for new files: {{$a}}", | ||||
|     "maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}", | ||||
|     "min": "min", | ||||
|     "mins": "mins", | ||||
|  | ||||
| @ -1114,10 +1114,8 @@ export class CoreFileProvider { | ||||
|         // Get existing files in the folder.
 | ||||
|         return this.getDirectoryContents(dirPath).then((entries) => { | ||||
|             const files = {}; | ||||
|             let num = 1, | ||||
|                 fileNameWithoutExtension = this.mimeUtils.removeExtension(fileName), | ||||
|                 extension = this.mimeUtils.getFileExtension(fileName) || defaultExt, | ||||
|                 newName; | ||||
|             let fileNameWithoutExtension = this.mimeUtils.removeExtension(fileName); | ||||
|             let extension = this.mimeUtils.getFileExtension(fileName) || defaultExt; | ||||
| 
 | ||||
|             // Clean the file name.
 | ||||
|             fileNameWithoutExtension = this.textUtils.removeSpecialCharactersForFiles( | ||||
| @ -1135,26 +1133,40 @@ export class CoreFileProvider { | ||||
|                 extension = ''; | ||||
|             } | ||||
| 
 | ||||
|             newName = fileNameWithoutExtension + extension; | ||||
|             if (typeof files[newName.toLowerCase()] == 'undefined') { | ||||
|                 // No file with the same name.
 | ||||
|                 return newName; | ||||
|             } else { | ||||
|                 // Repeated name. Add a number until we find a free name.
 | ||||
|                 do { | ||||
|                     newName = fileNameWithoutExtension + '(' + num + ')' + extension; | ||||
|                     num++; | ||||
|                 } while (typeof files[newName.toLowerCase()] != 'undefined'); | ||||
| 
 | ||||
|                 // Ask the user what he wants to do.
 | ||||
|                 return newName; | ||||
|             } | ||||
|             return this.calculateUniqueName(files, fileNameWithoutExtension + extension); | ||||
|         }).catch(() => { | ||||
|             // Folder doesn't exist, name is unique. Clean it and return it.
 | ||||
|             return this.textUtils.removeSpecialCharactersForFiles(this.textUtils.decodeURIComponent(fileName)); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Given a file name and a set of already used names, calculate a unique name. | ||||
|      * | ||||
|      * @param usedNames Object with names already used as keys. | ||||
|      * @param name Name to check. | ||||
|      * @return Unique name. | ||||
|      */ | ||||
|     calculateUniqueName(usedNames: {[name: string]: any}, name: string): string { | ||||
|         if (typeof usedNames[name.toLowerCase()] == 'undefined') { | ||||
|             // No file with the same name.
 | ||||
|             return name; | ||||
|         } else { | ||||
|             // Repeated name. Add a number until we find a free name.
 | ||||
|             const nameWithoutExtension = this.mimeUtils.removeExtension(name); | ||||
|             let extension = this.mimeUtils.getFileExtension(name); | ||||
|             let num = 1; | ||||
|             extension = extension ? '.' + extension : ''; | ||||
| 
 | ||||
|             do { | ||||
|                 name = nameWithoutExtension + '(' + num + ')' + extension; | ||||
|                 num++; | ||||
|             } while (typeof usedNames[name.toLowerCase()] != 'undefined'); | ||||
| 
 | ||||
|             return name; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Remove app temporary folder. | ||||
|      * | ||||
|  | ||||
| @ -19,6 +19,7 @@ import { TranslateService } from '@ngx-translate/core'; | ||||
| import { CoreLangProvider } from '../lang'; | ||||
| import { makeSingleton } from '@singletons/core.singletons'; | ||||
| import { CoreApp } from '../app'; | ||||
| import { CoreWSExternalFile } from '../ws'; | ||||
| 
 | ||||
| /** | ||||
|  * Different type of errors the app can treat. | ||||
| @ -699,6 +700,60 @@ export class CoreTextUtilsProvider { | ||||
|         return text.replace(/(?:\r\n|\r|\n)/g, newValue); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Replace draftfile URLs with the equivalent pluginfile URL. | ||||
|      * | ||||
|      * @param siteUrl URL of the site. | ||||
|      * @param text Text to treat, including draftfile URLs. | ||||
|      * @param files List of files of the area, using pluginfile URLs. | ||||
|      * @return Treated text and map with the replacements. | ||||
|      */ | ||||
|     replaceDraftfileUrls(siteUrl: string, text: string, files: CoreWSExternalFile[]) | ||||
|             : {text: string, replaceMap?: {[url: string]: string}} { | ||||
| 
 | ||||
|         if (!text || !files || !files.length) { | ||||
|             return {text}; | ||||
|         } | ||||
| 
 | ||||
|         const draftfileUrl = this.concatenatePaths(siteUrl, 'draftfile.php'); | ||||
|         const matches = text.match(new RegExp(this.escapeForRegex(draftfileUrl) + '[^\'" ]+', 'ig')); | ||||
| 
 | ||||
|         if (!matches || !matches.length) { | ||||
|             return {text}; | ||||
|         } | ||||
| 
 | ||||
|         // Index the pluginfile URLs by file name.
 | ||||
|         const pluginfileMap: {[name: string]: string} = {}; | ||||
|         files.forEach((file) => { | ||||
|             pluginfileMap[file.filename] = file.fileurl; | ||||
|         }); | ||||
| 
 | ||||
|         // Replace each draftfile with the corresponding pluginfile URL.
 | ||||
|         const replaceMap: {[url: string]: string} = {}; | ||||
|         matches.forEach((url) => { | ||||
|             if (replaceMap[url]) { | ||||
|                 // URL already treated, same file embedded more than once.
 | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             // Get the filename from the URL.
 | ||||
|             let filename = url.substr(url.lastIndexOf('/') + 1); | ||||
|             if (filename.indexOf('?') != -1) { | ||||
|                 filename = filename.substr(0, filename.indexOf('?')); | ||||
|             } | ||||
| 
 | ||||
|             if (pluginfileMap[filename]) { | ||||
|                 replaceMap[url] = pluginfileMap[filename]; | ||||
|                 text = text.replace(new RegExp(this.escapeForRegex(url), 'g'), pluginfileMap[filename]); | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         return { | ||||
|             text, | ||||
|             replaceMap, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Replace @@PLUGINFILE@@ wildcards with the real URL in a text. | ||||
|      * | ||||
| @ -717,6 +772,36 @@ export class CoreTextUtilsProvider { | ||||
|         return text; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Restore original draftfile URLs. | ||||
|      * | ||||
|      * @param text Text to treat, including pluginfile URLs. | ||||
|      * @param replaceMap Map of the replacements that were done. | ||||
|      * @return Treated text. | ||||
|      */ | ||||
|     restoreDraftfileUrls(siteUrl: string, treatedText: string, originalText: string, files: CoreWSExternalFile[]): string { | ||||
|         if (!treatedText || !files || !files.length) { | ||||
|             return treatedText; | ||||
|         } | ||||
| 
 | ||||
|         const draftfileUrl = this.concatenatePaths(siteUrl, 'draftfile.php'); | ||||
|         const draftfileUrlRegexPrefix = this.escapeForRegex(draftfileUrl) + '/[^/]+/[^/]+/[^/]+/[^/]+/'; | ||||
| 
 | ||||
|         files.forEach((file) => { | ||||
|             // Search the draftfile URL in the original text.
 | ||||
|             const matches = originalText.match(new RegExp( | ||||
|                     draftfileUrlRegexPrefix + this.escapeForRegex(file.filename) + '[^\'" ]*', 'i')); | ||||
| 
 | ||||
|             if (!matches || !matches[0]) { | ||||
|                 return; // Original URL not found, skip.
 | ||||
|             } | ||||
| 
 | ||||
|             treatedText = treatedText.replace(new RegExp(this.escapeForRegex(file.fileurl), 'g'), matches[0]); | ||||
|         }); | ||||
| 
 | ||||
|         return treatedText; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Replace pluginfile URLs with @@PLUGINFILE@@ wildcards. | ||||
|      * | ||||
|  | ||||
| @ -114,10 +114,10 @@ export class CoreUrlUtilsProvider { | ||||
|      * @param url URL to treat. | ||||
|      * @return Object with the params. | ||||
|      */ | ||||
|     extractUrlParams(url: string): any { | ||||
|     extractUrlParams(url: string): {[name: string]: string} { | ||||
|         const regex = /[?&]+([^=&]+)=?([^&]*)?/gi, | ||||
|             subParamsPlaceholder = '@@@SUBPARAMS@@@', | ||||
|             params: any = {}, | ||||
|             params: {[name: string]: string} = {}, | ||||
|             urlAndHash = url.split('#'), | ||||
|             questionMarkSplit = urlAndHash[0].split('?'); | ||||
|         let subParams; | ||||
|  | ||||
| @ -1,6 +1,10 @@ | ||||
| This files describes API changes in the Moodle Mobile app, | ||||
| information provided here is intended especially for developers. | ||||
| 
 | ||||
| === 3.9.3 === | ||||
| 
 | ||||
| - In the core-attachments component, passing a -1 as maxSize or maxSubmissions used to mean "unknown limit". Now -1 means unlimited. | ||||
| 
 | ||||
| === 3.8.3 === | ||||
| 
 | ||||
| - CoreFileProvider.writeFileDataInFile has been deprecated. Please use CoreFileHelperProvider.writeFileDataInFile instead. | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user