Merge pull request #2593 from dpalou/MOBILE-2272

Mobile 2272
main
Juan Leyva 2020-11-06 12:31:30 +01:00 committed by GitHub
commit 0cf2b3c74e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 1409 additions and 490 deletions

View File

@ -1861,6 +1861,7 @@
"core.mainmenu.help": "moodle", "core.mainmenu.help": "moodle",
"core.mainmenu.logout": "moodle", "core.mainmenu.logout": "moodle",
"core.mainmenu.website": "local_moodlemobileapp", "core.mainmenu.website": "local_moodlemobileapp",
"core.maxfilesize": "moodle",
"core.maxsizeandattachments": "moodle", "core.maxsizeandattachments": "moodle",
"core.min": "moodle", "core.min": "moodle",
"core.mins": "moodle", "core.mins": "moodle",
@ -1944,8 +1945,8 @@
"core.question.certainty": "qbehaviour_deferredcbm", "core.question.certainty": "qbehaviour_deferredcbm",
"core.question.complete": "question", "core.question.complete": "question",
"core.question.correct": "question", "core.question.correct": "question",
"core.question.errorattachmentsnotsupported": "local_moodlemobileapp", "core.question.errorattachmentsnotsupportedinsite": "local_moodlemobileapp",
"core.question.errorinlinefilesnotsupported": "local_moodlemobileapp", "core.question.errorembeddedfilesnotsupportedinsite": "local_moodlemobileapp",
"core.question.errorquestionnotsupported": "local_moodlemobileapp", "core.question.errorquestionnotsupported": "local_moodlemobileapp",
"core.question.feedback": "question", "core.question.feedback": "question",
"core.question.howtodraganddrop": "local_moodlemobileapp", "core.question.howtodraganddrop": "local_moodlemobileapp",

View File

@ -411,7 +411,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid
const params = this.urlUtils.extractUrlParams(response.data.reviewlesson.value); const params = this.urlUtils.extractUrlParams(response.data.reviewlesson.value);
if (params && params.pageid) { if (params && params.pageid) {
// The retake can be reviewed, mark it as finished. Don't block the user for this. // The retake can be reviewed, mark it as finished. Don't block the user for this.
this.setRetakeFinishedInSync(lessonId, retake.retake, params.pageid, siteId); this.setRetakeFinishedInSync(lessonId, retake.retake, Number(params.pageid), siteId);
} }
} }
} }

View File

