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.logout": "moodle",
"core.mainmenu.website": "local_moodlemobileapp",
"core.maxfilesize": "moodle",
"core.maxsizeandattachments": "moodle",
"core.min": "moodle",
"core.mins": "moodle",
@ -1944,8 +1945,8 @@
"core.question.certainty": "qbehaviour_deferredcbm",
"core.question.complete": "question",
"core.question.correct": "question",
"core.question.errorattachmentsnotsupported": "local_moodlemobileapp",
"core.question.errorinlinefilesnotsupported": "local_moodlemobileapp",
"core.question.errorattachmentsnotsupportedinsite": "local_moodlemobileapp",
"core.question.errorembeddedfilesnotsupportedinsite": "local_moodlemobileapp",
"core.question.errorquestionnotsupported": "local_moodlemobileapp",
"core.question.feedback": "question",
"core.question.howtodraganddrop": "local_moodlemobileapp",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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