@ -569,7 +569,8 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy {
// Prepare the answers to be sent for the attempt. // Prepare the answers to be sent for the attempt.
protected prepareAnswers(): Promise<any> { protected prepareAnswers(): Promise<any> {
return this.questionHelper.prepareAnswers(this.questions, this.getAnswers(), this.offline); return this.questionHelper.prepareAnswers(this.questions, this.getAnswers(), this.offline, this.component,
this.quiz.coursemodule);
} }
/** /**
@ -612,6 +613,8 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy {
if (this.formElement) { if (this.formElement) {
this.domUtils.triggerFormSubmittedEvent(this.formElement, !this.offline, this.sitesProvider.getCurrentSiteId()); this.domUtils.triggerFormSubmittedEvent(this.formElement, !this.offline, this.sitesProvider.getCurrentSiteId());
} }
return this.questionHelper.clearTmpData(this.questions, this.component, this.quiz.coursemodule);
}); });
} }

View File

@ -342,8 +342,8 @@ export class AddonModQuizOfflineProvider {
for (const slot in questionsWithAnswers) { for (const slot in questionsWithAnswers) {
const question = questionsWithAnswers[slot]; const question = questionsWithAnswers[slot];
promises.push(this.behaviourDelegate.determineNewState( promises.push(this.behaviourDelegate.determineNewState(quiz.preferredbehaviour, AddonModQuizProvider.COMPONENT,
quiz.preferredbehaviour, AddonModQuizProvider.COMPONENT, attempt.id, question, siteId).then((state) => { attempt.id, question, quiz.coursemodule, siteId).then((state) => {
// Check if state has changed. // Check if state has changed.
if (state && state.name != question.state) { if (state && state.name != question.state) {
newStates[question.slot] = state.name; newStates[question.slot] = state.name;

View File

@ -21,6 +21,7 @@ import { CoreSitesProvider, CoreSitesReadingStrategy } from '@providers/sites';
import { CoreSyncProvider } from '@providers/sync'; import { CoreSyncProvider } from '@providers/sync';
import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreUtils } from '@providers/utils/utils';
import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseProvider } from '@core/course/providers/course';
import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper';
import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate';
@ -77,25 +78,33 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
* @param quiz Quiz. * @param quiz Quiz.
* @param courseId Course ID. * @param courseId Course ID.
* @param warnings List of warnings generated by the sync. * @param warnings List of warnings generated by the sync.
* @param attemptId Last attempt ID. * @param options Other options.
* @param offlineAttempt Offline attempt synchronized, if any.
* @param onlineAttempt Online data for the offline attempt.
* @param removeAttempt Whether the offline data should be removed.
* @param updated Whether some data was sent to the site.
* @return Promise resolved on success. * @return Promise resolved on success.
*/ */
protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], attemptId?: number, offlineAttempt?: any, protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], options?: FinishSyncOptions)
onlineAttempt?: any, removeAttempt?: boolean, updated?: boolean): Promise<AddonModQuizSyncResult> { : Promise<AddonModQuizSyncResult> {
options = options || {};
// Invalidate the data for the quiz and attempt. // Invalidate the data for the quiz and attempt.
return this.quizProvider.invalidateAllQuizData(quiz.id, courseId, attemptId, siteId).catch(() => { return this.quizProvider.invalidateAllQuizData(quiz.id, courseId, options.attemptId, siteId).catch(() => {
// Ignore errors. // Ignore errors.
}).then(() => { }).then(() => {
if (removeAttempt && attemptId) { if (options.removeAttempt && options.attemptId) {
return this.quizOfflineProvider.removeAttemptAndAnswers(attemptId, siteId); const promises = [];
promises.push(this.quizOfflineProvider.removeAttemptAndAnswers(options.attemptId, siteId));
if (options.onlineQuestions) {
for (const slot in options.onlineQuestions) {
promises.push(this.questionDelegate.deleteOfflineData(options.onlineQuestions[slot],
AddonModQuizProvider.COMPONENT, quiz.coursemodule, siteId));
}
}
return Promise.all(promises);
} }
}).then(() => { }).then(() => {
if (updated) { if (options.updated) {
// Data has been sent. Update prefetched data. // Data has been sent. Update prefetched data.
return this.courseProvider.getModuleBasicInfoByInstance(quiz.id, 'quiz', siteId).then((module) => { return this.courseProvider.getModuleBasicInfoByInstance(quiz.id, 'quiz', siteId).then((module) => {
return this.prefetchAfterUpdateQuiz(module, quiz, courseId, undefined, siteId); return this.prefetchAfterUpdateQuiz(module, quiz, courseId, undefined, siteId);
@ -109,14 +118,14 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
}); });
}).then(() => { }).then(() => {
// Check if online attempt was finished because of the sync. // Check if online attempt was finished because of the sync.
if (onlineAttempt && !this.quizProvider.isAttemptFinished(onlineAttempt.state)) { if (options.onlineAttempt && !this.quizProvider.isAttemptFinished(options.onlineAttempt.state)) {
// Attempt wasn't finished at start. Check if it's finished now. // Attempt wasn't finished at start. Check if it's finished now.
return this.quizProvider.getUserAttempts(quiz.id, {cmId: quiz.coursemodule, siteId}).then((attempts) => { return this.quizProvider.getUserAttempts(quiz.id, {cmId: quiz.coursemodule, siteId}).then((attempts) => {
// Search the attempt. // Search the attempt.
for (const i in attempts) { for (const i in attempts) {
const attempt = attempts[i]; const attempt = attempts[i];
if (attempt.id == onlineAttempt.id) { if (attempt.id == options.onlineAttempt.id) {
return this.quizProvider.isAttemptFinished(attempt.state); return this.quizProvider.isAttemptFinished(attempt.state);
} }
} }
@ -288,16 +297,6 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
syncQuiz(quiz: any, askPreflight?: boolean, siteId?: string): Promise<AddonModQuizSyncResult> { syncQuiz(quiz: any, askPreflight?: boolean, siteId?: string): Promise<AddonModQuizSyncResult> {
siteId = siteId || this.sitesProvider.getCurrentSiteId(); siteId = siteId || this.sitesProvider.getCurrentSiteId();
const warnings = [];
const courseId = quiz.course;
const modOptions = {
cmId: quiz.coursemodule,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
let syncPromise;
let preflightData;
if (this.isSyncing(quiz.id, siteId)) { if (this.isSyncing(quiz.id, siteId)) {
// There's already a sync ongoing for this quiz, return the promise. // There's already a sync ongoing for this quiz, return the promise.
return this.getOngoingSync(quiz.id, siteId); return this.getOngoingSync(quiz.id, siteId);
@ -310,83 +309,111 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate}));
} }
return this.addOngoingSync(quiz.id, this.performSyncQuiz(quiz, askPreflight, siteId), siteId);
}
/**
* Perform the quiz sync.
*
* @param quiz Quiz.
* @param askPreflight Whether we should ask for preflight data if needed.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved in success.
*/
async performSyncQuiz(quiz: any, askPreflight?: boolean, siteId?: string): Promise<AddonModQuizSyncResult> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
const warnings = [];
const courseId = quiz.course;
const modOptions = {
cmId: quiz.coursemodule,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId,
};
this.logger.debug('Try to sync quiz ' + quiz.id + ' in site ' + siteId); this.logger.debug('Try to sync quiz ' + quiz.id + ' in site ' + siteId);
// Sync offline logs. // Sync offline logs.
syncPromise = this.logHelper.syncIfNeeded(AddonModQuizProvider.COMPONENT, quiz.id, siteId).catch(() => { await CoreUtils.instance.ignoreErrors(this.logHelper.syncIfNeeded(AddonModQuizProvider.COMPONENT, quiz.id, siteId));
// Ignore errors.
}).then(() => { // Get all the offline attempts for the quiz. It should always be 0 or 1 attempt
// Get all the offline attempts for the quiz. const offlineAttempts = await this.quizOfflineProvider.getQuizAttempts(quiz.id, siteId);
return this.quizOfflineProvider.getQuizAttempts(quiz.id, siteId);
}).then((attempts) => { if (!offlineAttempts.length) {
// Should return 0 or 1 attempt. // Nothing to sync, finish.
if (!attempts.length) {
return this.finishSync(siteId, quiz, courseId, warnings); return this.finishSync(siteId, quiz, courseId, warnings);
} }
const offlineAttempt = attempts.pop(); if (!this.appProvider.isOnline()) {
// Cannot sync in offline.
throw new Error(this.translate.instant('core.cannotconnect'));
}
const offlineAttempt = offlineAttempts.pop();
// Now get the list of online attempts to make sure this attempt exists and isn't finished. // 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 onlineAttempts = await this.quizProvider.getUserAttempts(quiz.id, modOptions);
const lastAttemptId = attempts.length ? attempts[attempts.length - 1].id : undefined;
let onlineAttempt;
// Search the attempt we retrieved from offline. const lastAttemptId = onlineAttempts.length ? onlineAttempts[onlineAttempts.length - 1].id : undefined;
for (const i in attempts) { const onlineAttempt = onlineAttempts.find((attempt) => {
const attempt = attempts[i]; return attempt.id == offlineAttempt.id;
});
if (attempt.id == offlineAttempt.id) {
onlineAttempt = attempt;
break;
}
}
if (!onlineAttempt || this.quizProvider.isAttemptFinished(onlineAttempt.state)) { if (!onlineAttempt || this.quizProvider.isAttemptFinished(onlineAttempt.state)) {
// Attempt not found or it's finished in online. Discard it. // Attempt not found or it's finished in online. Discard it.
warnings.push(this.translate.instant('addon.mod_quiz.warningattemptfinished')); warnings.push(this.translate.instant('addon.mod_quiz.warningattemptfinished'));
return this.finishSync(siteId, quiz, courseId, warnings, offlineAttempt.id, offlineAttempt, onlineAttempt, return this.finishSync(siteId, quiz, courseId, warnings, {
true); attemptId: offlineAttempt.id,
offlineAttempt,
onlineAttempt,
removeAttempt: true,
});
} }
// Get the data stored in offline. // Get the data stored in offline.
return this.quizOfflineProvider.getAttemptAnswers(offlineAttempt.id, siteId).then((answersList) => { const answersList = await this.quizOfflineProvider.getAttemptAnswers(offlineAttempt.id, siteId);
if (!answersList.length) { if (!answersList.length) {
// No answers stored, finish. // No answers stored, finish.
return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt, return this.finishSync(siteId, quiz, courseId, warnings, {
true); attemptId: lastAttemptId,
offlineAttempt,
onlineAttempt,
removeAttempt: true,
});
} }
const answers = this.questionProvider.convertAnswersArrayToObject(answersList), const offlineAnswers = this.questionProvider.convertAnswersArrayToObject(answersList);
offlineQuestions = this.quizOfflineProvider.classifyAnswersInQuestions(answers); const offlineQuestions = this.quizOfflineProvider.classifyAnswersInQuestions(offlineAnswers);
let finish;
// We're going to need preflightData, get it. // We're going to need preflightData, get it.
return this.quizProvider.getQuizAccessInformation(quiz.id, modOptions).then((info) => { const info = await this.quizProvider.getQuizAccessInformation(quiz.id, modOptions);
return this.prefetchHandler.getPreflightData(quiz, info, onlineAttempt, askPreflight, const preflightData = await this.prefetchHandler.getPreflightData(quiz, info, onlineAttempt, askPreflight,
'core.settings.synchronization', siteId); 'core.settings.synchronization', siteId);
}).then((data) => {
preflightData = data;
// Now get the online questions data. // Now get the online questions data.
const pages = this.quizProvider.getPagesFromLayoutAndQuestions(onlineAttempt.layout, offlineQuestions); const onlineQuestions = await this.quizProvider.getAllQuestionsData(quiz, onlineAttempt, preflightData, {
pages: this.quizProvider.getPagesFromLayoutAndQuestions(onlineAttempt.layout, offlineQuestions),
return this.quizProvider.getAllQuestionsData(quiz, onlineAttempt, preflightData, {
pages,
readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, readingStrategy: CoreSitesReadingStrategy.OnlyNetwork,
siteId, siteId,
}); });
}).then((onlineQuestions) => {
// Validate questions, discarding the offline answers that can't be synchronized. // Validate questions, discarding the offline answers that can't be synchronized.
return this.validateQuestions(onlineAttempt.id, onlineQuestions, offlineQuestions, siteId); const discardedData = await this.validateQuestions(onlineAttempt.id, onlineQuestions, offlineQuestions, siteId);
}).then((discardedData) => {
// 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. // Get the answers to send.
const answers = this.quizOfflineProvider.extractAnswersFromQuestions(offlineQuestions); const answers = this.quizOfflineProvider.extractAnswersFromQuestions(offlineQuestions);
finish = offlineAttempt.finished && !discardedData; const finish = offlineAttempt.finished && !discardedData;
if (discardedData) { if (discardedData) {
if (offlineAttempt.finished) { if (offlineAttempt.finished) {
@ -396,29 +423,25 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
} }
} }
return this.quizProvider.processAttempt(quiz, onlineAttempt, answers, preflightData, finish, false, false, // Send the answers.
siteId); await 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) { 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. // 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, await CoreUtils.instance.ignoreErrors(this.quizProvider.logViewAttempt(onlineAttempt.id, offlineAttempt.currentpage,
false, undefined, siteId).catch(() => { preflightData, false, undefined, siteId));
// Ignore errors.
});
} }
}).then(() => {
// Data sent. Finish the sync. // Data sent. Finish the sync.
return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt, return this.finishSync(siteId, quiz, courseId, warnings, {
true, true); attemptId: lastAttemptId,
offlineAttempt,
onlineAttempt,
removeAttempt: true,
updated: true,
onlineQuestions,
}); });
});
});
});
return this.addOngoingSync(quiz.id, syncPromise, siteId);
} }
/** /**
@ -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.
};

View File

@ -215,6 +215,8 @@ export class AddonModQuizProvider {
}; };
return site.read('mod_quiz_get_attempt_data', params, preSets); return site.read('mod_quiz_get_attempt_data', params, preSets);
}).then((result) => {
return this.parseQuestions(result);
}); });
} }
@ -389,7 +391,9 @@ export class AddonModQuizProvider {
...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. ...this.sitesProvider.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
}; };
return site.read('mod_quiz_get_attempt_review', params, preSets); return site.read('mod_quiz_get_attempt_review', params, preSets).then((result) => {
return this.parseQuestions(result);
});
}); });
} }
@ -427,6 +431,8 @@ export class AddonModQuizProvider {
return site.read('mod_quiz_get_attempt_summary', params, preSets).then((response) => { return site.read('mod_quiz_get_attempt_summary', params, preSets).then((response) => {
if (response && response.questions) { if (response && response.questions) {
response = this.parseQuestions(response);
if (options.loadLocal) { if (options.loadLocal) {
return this.quizOfflineProvider.loadQuestionsLocalStates(attemptId, response.questions, site.getId()); return this.quizOfflineProvider.loadQuestionsLocalStates(attemptId, response.questions, site.getId());
} }
@ -1560,6 +1566,27 @@ export class AddonModQuizProvider {
siteId); siteId);
} }
/**
* Parse questions of a WS response.
*
* @param result Result to parse.
* @return Parsed result.
*/
parseQuestions(result: any): any {
for (let i = 0; i < result.questions.length; i++) {
const question = result.questions[i];
if (!question.settings) {
// Site doesn't return settings, stop.
break;
}
question.settings = this.textUtils.parseJSON(question.settings, null);
}
return result;
}
/** /**
* Process an attempt, saving its data. * Process an attempt, saving its data.
* *

View File

@ -40,13 +40,14 @@ export class AddonQbehaviourDeferredCBMHandler implements CoreQuestionBehaviourH
* @param component Component the question belongs to. * @param component Component the question belongs to.
* @param attemptId Attempt ID the question belongs to. * @param attemptId Attempt ID the question belongs to.
* @param question The question. * @param question The question.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return New state (or promise resolved with state). * @return New state (or promise resolved with state).
*/ */
determineNewState(component: string, attemptId: number, question: any, siteId?: string) determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string)
: CoreQuestionState | Promise<CoreQuestionState> { : CoreQuestionState | Promise<CoreQuestionState> {
// Depends on deferredfeedback. // Depends on deferredfeedback.
return this.deferredFeedbackHandler.determineNewStateDeferred(component, attemptId, question, siteId, return this.deferredFeedbackHandler.determineNewStateDeferred(component, attemptId, question, componentId, siteId,
this.isCompleteResponse.bind(this), this.isSameResponse.bind(this)); this.isCompleteResponse.bind(this), this.isSameResponse.bind(this));
} }
@ -71,11 +72,13 @@ export class AddonQbehaviourDeferredCBMHandler implements CoreQuestionBehaviourH
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
protected isCompleteResponse(question: any, answers: any): number { protected isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
// First check if the question answer is complete. // First check if the question answer is complete.
const complete = this.questionDelegate.isCompleteResponse(question, answers); const complete = this.questionDelegate.isCompleteResponse(question, answers, component, componentId);
if (complete > 0) { if (complete > 0) {
// Answer is complete, check the user answered CBM too. // Answer is complete, check the user answered CBM too.
return answers['-certainty'] ? 1 : 0; return answers['-certainty'] ? 1 : 0;
@ -101,12 +104,14 @@ export class AddonQbehaviourDeferredCBMHandler implements CoreQuestionBehaviourH
* @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). * @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). * @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
protected isSameResponse(question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any, newBasicAnswers: any) protected isSameResponse(question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any, newBasicAnswers: any,
: boolean { component: string, componentId: string | number): boolean {
// First check if the question answer is the same. // First check if the question answer is the same.
const same = this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers); const same = this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers, component, componentId);
if (same) { if (same) {
// Same response, check the CBM is the same too. // Same response, check the CBM is the same too.
return prevAnswers['-certainty'] == newAnswers['-certainty']; return prevAnswers['-certainty'] == newAnswers['-certainty'];

View File

@ -23,9 +23,11 @@ import { CoreQuestionProvider, CoreQuestionState } from '@core/question/provider
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
export type isCompleteResponseFunction = (question: any, answers: any) => number; export type isCompleteResponseFunction = (question: any, answers: any, component: string, componentId: string | number) => number;
/** /**
* Check if two responses are the same. * Check if two responses are the same.
@ -35,10 +37,12 @@ export type isCompleteResponseFunction = (question: any, answers: any) => number
* @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). * @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). * @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
export type isSameResponseFunction = (question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any, export type isSameResponseFunction = (question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any,
newBasicAnswers: any) => boolean; newBasicAnswers: any, component: string, componentId: string | number) => boolean;
/** /**
* Handler to support deferred feedback question behaviour. * Handler to support deferred feedback question behaviour.
@ -58,12 +62,13 @@ export class AddonQbehaviourDeferredFeedbackHandler implements CoreQuestionBehav
* @param component Component the question belongs to. * @param component Component the question belongs to.
* @param attemptId Attempt ID the question belongs to. * @param attemptId Attempt ID the question belongs to.
* @param question The question. * @param question The question.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return New state (or promise resolved with state). * @return New state (or promise resolved with state).
*/ */
determineNewState(component: string, attemptId: number, question: any, siteId?: string) determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string)
: CoreQuestionState | Promise<CoreQuestionState> { : CoreQuestionState | Promise<CoreQuestionState> {
return this.determineNewStateDeferred(component, attemptId, question, siteId); return this.determineNewStateDeferred(component, attemptId, question, componentId, siteId);
} }
/** /**
@ -72,19 +77,25 @@ export class AddonQbehaviourDeferredFeedbackHandler implements CoreQuestionBehav
* @param component Component the question belongs to. * @param component Component the question belongs to.
* @param attemptId Attempt ID the question belongs to. * @param attemptId Attempt ID the question belongs to.
* @param question The question. * @param question The question.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @param isCompleteFn Function to override the default isCompleteResponse check. * @param isCompleteFn Function to override the default isCompleteResponse check.
* @param isSameFn Function to override the default isSameResponse check. * @param isSameFn Function to override the default isSameResponse check.
* @return Promise resolved with state. * @return Promise resolved with state.
*/ */
determineNewStateDeferred(component: string, attemptId: number, question: any, siteId?: string, async determineNewStateDeferred(component: string, attemptId: number, question: any, componentId: string | number,
isCompleteFn?: isCompleteResponseFunction, isSameFn?: isSameResponseFunction): Promise<CoreQuestionState> { siteId?: string, isCompleteFn?: isCompleteResponseFunction, isSameFn?: isSameResponseFunction)
: Promise<CoreQuestionState> {
// Check if we have local data for the question. // Check if we have local data for the question.
return this.questionProvider.getQuestion(component, attemptId, question.slot, siteId).catch(() => { let dbQuestion;
try {
dbQuestion = await this.questionProvider.getQuestion(component, attemptId, question.slot, siteId);
} catch (error) {
// No entry found, use the original data. // No entry found, use the original data.
return question; dbQuestion = question;
}).then((dbQuestion) => { }
const state = this.questionProvider.getState(dbQuestion.state); const state = this.questionProvider.getState(dbQuestion.state);
if (state.finished || !state.active) { if (state.finished || !state.active) {
@ -92,35 +103,38 @@ export class AddonQbehaviourDeferredFeedbackHandler implements CoreQuestionBehav
return state; 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); 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); prevAnswers = this.questionProvider.convertAnswersArrayToObject(prevAnswers, true);
const prevBasicAnswers = this.questionProvider.getBasicAnswers(prevAnswers); const prevBasicAnswers = this.questionProvider.getBasicAnswers(prevAnswers);
// If answers haven't changed the state is the same. // If answers haven't changed the state is the same.
if (isSameFn) { if (isSameFn) {
if (isSameFn(question, prevAnswers, prevBasicAnswers, question.answers, newBasicAnswers)) { if (isSameFn(question, prevAnswers, prevBasicAnswers, question.answers, newBasicAnswers,
component, componentId)) {
return state; return state;
} }
} else { } else {
if (this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers)) { if (this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers, component, componentId)) {
return state; return state;
} }
} }
}
// Answers have changed. Now check if the response is complete and calculate the new state. // Answers have changed. Now check if the response is complete and calculate the new state.
let complete: number, let complete: number;
newState: string; let newState: string;
if (isCompleteFn) { if (isCompleteFn) {
// Pass all the answers since some behaviours might need the extra data. // Pass all the answers since some behaviours might need the extra data.
complete = isCompleteFn(question, question.answers); complete = isCompleteFn(question, question.answers, component, componentId);
} else { } else {
// Only pass the basic answers since questions should be independent of extra data. // Only pass the basic answers since questions should be independent of extra data.
complete = this.questionDelegate.isCompleteResponse(question, newBasicAnswers); complete = this.questionDelegate.isCompleteResponse(question, newBasicAnswers, component, componentId);
} }
if (complete < 0) { if (complete < 0) {
@ -128,7 +142,7 @@ export class AddonQbehaviourDeferredFeedbackHandler implements CoreQuestionBehav
} else if (complete > 0) { } else if (complete > 0) {
newState = 'complete'; newState = 'complete';
} else { } else {
const gradable = this.questionDelegate.isGradableResponse(question, newBasicAnswers); const gradable = this.questionDelegate.isGradableResponse(question, newBasicAnswers, component, componentId);
if (gradable < 0) { if (gradable < 0) {
newState = 'cannotdeterminestatus'; newState = 'cannotdeterminestatus';
} else if (gradable > 0) { } else if (gradable > 0) {
@ -139,8 +153,6 @@ export class AddonQbehaviourDeferredFeedbackHandler implements CoreQuestionBehav
} }
return this.questionProvider.getState(newState); return this.questionProvider.getState(newState);
});
});
} }
/** /**

View File

@ -35,10 +35,11 @@ export class AddonQbehaviourInformationItemHandler implements CoreQuestionBehavi
* @param component Component the question belongs to. * @param component Component the question belongs to.
* @param attemptId Attempt ID the question belongs to. * @param attemptId Attempt ID the question belongs to.
* @param question The question. * @param question The question.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return New state (or promise resolved with state). * @return New state (or promise resolved with state).
*/ */
determineNewState(component: string, attemptId: number, question: any, siteId?: string) determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string)
: CoreQuestionState | Promise<CoreQuestionState> { : CoreQuestionState | Promise<CoreQuestionState> {
if (question.answers['-seen']) { if (question.answers['-seen']) {
return this.questionProvider.getState('complete'); return this.questionProvider.getState('complete');

View File

@ -17,28 +17,7 @@ import { Injectable } from '@angular/core';
import { CoreQuestionBehaviourHandler } from '@core/question/providers/behaviour-delegate'; import { CoreQuestionBehaviourHandler } from '@core/question/providers/behaviour-delegate';
import { CoreQuestionDelegate } from '@core/question/providers/delegate'; import { CoreQuestionDelegate } from '@core/question/providers/delegate';
import { CoreQuestionProvider, CoreQuestionState } from '@core/question/providers/question'; import { CoreQuestionProvider, CoreQuestionState } from '@core/question/providers/question';
import { AddonQbehaviourDeferredFeedbackHandler } from '@addon/qbehaviour/deferredfeedback/providers/handler';
/**
* Check if a response is complete.
*
* @param question The question.
* @param answers Object with the question answers (without prefix).
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/
export type isCompleteResponseFunction = (question: any, answers: any) => number;
/**
* Check if two responses are the same.
*
* @param question Question.
* @param prevAnswers Object with the previous question answers.
* @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
* @param newAnswers Object with the new question answers.
* @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...).
* @return Whether they're the same.
*/
export type isSameResponseFunction = (question: any, prevAnswers: any, prevBasicAnswers: any, newAnswers: any,
newBasicAnswers: any) => boolean;
/** /**
* Handler to support manual graded question behaviour. * Handler to support manual graded question behaviour.
@ -48,7 +27,9 @@ export class AddonQbehaviourManualGradedHandler implements CoreQuestionBehaviour
name = 'AddonQbehaviourManualGraded'; name = 'AddonQbehaviourManualGraded';
type = 'manualgraded'; type = 'manualgraded';
constructor(private questionDelegate: CoreQuestionDelegate, private questionProvider: CoreQuestionProvider) { constructor(protected questionDelegate: CoreQuestionDelegate,
protected questionProvider: CoreQuestionProvider,
protected deferredFeedbackHandler: AddonQbehaviourDeferredFeedbackHandler) {
// Nothing to do. // Nothing to do.
} }
@ -58,82 +39,14 @@ export class AddonQbehaviourManualGradedHandler implements CoreQuestionBehaviour
* @param component Component the question belongs to. * @param component Component the question belongs to.
* @param attemptId Attempt ID the question belongs to. * @param attemptId Attempt ID the question belongs to.
* @param question The question. * @param question The question.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return New state (or promise resolved with state). * @return New state (or promise resolved with state).
*/ */
determineNewState(component: string, attemptId: number, question: any, siteId?: string) determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string)
: CoreQuestionState | Promise<CoreQuestionState> { : CoreQuestionState | Promise<CoreQuestionState> {
return this.determineNewStateManualGraded(component, attemptId, question, siteId); // Same implementation as the deferred feedback. Use that function instead of replicating it.
} return this.deferredFeedbackHandler.determineNewStateDeferred(component, attemptId, question, componentId, siteId);
/**
* Determine a question new state based on its answer(s) for manual graded question behaviour.
*
* @param component Component the question belongs to.
* @param attemptId Attempt ID the question belongs to.
* @param question The question.
* @param siteId Site ID. If not defined, current site.
* @param isCompleteFn Function to override the default isCompleteResponse check.
* @param isSameFn Function to override the default isSameResponse check.
* @return Promise resolved with state.
*/
determineNewStateManualGraded(component: string, attemptId: number, question: any, siteId?: string,
isCompleteFn?: isCompleteResponseFunction, isSameFn?: isSameResponseFunction): Promise<CoreQuestionState> {
// Check if we have local data for the question.
return this.questionProvider.getQuestion(component, attemptId, question.slot, siteId).catch(() => {
// No entry found, use the original data.
return question;
}).then((dbQuestion) => {
const state = this.questionProvider.getState(dbQuestion.state);
if (state.finished || !state.active) {
// Question is finished, it cannot change.
return state;
}
// We need to check if the answers have changed. Retrieve current stored answers.
return this.questionProvider.getQuestionAnswers(component, attemptId, question.slot, false, siteId)
.then((prevAnswers) => {
const newBasicAnswers = this.questionProvider.getBasicAnswers(question.answers);
prevAnswers = this.questionProvider.convertAnswersArrayToObject(prevAnswers, true);
const prevBasicAnswers = this.questionProvider.getBasicAnswers(prevAnswers);
// If answers haven't changed the state is the same.
if (isSameFn) {
if (isSameFn(question, prevAnswers, prevBasicAnswers, question.answers, newBasicAnswers)) {
return state;
}
} else {
if (this.questionDelegate.isSameResponse(question, prevBasicAnswers, newBasicAnswers)) {
return state;
}
}
// Answers have changed. Now check if the response is complete and calculate the new state.
let complete: number,
newState: string;
if (isCompleteFn) {
// Pass all the answers since some behaviours might need the extra data.
complete = isCompleteFn(question, question.answers);
} else {
// Only pass the basic answers since questions should be independent of extra data.
complete = this.questionDelegate.isCompleteResponse(question, newBasicAnswers);
}
if (complete < 0) {
newState = 'cannotdeterminestatus';
} else if (complete > 0) {
newState = 'complete';
} else {
newState = 'todo';
}
return this.questionProvider.getState(newState);
});
});
} }
/** /**

View File

@ -24,6 +24,14 @@ import { AddonQtypeCalculatedComponent } from '../component/calculated';
*/ */
@Injectable() @Injectable()
export class AddonQtypeCalculatedHandler implements CoreQuestionHandler { export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
static UNITINPUT = '0';
static UNITRADIO = '1';
static UNITSELECT = '2';
static UNITNONE = '3';
static UNITGRADED = '1';
static UNITOPTIONAL = '0';
name = 'AddonQtypeCalculated'; name = 'AddonQtypeCalculated';
type = 'qtype_calculated'; type = 'qtype_calculated';
@ -41,25 +49,71 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
return AddonQtypeCalculatedComponent; return AddonQtypeCalculatedComponent;
} }
/**
* Check if the units are in a separate field for the question.
*
* @param question Question.
* @return Whether units are in a separate field.
*/
hasSeparateUnitField(question: any): boolean {
if (!question.settings) {
const element = this.domUtils.convertToElement(question.html);
return !!(element.querySelector('select[name*=unit]') || element.querySelector('input[type="radio"]'));
}
return question.settings.unitdisplay === AddonQtypeCalculatedHandler.UNITRADIO ||
question.settings.unitdisplay === AddonQtypeCalculatedHandler.UNITSELECT;
}
/** /**
* Check if a response is complete. * Check if a response is complete.
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse(question: any, answers: any): number { isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
if (this.isGradableResponse(question, answers) === 0 || !this.validateUnits(answers['answer'])) { if (!this.isGradableResponse(question, answers, component, componentId)) {
return 0; return 0;
} }
if (this.requiresUnits(question)) { const parsedAnswer = this.parseAnswer(question, answers['answer']);
if (parsedAnswer.answer === null) {
return 0;
}
if (!question.settings) {
if (this.hasSeparateUnitField(question)) {
return this.isValidValue(answers['unit']) ? 1 : 0; return this.isValidValue(answers['unit']) ? 1 : 0;
} }
// We cannot know if the answer should contain units or not.
return -1; 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;
}
/** /**
* Whether or not the handler is enabled on a site level. * Whether or not the handler is enabled on a site level.
* *
@ -75,16 +129,12 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/ */
isGradableResponse(question: any, answers: any): number { isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
let isGradable = this.isValidValue(answers['answer']); return this.isValidValue(answers['answer']) ? 1 : 0;
if (isGradable && this.requiresUnits(question)) {
// The question requires a unit.
isGradable = this.isValidValue(answers['unit']);
}
return isGradable ? 1 : 0;
} }
/** /**
@ -93,9 +143,11 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
* @param question Question. * @param question Question.
* @param prevAnswers Object with the previous question answers. * @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') && return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') &&
this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'unit'); this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'unit');
} }
@ -111,36 +163,24 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
} }
/** /**
* Check if a question requires units in a separate input. * Parse an answer string.
*
* @param question The question.
* @return Whether the question requires units.
*/
requiresUnits(question: any): boolean {
const element = this.domUtils.convertToElement(question.html);
return !!(element.querySelector('select[name*=unit]') || element.querySelector('input[type="radio"]'));
}
/**
* Validate a number with units. We don't have the list of valid units and conversions, so we can't perform
* a full validation. If this function returns true it means we can't be sure it's valid.
* *
* @param question Question.
* @param answer Answer. * @param answer Answer.
* @return False if answer isn't valid, true if we aren't sure if it's valid. * @return 0 if answer isn't valid, 1 if answer is valid, -1 if we aren't sure if it's valid.
*/ */
validateUnits(answer: string): boolean { parseAnswer(question: any, answer: string): {answer: number, unit: string} {
if (!answer) { if (!answer) {
return false; return {answer: null, unit: null};
} }
const regexString = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?'; let regexString = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?';
// Strip spaces (which may be thousands separators) and change other forms of writing e to e. // Strip spaces (which may be thousands separators) and change other forms of writing e to e.
answer = answer.replace(' ', ''); answer = answer.replace(/ /g, '');
answer = answer.replace(/(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)/, 'e$1'); answer = answer.replace(/(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)/, 'e$1');
// If a '.' is present or there are multiple ',' (i.e. 2,456,789) assume ',' is a thousands separator and stip it. // If a '.' is present or there are multiple ',' (i.e. 2,456,789) assume ',' is a thousands separator and strip it.
// Else assume it is a decimal separator, and change it to '.'. // Else assume it is a decimal separator, and change it to '.'.
if (answer.indexOf('.') != -1 || answer.split(',').length - 1 > 1) { if (answer.indexOf('.') != -1 || answer.split(',').length - 1 > 1) {
answer = answer.replace(',', ''); answer = answer.replace(',', '');
@ -148,11 +188,31 @@ export class AddonQtypeCalculatedHandler implements CoreQuestionHandler {
answer = answer.replace(',', '.'); answer = answer.replace(',', '.');
} }
let unitsLeft = false;
let match = null;
if (!question.settings) {
// We don't know if units should be before or after so we check both. // 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) { match = answer.match(new RegExp('^' + regexString));
return false; 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};
} }
} }

View File

@ -46,9 +46,11 @@ export class AddonQtypeCalculatedMultiHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse(question: any, answers: any): number { isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
// This question type depends on multichoice. // This question type depends on multichoice.
return this.multichoiceHandler.isCompleteResponseSingle(answers); return this.multichoiceHandler.isCompleteResponseSingle(answers);
} }
@ -68,9 +70,11 @@ export class AddonQtypeCalculatedMultiHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/ */
isGradableResponse(question: any, answers: any): number { isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
// This question type depends on multichoice. // This question type depends on multichoice.
return this.multichoiceHandler.isGradableResponseSingle(answers); return this.multichoiceHandler.isGradableResponseSingle(answers);
} }
@ -81,9 +85,11 @@ export class AddonQtypeCalculatedMultiHandler implements CoreQuestionHandler {
* @param question Question. * @param question Question.
* @param prevAnswers Object with the previous question answers. * @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
// This question type depends on multichoice. // This question type depends on multichoice.
return this.multichoiceHandler.isSameResponseSingle(prevAnswers, newAnswers); return this.multichoiceHandler.isSameResponseSingle(prevAnswers, newAnswers);
} }

View File

@ -46,11 +46,13 @@ export class AddonQtypeCalculatedSimpleHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse(question: any, answers: any): number { isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
// This question type depends on calculated. // This question type depends on calculated.
return this.calculatedHandler.isCompleteResponse(question, answers); return this.calculatedHandler.isCompleteResponse(question, answers, component, componentId);
} }
/** /**
@ -68,11 +70,13 @@ export class AddonQtypeCalculatedSimpleHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/ */
isGradableResponse(question: any, answers: any): number { isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
// This question type depends on calculated. // This question type depends on calculated.
return this.calculatedHandler.isGradableResponse(question, answers); return this.calculatedHandler.isGradableResponse(question, answers, component, componentId);
} }
/** /**
@ -81,10 +85,12 @@ export class AddonQtypeCalculatedSimpleHandler implements CoreQuestionHandler {
* @param question Question. * @param question Question.
* @param prevAnswers Object with the previous question answers. * @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
// This question type depends on calculated. // This question type depends on calculated.
return this.calculatedHandler.isSameResponse(question, prevAnswers, newAnswers); return this.calculatedHandler.isSameResponse(question, prevAnswers, newAnswers, component, componentId);
} }
} }

View File

@ -61,9 +61,11 @@ export class AddonQtypeDdImageOrTextHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse(question: any, answers: any): number { isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
// An answer is complete if all drop zones have an answer. // An answer is complete if all drop zones have an answer.
// We should always receive all the drop zones with their value ('' if not answered). // We should always receive all the drop zones with their value ('' if not answered).
for (const name in answers) { for (const name in answers) {
@ -91,9 +93,11 @@ export class AddonQtypeDdImageOrTextHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/ */
isGradableResponse(question: any, answers: any): number { isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
for (const name in answers) { for (const name in answers) {
const value = answers[name]; const value = answers[name];
if (value && value !== '0') { if (value && value !== '0') {
@ -110,9 +114,11 @@ export class AddonQtypeDdImageOrTextHandler implements CoreQuestionHandler {
* @param question Question. * @param question Question.
* @param prevAnswers Object with the previous question answers. * @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers);
} }
} }

View File

@ -62,9 +62,11 @@ export class AddonQtypeDdMarkerHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse(question: any, answers: any): number { isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
// If 1 dragitem is set we assume the answer is complete (like Moodle does). // If 1 dragitem is set we assume the answer is complete (like Moodle does).
for (const name in answers) { for (const name in answers) {
if (answers[name]) { if (answers[name]) {
@ -90,10 +92,12 @@ export class AddonQtypeDdMarkerHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/ */
isGradableResponse(question: any, answers: any): number { isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
return this.isCompleteResponse(question, answers); return this.isCompleteResponse(question, answers, component, componentId);
} }
/** /**
@ -102,9 +106,11 @@ export class AddonQtypeDdMarkerHandler implements CoreQuestionHandler {
* @param question Question. * @param question Question.
* @param prevAnswers Object with the previous question answers. * @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers);
} }

View File

@ -61,9 +61,11 @@ export class AddonQtypeDdwtosHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse(question: any, answers: any): number { isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
for (const name in answers) { for (const name in answers) {
const value = answers[name]; const value = answers[name];
if (!value || value === '0') { if (!value || value === '0') {
@ -89,9 +91,11 @@ export class AddonQtypeDdwtosHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/ */
isGradableResponse(question: any, answers: any): number { isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
for (const name in answers) { for (const name in answers) {
const value = answers[name]; const value = answers[name];
if (value && value !== '0') { if (value && value !== '0') {
@ -108,9 +112,11 @@ export class AddonQtypeDdwtosHandler implements CoreQuestionHandler {
* @param question Question. * @param question Question.
* @param prevAnswers Object with the previous question answers. * @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers);
} }
} }

View File

@ -5,9 +5,10 @@
</ion-item> </ion-item>
<!-- Textarea. --> <!-- Textarea. -->
<ion-item *ngIf="question.textarea && !question.hasDraftFiles"> <ion-item *ngIf="question.textarea && (!question.hasDraftFiles || uploadFilesSupported)">
<!-- "Format" hidden input --> <!-- "Format" and draftid hidden inputs -->
<input item-content *ngIf="question.formatInput" type="hidden" [name]="question.formatInput.name" [value]="question.formatInput.value" > <input item-content *ngIf="question.formatInput" type="hidden" [name]="question.formatInput.name" [value]="question.formatInput.value" >
<input item-content *ngIf="question.answerDraftIdInput" type="hidden" [name]="question.answerDraftIdInput.name" [value]="question.answerDraftIdInput.value" >
<!-- Plain text textarea. --> <!-- Plain text textarea. -->
<ion-textarea *ngIf="question.isPlainText" class="core-question-textarea" [ngClass]='{"core-monospaced": question.isMonospaced}' placeholder="{{ 'core.question.answer' | translate }}" [attr.name]="question.textarea.name" aria-multiline="true" [ngModel]="question.textarea.text"></ion-textarea> <ion-textarea *ngIf="question.isPlainText" class="core-question-textarea" [ngClass]='{"core-monospaced": question.isMonospaced}' placeholder="{{ 'core.question.answer' | translate }}" [attr.name]="question.textarea.name" aria-multiline="true" [ngModel]="question.textarea.text"></ion-textarea>
<!-- Rich text editor. --> <!-- Rich text editor. -->
@ -15,22 +16,29 @@
</ion-item> </ion-item>
<!-- Draft files not supported. --> <!-- Draft files not supported. -->
<ng-container *ngIf="question.textarea && question.hasDraftFiles"> <ng-container *ngIf="question.textarea && question.hasDraftFiles && !uploadFilesSupported">
<ion-item text-wrap class="core-danger-item"> <ion-item text-wrap class="core-danger-item">
<p class="core-question-warning">{{ 'core.question.errorinlinefilesnotsupported' | translate }}</p> <p class="core-question-warning">{{ 'core.question.errorinlinefilesnotsupportedinsite' | translate }}</p>
</ion-item> </ion-item>
<ion-item text-wrap> <ion-item text-wrap>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="question.textarea.text" [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId"></core-format-text></p> <p><core-format-text [component]="component" [componentId]="componentId" [text]="question.textarea.text" [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId"></core-format-text></p>
</ion-item> </ion-item>
</ng-container> </ng-container>
<!-- Attachments not supported in the app yet. --> <!-- Attachments. -->
<ion-item text-wrap *ngIf="question.allowsAttachments" class="core-danger-item"> <ng-container *ngIf="question.allowsAttachments">
<p class="core-question-warning">{{ 'core.question.errorattachmentsnotsupported' | translate }}</p> <core-attachments *ngIf="uploadFilesSupported" [files]="attachments" [component]="component" [componentId]="componentId" [maxSize]="question.attachmentsMaxBytes" [maxSubmissions]="question.attachmentsMaxFiles" [allowOffline]="offlineEnabled" [acceptedTypes]="question.attachmentsAcceptedTypes"></core-attachments>
<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> </ion-item>
</ng-container>
<!-- Answer to the question and attachments (reviewing). --> <!-- Answer to the question and attachments (reviewing). -->
<ion-item text-wrap *ngIf="!question.textarea && (question.answer || (!question.attachments.length && !question.allowsAttachments))"> <ion-item text-wrap *ngIf="!question.textarea && (question.answer || question.answer == '')">
<p><core-format-text [ngClass]='{"core-monospaced": question.isMonospaced}' [component]="component" [componentId]="componentId" [text]="question.answer" [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId"></core-format-text></p> <p><core-format-text [ngClass]='{"core-monospaced": question.isMonospaced}' [component]="component" [componentId]="componentId" [text]="question.answer" [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId"></core-format-text></p>
</ion-item> </ion-item>

View File

@ -14,8 +14,12 @@
import { Component, OnInit, Injector } from '@angular/core'; import { Component, OnInit, Injector } from '@angular/core';
import { CoreLoggerProvider } from '@providers/logger'; import { CoreLoggerProvider } from '@providers/logger';
import { CoreWSExternalFile } from '@providers/ws';
import { CoreFileUploader } from '@core/fileuploader/providers/fileuploader';
import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component'; import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component';
import { CoreQuestion } from '@core/question/providers/question';
import { FormControl, FormBuilder } from '@angular/forms'; import { FormControl, FormBuilder } from '@angular/forms';
import { CoreFileSession } from '@providers/file-session';
/** /**
* Component to render an essay question. * Component to render an essay question.
@ -28,6 +32,9 @@ export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implemen
protected formControl: FormControl; protected formControl: FormControl;
attachments: CoreWSExternalFile[];
uploadFilesSupported: boolean;
constructor(logger: CoreLoggerProvider, injector: Injector, protected fb: FormBuilder) { constructor(logger: CoreLoggerProvider, injector: Injector, protected fb: FormBuilder) {
super(logger, 'AddonQtypeEssayComponent', injector); super(logger, 'AddonQtypeEssayComponent', injector);
} }
@ -36,8 +43,39 @@ export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implemen
* Component being initialized. * Component being initialized.
*/ */
ngOnInit(): void { ngOnInit(): void {
this.uploadFilesSupported = typeof this.question.responsefileareas != 'undefined';
this.initEssayComponent(); this.initEssayComponent();
this.formControl = this.fb.control(this.question.textarea && this.question.textarea.text); this.formControl = this.fb.control(this.question.textarea && this.question.textarea.text);
if (this.question.allowsAttachments && this.uploadFilesSupported) {
this.loadAttachments();
}
}
/**
* Load attachments.
*
* @return Promise resolved when done.
*/
async loadAttachments(): Promise<void> {
if (this.offlineEnabled && this.question.localAnswers['attachments_offline']) {
const attachmentsData = this.textUtils.parseJSON(this.question.localAnswers['attachments_offline'], {});
let offlineFiles = [];
if (attachmentsData.offline) {
offlineFiles = await this.questionHelper.getStoredQuestionFiles(this.question, this.component, this.componentId);
offlineFiles = CoreFileUploader.instance.markOfflineFiles(offlineFiles);
}
this.attachments = (attachmentsData.online || []).concat(offlineFiles);
} else {
this.attachments = Array.from(this.questionHelper.getResponseFileAreaFiles(this.question, 'attachments'));
}
CoreFileSession.instance.setFiles(this.component,
CoreQuestion.instance.getQuestionComponentId(this.question, this.componentId), this.attachments);
} }
} }

View File

@ -14,11 +14,15 @@
// limitations under the License. // limitations under the License.
import { Injectable, Injector } from '@angular/core'; import { Injectable, Injector } from '@angular/core';
import { CoreFileSession } from '@providers/file-session';
import { CoreSites } from '@providers/sites';
import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreFileUploader } from '@core/fileuploader/providers/fileuploader';
import { CoreQuestionHandler } from '@core/question/providers/delegate'; import { CoreQuestionHandler } from '@core/question/providers/delegate';
import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; import { CoreQuestionHelperProvider } from '@core/question/providers/helper';
import { CoreQuestion } from '@core/question/providers/question';
import { AddonQtypeEssayComponent } from '../component/essay'; import { AddonQtypeEssayComponent } from '../component/essay';
/** /**
@ -32,6 +36,59 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
constructor(private utils: CoreUtilsProvider, private questionHelper: CoreQuestionHelperProvider, constructor(private utils: CoreUtilsProvider, private questionHelper: CoreQuestionHelperProvider,
private textUtils: CoreTextUtilsProvider, private domUtils: CoreDomUtilsProvider) { } private textUtils: CoreTextUtilsProvider, private domUtils: CoreDomUtilsProvider) { }
/**
* Clear temporary data after the data has been saved.
*
* @param question Question.
* @param component The component the question is related to.
* @param componentId Component ID.
*/
clearTmpData(question: any, component: string, componentId: string | number): void {
const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
const files = CoreFileSession.instance.getFiles(component, questionComponentId);
// Clear the files in session for this question.
CoreFileSession.instance.clearFiles(component, questionComponentId);
// Now delete the local files from the tmp folder.
CoreFileUploader.instance.clearTmpFiles(files);
}
/**
* Delete any stored data for the question.
*
* @param question Question.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
deleteOfflineData(question: any, component: string, componentId: string | number, siteId?: string): Promise<void> {
return this.questionHelper.deleteStoredQuestionFiles(question, component, componentId, siteId);
}
/**
* Check whether the question allows text and/or attachments.
*
* @param question Question to check.
* @return Allowed options.
*/
protected getAllowedOptions(question: any): {text: boolean, attachments: boolean} {
if (question.settings) {
return {
text: question.settings.responseformat != 'noinline',
attachments: question.settings.attachments != '0',
};
} else {
const element = this.domUtils.convertToElement(question.html);
return {
text: !!element.querySelector('textarea[name*=_answer]'),
attachments: !!element.querySelector('div[id*=filemanager]'),
};
}
}
/** /**
* Return the name of the behaviour to use for the question. * Return the name of the behaviour to use for the question.
* If the question should use the default behaviour you shouldn't implement this function. * If the question should use the default behaviour you shouldn't implement this function.
@ -65,14 +122,15 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
*/ */
getPreventSubmitMessage(question: any): string { getPreventSubmitMessage(question: any): string {
const element = this.domUtils.convertToElement(question.html); const element = this.domUtils.convertToElement(question.html);
const uploadFilesSupported = typeof question.responsefileareas != 'undefined';
if (element.querySelector('div[id*=filemanager]')) { if (!uploadFilesSupported && element.querySelector('div[id*=filemanager]')) {
// The question allows attachments. Since the app cannot attach files yet we will prevent submitting the question. // The question allows attachments. Since the app cannot attach files yet we will prevent submitting the question.
return 'core.question.errorattachmentsnotsupported'; return 'core.question.errorattachmentsnotsupportedinsite';
} }
if (this.questionHelper.hasDraftFileUrls(element.innerHTML)) { if (!uploadFilesSupported && this.questionHelper.hasDraftFileUrls(element.innerHTML)) {
return 'core.question.errorinlinefilesnotsupported'; return 'core.question.errorinlinefilesnotsupportedinsite';
} }
} }
@ -81,22 +139,36 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse(question: any, answers: any): number { isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
const element = this.domUtils.convertToElement(question.html);
const hasInlineText = answers['answer'] && answers['answer'] !== '', const hasTextAnswer = answers['answer'] && answers['answer'] !== '';
allowsAttachments = !!element.querySelector('div[id*=filemanager]'); const uploadFilesSupported = typeof question.responsefileareas != 'undefined';
const allowedOptions = this.getAllowedOptions(question);
if (!allowsAttachments) { if (!allowedOptions.attachments) {
return hasInlineText ? 1 : 0; return hasTextAnswer ? 1 : 0;
} }
if (!uploadFilesSupported) {
// We can't know if the attachments are required or if the user added any in web. // We can't know if the attachments are required or if the user added any in web.
return -1; 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;
}
/** /**
* Whether or not the handler is enabled on a site level. * Whether or not the handler is enabled on a site level.
* *
@ -112,10 +184,20 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/ */
isGradableResponse(question: any, answers: any): number { isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
return 0; if (typeof question.responsefileareas == 'undefined') {
return -1;
}
const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
// Determine if the given response has online text or attachments.
return (answers['answer'] && answers['answer'] !== '') || (attachments && attachments.length > 0) ? 1 : 0;
} }
/** /**
@ -124,30 +206,165 @@ export class AddonQtypeEssayHandler implements CoreQuestionHandler {
* @param question Question. * @param question Question.
* @param prevAnswers Object with the previous question answers. * @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer'); const uploadFilesSupported = typeof question.responsefileareas != 'undefined';
const allowedOptions = this.getAllowedOptions(question);
// First check the inline text.
const answerIsEqual = allowedOptions.text ? this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') : true;
if (!allowedOptions.attachments || !uploadFilesSupported || !answerIsEqual) {
// No need to check attachments.
return answerIsEqual;
}
// Check attachments now.
const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
const originalAttachments = this.questionHelper.getResponseFileAreaFiles(question, 'attachments');
return !CoreFileUploader.instance.areFileListDifferent(attachments, originalAttachments);
} }
/** /**
* Prepare and add to answers the data to send to server based in the input. Return promise if async. * Prepare and add to answers the data to send to server based in the input.
* *
* @param question Question. * @param question Question.
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object. * @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
* @param offline Whether the data should be saved in offline. * @param offline Whether the data should be saved in offline.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return Return a promise resolved when done if async, void if sync. * @return Return a promise resolved when done if async, void if sync.
*/ */
prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): void | Promise<any> { async prepareAnswers(question: any, answers: any, offline: boolean, component: string, componentId: string | number,
siteId?: string): Promise<void> {
const element = this.domUtils.convertToElement(question.html); const element = this.domUtils.convertToElement(question.html);
const attachmentsInput = <HTMLInputElement> element.querySelector('.attachments input[name*=_attachments]');
// Search the textarea to get its name. // Search the textarea to get its name.
const textarea = <HTMLTextAreaElement> element.querySelector('textarea[name*=_answer]'); const textarea = <HTMLTextAreaElement> element.querySelector('textarea[name*=_answer]');
if (textarea && typeof answers[textarea.name] != 'undefined') { if (textarea && typeof answers[textarea.name] != 'undefined') {
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. // Add some HTML to the text if needed.
answers[textarea.name] = this.textUtils.formatHtmlLines(answers[textarea.name]); answers[textarea.name] = this.textUtils.formatHtmlLines(answers[textarea.name]);
} }
}
} }

View File

@ -61,9 +61,11 @@ export class AddonQtypeGapSelectHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse(question: any, answers: any): number { isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
// We should always get a value for each select so we can assume we receive all the possible answers. // We should always get a value for each select so we can assume we receive all the possible answers.
for (const name in answers) { for (const name in answers) {
const value = answers[name]; const value = answers[name];
@ -90,9 +92,11 @@ export class AddonQtypeGapSelectHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/ */
isGradableResponse(question: any, answers: any): number { isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
// We should always get a value for each select so we can assume we receive all the possible answers. // We should always get a value for each select so we can assume we receive all the possible answers.
for (const name in answers) { for (const name in answers) {
const value = answers[name]; const value = answers[name];
@ -110,9 +114,11 @@ export class AddonQtypeGapSelectHandler implements CoreQuestionHandler {
* @param question Question. * @param question Question.
* @param prevAnswers Object with the previous question answers. * @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers);
} }
} }

View File

@ -61,9 +61,11 @@ export class AddonQtypeMatchHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse(question: any, answers: any): number { isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
// We should always get a value for each select so we can assume we receive all the possible answers. // We should always get a value for each select so we can assume we receive all the possible answers.
for (const name in answers) { for (const name in answers) {
const value = answers[name]; const value = answers[name];
@ -90,9 +92,11 @@ export class AddonQtypeMatchHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/ */
isGradableResponse(question: any, answers: any): number { isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
// We should always get a value for each select so we can assume we receive all the possible answers. // We should always get a value for each select so we can assume we receive all the possible answers.
for (const name in answers) { for (const name in answers) {
const value = answers[name]; const value = answers[name];
@ -110,9 +114,11 @@ export class AddonQtypeMatchHandler implements CoreQuestionHandler {
* @param question Question. * @param question Question.
* @param prevAnswers Object with the previous question answers. * @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers);
} }
} }

View File

@ -62,9 +62,11 @@ export class AddonQtypeMultiAnswerHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse(question: any, answers: any): number { isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
// Get all the inputs in the question to check if they've all been answered. // Get all the inputs in the question to check if they've all been answered.
const names = this.questionProvider.getBasicAnswers(this.questionHelper.getAllInputNamesFromHtml(question.html)); const names = this.questionProvider.getBasicAnswers(this.questionHelper.getAllInputNamesFromHtml(question.html));
for (const name in names) { for (const name in names) {
@ -92,9 +94,11 @@ export class AddonQtypeMultiAnswerHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/ */
isGradableResponse(question: any, answers: any): number { isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
// We should always get a value for each select so we can assume we receive all the possible answers. // We should always get a value for each select so we can assume we receive all the possible answers.
for (const name in answers) { for (const name in answers) {
const value = answers[name]; const value = answers[name];
@ -112,9 +116,11 @@ export class AddonQtypeMultiAnswerHandler implements CoreQuestionHandler {
* @param question Question. * @param question Question.
* @param prevAnswers Object with the previous question answers. * @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers);
} }

View File

@ -45,9 +45,11 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse(question: any, answers: any): number { isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
let isSingle = true, let isSingle = true,
isMultiComplete = false; isMultiComplete = false;
@ -95,10 +97,12 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/ */
isGradableResponse(question: any, answers: any): number { isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
return this.isCompleteResponse(question, answers); return this.isCompleteResponse(question, answers, component, componentId);
} }
/** /**
@ -118,9 +122,11 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler {
* @param question Question. * @param question Question.
* @param prevAnswers Object with the previous question answers. * @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
let isSingle = true, let isSingle = true,
isMultiSame = true; isMultiSame = true;
@ -158,10 +164,13 @@ export class AddonQtypeMultichoiceHandler implements CoreQuestionHandler {
* @param question Question. * @param question Question.
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object. * @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
* @param offline Whether the data should be saved in offline. * @param offline Whether the data should be saved in offline.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return Return a promise resolved when done if async, void if sync. * @return Return a promise resolved when done if async, void if sync.
*/ */
prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): void | Promise<any> { prepareAnswers(question: any, answers: any, offline: boolean, component: string, componentId: string | number, siteId?: string)
: void | Promise<any> {
if (question && !question.multi && typeof answers[question.optionsName] != 'undefined' && !answers[question.optionsName]) { if (question && !question.multi && typeof answers[question.optionsName] != 'undefined' && !answers[question.optionsName]) {
/* It's a single choice and the user hasn't answered. Delete the answer because /* It's a single choice and the user hasn't answered. Delete the answer because
sending an empty string (default value) will mark the first option as selected. */ sending an empty string (default value) will mark the first option as selected. */

View File

@ -46,11 +46,13 @@ export class AddonQtypeRandomSaMatchHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse(question: any, answers: any): number { isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
// This question behaves like a match question. // This question behaves like a match question.
return this.matchHandler.isCompleteResponse(question, answers); return this.matchHandler.isCompleteResponse(question, answers, component, componentId);
} }
/** /**
@ -68,11 +70,13 @@ export class AddonQtypeRandomSaMatchHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/ */
isGradableResponse(question: any, answers: any): number { isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
// This question behaves like a match question. // This question behaves like a match question.
return this.matchHandler.isGradableResponse(question, answers); return this.matchHandler.isGradableResponse(question, answers, component, componentId);
} }
/** /**
@ -81,10 +85,12 @@ export class AddonQtypeRandomSaMatchHandler implements CoreQuestionHandler {
* @param question Question. * @param question Question.
* @param prevAnswers Object with the previous question answers. * @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
// This question behaves like a match question. // This question behaves like a match question.
return this.matchHandler.isSameResponse(question, prevAnswers, newAnswers); return this.matchHandler.isSameResponse(question, prevAnswers, newAnswers, component, componentId);
} }
} }

View File

@ -45,9 +45,11 @@ export class AddonQtypeShortAnswerHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse(question: any, answers: any): number { isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
return (answers['answer'] || answers['answer'] === 0) ? 1 : 0; return (answers['answer'] || answers['answer'] === 0) ? 1 : 0;
} }
@ -66,10 +68,12 @@ export class AddonQtypeShortAnswerHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/ */
isGradableResponse(question: any, answers: any): number { isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
return this.isCompleteResponse(question, answers); return this.isCompleteResponse(question, answers, null, null);
} }
/** /**
@ -78,9 +82,11 @@ export class AddonQtypeShortAnswerHandler implements CoreQuestionHandler {
* @param question Question. * @param question Question.
* @param prevAnswers Object with the previous question answers. * @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer'); return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer');
} }
} }

View File

@ -46,9 +46,11 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse(question: any, answers: any): number { isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
return answers['answer'] ? 1 : 0; return answers['answer'] ? 1 : 0;
} }
@ -67,10 +69,12 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/ */
isGradableResponse(question: any, answers: any): number { isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
return this.isCompleteResponse(question, answers); return this.isCompleteResponse(question, answers, null, null);
} }
/** /**
@ -79,9 +83,11 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler {
* @param question Question. * @param question Question.
* @param prevAnswers Object with the previous question answers. * @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer'); return this.utils.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer');
} }
@ -91,10 +97,13 @@ export class AddonQtypeTrueFalseHandler implements CoreQuestionHandler {
* @param question Question. * @param question Question.
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object. * @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
* @param offline Whether the data should be saved in offline. * @param offline Whether the data should be saved in offline.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return Return a promise resolved when done if async, void if sync. * @return Return a promise resolved when done if async, void if sync.
*/ */
prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): void | Promise<any> { prepareAnswers(question: any, answers: any, offline: boolean, component: string, componentId: string | number, siteId?: string)
: void | Promise<any> {
if (question && typeof answers[question.optionsName] != 'undefined' && !answers[question.optionsName]) { if (question && typeof answers[question.optionsName] != 'undefined' && !answers[question.optionsName]) {
// The user hasn't answered. Delete the answer to prevent marking one of the answers automatically. // The user hasn't answered. Delete the answer to prevent marking one of the answers automatically.
delete answers[question.optionsName]; delete answers[question.optionsName];

View File

@ -1861,6 +1861,7 @@
"core.mainmenu.help": "Help", "core.mainmenu.help": "Help",
"core.mainmenu.logout": "Log out", "core.mainmenu.logout": "Log out",
"core.mainmenu.website": "Website", "core.mainmenu.website": "Website",
"core.maxfilesize": "Maximum size for new files: {{$a}}",
"core.maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}", "core.maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}",
"core.min": "min", "core.min": "min",
"core.mins": "mins", "core.mins": "mins",
@ -1944,8 +1945,8 @@
"core.question.certainty": "Certainty", "core.question.certainty": "Certainty",
"core.question.complete": "Complete", "core.question.complete": "Complete",
"core.question.correct": "Correct", "core.question.correct": "Correct",
"core.question.errorattachmentsnotsupported": "The application doesn't support attaching files to answers yet.", "core.question.errorattachmentsnotsupportedinsite": "Your site doesn't support attaching files to answers yet.",
"core.question.errorinlinefilesnotsupported": "The application doesn't support editing inline files yet.", "core.question.errorembeddedfilesnotsupportedinsite": "Your site doesn't support editing embedded files yet.",
"core.question.errorquestionnotsupported": "This question type is not supported by the app: {{$a}}.", "core.question.errorquestionnotsupported": "This question type is not supported by the app: {{$a}}.",
"core.question.feedback": "Feedback", "core.question.feedback": "Feedback",
"core.question.howtodraganddrop": "Tap to select then tap to drop.", "core.question.howtodraganddrop": "Tap to select then tap to drop.",

View File

@ -221,13 +221,16 @@ export class CoreSite {
// Versions of Moodle releases. // Versions of Moodle releases.
protected MOODLE_RELEASES = { protected MOODLE_RELEASES = {
3.1: 2016052300, '3.1': 2016052300,
3.2: 2016120500, '3.2': 2016120500,
3.3: 2017051503, '3.3': 2017051503,
3.4: 2017111300, '3.4': 2017111300,
3.5: 2018051700, '3.5': 2018051700,
3.6: 2018120300, '3.6': 2018120300,
3.7: 2019052000 '3.7': 2019052000,
'3.8': 2019111800,
'3.9': 2020061500,
'3.10': 2020092400, // @todo: Replace with the right value once 3.10 is released.
}; };
static MINIMUM_MOODLE_VERSION = '3.1'; static MINIMUM_MOODLE_VERSION = '3.1';

View File

@ -39,8 +39,8 @@ import { CoreFileUploaderHelperProvider } from '@core/fileuploader/providers/hel
}) })
export class CoreAttachmentsComponent implements OnInit { export class CoreAttachmentsComponent implements OnInit {
@Input() files: any[]; // List of attachments. New attachments will be added to this array. @Input() files: any[]; // List of attachments. New attachments will be added to this array.
@Input() maxSize: number; // Max size for attachments. If not defined, 0 or -1, unknown size. @Input() maxSize: number; // Max size for attachments. -1 means unlimited, not defined or 0 means unknown limit.
@Input() maxSubmissions: number; // Max number of attachments. If -1 or not defined, unknown limit. @Input() maxSubmissions: number; // Max number of attachments. -1 means unlimited, not defined means unknown limit.
@Input() component: string; // Component the downloaded files will be linked to. @Input() component: string; // Component the downloaded files will be linked to.
@Input() componentId: string | number; // Component ID. @Input() componentId: string | number; // Component ID.
@Input() allowOffline: boolean | string; // Whether to allow selecting files in offline. @Input() allowOffline: boolean | string; // Whether to allow selecting files in offline.
@ -61,17 +61,18 @@ export class CoreAttachmentsComponent implements OnInit {
* Component being initialized. * Component being initialized.
*/ */
ngOnInit(): void { ngOnInit(): void {
this.maxSize = Number(this.maxSize); // Make sure it's defined and it's a number. this.maxSize = Number(this.maxSize) || 0; // Make sure it's defined and it's a number.
this.maxSize = !isNaN(this.maxSize) && this.maxSize > 0 ? this.maxSize : -1;
if (this.maxSize == -1) { if (this.maxSize === 0) {
this.maxSizeReadable = this.translate.instant('core.unknown'); this.maxSizeReadable = this.translate.instant('core.unknown');
} else { } else if (this.maxSize > 0) {
this.maxSizeReadable = this.textUtils.bytesToSize(this.maxSize, 2); this.maxSizeReadable = this.textUtils.bytesToSize(this.maxSize, 2);
} else {
this.maxSizeReadable = this.translate.instant('core.unlimited');
} }
if (typeof this.maxSubmissions == 'undefined' || this.maxSubmissions < 0) { if (typeof this.maxSubmissions == 'undefined' || this.maxSubmissions < 0) {
this.maxSubmissionsReadable = this.translate.instant('core.unknown'); this.maxSubmissionsReadable = this.maxSubmissions < 0 ? undefined : this.translate.instant('core.unknown');
this.unlimitedFiles = true; this.unlimitedFiles = true;
} else { } else {
this.maxSubmissionsReadable = String(this.maxSubmissions); this.maxSubmissionsReadable = String(this.maxSubmissions);

View File

@ -1,5 +1,6 @@
<ion-item text-wrap> <ion-item text-wrap>
{{ 'core.maxsizeandattachments' | translate:{$a: {size: maxSizeReadable, attachments: maxSubmissionsReadable} } }} <span *ngIf="maxSubmissionsReadable">{{ 'core.maxsizeandattachments' | translate:{$a: {size: maxSizeReadable, attachments: maxSubmissionsReadable} } }}</span>
<span *ngIf="!maxSubmissionsReadable">{{ 'core.maxfilesize' | translate:{$a: maxSizeReadable} }}</span>
<span [core-mark-required]="required" class="core-mark-required"></span> <span [core-mark-required]="required" class="core-mark-required"></span>
</ion-item> </ion-item>
<ion-item text-wrap *ngIf="fileTypes && fileTypes.mimetypes && fileTypes.mimetypes.length"> <ion-item text-wrap *ngIf="fileTypes && fileTypes.mimetypes && fileTypes.mimetypes.length">

View File

@ -56,6 +56,7 @@ ion-app.app-root core-rich-text-editor {
img { img {
@include padding(null, null, null, 2px); @include padding(null, null, null, 2px);
max-width: 95%; max-width: 95%;
width: auto;
} }
&:empty:before { &:empty:before {
content: attr(data-placeholder-text); content: attr(data-placeholder-text);

View File

@ -15,6 +15,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { ModalController } from 'ionic-angular'; import { ModalController } from 'ionic-angular';
import { Camera, CameraOptions } from '@ionic-native/camera'; import { Camera, CameraOptions } from '@ionic-native/camera';
import { FileEntry } from '@ionic-native/file';
import { MediaCapture, MediaFile, CaptureError, CaptureAudioOptions, CaptureVideoOptions } from '@ionic-native/media-capture'; import { MediaCapture, MediaFile, CaptureError, CaptureAudioOptions, CaptureVideoOptions } from '@ionic-native/media-capture';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { CoreFileProvider } from '@providers/file'; import { CoreFileProvider } from '@providers/file';
@ -25,9 +26,11 @@ import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype';
import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreUtilsProvider } from '@providers/utils/utils';
import { CoreWSFileUploadOptions } from '@providers/ws'; import { CoreWSFileUploadOptions, CoreWSExternalFile } from '@providers/ws';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { CoreApp } from '@providers/app'; import { CoreApp } from '@providers/app';
import { CoreSite } from '@classes/site';
import { makeSingleton } from '@singletons/core.singletons';
/** /**
* File upload options. * File upload options.
@ -96,7 +99,7 @@ export class CoreFileUploaderProvider {
// Currently we are going to compare the order of the files as well. // Currently we are going to compare the order of the files as well.
// This function can be improved comparing more fields or not comparing the order. // This function can be improved comparing more fields or not comparing the order.
for (let i = 0; i < a.length; i++) { for (let i = 0; i < a.length; i++) {
if ((a[i].name || a[i].filename) != (b[i].name || b[i].filename)) { if (a[i].name != b[i].name || a[i].filename != b[i].filename) {
return true; return true;
} }
} }
@ -104,6 +107,36 @@ export class CoreFileUploaderProvider {
return false; return false;
} }
/**
* Check if a certain site allows deleting draft files.
*
* @param siteId Site Id. If not defined, use current site.
* @return Promise resolved with true if can delete.
* @since 3.10
*/
async canDeleteDraftFiles(siteId?: string): Promise<boolean> {
try {
const site = await this.sitesProvider.getSite(siteId);
return this.canDeleteDraftFilesInSite(site);
} catch (error) {
return false;
}
}
/**
* Check if a certain site allows deleting draft files.
*
* @param site Site. If not defined, use current site.
* @return Whether draft files can be deleted.
* @since 3.10
*/
canDeleteDraftFilesInSite(site?: CoreSite): boolean {
site = site || this.sitesProvider.getCurrentSite();
return site.wsAvailable('core_files_delete_draft_files');
}
/** /**
* Start the audio recorder application and return information about captured audio clip files. * Start the audio recorder application and return information about captured audio clip files.
* *
@ -173,6 +206,25 @@ export class CoreFileUploaderProvider {
}); });
} }
/**
* Delete draft files.
*
* @param draftId Draft ID.
* @param files Files to delete.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async deleteDraftFiles(draftId: number, files: {filepath: string, filename: string}[], siteId?: string): Promise<void> {
const site = await this.sitesProvider.getSite(siteId);
const params = {
draftitemid: draftId,
files: files,
};
return site.write('core_files_delete_draft_files', params);
}
/** /**
* Get the upload options for a file taken with the Camera Cordova plugin. * Get the upload options for a file taken with the Camera Cordova plugin.
* *
@ -215,6 +267,35 @@ export class CoreFileUploaderProvider {
return options; return options;
} }
/**
* Given a list of original files and a list of current files, return the list of files to delete.
*
* @param originalFiles Original files.
* @param currentFiles Current files.
* @return List of files to delete.
*/
getFilesToDelete(originalFiles: CoreWSExternalFile[], currentFiles: (CoreWSExternalFile | FileEntry)[])
: {filepath: string, filename: string}[] {
const filesToDelete: {filepath: string, filename: string}[] = [];
currentFiles = currentFiles || [];
originalFiles.forEach((file) => {
const stillInList = currentFiles.some((currentFile) => {
return (<CoreWSExternalFile> currentFile).fileurl == file.fileurl;
});
if (!stillInList) {
filesToDelete.push({
filepath: file.filepath,
filename: file.filename,
});
}
});
return filesToDelete;
}
/** /**
* Get the upload options for a file of any type. * Get the upload options for a file of any type.
* *
@ -534,6 +615,47 @@ export class CoreFileUploaderProvider {
}); });
} }
/**
* Given a list of files (either online files or local files), upload the local files to the draft area.
* Local files are not deleted from the device after upload.
*
* @param itemId Draft ID.
* @param files List of files.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the itemId.
*/
async uploadFiles(itemId: number, files: (CoreWSExternalFile | FileEntry)[], siteId?: string): Promise<void> {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
if (!files || !files.length) {
return;
}
// Index the online files by name.
const usedNames: {[name: string]: (CoreWSExternalFile | FileEntry)} = {};
const filesToUpload: FileEntry[] = [];
files.forEach((file) => {
const isOnlineFile = (<CoreWSExternalFile> file).filename && !(<FileEntry> file).name;
if (isOnlineFile) {
usedNames[(<CoreWSExternalFile> file).filename.toLowerCase()] = file;
} else {
filesToUpload.push(<FileEntry> file);
}
});
await Promise.all(filesToUpload.map(async (file) => {
// Make sure the file name is unique in the area.
const name = this.fileProvider.calculateUniqueName(usedNames, file.name);
usedNames[name] = file;
// Now upload the file.
const options = this.getFileUploadOptions(file.toURL(), name, undefined, false, 'draft', itemId);
await this.uploadFile(file.toURL(), options, undefined, siteId);
}));
}
/** /**
* Upload a file to a draft area and return the draft ID. * Upload a file to a draft area and return the draft ID.
* *
@ -615,3 +737,5 @@ export class CoreFileUploaderProvider {
}); });
} }
} }
export class CoreFileUploader extends makeSingleton(CoreFileUploaderProvider) {}

View File

@ -407,7 +407,7 @@ export class CoreGradesHelperProvider {
if (matches && matches.length) { if (matches && matches.length) {
const hrefParams = this.urlUtils.extractUrlParams(matches[1]); const hrefParams = this.urlUtils.extractUrlParams(matches[1]);
return hrefParams && hrefParams.id == moduleId; return hrefParams && Number(hrefParams.id) == moduleId;
} }
} }

View File

@ -1152,7 +1152,7 @@ export class CoreLoginHelperProvider {
const providerToUse = identityProviders.find((provider) => { const providerToUse = identityProviders.find((provider) => {
const params = this.urlUtils.extractUrlParams(provider.url); const params = this.urlUtils.extractUrlParams(provider.url);
return params.id == currentSite.getOAuthId(); return Number(params.id) == currentSite.getOAuthId();
}); });
if (providerToUse) { if (providerToUse) {

View File

@ -34,10 +34,11 @@ export class CoreQuestionBehaviourBaseHandler implements CoreQuestionBehaviourHa
* @param component Component the question belongs to. * @param component Component the question belongs to.
* @param attemptId Attempt ID the question belongs to. * @param attemptId Attempt ID the question belongs to.
* @param question The question. * @param question The question.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return New state (or promise resolved with state). * @return New state (or promise resolved with state).
*/ */
determineNewState(component: string, attemptId: number, question: any, siteId?: string) determineNewState(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string)
: CoreQuestionState | Promise<CoreQuestionState> { : CoreQuestionState | Promise<CoreQuestionState> {
// Return the current state. // Return the current state.
return this.questionProvider.getState(question.state); return this.questionProvider.getState(question.state);

View File

@ -14,8 +14,10 @@
import { Input, Output, EventEmitter, Injector, ElementRef } from '@angular/core'; import { Input, Output, EventEmitter, Injector, ElementRef } from '@angular/core';
import { CoreLoggerProvider } from '@providers/logger'; import { CoreLoggerProvider } from '@providers/logger';
import { CoreSites } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreUrlUtils } from '@providers/utils/url';
import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; import { CoreQuestionHelperProvider } from '@core/question/providers/helper';
/** /**
@ -105,9 +107,13 @@ export class CoreQuestionBaseComponent {
this.question.select = selectModel; this.question.select = selectModel;
// Check which one should be displayed first: the select or the input. // Check which one should be displayed first: the select or the input.
if (this.question.settings) {
this.question.selectFirst = this.question.settings.unitsleft == '1';
} else {
const input = questionEl.querySelector('input[type="text"][name*=answer]'); const input = questionEl.querySelector('input[type="text"][name*=answer]');
this.question.selectFirst = this.question.selectFirst =
questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(select.outerHTML); questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(select.outerHTML);
}
return questionEl; return questionEl;
} }
@ -159,10 +165,16 @@ export class CoreQuestionBaseComponent {
} }
// Check which one should be displayed first: the options or the input. // Check which one should be displayed first: the options or the input.
if (this.question.settings) {
this.question.optionsFirst = this.question.settings.unitsleft == '1';
} else {
const input = questionEl.querySelector('input[type="text"][name*=answer]'); const input = questionEl.querySelector('input[type="text"][name*=answer]');
this.question.optionsFirst = this.question.optionsFirst =
questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(radios[0].outerHTML); questionEl.innerHTML.indexOf(input.outerHTML) > questionEl.innerHTML.indexOf(radios[0].outerHTML);
} }
return questionEl;
}
} }
/** /**
@ -201,27 +213,46 @@ export class CoreQuestionBaseComponent {
const questionEl = this.initComponent(); const questionEl = this.initComponent();
if (questionEl) { if (questionEl) {
// First search the textarea.
const textarea = <HTMLTextAreaElement> questionEl.querySelector('textarea[name*=_answer]'); const textarea = <HTMLTextAreaElement> questionEl.querySelector('textarea[name*=_answer]');
const answerDraftIdInput = <HTMLInputElement> questionEl.querySelector('input[name*="_answer:itemid"]');
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.allowsAttachments = !!questionEl.querySelector('div[id*=filemanager]');
this.question.allowsAnswerFiles = !!answerDraftIdInput;
this.question.isMonospaced = !!questionEl.querySelector('.qtype_essay_monospaced'); this.question.isMonospaced = !!questionEl.querySelector('.qtype_essay_monospaced');
this.question.isPlainText = this.question.isMonospaced || !!questionEl.querySelector('.qtype_essay_plain'); this.question.isPlainText = this.question.isMonospaced || !!questionEl.querySelector('.qtype_essay_plain');
this.question.hasDraftFiles = this.questionHelper.hasDraftFileUrls(questionEl.innerHTML); }
if (!textarea) { this.question.hasDraftFiles = this.question.allowsAnswerFiles &&
// Textarea not found, we might be in review. Search the answer and the attachments. this.questionHelper.hasDraftFileUrls(questionEl.innerHTML);
if (!textarea && !this.question.allowsAttachments) {
// Textarea and filemanager not found, we might be in review. Search the answer and the attachments.
this.question.answer = this.domUtils.getContentsOfElement(questionEl, '.qtype_essay_response'); this.question.answer = this.domUtils.getContentsOfElement(questionEl, '.qtype_essay_response');
this.question.attachments = this.questionHelper.getQuestionAttachmentsFromHtml( this.question.attachments = this.questionHelper.getQuestionAttachmentsFromHtml(
this.domUtils.getContentsOfElement(questionEl, '.attachments')); this.domUtils.getContentsOfElement(questionEl, '.attachments'));
} else {
// Textarea found. return questionEl;
const input = <HTMLInputElement> questionEl.querySelector('input[type="hidden"][name*=answerformat]'), }
content = textarea.innerHTML;
if (textarea) {
const input = <HTMLInputElement> questionEl.querySelector('input[type="hidden"][name*=answerformat]');
let content = this.textUtils.decodeHTML(textarea.innerHTML || '');
if (this.question.hasDraftFiles && this.question.responsefileareas) {
content = this.textUtils.replaceDraftfileUrls(CoreSites.instance.getCurrentSite().getURL(), content,
this.questionHelper.getResponseFileAreaFiles(this.question, 'answer')).text;
}
this.question.textarea = { this.question.textarea = {
id: textarea.id, id: textarea.id,
name: textarea.name, name: textarea.name,
text: content ? this.textUtils.decodeHTML(content) : '' text: content,
}; };
if (input) { if (input) {
@ -231,6 +262,43 @@ export class CoreQuestionBaseComponent {
}; };
} }
} }
if (answerDraftIdInput) {
this.question.answerDraftIdInput = {
name: answerDraftIdInput.name,
value: Number(answerDraftIdInput.value),
};
}
if (this.question.allowsAttachments) {
const attachmentsInput = <HTMLInputElement> questionEl.querySelector('.attachments input[name*=_attachments]');
const objectElement = <HTMLObjectElement> questionEl.querySelector('.attachments object');
const fileManagerUrl = objectElement && objectElement.data;
if (attachmentsInput) {
this.question.attachmentsDraftIdInput = {
name: attachmentsInput.name,
value: Number(attachmentsInput.value),
};
}
if (this.question.settings) {
this.question.attachmentsMaxFiles = Number(this.question.settings.attachments);
this.question.attachmentsAcceptedTypes = this.question.settings.filetypeslist &&
this.question.settings.filetypeslist.join(',');
}
if (fileManagerUrl) {
const params = CoreUrlUtils.instance.extractUrlParams(fileManagerUrl);
const maxBytes = Number(params.maxbytes);
const areaMaxBytes = Number(params.areamaxbytes);
this.question.attachmentsMaxBytes = maxBytes === -1 || areaMaxBytes === -1 ?
Math.max(maxBytes, areaMaxBytes) : Math.min(maxBytes, areaMaxBytes);
}
}
return questionEl;
} }
} }
@ -270,6 +338,8 @@ export class CoreQuestionBaseComponent {
// Set the question text. // Set the question text.
this.question.text = content.innerHTML; this.question.text = content.innerHTML;
return element;
} }
/** /**

View File

@ -79,9 +79,11 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse(question: any, answers: any): number { isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
return -1; return -1;
} }
@ -91,9 +93,11 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/ */
isGradableResponse(question: any, answers: any): number { isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
return -1; return -1;
} }
@ -103,9 +107,11 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler {
* @param question Question. * @param question Question.
* @param prevAnswers Object with the previous question answers. * @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
return false; return false;
} }
@ -115,10 +121,13 @@ export class CoreQuestionBaseHandler implements CoreQuestionHandler {
* @param question Question. * @param question Question.
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object. * @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
* @param offline Whether the data should be saved in offline. * @param offline Whether the data should be saved in offline.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return Return a promise resolved when done if async, void if sync. * @return Return a promise resolved when done if async, void if sync.
*/ */
prepareAnswers(question: any, answers: any, offline: boolean, siteId?: string): void | Promise<any> { prepareAnswers(question: any, answers: any, offline: boolean, component: string, componentId: string | number, siteId?: string)
: void | Promise<any> {
// Nothing to do. // Nothing to do.
} }

View File

@ -5,8 +5,8 @@
"certainty": "Certainty", "certainty": "Certainty",
"complete": "Complete", "complete": "Complete",
"correct": "Correct", "correct": "Correct",
"errorattachmentsnotsupported": "The application doesn't support attaching files to answers yet.", "errorattachmentsnotsupportedinsite": "Your site doesn't support attaching files to answers yet.",
"errorinlinefilesnotsupported": "The application doesn't support editing inline files yet.", "errorembeddedfilesnotsupportedinsite": "Your site doesn't support editing embedded files yet.",
"errorquestionnotsupported": "This question type is not supported by the app: {{$a}}.", "errorquestionnotsupported": "This question type is not supported by the app: {{$a}}.",
"feedback": "Feedback", "feedback": "Feedback",
"howtodraganddrop": "Tap to select then tap to drop.", "howtodraganddrop": "Tap to select then tap to drop.",

View File

@ -36,10 +36,11 @@ export interface CoreQuestionBehaviourHandler extends CoreDelegateHandler {
* @param component Component the question belongs to. * @param component Component the question belongs to.
* @param attemptId Attempt ID the question belongs to. * @param attemptId Attempt ID the question belongs to.
* @param question The question. * @param question The question.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return State (or promise resolved with state). * @return State (or promise resolved with state).
*/ */
determineNewState?(component: string, attemptId: number, question: any, siteId?: string) determineNewState?(component: string, attemptId: number, question: any, componentId: string | number, siteId?: string)
: CoreQuestionState | Promise<CoreQuestionState>; : CoreQuestionState | Promise<CoreQuestionState>;
/** /**
@ -74,15 +75,16 @@ export class CoreQuestionBehaviourDelegate extends CoreDelegate {
* @param component Component the question belongs to. * @param component Component the question belongs to.
* @param attemptId Attempt ID the question belongs to. * @param attemptId Attempt ID the question belongs to.
* @param question The question. * @param question The question.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return Promise resolved with state. * @return Promise resolved with state.
*/ */
determineNewState(behaviour: string, component: string, attemptId: number, question: any, siteId?: string) determineNewState(behaviour: string, component: string, attemptId: number, question: any, componentId: string | number,
: Promise<CoreQuestionState> { siteId?: string): Promise<CoreQuestionState> {
behaviour = this.questionDelegate.getBehaviourForQuestion(question, behaviour); behaviour = this.questionDelegate.getBehaviourForQuestion(question, behaviour);
return Promise.resolve(this.executeFunctionOnEnabled(behaviour, 'determineNewState', return Promise.resolve(this.executeFunctionOnEnabled(behaviour, 'determineNewState',
[component, attemptId, question, siteId])); [component, attemptId, question, componentId, siteId]));
} }
/** /**

View File

@ -62,9 +62,11 @@ export interface CoreQuestionHandler extends CoreDelegateHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse?(question: any, answers: any): number; isCompleteResponse?(question: any, answers: any, component: string, componentId: string | number): number;
/** /**
* Check if a student has provided enough of an answer for the question to be graded automatically, * Check if a student has provided enough of an answer for the question to be graded automatically,
@ -72,9 +74,11 @@ export interface CoreQuestionHandler extends CoreDelegateHandler {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/ */
isGradableResponse?(question: any, answers: any): number; isGradableResponse?(question: any, answers: any, component: string, componentId: string | number): number;
/** /**
* Check if two responses are the same. * Check if two responses are the same.
@ -84,7 +88,7 @@ export interface CoreQuestionHandler extends CoreDelegateHandler {
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
isSameResponse?(question: any, prevAnswers: any, newAnswers: any): boolean; isSameResponse?(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean;
/** /**
* Prepare and add to answers the data to send to server based in the input. Return promise if async. * Prepare and add to answers the data to send to server based in the input. Return promise if async.
@ -92,10 +96,13 @@ export interface CoreQuestionHandler extends CoreDelegateHandler {
* @param question Question. * @param question Question.
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object. * @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
* @param offline Whether the data should be saved in offline. * @param offline Whether the data should be saved in offline.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return Return a promise resolved when done if async, void if sync. * @return Return a promise resolved when done if async, void if sync.
*/ */
prepareAnswers?(question: any, answers: any, offline: boolean, siteId?: string): void | Promise<any>; prepareAnswers?(question: any, answers: any, offline: boolean, component: string, componentId: string | number,
siteId?: string): void | Promise<any>;
/** /**
* Validate if an offline sequencecheck is valid compared with the online one. * Validate if an offline sequencecheck is valid compared with the online one.
@ -115,6 +122,40 @@ export interface CoreQuestionHandler extends CoreDelegateHandler {
* @return List of URLs. * @return List of URLs.
*/ */
getAdditionalDownloadableFiles?(question: any, usageId: number): string[]; getAdditionalDownloadableFiles?(question: any, usageId: number): string[];
/**
* Clear temporary data after the data has been saved.
*
* @param question Question.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return If async, promise resolved when done.
*/
clearTmpData?(question: any, component: string, componentId: string | number): void | Promise<void>;
/**
* Delete any stored data for the question.
*
* @param question Question.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site.
* @return If async, promise resolved when done.
*/
deleteOfflineData?(question: any, component: string, componentId: string | number, siteId?: string): void | Promise<void>;
/**
* Prepare data to send when performing a synchronization.
*
* @param question Question.
* @param answers Answers of the question, without the prefix.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site.
* @return If async, promise resolved when done.
*/
prepareSyncData?(question: any, answers: {[name: string]: any}, component: string, componentId: string | number,
siteId?: string): void | Promise<void>;
} }
/** /**
@ -196,12 +237,14 @@ export class CoreQuestionDelegate extends CoreDelegate {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine. * @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/ */
isCompleteResponse(question: any, answers: any): number { isCompleteResponse(question: any, answers: any, component: string, componentId: string | number): number {
const type = this.getTypeName(question); const type = this.getTypeName(question);
return this.executeFunctionOnEnabled(type, 'isCompleteResponse', [question, answers]); return this.executeFunctionOnEnabled(type, 'isCompleteResponse', [question, answers, component, componentId]);
} }
/** /**
@ -210,12 +253,14 @@ export class CoreQuestionDelegate extends CoreDelegate {
* *
* @param question The question. * @param question The question.
* @param answers Object with the question answers (without prefix). * @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine. * @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/ */
isGradableResponse(question: any, answers: any): number { isGradableResponse(question: any, answers: any, component: string, componentId: string | number): number {
const type = this.getTypeName(question); const type = this.getTypeName(question);
return this.executeFunctionOnEnabled(type, 'isGradableResponse', [question, answers]); return this.executeFunctionOnEnabled(type, 'isGradableResponse', [question, answers, component, componentId]);
} }
/** /**
@ -226,10 +271,10 @@ export class CoreQuestionDelegate extends CoreDelegate {
* @param newAnswers Object with the new question answers. * @param newAnswers Object with the new question answers.
* @return Whether they're the same. * @return Whether they're the same.
*/ */
isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { isSameResponse(question: any, prevAnswers: any, newAnswers: any, component: string, componentId: string | number): boolean {
const type = this.getTypeName(question); const type = this.getTypeName(question);
return this.executeFunctionOnEnabled(type, 'isSameResponse', [question, prevAnswers, newAnswers]); return this.executeFunctionOnEnabled(type, 'isSameResponse', [question, prevAnswers, newAnswers, component, componentId]);
} }
/** /**
@ -248,13 +293,17 @@ export class CoreQuestionDelegate extends CoreDelegate {
* @param question Question. * @param question Question.
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object. * @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
* @param offline Whether the data should be saved in offline. * @param offline Whether the data should be saved in offline.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return Promise resolved when data has been prepared. * @return Promise resolved when data has been prepared.
*/ */
prepareAnswersForQuestion(question: any, answers: any, offline: boolean, siteId?: string): Promise<any> { prepareAnswersForQuestion(question: any, answers: any, offline: boolean, component: string, componentId: string | number,
siteId?: string): Promise<any> {
const type = this.getTypeName(question); const type = this.getTypeName(question);
return Promise.resolve(this.executeFunctionOnEnabled(type, 'prepareAnswers', [question, answers, offline, siteId])); return Promise.resolve(this.executeFunctionOnEnabled(type, 'prepareAnswers',
[question, answers, offline, component, componentId, siteId]));
} }
/** /**
@ -282,4 +331,50 @@ export class CoreQuestionDelegate extends CoreDelegate {
return this.executeFunctionOnEnabled(type, 'getAdditionalDownloadableFiles', [question, usageId]) || []; return this.executeFunctionOnEnabled(type, 'getAdditionalDownloadableFiles', [question, usageId]) || [];
} }
/**
* Clear temporary data after the data has been saved.
*
* @param question Question.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return If async, promise resolved when done.
*/
clearTmpData(question: any, component: string, componentId: string | number): void | Promise<void> {
const type = this.getTypeName(question);
return this.executeFunctionOnEnabled(type, 'clearTmpData', [question, component, componentId]);
}
/**
* Clear temporary data after the data has been saved.
*
* @param question Question.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site.
* @return If async, promise resolved when done.
*/
deleteOfflineData(question: any, component: string, componentId: string | number, siteId?: string): void | Promise<void> {
const type = this.getTypeName(question);
return this.executeFunctionOnEnabled(type, 'deleteOfflineData', [question, component, componentId, siteId]);
}
/**
* Prepare data to send when performing a synchronization.
*
* @param question Question.
* @param answers Answers of the question, without the prefix.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site.
* @return If async, promise resolved when done.
*/
prepareSyncData?(question: any, answers: {[name: string]: any}, component: string, componentId: string | number,
siteId?: string): void | Promise<void> {
const type = this.getTypeName(question);
return this.executeFunctionOnEnabled(type, 'prepareSyncData', [question, answers, component, componentId, siteId]);
}
} }

View File

@ -13,9 +13,12 @@
// limitations under the License. // limitations under the License.
import { Injectable, EventEmitter } from '@angular/core'; import { Injectable, EventEmitter } from '@angular/core';
import { FileEntry } from '@ionic-native/file';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { CoreFile } from '@providers/file';
import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreFilepoolProvider } from '@providers/filepool';
import { CoreSitesProvider } from '@providers/sites'; import { CoreSitesProvider } from '@providers/sites';
import { CoreWSExternalFile } from '@providers/ws';
import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreUrlUtilsProvider } from '@providers/utils/url'; import { CoreUrlUtilsProvider } from '@providers/utils/url';
@ -59,6 +62,41 @@ export class CoreQuestionHelperProvider {
}); });
} }
/**
* Clear questions temporary data after the data has been saved.
*
* @param questions The list of questions.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Promise resolved when done.
*/
async clearTmpData(questions: any[], component: string, componentId: string | number): Promise<void> {
questions = questions || [];
await Promise.all(questions.map(async (question) => {
await this.questionDelegate.clearTmpData(question, component, componentId);
}));
}
/**
* Delete files stored for a question.
*
* @param question Question.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async deleteStoredQuestionFiles(question: any, component: string, componentId: string | number, siteId?: string)
: Promise<void> {
const questionComponentId = this.questionProvider.getQuestionComponentId(question, componentId);
const folderPath = this.questionProvider.getQuestionFolder(question.type, component, questionComponentId, siteId);
// Ignore errors, maybe the folder doesn't exist.
await this.utils.ignoreErrors(CoreFile.instance.removeDir(folderPath));
}
/** /**
* Extract question behaviour submit buttons from the question's HTML and add them to "behaviourButtons" property. * Extract question behaviour submit buttons from the question's HTML and add them to "behaviourButtons" property.
* The buttons aren't deleted from the content because all the im-controls block will be removed afterwards. * The buttons aren't deleted from the content because all the im-controls block will be removed afterwards.
@ -421,6 +459,41 @@ export class CoreQuestionHelperProvider {
return state ? state.class : ''; return state ? state.class : '';
} }
/**
* Return the files of a certain response file area.
*
* @param question Question.
* @param areaName Name of the area, e.g. 'attachments'.
* @return List of files.
*/
getResponseFileAreaFiles(question: any, areaName: string): CoreWSExternalFile[] {
if (!question.responsefileareas) {
return [];
}
const area = question.responsefileareas.find((area) => {
return area.area == areaName;
});
return area && area.files || [];
}
/**
* Get files stored for a question.
*
* @param question Question.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the files.
*/
getStoredQuestionFiles(question: any, component: string, componentId: string | number, siteId?: string): Promise<FileEntry[]> {
const questionComponentId = this.questionProvider.getQuestionComponentId(question, componentId);
const folderPath = this.questionProvider.getQuestionFolder(question.type, component, questionComponentId, siteId);
return CoreFile.instance.getDirectoryContents(folderPath);
}
/** /**
* Get the validation error message from a question HTML if it's there. * Get the validation error message from a question HTML if it's there.
* *
@ -521,7 +594,7 @@ export class CoreQuestionHelperProvider {
if (!component) { if (!component) {
component = CoreQuestionProvider.COMPONENT; component = CoreQuestionProvider.COMPONENT;
componentId = question.id; componentId = question.number;
} }
urls.push(...this.questionDelegate.getAdditionalDownloadableFiles(question, usageId)); urls.push(...this.questionDelegate.getAdditionalDownloadableFiles(question, usageId));
@ -552,15 +625,19 @@ export class CoreQuestionHelperProvider {
* @param questions The list of questions. * @param questions The list of questions.
* @param answers The input data. * @param answers The input data.
* @param offline True if data should be saved in offline. * @param offline True if data should be saved in offline.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site. * @param siteId Site ID. If not defined, current site.
* @return Promise resolved with answers to send to server. * @return Promise resolved with answers to send to server.
*/ */
prepareAnswers(questions: any[], answers: any, offline?: boolean, siteId?: string): Promise<any> { prepareAnswers(questions: any[], answers: any, offline: boolean, component: string, componentId: string | number,
siteId?: string): Promise<any> {
const promises = []; const promises = [];
questions = questions || []; questions = questions || [];
questions.forEach((question) => { questions.forEach((question) => {
promises.push(this.questionDelegate.prepareAnswersForQuestion(question, answers, offline, siteId)); promises.push(this.questionDelegate.prepareAnswersForQuestion(question, answers, offline, component, componentId,
siteId));
}); });
return this.utils.allPromises(promises).then(() => { return this.utils.allPromises(promises).then(() => {

View File

@ -13,10 +13,13 @@
// limitations under the License. // limitations under the License.
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CoreFile } from '@providers/file';
import { CoreLoggerProvider } from '@providers/logger'; import { CoreLoggerProvider } from '@providers/logger';
import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites';
import { CoreTextUtils } from '@providers/utils/text';
import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreUtilsProvider } from '@providers/utils/utils';
import { makeSingleton } from '@singletons/core.singletons';
/** /**
* An object to represent a question state. * An object to represent a question state.
@ -413,6 +416,35 @@ export class CoreQuestionProvider {
}); });
} }
/**
* Given a question and a componentId, return a componentId that is unique for the question.
*
* @param question Question.
* @param componentId Component ID.
* @return Question component ID.
*/
getQuestionComponentId(question: any, componentId: string | number): string {
return componentId + '_' + question.number;
}
/**
* Get the path to the folder where to store files for an offline question.
*
* @param type Question type.
* @param component Component the question is related to.
* @param componentId Question component ID, returned by getQuestionComponentId.
* @param siteId Site ID. If not defined, current site.
* @return Folder path.
*/
getQuestionFolder(type: string, component: string, componentId: string, siteId?: string): string {
siteId = siteId || this.sitesProvider.getCurrentSiteId();
const siteFolderPath = CoreFile.instance.getSiteFolder(siteId);
const questionFolderPath = 'offlinequestion/' + type + '/' + component + '/' + componentId;
return CoreTextUtils.instance.concatenatePaths(siteFolderPath, questionFolderPath);
}
/** /**
* Extract the question slot from a question name. * Extract the question slot from a question name.
* *
@ -612,3 +644,5 @@ export class CoreQuestionProvider {
}); });
} }
} }
export class CoreQuestion extends makeSingleton(CoreQuestionProvider) {}

View File

@ -143,6 +143,7 @@
"loadmore": "Load more", "loadmore": "Load more",
"location": "Location", "location": "Location",
"lostconnection": "Your authentication token is invalid or has expired. You will have to reconnect to the site.", "lostconnection": "Your authentication token is invalid or has expired. You will have to reconnect to the site.",
"maxfilesize": "Maximum size for new files: {{$a}}",
"maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}", "maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}",
"min": "min", "min": "min",
"mins": "mins", "mins": "mins",

View File

@ -1114,10 +1114,8 @@ export class CoreFileProvider {
// Get existing files in the folder. // Get existing files in the folder.
return this.getDirectoryContents(dirPath).then((entries) => { return this.getDirectoryContents(dirPath).then((entries) => {
const files = {}; const files = {};
let num = 1, let fileNameWithoutExtension = this.mimeUtils.removeExtension(fileName);
fileNameWithoutExtension = this.mimeUtils.removeExtension(fileName), let extension = this.mimeUtils.getFileExtension(fileName) || defaultExt;
extension = this.mimeUtils.getFileExtension(fileName) || defaultExt,
newName;
// Clean the file name. // Clean the file name.
fileNameWithoutExtension = this.textUtils.removeSpecialCharactersForFiles( fileNameWithoutExtension = this.textUtils.removeSpecialCharactersForFiles(
@ -1135,26 +1133,40 @@ export class CoreFileProvider {
extension = ''; extension = '';
} }
newName = fileNameWithoutExtension + extension; return this.calculateUniqueName(files, fileNameWithoutExtension + extension);
if (typeof files[newName.toLowerCase()] == 'undefined') {
// No file with the same name.
return newName;
} else {
// Repeated name. Add a number until we find a free name.
do {
newName = fileNameWithoutExtension + '(' + num + ')' + extension;
num++;
} while (typeof files[newName.toLowerCase()] != 'undefined');
// Ask the user what he wants to do.
return newName;
}
}).catch(() => { }).catch(() => {
// Folder doesn't exist, name is unique. Clean it and return it. // Folder doesn't exist, name is unique. Clean it and return it.
return this.textUtils.removeSpecialCharactersForFiles(this.textUtils.decodeURIComponent(fileName)); return this.textUtils.removeSpecialCharactersForFiles(this.textUtils.decodeURIComponent(fileName));
}); });
} }
/**
* Given a file name and a set of already used names, calculate a unique name.
*
* @param usedNames Object with names already used as keys.
* @param name Name to check.
* @return Unique name.
*/
calculateUniqueName(usedNames: {[name: string]: any}, name: string): string {
if (typeof usedNames[name.toLowerCase()] == 'undefined') {
// No file with the same name.
return name;
} else {
// Repeated name. Add a number until we find a free name.
const nameWithoutExtension = this.mimeUtils.removeExtension(name);
let extension = this.mimeUtils.getFileExtension(name);
let num = 1;
extension = extension ? '.' + extension : '';
do {
name = nameWithoutExtension + '(' + num + ')' + extension;
num++;
} while (typeof usedNames[name.toLowerCase()] != 'undefined');
return name;
}
}
/** /**
* Remove app temporary folder. * Remove app temporary folder.
* *

View File

@ -19,6 +19,7 @@ import { TranslateService } from '@ngx-translate/core';
import { CoreLangProvider } from '../lang'; import { CoreLangProvider } from '../lang';
import { makeSingleton } from '@singletons/core.singletons'; import { makeSingleton } from '@singletons/core.singletons';
import { CoreApp } from '../app'; import { CoreApp } from '../app';
import { CoreWSExternalFile } from '../ws';
/** /**
* Different type of errors the app can treat. * Different type of errors the app can treat.
@ -699,6 +700,60 @@ export class CoreTextUtilsProvider {
return text.replace(/(?:\r\n|\r|\n)/g, newValue); return text.replace(/(?:\r\n|\r|\n)/g, newValue);
} }
/**
* Replace draftfile URLs with the equivalent pluginfile URL.
*
* @param siteUrl URL of the site.
* @param text Text to treat, including draftfile URLs.
* @param files List of files of the area, using pluginfile URLs.
* @return Treated text and map with the replacements.
*/
replaceDraftfileUrls(siteUrl: string, text: string, files: CoreWSExternalFile[])
: {text: string, replaceMap?: {[url: string]: string}} {
if (!text || !files || !files.length) {
return {text};
}
const draftfileUrl = this.concatenatePaths(siteUrl, 'draftfile.php');
const matches = text.match(new RegExp(this.escapeForRegex(draftfileUrl) + '[^\'" ]+', 'ig'));
if (!matches || !matches.length) {
return {text};
}
// Index the pluginfile URLs by file name.
const pluginfileMap: {[name: string]: string} = {};
files.forEach((file) => {
pluginfileMap[file.filename] = file.fileurl;
});
// Replace each draftfile with the corresponding pluginfile URL.
const replaceMap: {[url: string]: string} = {};
matches.forEach((url) => {
if (replaceMap[url]) {
// URL already treated, same file embedded more than once.
return;
}
// Get the filename from the URL.
let filename = url.substr(url.lastIndexOf('/') + 1);
if (filename.indexOf('?') != -1) {
filename = filename.substr(0, filename.indexOf('?'));
}
if (pluginfileMap[filename]) {
replaceMap[url] = pluginfileMap[filename];
text = text.replace(new RegExp(this.escapeForRegex(url), 'g'), pluginfileMap[filename]);
}
});
return {
text,
replaceMap,
};
}
/** /**
* Replace @@PLUGINFILE@@ wildcards with the real URL in a text. * Replace @@PLUGINFILE@@ wildcards with the real URL in a text.
* *
@ -717,6 +772,36 @@ export class CoreTextUtilsProvider {
return text; return text;
} }
/**
* Restore original draftfile URLs.
*
* @param text Text to treat, including pluginfile URLs.
* @param replaceMap Map of the replacements that were done.
* @return Treated text.
*/
restoreDraftfileUrls(siteUrl: string, treatedText: string, originalText: string, files: CoreWSExternalFile[]): string {
if (!treatedText || !files || !files.length) {
return treatedText;
}
const draftfileUrl = this.concatenatePaths(siteUrl, 'draftfile.php');
const draftfileUrlRegexPrefix = this.escapeForRegex(draftfileUrl) + '/[^/]+/[^/]+/[^/]+/[^/]+/';
files.forEach((file) => {
// Search the draftfile URL in the original text.
const matches = originalText.match(new RegExp(
draftfileUrlRegexPrefix + this.escapeForRegex(file.filename) + '[^\'" ]*', 'i'));
if (!matches || !matches[0]) {
return; // Original URL not found, skip.
}
treatedText = treatedText.replace(new RegExp(this.escapeForRegex(file.fileurl), 'g'), matches[0]);
});
return treatedText;
}
/** /**
* Replace pluginfile URLs with @@PLUGINFILE@@ wildcards. * Replace pluginfile URLs with @@PLUGINFILE@@ wildcards.
* *

View File

@ -114,10 +114,10 @@ export class CoreUrlUtilsProvider {
* @param url URL to treat. * @param url URL to treat.
* @return Object with the params. * @return Object with the params.
*/ */
extractUrlParams(url: string): any { extractUrlParams(url: string): {[name: string]: string} {
const regex = /[?&]+([^=&]+)=?([^&]*)?/gi, const regex = /[?&]+([^=&]+)=?([^&]*)?/gi,
subParamsPlaceholder = '@@@SUBPARAMS@@@', subParamsPlaceholder = '@@@SUBPARAMS@@@',
params: any = {}, params: {[name: string]: string} = {},
urlAndHash = url.split('#'), urlAndHash = url.split('#'),
questionMarkSplit = urlAndHash[0].split('?'); questionMarkSplit = urlAndHash[0].split('?');
let subParams; let subParams;

View File

@ -1,6 +1,10 @@
This files describes API changes in the Moodle Mobile app, This files describes API changes in the Moodle Mobile app,
information provided here is intended especially for developers. information provided here is intended especially for developers.
=== 3.9.3 ===
- In the core-attachments component, passing a -1 as maxSize or maxSubmissions used to mean "unknown limit". Now -1 means unlimited.
=== 3.8.3 === === 3.8.3 ===
- CoreFileProvider.writeFileDataInFile has been deprecated. Please use CoreFileHelperProvider.writeFileDataInFile instead. - CoreFileProvider.writeFileDataInFile has been deprecated. Please use CoreFileHelperProvider.writeFileDataInFile instead.