diff --git a/src/addons/addons.module.ts b/src/addons/addons.module.ts
index fd704ef20..7b2688efe 100644
--- a/src/addons/addons.module.ts
+++ b/src/addons/addons.module.ts
@@ -23,6 +23,7 @@ import { AddonCalendarModule } from './calendar/calendar.module';
import { AddonNotificationsModule } from './notifications/notifications.module';
import { AddonMessageOutputModule } from './messageoutput/messageoutput.module';
import { AddonMessagesModule } from './messages/messages.module';
+import { AddonModModule } from './mod/mod.module';
@NgModule({
imports: [
@@ -35,6 +36,7 @@ import { AddonMessagesModule } from './messages/messages.module';
AddonUserProfileFieldModule,
AddonNotificationsModule,
AddonMessageOutputModule,
+ AddonModModule,
],
})
export class AddonsModule {}
diff --git a/src/addons/mod/lesson/lang.json b/src/addons/mod/lesson/lang.json
new file mode 100644
index 000000000..3c310fca4
--- /dev/null
+++ b/src/addons/mod/lesson/lang.json
@@ -0,0 +1,86 @@
+{
+ "answer": "Answer",
+ "attempt": "Attempt: {{$a}}",
+ "attemptheader": "Attempt",
+ "attemptsremaining": "You have {{$a}} attempt(s) remaining",
+ "averagescore": "Average score",
+ "averagetime": "Average time",
+ "branchtable": "Content",
+ "cannotfindattempt": "Error: could not find attempt",
+ "cannotfinduser": "Error: could not find users",
+ "clusterjump": "Unseen question within a cluster",
+ "completed": "Completed",
+ "congratulations": "Congratulations - end of lesson reached",
+ "continue": "Continue",
+ "continuetonextpage": "Continue to next page.",
+ "defaultessayresponse": "Your essay will be graded by your teacher.",
+ "detailedstats": "Detailed statistics",
+ "didnotanswerquestion": "Did not answer this question.",
+ "displayofgrade": "Display of grade (for students only)",
+ "displayscorewithessays": "
You earned {{$a.score}} out of {{$a.tempmaxgrade}} for the automatically graded questions.
\nYour {{$a.essayquestions}} essay question(s) will be graded and added into your final score at a later date.
\nYour current grade without the essay question(s) is {{$a.score}} out of {{$a.grade}}.
",
+ "displayscorewithoutessays": "Your score is {{$a.score}} (out of {{$a.grade}}).",
+ "emptypassword": "Password cannot be empty",
+ "enterpassword": "Please enter the password:",
+ "eolstudentoutoftimenoanswers": "You did not answer any questions. You have received a 0 for this lesson.",
+ "errorprefetchrandombranch": "This lesson contains a jump to a random content page. It can't be attempted in the app until it has been started in a web browser.",
+ "errorreviewretakenotlast": "This attempt can no longer be reviewed because another attempt has been finished.",
+ "finish": "Finish",
+ "finishretakeoffline": "This attempt was finished offline.",
+ "firstwrong": "You have answered incorrectly. Would you like to attempt the question again? (If you now answer the question correctly, it will not count towards your final score.)",
+ "gotoendoflesson": "Go to the end of the lesson",
+ "grade": "Grade",
+ "highscore": "High score",
+ "hightime": "High time",
+ "leftduringtimed": "You have left during a timed lesson.
Please click on Continue to restart the lesson.",
+ "leftduringtimednoretake": "You have left during a timed lesson and you are
not allowed to retake or continue the lesson.",
+ "lessonmenu": "Lesson menu",
+ "lessonstats": "Lesson statistics",
+ "linkedmedia": "Linked media",
+ "loginfail": "Login failed, please try again...",
+ "lowscore": "Low score",
+ "lowtime": "Low time",
+ "maximumnumberofattemptsreached": "Maximum number of attempts reached - Moving to next page",
+ "modattemptsnoteacher": "Student review only works for students.",
+ "modulenameplural": "Lessons",
+ "noanswer": "One or more questions have no answer given. Please go back and submit an answer.",
+ "nolessonattempts": "No attempts have been made on this lesson.",
+ "nolessonattemptsgroup": "No attempts have been made by {{$a}} group members on this lesson.",
+ "notcompleted": "Not completed",
+ "numberofcorrectanswers": "Number of correct answers: {{$a}}",
+ "numberofpagesviewed": "Number of questions answered: {{$a}}",
+ "numberofpagesviewednotice": "Number of questions answered: {{$a.nquestions}} (You should answer at least {{$a.minquestions}})",
+ "ongoingcustom": "You have earned {{$a.score}} point(s) out of {{$a.currenthigh}} point(s) thus far.",
+ "ongoingnormal": "You have answered {{$a.correct}} correctly out of {{$a.viewed}} attempts.",
+ "or": "OR",
+ "overview": "Overview",
+ "preview": "Preview",
+ "progressbarteacherwarning2": "You will not see the progress bar because you can edit this lesson",
+ "progresscompleted": "You have completed {{$a}}% of the lesson",
+ "question": "Question",
+ "rawgrade": "Raw grade",
+ "reports": "Reports",
+ "response": "Response",
+ "retakefinishedinsync": "An offline attempt was synchronised. Do you want to review it?",
+ "retakelabelfull": "{{retake}}: {{grade}} {{timestart}} ({{duration}})",
+ "retakelabelshort": "{{retake}}: {{grade}} {{timestart}}",
+ "review": "Review",
+ "reviewlesson": "Review lesson",
+ "reviewquestionback": "Yes, I'd like to try again",
+ "reviewquestioncontinue": "No, I just want to go on to the next question",
+ "secondpluswrong": "Not quite. Would you like to try again?",
+ "submit": "Submit",
+ "teacherjumpwarning": "A {{$a.cluster}} jump or an {{$a.unseen}} jump is being used in this lesson. The next page jump will be used instead. Log in as a student to test these jumps.",
+ "teacherongoingwarning": "The ongoing score is only displayed for the student. Log in as a student to test the ongoing score.",
+ "teachertimerwarning": "Timer only works for students. Test the timer by logging in as a student.",
+ "thatsthecorrectanswer": "That's the correct answer",
+ "thatsthewronganswer": "That's the wrong answer",
+ "timeremaining": "Time remaining",
+ "timetaken": "Time taken",
+ "unseenpageinbranch": "Unseen question within a content page",
+ "warningretakefinished": "The attempt was finished on the site.",
+ "welldone": "Well done!",
+ "youhaveseen": "You have seen more than one page of this lesson already.
Do you want to start at the last page you saw?",
+ "youranswer": "Your answer",
+ "yourcurrentgradeisoutof": "Your current grade is {{$a.grade}} out of {{$a.total}}",
+ "youshouldview": "You should answer at least: {{$a}}"
+}
\ No newline at end of file
diff --git a/src/addons/mod/lesson/lesson.module.ts b/src/addons/mod/lesson/lesson.module.ts
new file mode 100644
index 000000000..83df48fce
--- /dev/null
+++ b/src/addons/mod/lesson/lesson.module.ts
@@ -0,0 +1,31 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { NgModule } from '@angular/core';
+
+import { CORE_SITE_SCHEMAS } from '@services/sites';
+import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/database/lesson';
+
+@NgModule({
+ imports: [
+ ],
+ providers: [
+ {
+ provide: CORE_SITE_SCHEMAS,
+ useValue: [SITE_SCHEMA, OFFLINE_SITE_SCHEMA],
+ multi: true,
+ },
+ ],
+})
+export class AddonModLessonModule {}
diff --git a/src/addons/mod/lesson/services/database/lesson.ts b/src/addons/mod/lesson/services/database/lesson.ts
new file mode 100644
index 000000000..ec4b6ec2b
--- /dev/null
+++ b/src/addons/mod/lesson/services/database/lesson.ts
@@ -0,0 +1,185 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { CoreSiteSchema } from '@services/sites';
+
+/**
+ * Database variables for AddonModLessonOfflineProvider.
+ */
+export const PASSWORD_TABLE_NAME = 'addon_mod_lesson_password';
+export const SITE_SCHEMA: CoreSiteSchema = {
+ name: 'AddonModLessonProvider',
+ version: 1,
+ tables: [
+ {
+ name: PASSWORD_TABLE_NAME,
+ columns: [
+ {
+ name: 'lessonid',
+ type: 'INTEGER',
+ primaryKey: true,
+ },
+ {
+ name: 'password',
+ type: 'TEXT',
+ },
+ {
+ name: 'timemodified',
+ type: 'INTEGER',
+ },
+ ],
+ },
+ ],
+};
+
+/**
+ * Database variables for AddonModLessonOfflineProvider.
+ */
+export const RETAKES_TABLE_NAME = 'addon_mod_lesson_retakes';
+export const PAGE_ATTEMPTS_TABLE_NAME = 'addon_mod_lesson_page_attempts';
+export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = {
+ name: 'AddonModLessonOfflineProvider',
+ version: 1,
+ tables: [
+ {
+ name: RETAKES_TABLE_NAME,
+ columns: [
+ {
+ name: 'lessonid',
+ type: 'INTEGER',
+ primaryKey: true, // Only 1 offline retake per lesson.
+ },
+ {
+ name: 'retake', // Retake number.
+ type: 'INTEGER',
+ notNull: true,
+ },
+ {
+ name: 'courseid',
+ type: 'INTEGER',
+ },
+ {
+ name: 'finished',
+ type: 'INTEGER',
+ },
+ {
+ name: 'outoftime',
+ type: 'INTEGER',
+ },
+ {
+ name: 'timemodified',
+ type: 'INTEGER',
+ },
+ {
+ name: 'lastquestionpage',
+ type: 'INTEGER',
+ },
+ ],
+ },
+ {
+ name: PAGE_ATTEMPTS_TABLE_NAME,
+ columns: [
+ {
+ name: 'lessonid',
+ type: 'INTEGER',
+ notNull: true,
+ },
+ {
+ name: 'retake', // Retake number.
+ type: 'INTEGER',
+ notNull: true,
+ },
+ {
+ name: 'pageid',
+ type: 'INTEGER',
+ notNull: true,
+ },
+ {
+ name: 'timemodified',
+ type: 'INTEGER',
+ notNull: true,
+ },
+ {
+ name: 'courseid',
+ type: 'INTEGER',
+ },
+ {
+ name: 'data',
+ type: 'TEXT',
+ },
+ {
+ name: 'type',
+ type: 'INTEGER',
+ },
+ {
+ name: 'newpageid',
+ type: 'INTEGER',
+ },
+ {
+ name: 'correct',
+ type: 'INTEGER',
+ },
+ {
+ name: 'answerid',
+ type: 'INTEGER',
+ },
+ {
+ name: 'useranswer',
+ type: 'TEXT',
+ },
+ ],
+ // A user can attempt several times per page and retake.
+ primaryKeys: ['lessonid', 'retake', 'pageid', 'timemodified'],
+ },
+ ],
+};
+
+/**
+ * Lesson retake data.
+ */
+export type AddonModLessonPasswordDBRecord = {
+ lessonid: number;
+ password: string;
+ timemodified: number;
+};
+
+/**
+ * Lesson retake data.
+ */
+export type AddonModLessonRetakeDBRecord = {
+ lessonid: number;
+ retake: number;
+ courseid: number;
+ finished: number;
+ outoftime?: number | null;
+ timemodified?: number | null;
+ lastquestionpage?: number | null;
+};
+
+/**
+ * Lesson page attempts data.
+ */
+export type AddonModLessonPageAttemptDBRecord = {
+ lessonid: number;
+ retake: number;
+ pageid: number;
+ timemodified: number;
+ courseid: number;
+ data: string | null;
+ type: number;
+ newpageid: number;
+ correct: number;
+ answerid: number | null;
+ useranswer: string | null;
+};
diff --git a/src/addons/mod/lesson/services/lesson-helper.ts b/src/addons/mod/lesson/services/lesson-helper.ts
new file mode 100644
index 000000000..738ff63d5
--- /dev/null
+++ b/src/addons/mod/lesson/services/lesson-helper.ts
@@ -0,0 +1,723 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Injectable } from '@angular/core';
+import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
+
+import { CoreDomUtils } from '@services/utils/dom';
+import { CoreTextUtils } from '@services/utils/text';
+import { CoreTimeUtils } from '@services/utils/time';
+import { makeSingleton, Translate } from '@singletons';
+import {
+ AddonModLesson,
+ AddonModLessonAttemptsOverviewsAttemptWSData,
+ AddonModLessonGetPageDataWSResponse,
+ AddonModLessonProvider,
+} from './lesson';
+
+/**
+ * Helper service that provides some features for quiz.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModLessonHelperProvider {
+
+ constructor(
+ protected formBuilder: FormBuilder,
+ ) {}
+
+ /**
+ * Given the HTML of next activity link, format it to extract the href and the text.
+ *
+ * @param activityLink HTML of the activity link.
+ * @return Formatted data.
+ */
+ formatActivityLink(activityLink: string): {formatted: boolean; label: string; href: string} {
+ const element = CoreDomUtils.instance.convertToElement(activityLink);
+ const anchor = element.querySelector('a');
+
+ if (!anchor) {
+ // Anchor not found, return the original HTML.
+ return {
+ formatted: false,
+ label: activityLink,
+ href: '',
+ };
+ }
+
+ return {
+ formatted: true,
+ label: anchor.innerHTML,
+ href: anchor.href,
+ };
+ }
+
+ /**
+ * Given the HTML of an answer from a content page, extract the data to render the answer.
+ *
+ * @param html Answer's HTML.
+ * @return Data to render the answer.
+ */
+ getContentPageAnswerDataFromHtml(html: string): {buttonText: string; content: string} {
+ const data = {
+ buttonText: '',
+ content: '',
+ };
+ const element = CoreDomUtils.instance.convertToElement(html);
+
+ // Search the input button.
+ const button = element.querySelector('input[type="button"]');
+
+ if (button) {
+ // Extract the button content and remove it from the HTML.
+ data.buttonText = button.value;
+ button.remove();
+ }
+
+ data.content = element.innerHTML.trim();
+
+ return data;
+ }
+
+ /**
+ * Get the buttons to change pages.
+ *
+ * @param html Page's HTML.
+ * @return List of buttons.
+ */
+ getPageButtonsFromHtml(html: string): AddonModLessonPageButton[] {
+ const buttons: AddonModLessonPageButton[] = [];
+ const element = CoreDomUtils.instance.convertToElement(html);
+
+ // Get the container of the buttons if it exists.
+ let buttonsContainer = element.querySelector('.branchbuttoncontainer');
+
+ if (!buttonsContainer) {
+ // Button container not found, might be a legacy lesson (from 1.9).
+ if (!element.querySelector('form input[type="submit"]')) {
+ // No buttons found.
+ return buttons;
+ }
+ buttonsContainer = element;
+ }
+
+ const forms = Array.from(buttonsContainer.querySelectorAll('form'));
+ forms.forEach((form) => {
+ const buttonSelector = 'input[type="submit"], button[type="submit"]';
+ const buttonEl = form.querySelector(buttonSelector);
+ const inputs = Array.from(form.querySelectorAll('input'));
+
+ if (!buttonEl || !inputs || !inputs.length) {
+ // Button not found or no inputs, ignore it.
+ return;
+ }
+
+ const button: AddonModLessonPageButton = {
+ id: buttonEl.id,
+ title: buttonEl.title || buttonEl.value,
+ content: buttonEl.tagName == 'INPUT' ? buttonEl.value : buttonEl.innerHTML.trim(),
+ data: {},
+ };
+
+ inputs.forEach((input) => {
+ if (input.type != 'submit') {
+ button.data[input.name] = input.value;
+ }
+ });
+
+ buttons.push(button);
+ });
+
+ return buttons;
+ }
+
+ /**
+ * Given a page data, get the page contents.
+ *
+ * @param data Page data.
+ * @return Page contents.
+ */
+ getPageContentsFromPageData(data: AddonModLessonGetPageDataWSResponse): string {
+ // Search the page contents inside the whole page HTML. Use data.pagecontent because it's filtered.
+ const element = CoreDomUtils.instance.convertToElement(data.pagecontent || '');
+ const contents = element.querySelector('.contents');
+
+ if (contents) {
+ return contents.innerHTML.trim();
+ }
+
+ // Cannot find contents element.
+ if (AddonModLesson.instance.isQuestionPage(data.page?.type || -1) ||
+ data.page?.qtype == AddonModLessonProvider.LESSON_PAGE_BRANCHTABLE) {
+ // Return page.contents to prevent having duplicated elements (some elements like videos might not work).
+ return data.page?.contents || '';
+ } else {
+ // It's an end of cluster, end of branch, etc. Return the whole pagecontent to match what's displayed in web.
+ return data.pagecontent || '';
+ }
+ }
+
+ /**
+ * Get a question and all the data required to render it from the page data.
+ *
+ * @param questionForm The form group where to add the controls.
+ * @param pageData Page data.
+ * @return Question data.
+ */
+ getQuestionFromPageData(questionForm: FormGroup, pageData: AddonModLessonGetPageDataWSResponse): AddonModLessonQuestion {
+ const element = CoreDomUtils.instance.convertToElement(pageData.pagecontent || '');
+
+ // Get the container of the question answers if it exists.
+ const fieldContainer = element.querySelector('.fcontainer');
+
+ // Get hidden inputs and add their data to the form group.
+ const hiddenInputs = Array.from(element.querySelectorAll('input[type="hidden"]'));
+ hiddenInputs.forEach((input) => {
+ questionForm.addControl(input.name, this.formBuilder.control(input.value));
+ });
+
+ // Get the submit button and extract its value.
+ const submitButton = element.querySelector('input[type="submit"]');
+ const question: AddonModLessonQuestion = {
+ template: '',
+ submitLabel: submitButton ? submitButton.value : Translate.instance.instant('addon.mod_lesson.submit'),
+ };
+
+ if (!fieldContainer) {
+ // Element not found, return.
+ return question;
+ }
+
+ let type = 'text';
+
+ switch (pageData.page?.qtype) {
+ case AddonModLessonProvider.LESSON_PAGE_TRUEFALSE:
+ case AddonModLessonProvider.LESSON_PAGE_MULTICHOICE:
+ return this.getMultiChoiceQuestionData(questionForm, question, fieldContainer);
+
+ case AddonModLessonProvider.LESSON_PAGE_NUMERICAL:
+ type = 'number';
+ case AddonModLessonProvider.LESSON_PAGE_SHORTANSWER:
+ return this.getInputQuestionData(questionForm, question, fieldContainer, type);
+
+ case AddonModLessonProvider.LESSON_PAGE_ESSAY: {
+ return this.getEssayQuestionData(questionForm, question, fieldContainer);
+ }
+
+ case AddonModLessonProvider.LESSON_PAGE_MATCHING: {
+ return this.getMatchingQuestionData(questionForm, question, fieldContainer);
+ }
+ }
+
+ return question;
+ }
+
+ /**
+ * Get a multichoice question data.
+ *
+ * @param questionForm The form group where to add the controls.
+ * @param question Basic question data.
+ * @param fieldContainer HTMLElement containing the data.
+ * @return Question data.
+ */
+ protected getMultiChoiceQuestionData(
+ questionForm: FormGroup,
+ question: AddonModLessonQuestion,
+ fieldContainer: HTMLElement,
+ ): AddonModLessonMultichoiceQuestion {
+ const multiChoiceQuestion = {
+ ...question,
+ template: 'multichoice',
+ options: [],
+ multi: false,
+ };
+
+ // Get all the inputs. Search radio first.
+ let inputs = Array.from(fieldContainer.querySelectorAll('input[type="radio"]'));
+ if (!inputs || !inputs.length) {
+ // Radio buttons not found, it might be a multi answer. Search for checkbox.
+ multiChoiceQuestion.multi = true;
+ inputs = Array.from(fieldContainer.querySelectorAll('input[type="checkbox"]'));
+
+ if (!inputs || !inputs.length) {
+ // No checkbox found either. Stop.
+ return multiChoiceQuestion;
+ }
+ }
+
+ let controlAdded = false;
+ inputs.forEach((input) => {
+ const parent = input.parentElement;
+ const option: AddonModLessonMultichoiceOption = {
+ id: input.id,
+ name: input.name,
+ value: input.value,
+ checked: !!input.checked,
+ disabled: !!input.disabled,
+ text: parent?.innerHTML.trim() || '',
+ };
+
+ if (option.checked || multiChoiceQuestion.multi) {
+ // Add the control.
+ const value = multiChoiceQuestion.multi ?
+ { value: option.checked, disabled: option.disabled } : option.value;
+ questionForm.addControl(option.name, this.formBuilder.control(value));
+ controlAdded = true;
+ }
+
+ // Remove the input and use the rest of the parent contents as the label.
+ input.remove();
+ multiChoiceQuestion.options!.push(option);
+ });
+
+ if (!multiChoiceQuestion.multi) {
+ multiChoiceQuestion.controlName = inputs[0].name;
+
+ if (!controlAdded) {
+ // No checked option for single choice, add the control with an empty value.
+ questionForm.addControl(multiChoiceQuestion.controlName, this.formBuilder.control(''));
+ }
+ }
+
+ return multiChoiceQuestion;
+ }
+
+ /**
+ * Get an input question data.
+ *
+ * @param questionForm The form group where to add the controls.
+ * @param question Basic question data.
+ * @param fieldContainer HTMLElement containing the data.
+ * @param type Type of the input.
+ * @return Question data.
+ */
+ protected getInputQuestionData(
+ questionForm: FormGroup,
+ question: AddonModLessonQuestion,
+ fieldContainer: HTMLElement,
+ type: string,
+ ): AddonModLessonInputQuestion {
+
+ const inputQuestion = question;
+ inputQuestion.template = 'shortanswer';
+
+ // Get the input.
+ const input = fieldContainer.querySelector('input[type="text"], input[type="number"]');
+ if (!input) {
+ return inputQuestion;
+ }
+
+ inputQuestion.input = {
+ id: input.id,
+ name: input.name,
+ maxlength: input.maxLength,
+ type,
+ };
+
+ // Init the control.
+ questionForm.addControl(input.name, this.formBuilder.control({ value: input.value, disabled: input.readOnly }));
+
+ return inputQuestion;
+ }
+
+ /**
+ * Get an essay question data.
+ *
+ * @param questionForm The form group where to add the controls.
+ * @param question Basic question data.
+ * @param fieldContainer HTMLElement containing the data.
+ * @return Question data.
+ */
+ protected getEssayQuestionData(
+ questionForm: FormGroup,
+ question: AddonModLessonQuestion,
+ fieldContainer: HTMLElement,
+ ): AddonModLessonEssayQuestion {
+ const essayQuestion = question;
+ essayQuestion.template = 'essay';
+
+ // Get the textarea.
+ const textarea = fieldContainer.querySelector('textarea');
+
+ if (!textarea) {
+ // Textarea not found, probably review mode.
+ const answerEl = fieldContainer.querySelector('.reviewessay');
+ if (!answerEl) {
+ // Answer not found, stop.
+ return essayQuestion;
+ }
+ essayQuestion.useranswer = answerEl.innerHTML;
+
+ } else {
+ essayQuestion.textarea = {
+ id: textarea.id,
+ name: textarea.name || 'answer[text]',
+ };
+
+ // Init the control.
+ essayQuestion.control = this.formBuilder.control('');
+ questionForm.addControl(essayQuestion.textarea.name, essayQuestion.control);
+ }
+
+ return essayQuestion;
+ }
+
+ /**
+ * Get a matching question data.
+ *
+ * @param questionForm The form group where to add the controls.
+ * @param question Basic question data.
+ * @param fieldContainer HTMLElement containing the data.
+ * @return Question data.
+ */
+ protected getMatchingQuestionData(
+ questionForm: FormGroup,
+ question: AddonModLessonQuestion,
+ fieldContainer: HTMLElement,
+ ): AddonModLessonMatchingQuestion {
+
+ const matchingQuestion = {
+ ...question,
+ template: 'matching',
+ rows: [],
+ };
+
+ const rows = Array.from(fieldContainer.querySelectorAll('.answeroption'));
+
+ rows.forEach((row) => {
+ const label = row.querySelector('label');
+ const select = row.querySelector('select');
+ const options = Array.from(row.querySelectorAll('option'));
+
+ if (!label || !select || !options || !options.length) {
+ return;
+ }
+
+ // Get the row's text (label).
+ const rowData: AddonModLessonMatchingRow = {
+ text: label.innerHTML.trim(),
+ id: select.id,
+ name: select.name,
+ options: [],
+ };
+
+ // Treat each option.
+ let controlAdded = false;
+ options.forEach((option) => {
+ if (typeof option.value == 'undefined') {
+ // Option not valid, ignore it.
+ return;
+ }
+
+ const optionData: AddonModLessonMatchingRowOption = {
+ value: option.value,
+ label: option.innerHTML.trim(),
+ selected: option.selected,
+ };
+
+ if (optionData.selected) {
+ controlAdded = true;
+ questionForm.addControl(
+ rowData.name,
+ this.formBuilder.control({ value: optionData.value, disabled: !!select.disabled }),
+ );
+ }
+
+ rowData.options.push(optionData);
+ });
+
+ if (!controlAdded) {
+ // No selected option, add the control with an empty value.
+ questionForm.addControl(rowData.name, this.formBuilder.control({ value: '', disabled: !!select.disabled }));
+ }
+
+ matchingQuestion.rows.push(rowData);
+ });
+
+ return matchingQuestion;
+ }
+
+ /**
+ * Given the HTML of an answer from a question page, extract the data to render the answer.
+ *
+ * @param html Answer's HTML.
+ * @return Object with the data to render the answer. If the answer doesn't require any parsing, return a string with the HTML.
+ */
+ getQuestionPageAnswerDataFromHtml(html: string): AddonModLessonAnswerData {
+ const element = CoreDomUtils.instance.convertToElement(html);
+
+ // Check if it has a checkbox.
+ let input = element.querySelector('input[type="checkbox"][name*="answer"]');
+ if (input) {
+ // Truefalse or multichoice.
+ const data: AddonModLessonCheckboxAnswerData = {
+ isCheckbox: true,
+ checked: !!input.checked,
+ name: input.name,
+ highlight: !!element.querySelector('.highlight'),
+ content: '',
+ };
+
+ input.remove();
+ data.content = element.innerHTML.trim();
+
+ return data;
+ }
+
+ // Check if it has an input text or number.
+ input = element.querySelector('input[type="number"],input[type="text"]');
+ if (input) {
+ // Short answer or numeric.
+ return {
+ isText: true,
+ value: input.value,
+ };
+ }
+
+ // Check if it has a select.
+ const select = element.querySelector('select');
+ if (select?.options) {
+ // Matching.
+ const selectedOption = select.options[select.selectedIndex];
+ const data: AddonModLessonSelectAnswerData = {
+ isSelect: true,
+ id: select.id,
+ value: selectedOption ? selectedOption.value : '',
+ content: '',
+ };
+
+ select.remove();
+ data.content = element.innerHTML.trim();
+
+ return data;
+ }
+
+ // The answer doesn't need any parsing, return the HTML as it is.
+ return html;
+ }
+
+ /**
+ * Get a label to identify a retake (lesson attempt).
+ *
+ * @param retake Retake object.
+ * @param includeDuration Whether to include the duration of the retake.
+ * @return Retake label.
+ */
+ getRetakeLabel(retake: AddonModLessonAttemptsOverviewsAttemptWSData, includeDuration?: boolean): string {
+ const data = {
+ retake: retake.try + 1,
+ grade: '',
+ timestart: '',
+ duration: '',
+ };
+ const hasGrade = retake.grade != null;
+
+ if (hasGrade || retake.end) {
+ // Retake finished with or without grade (if the lesson only has content pages, it has no grade).
+ if (hasGrade) {
+ data.grade = Translate.instance.instant('core.percentagenumber', { $a: retake.grade });
+ }
+ data.timestart = CoreTimeUtils.instance.userDate(retake.timestart * 1000);
+ if (includeDuration) {
+ data.duration = CoreTimeUtils.instance.formatTime(retake.timeend - retake.timestart);
+ }
+ } else {
+ // The user has not completed the retake.
+ data.grade = Translate.instance.instant('addon.mod_lesson.notcompleted');
+ if (retake.timestart) {
+ data.timestart = CoreTimeUtils.instance.userDate(retake.timestart * 1000);
+ }
+ }
+
+ return Translate.instance.instant('addon.mod_lesson.retakelabel' + (includeDuration ? 'full' : 'short'), data);
+ }
+
+ /**
+ * Prepare the question data to be sent to server.
+ *
+ * @param question Question to prepare.
+ * @param data Data to prepare.
+ * @return Data to send.
+ */
+ prepareQuestionData(question: AddonModLessonQuestion, data: Record): Record {
+ if (question.template == 'essay') {
+ const textarea = ( question).textarea;
+
+ // Add some HTML to the answer if needed.
+ if (textarea) {
+ data[textarea.name] = CoreTextUtils.instance.formatHtmlLines( data[textarea.name]);
+ }
+ } else if (question.template == 'multichoice' && ( question).multi) {
+ // Only send the options with value set to true.
+ for (const name in data) {
+ if (name.match(/answer\[\d+\]/) && data[name] == false) {
+ delete data[name];
+ }
+ }
+ }
+
+ return data;
+ }
+
+ /**
+ * Given the feedback of a process page in HTML, remove the question text.
+ *
+ * @param html Feedback's HTML.
+ * @return Feedback without the question text.
+ */
+ removeQuestionFromFeedback(html: string): string {
+ const element = CoreDomUtils.instance.convertToElement(html);
+
+ // Remove the question text.
+ CoreDomUtils.instance.removeElement(element, '.generalbox:not(.feedback):not(.correctanswer)');
+
+ return element.innerHTML.trim();
+ }
+
+}
+
+export class AddonModLessonHelper extends makeSingleton(AddonModLessonHelperProvider) {}
+
+/**
+ * Page button data.
+ */
+export type AddonModLessonPageButton = {
+ id: string;
+ title: string;
+ content: string;
+ data: Record;
+};
+
+/**
+ * Generic question data.
+ */
+export type AddonModLessonQuestion = {
+ template: string; // Name of the template to use.
+ submitLabel: string; // Text to display in submit.
+};
+
+/**
+ * Multichoice question data.
+ */
+export type AddonModLessonMultichoiceQuestion = AddonModLessonQuestion & {
+ multi: boolean; // Whether it allows multiple answers.
+ options: AddonModLessonMultichoiceOption[]; // Options for multichoice question.
+ controlName?: string; // Name of the form control, for single choice.
+};
+
+/**
+ * Short answer or numeric question data.
+ */
+export type AddonModLessonInputQuestion = AddonModLessonQuestion & {
+ input?: AddonModLessonQuestionInput; // Text input for text/number questions.
+};
+
+/**
+ * Essay question data.
+ */
+export type AddonModLessonEssayQuestion = AddonModLessonQuestion & {
+ useranswer?: string; // User answer, for reviewing.
+ textarea?: AddonModLessonTextareaData; // Data for the textarea.
+ control?: FormControl; // Form control.
+};
+
+/**
+ * Matching question data.
+ */
+export type AddonModLessonMatchingQuestion = AddonModLessonQuestion & {
+ rows: AddonModLessonMatchingRow[];
+};
+
+/**
+ * Data for each option in a multichoice question.
+ */
+export type AddonModLessonMultichoiceOption = {
+ id: string;
+ name: string;
+ value: string;
+ checked: boolean;
+ disabled: boolean;
+ text: string;
+};
+
+/**
+ * Input data for text/number questions.
+ */
+export type AddonModLessonQuestionInput = {
+ id: string;
+ name: string;
+ maxlength: number;
+ type: string;
+};
+
+/**
+ * Textarea data for essay questions.
+ */
+export type AddonModLessonTextareaData = {
+ id: string;
+ name: string;
+};
+
+/**
+ * Data for each row in a matching question.
+ */
+export type AddonModLessonMatchingRow = {
+ id: string;
+ name: string;
+ text: string;
+ options: AddonModLessonMatchingRowOption[];
+};
+
+/**
+ * Data for each option in a row in a matching question.
+ */
+export type AddonModLessonMatchingRowOption = {
+ value: string;
+ label: string;
+ selected: boolean;
+};
+
+/**
+ * Checkbox answer.
+ */
+export type AddonModLessonCheckboxAnswerData = {
+ isCheckbox: true;
+ checked: boolean;
+ name: string;
+ highlight: boolean;
+ content: string;
+};
+
+/**
+ * Text answer.
+ */
+export type AddonModLessonTextAnswerData = {
+ isText: true;
+ value: string;
+};
+
+/**
+ * Select answer.
+ */
+export type AddonModLessonSelectAnswerData = {
+ isSelect: true;
+ id: string;
+ value: string;
+ content: string;
+};
+
+/**
+ * Any possible answer data.
+ */
+export type AddonModLessonAnswerData =
+ AddonModLessonCheckboxAnswerData | AddonModLessonTextAnswerData | AddonModLessonSelectAnswerData | string;
diff --git a/src/addons/mod/lesson/services/lesson-offline.ts b/src/addons/mod/lesson/services/lesson-offline.ts
new file mode 100644
index 000000000..b557428f9
--- /dev/null
+++ b/src/addons/mod/lesson/services/lesson-offline.ts
@@ -0,0 +1,565 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Injectable } from '@angular/core';
+import { CoreSites } from '@services/sites';
+import { CoreTextUtils } from '@services/utils/text';
+import { CoreTimeUtils } from '@services/utils/time';
+import { CoreUtils } from '@services/utils/utils';
+import { makeSingleton } from '@singletons';
+import {
+ AddonModLessonPageAttemptDBRecord,
+ AddonModLessonRetakeDBRecord,
+ PAGE_ATTEMPTS_TABLE_NAME,
+ RETAKES_TABLE_NAME,
+} from './database/lesson';
+
+import { AddonModLessonPageWSData, AddonModLessonProvider } from './lesson';
+
+/**
+ * Service to handle offline lesson.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModLessonOfflineProvider {
+
+ /**
+ * Delete an offline attempt.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Lesson retake number.
+ * @param pageId Page ID.
+ * @param timemodified The timemodified of the attempt.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when done.
+ */
+ async deleteAttempt(lessonId: number, retake: number, pageId: number, timemodified: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.getDb().deleteRecords(PAGE_ATTEMPTS_TABLE_NAME, > {
+ lessonid: lessonId,
+ retake: retake,
+ pageid: pageId,
+ timemodified: timemodified,
+ });
+ }
+
+ /**
+ * Delete offline lesson retake.
+ *
+ * @param lessonId Lesson ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when done.
+ */
+ async deleteRetake(lessonId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.getDb().deleteRecords(RETAKES_TABLE_NAME, > { lessonid: lessonId });
+ }
+
+ /**
+ * Delete offline attempts for a retake and page.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Lesson retake number.
+ * @param pageId Page ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when done.
+ */
+ async deleteRetakeAttemptsForPage(lessonId: number, retake: number, pageId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.getDb().deleteRecords(PAGE_ATTEMPTS_TABLE_NAME, > {
+ lessonid: lessonId,
+ retake: retake,
+ pageid: pageId,
+ });
+ }
+
+ /**
+ * Mark a retake as finished.
+ *
+ * @param lessonId Lesson ID.
+ * @param courseId Course ID the lesson belongs to.
+ * @param retake Retake number.
+ * @param finished Whether retake is finished.
+ * @param outOfTime If the user ran out of time.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved in success, rejected otherwise.
+ */
+ async finishRetake(
+ lessonId: number,
+ courseId: number,
+ retake: number,
+ finished?: boolean,
+ outOfTime?: boolean,
+ siteId?: string,
+ ): Promise {
+
+ const site = await CoreSites.instance.getSite(siteId);
+
+ // Get current stored retake (if any). If not found, it will create a new one.
+ const entry = await this.getRetakeWithFallback(lessonId, courseId, retake, site.id);
+
+ entry.finished = finished ? 1 : 0;
+ entry.outoftime = outOfTime ? 1 : 0;
+ entry.timemodified = CoreTimeUtils.instance.timestamp();
+
+ await site.getDb().insertRecord(RETAKES_TABLE_NAME, entry);
+ }
+
+ /**
+ * Get all the offline page attempts in a certain site.
+ *
+ * @param siteId Site ID. If not set, use current site.
+ * @return Promise resolved when the offline attempts are retrieved.
+ */
+ async getAllAttempts(siteId?: string): Promise {
+ const db = await CoreSites.instance.getSiteDb(siteId);
+
+ const attempts = await db.getAllRecords(PAGE_ATTEMPTS_TABLE_NAME);
+
+ return this.parsePageAttempts(attempts);
+ }
+
+ /**
+ * Get all the lessons that have offline data in a certain site.
+ *
+ * @param siteId Site ID. If not set, use current site.
+ * @return Promise resolved with an object containing the lessons.
+ */
+ async getAllLessonsWithData(siteId?: string): Promise {
+ const lessons: Record = {};
+
+ const [pageAttempts, retakes] = await Promise.all([
+ CoreUtils.instance.ignoreErrors(this.getAllAttempts(siteId)),
+ CoreUtils.instance.ignoreErrors(this.getAllRetakes(siteId)),
+ ]);
+
+ this.getLessonsFromEntries(lessons, pageAttempts || []);
+ this.getLessonsFromEntries(lessons, retakes || []);
+
+ return CoreUtils.instance.objectToArray(lessons);
+ }
+
+ /**
+ * Get all the offline retakes in a certain site.
+ *
+ * @param siteId Site ID. If not set, use current site.
+ * @return Promise resolved when the offline retakes are retrieved.
+ */
+ async getAllRetakes(siteId?: string): Promise {
+ const db = await CoreSites.instance.getSiteDb(siteId);
+
+ return db.getAllRecords(RETAKES_TABLE_NAME);
+ }
+
+ /**
+ * Retrieve the last offline attempt stored in a retake.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with the attempt (undefined if no attempts).
+ */
+ async getLastQuestionPageAttempt(
+ lessonId: number,
+ retake: number,
+ siteId?: string,
+ ): Promise {
+ siteId = siteId || CoreSites.instance.getCurrentSiteId();
+
+ try {
+ const retakeData = await this.getRetakeWithFallback(lessonId, 0, retake, siteId);
+ if (!retakeData.lastquestionpage) {
+ // No question page attempted.
+ return;
+ }
+
+ const attempts = await this.getRetakeAttemptsForPage(lessonId, retake, retakeData.lastquestionpage, siteId);
+
+ // Return the attempt with highest timemodified.
+ return attempts.reduce((a, b) => a.timemodified > b.timemodified ? a : b);
+ } catch {
+ // Error, return undefined.
+ }
+ }
+
+ /**
+ * Retrieve all offline attempts for a lesson.
+ *
+ * @param lessonId Lesson ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with the attempts.
+ */
+ async getLessonAttempts(lessonId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ const attempts = await site.getDb().getRecords(
+ PAGE_ATTEMPTS_TABLE_NAME,
+ { lessonid: lessonId },
+ );
+
+ return this.parsePageAttempts(attempts);
+ }
+
+ /**
+ * Given a list of DB entries (either retakes or page attempts), get the list of lessons.
+ *
+ * @param lessons Object where to store the lessons.
+ * @param entries List of DB entries.
+ */
+ protected getLessonsFromEntries(
+ lessons: Record,
+ entries: (AddonModLessonPageAttemptRecord | AddonModLessonRetakeDBRecord)[],
+ ): void {
+ entries.forEach((entry) => {
+ if (!lessons[entry.lessonid]) {
+ lessons[entry.lessonid] = {
+ id: entry.lessonid,
+ courseId: entry.courseid,
+ };
+ }
+ });
+ }
+
+ /**
+ * Get attempts for question pages and retake in a lesson.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number.
+ * @param correct True to only fetch correct attempts, false to get them all.
+ * @param pageId If defined, only get attempts on this page.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with the attempts.
+ */
+ async getQuestionsAttempts(
+ lessonId: number,
+ retake: number,
+ correct?: boolean,
+ pageId?: number,
+ siteId?: string,
+ ): Promise {
+ const attempts = pageId ?
+ await this.getRetakeAttemptsForPage(lessonId, retake, pageId, siteId) :
+ await this.getRetakeAttemptsForType(lessonId, retake, AddonModLessonProvider.TYPE_QUESTION, siteId);
+
+ if (correct) {
+ return attempts.filter((attempt) => !!attempt.correct);
+ }
+
+ return attempts;
+ }
+
+ /**
+ * Retrieve a retake from site DB.
+ *
+ * @param lessonId Lesson ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with the retake.
+ */
+ async getRetake(lessonId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ return site.getDb().getRecord(RETAKES_TABLE_NAME, { lessonid: lessonId });
+ }
+
+ /**
+ * Retrieve all offline attempts for a retake.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with the retake attempts.
+ */
+ async getRetakeAttempts(lessonId: number, retake: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ const attempts = await site.getDb().getRecords(
+ PAGE_ATTEMPTS_TABLE_NAME,
+ > {
+ lessonid: lessonId,
+ retake,
+ },
+ );
+
+ return this.parsePageAttempts(attempts);
+ }
+
+ /**
+ * Retrieve offline attempts for a retake and page.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Lesson retake number.
+ * @param pageId Page ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with the retake attempts.
+ */
+ async getRetakeAttemptsForPage(
+ lessonId: number,
+ retake: number,
+ pageId: number,
+ siteId?: string,
+ ): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ const attempts = await site.getDb().getRecords(
+ PAGE_ATTEMPTS_TABLE_NAME,
+ > {
+ lessonid: lessonId,
+ retake,
+ pageid: pageId,
+ },
+ );
+
+ return this.parsePageAttempts(attempts);
+ }
+
+ /**
+ * Retrieve offline attempts for certain pages for a retake.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number.
+ * @param type Type of the pages to get: TYPE_QUESTION or TYPE_STRUCTURE.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with the retake attempts.
+ */
+ async getRetakeAttemptsForType(
+ lessonId: number,
+ retake: number,
+ type: number,
+ siteId?: string,
+ ): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ const attempts = await site.getDb().getRecords(
+ PAGE_ATTEMPTS_TABLE_NAME,
+ > {
+ lessonid: lessonId,
+ retake,
+ type,
+ },
+ );
+
+ return this.parsePageAttempts(attempts);
+ }
+
+ /**
+ * Get stored retake. If not found or doesn't match the retake number, return a new one.
+ *
+ * @param lessonId Lesson ID.
+ * @param courseId Course ID the lesson belongs to.
+ * @param retake Retake number.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with the retake.
+ */
+ protected async getRetakeWithFallback(
+ lessonId: number,
+ courseId: number,
+ retake: number,
+ siteId?: string,
+ ): Promise {
+ try {
+ // Get current stored retake.
+ const retakeData = await this.getRetake(lessonId, siteId);
+
+ if (retakeData.retake == retake) {
+ return retakeData;
+ }
+ } catch {
+ // No retake, create a new one.
+ }
+
+ // Create a new retake.
+ return {
+ lessonid: lessonId,
+ retake,
+ courseid: courseId,
+ finished: 0,
+ };
+ }
+
+ /**
+ * Check if there is a finished retake for a certain lesson.
+ *
+ * @param lessonId Lesson ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with boolean.
+ */
+ async hasFinishedRetake(lessonId: number, siteId?: string): Promise {
+ try {
+ const retake = await this.getRetake(lessonId, siteId);
+
+ return !!retake.finished;
+ } catch {
+ return false;
+ }
+ }
+
+ /**
+ * Check if a lesson has offline data.
+ *
+ * @param lessonId Lesson ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with boolean.
+ */
+ async hasOfflineData(lessonId: number, siteId?: string): Promise {
+ const [retake, attempts] = await Promise.all([
+ CoreUtils.instance.ignoreErrors(this.getRetake(lessonId, siteId)),
+ CoreUtils.instance.ignoreErrors(this.getLessonAttempts(lessonId, siteId)),
+ ]);
+
+ return !!retake || !!attempts?.length;
+ }
+
+ /**
+ * Check if there are offline attempts for a retake.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with a boolean.
+ */
+ async hasRetakeAttempts(lessonId: number, retake: number, siteId?: string): Promise {
+ try {
+ const list = await this.getRetakeAttempts(lessonId, retake, siteId);
+
+ return !!list.length;
+ } catch {
+ return false;
+ }
+ }
+
+ /**
+ * Parse some properties of a page attempt.
+ *
+ * @param attempt The attempt to treat.
+ * @return The treated attempt.
+ */
+ protected parsePageAttempt(attempt: AddonModLessonPageAttemptDBRecord): AddonModLessonPageAttemptRecord {
+ return {
+ ...attempt,
+ data: attempt.data ? CoreTextUtils.instance.parseJSON(attempt.data) : null,
+ useranswer: attempt.useranswer ? CoreTextUtils.instance.parseJSON(attempt.useranswer) : null,
+ };
+ }
+
+ /**
+ * Parse some properties of some page attempts.
+ *
+ * @param attempts The attempts to treat.
+ * @return The treated attempts.
+ */
+ protected parsePageAttempts(attempts: AddonModLessonPageAttemptDBRecord[]): AddonModLessonPageAttemptRecord[] {
+ return attempts.map((attempt) => this.parsePageAttempt(attempt));
+ }
+
+ /**
+ * Process a lesson page, saving its data.
+ *
+ * @param lessonId Lesson ID.
+ * @param courseId Course ID the lesson belongs to.
+ * @param retake Retake number.
+ * @param page Page.
+ * @param data Data to save.
+ * @param newPageId New page ID (calculated).
+ * @param answerId The answer ID that the user answered.
+ * @param correct If answer is correct. Only for question pages.
+ * @param userAnswer The user's answer (userresponse from checkAnswer).
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved in success, rejected otherwise.
+ */
+ async processPage(
+ lessonId: number,
+ courseId: number,
+ retake: number,
+ page: AddonModLessonPageWSData,
+ data: Record,
+ newPageId: number,
+ answerId?: number,
+ correct?: boolean,
+ userAnswer?: unknown,
+ siteId?: string,
+ ): Promise {
+
+ const site = await CoreSites.instance.getSite(siteId);
+
+ const entry: AddonModLessonPageAttemptDBRecord = {
+ lessonid: lessonId,
+ retake: retake,
+ pageid: page.id,
+ timemodified: CoreTimeUtils.instance.timestamp(),
+ courseid: courseId,
+ data: data ? JSON.stringify(data) : null,
+ type: page.type,
+ newpageid: newPageId,
+ correct: correct ? 1 : 0,
+ answerid: answerId || null,
+ useranswer: userAnswer ? JSON.stringify(userAnswer) : null,
+ };
+
+ await site.getDb().insertRecord(PAGE_ATTEMPTS_TABLE_NAME, entry);
+
+ if (page.type == AddonModLessonProvider.TYPE_QUESTION) {
+ // It's a question page, set it as last question page attempted.
+ await this.setLastQuestionPageAttempted(lessonId, courseId, retake, page.id, siteId);
+ }
+ }
+
+ /**
+ * Set the last question page attempted in a retake.
+ *
+ * @param lessonId Lesson ID.
+ * @param courseId Course ID the lesson belongs to.
+ * @param retake Retake number.
+ * @param lastPage ID of the last question page attempted.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved in success, rejected otherwise.
+ */
+ async setLastQuestionPageAttempted(
+ lessonId: number,
+ courseId: number,
+ retake: number,
+ lastPage: number,
+ siteId?: string,
+ ): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ // Get current stored retake (if any). If not found, it will create a new one.
+ const entry = await this.getRetakeWithFallback(lessonId, courseId, retake, site.id);
+
+ entry.lastquestionpage = lastPage;
+ entry.timemodified = CoreTimeUtils.instance.timestamp();
+
+ await site.getDb().insertRecord(RETAKES_TABLE_NAME, entry);
+ }
+
+}
+
+export class AddonModLessonOffline extends makeSingleton(AddonModLessonOfflineProvider) {}
+
+/**
+ * Attempt DB record with parsed data.
+ */
+export type AddonModLessonPageAttemptRecord = Omit & {
+ data: Record | null;
+ useranswer: unknown | null;
+};
+
+/**
+ * Lesson data stored in DB.
+ */
+export type AddonModLessonLessonStoredData = {
+ id: number;
+ courseId: number;
+};
diff --git a/src/addons/mod/lesson/services/lesson.ts b/src/addons/mod/lesson/services/lesson.ts
new file mode 100644
index 000000000..2e1a8c2a4
--- /dev/null
+++ b/src/addons/mod/lesson/services/lesson.ts
@@ -0,0 +1,4231 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Injectable } from '@angular/core';
+import { CoreError } from '@classes/errors/error';
+import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
+import { CoreCourseCommonModWSOptions } from '@features/course/services/course';
+import { CoreCourseLogHelper } from '@features/course/services/log-helper';
+import { CoreGradesProvider } from '@features/grades/services/grades';
+import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites';
+import { CoreDomUtils } from '@services/utils/dom';
+import { CoreTextUtils } from '@services/utils/text';
+import { CoreUtils } from '@services/utils/utils';
+import { CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws';
+import { makeSingleton, Translate } from '@singletons';
+import { CoreEvents, CoreEventSiteData } from '@singletons/events';
+import { AddonModLessonPasswordDBRecord, PASSWORD_TABLE_NAME } from './database/lesson';
+import { AddonModLessonOffline, AddonModLessonPageAttemptRecord } from './lesson-offline';
+
+const ROOT_CACHE_KEY = 'mmaModLesson:';
+
+/**
+ * Service that provides some features for lesson.
+ *
+ * Lesson terminology is a bit confusing and ambiguous in Moodle. For that reason, in the app it has been decided to use
+ * the following terminology:
+ * - Retake: An attempt in a lesson. In Moodle it's sometimes called "attempt", "try" or "retry".
+ * - Attempt: An attempt in a page inside a retake. In the app, this includes content pages.
+ * - Content page: A page with only content (no question). In Moodle it's sometimes called "branch table".
+ * - Page answers: List of possible answers for a page (configured by the teacher). NOT the student answer for the page.
+ *
+ * This terminology sometimes won't match with WebServices names, params or responses.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModLessonProvider {
+
+ static readonly COMPONENT = 'mmaModLesson';
+ static readonly DATA_SENT_EVENT = 'addon_mod_lesson_data_sent';
+
+ static readonly LESSON_THISPAGE = 0; // This page.
+ static readonly LESSON_UNSEENPAGE = 1; // Next page -> any page not seen before.
+ static readonly LESSON_UNANSWEREDPAGE = 2; // Next page -> any page not answered correctly.
+ static readonly LESSON_NEXTPAGE = -1; // Jump to Next Page.
+ static readonly LESSON_EOL = -9; // End of Lesson.
+ static readonly LESSON_UNSEENBRANCHPAGE = -50; // Jump to an unseen page within a branch and end of branch or end of lesson.
+ static readonly LESSON_RANDOMPAGE = -60; // Jump to a random page within a branch and end of branch or end of lesson.
+ static readonly LESSON_RANDOMBRANCH = -70; // Jump to a random Branch.
+ static readonly LESSON_CLUSTERJUMP = -80; // Cluster Jump.
+
+ // Type of page: question or structure (content).
+ static readonly TYPE_QUESTION = 0;
+ static readonly TYPE_STRUCTURE = 1;
+
+ // Type of question pages.
+ static readonly LESSON_PAGE_SHORTANSWER = 1;
+ static readonly LESSON_PAGE_TRUEFALSE = 2;
+ static readonly LESSON_PAGE_MULTICHOICE = 3;
+ static readonly LESSON_PAGE_MATCHING = 5;
+ static readonly LESSON_PAGE_NUMERICAL = 8;
+ static readonly LESSON_PAGE_ESSAY = 10;
+ static readonly LESSON_PAGE_BRANCHTABLE = 20; // Content page.
+ static readonly LESSON_PAGE_ENDOFBRANCH = 21;
+ static readonly LESSON_PAGE_CLUSTER = 30;
+ static readonly LESSON_PAGE_ENDOFCLUSTER = 31;
+
+ static readonly MULTIANSWER_DELIMITER = '@^#|'; // Constant used as a delimiter when parsing multianswer questions.
+ static readonly LESSON_OTHER_ANSWERS = '@#wronganswer#@';
+
+ /**
+ * Add an answer and its response to a feedback string (HTML).
+ *
+ * @param feedback The current feedback.
+ * @param answer Student answer.
+ * @param answerFormat Answer format.
+ * @param response Response.
+ * @param className Class to add to the response.
+ * @return New feedback.
+ */
+ protected addAnswerAndResponseToFeedback(
+ feedback: string,
+ answer: string,
+ answerFormat: number,
+ response: string,
+ className: string,
+ ): string {
+ // Add a table row containing the answer.
+ feedback += '' + (answerFormat ? answer : CoreTextUtils.instance.cleanTags(answer)) +
+ ' |
';
+
+ // If the response exists, add a table row containing the response. If not, add en empty row.
+ if (response?.trim()) {
+ feedback += '' +
+ Translate.instance.instant('addon.mod_lesson.response') + ': ' +
+ response + ' |
';
+ } else {
+ feedback += ' |
';
+ }
+
+ return feedback;
+ }
+
+ /**
+ * Add a message to a list of messages, following the format of the messages returned by WS.
+ *
+ * @param messages List of messages where to add the message.
+ * @param stringId The ID of the message to be translated. E.g. 'addon.mod_lesson.numberofpagesviewednotice'.
+ * @param stringParams The params of the message (if any).
+ */
+ protected addMessage(messages: AddonModLessonMessageWSData[], stringId: string, stringParams?: Record): void {
+ messages.push({
+ message: Translate.instance.instant(stringId, stringParams),
+ type: '',
+ });
+ }
+
+ /**
+ * Add a property to the result of the "process EOL page" simulation in offline.
+ *
+ * @param result Result where to add the value.
+ * @param name Name of the property.
+ * @param value Value to add.
+ * @param addMessage Whether to add a message related to the value.
+ */
+ protected addResultValueEolPage(
+ result: AddonModLessonFinishRetakeResponse,
+ name: string,
+ value: unknown,
+ addMessage?: boolean,
+ ): void {
+ let message = '';
+
+ if (addMessage) {
+ const params = typeof value != 'boolean' ? { $a: value } : undefined;
+ message = Translate.instance.instant('addon.mod_lesson.' + name, params);
+ }
+
+ result.data[name] = {
+ name: name,
+ value: value,
+ message: message,
+ };
+ }
+
+ /**
+ * Check if an answer page (from getUserRetake) is a content page.
+ *
+ * @param page Answer page.
+ * @return Whether it's a content page.
+ */
+ answerPageIsContent(page: AddonModLessonUserAttemptAnswerPageWSData): boolean {
+ // The page doesn't have any reliable field to use for checking this. Check qtype first (translated string).
+ if (page.qtype == Translate.instance.instant('addon.mod_lesson.branchtable')) {
+ return true;
+ }
+
+ // The qtype doesn't match, but that doesn't mean it's not a content page, maybe the language is different.
+ // Check it's not a question page.
+ if (page.answerdata && !this.answerPageIsQuestion(page)) {
+ // It isn't a question page, but it can be an end of branch, etc. Check if the first answer has a button.
+ if (page.answerdata.answers && page.answerdata.answers[0]) {
+ const element = CoreDomUtils.instance.convertToElement(page.answerdata.answers[0][0]);
+
+ return !!element.querySelector('input[type="button"]');
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if an answer page (from getUserRetake) is a question page.
+ *
+ * @param page Answer page.
+ * @return Whether it's a question page.
+ */
+ answerPageIsQuestion(page: AddonModLessonUserAttemptAnswerPageWSData): boolean {
+ if (!page.answerdata) {
+ return false;
+ }
+
+ if (page.answerdata.score) {
+ // Only question pages have a score.
+ return true;
+ }
+
+ if (page.answerdata.answers) {
+ for (let i = 0; i < page.answerdata.answers.length; i++) {
+ const answer = page.answerdata.answers[i];
+ if (answer[1]) {
+ // Only question pages have a statistic.
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Calculate some offline data like progress and ongoingscore.
+ *
+ * @param lesson Lesson.
+ * @param options Other options.
+ * @return Promise resolved with the data.
+ */
+ protected async calculateOfflineData(
+ lesson: AddonModLessonLessonWSData,
+ options: AddonModLessonCalculateOfflineDataOptions = {},
+ ): Promise<{reviewmode: boolean; progress?: number; ongoingscore: string}> {
+
+ const reviewMode = !!(options.review || options.accessInfo?.reviewmode);
+ let ongoingMessage = '';
+ let progress: number | undefined;
+
+ if (options.accessInfo && !options.accessInfo.canmanage) {
+ if (lesson.ongoing && !reviewMode) {
+ ongoingMessage = await this.getOngoingScoreMessage(lesson, options.accessInfo, options);
+ }
+
+ if (lesson.progressbar) {
+ const modOptions = {
+ cmId: lesson.coursemodule,
+ ...options, // Include all options.
+ };
+
+ progress = await this.calculateProgress(lesson.id, options.accessInfo, modOptions);
+ }
+ }
+
+ return {
+ reviewmode: reviewMode,
+ progress,
+ ongoingscore: ongoingMessage,
+ };
+ }
+
+ /**
+ * Calculate the progress of the current user in the lesson.
+ * Based on Moodle's calculate_progress.
+ *
+ * @param lessonId Lesson ID.
+ * @param accessInfo Access info.
+ * @param options Other options.
+ * @return Promise resolved with a number: the progress (scale 0-100).
+ */
+ async calculateProgress(
+ lessonId: number,
+ accessInfo: AddonModLessonGetAccessInformationWSResponse,
+ options: AddonModLessonCalculateProgressOptions = {},
+ ): Promise {
+ options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId();
+
+ // Check if the user is reviewing the attempt.
+ if (options.review) {
+ return 100;
+ }
+
+ const retake = accessInfo.attemptscount;
+ const commonOptions = {
+ cmId: options.cmId,
+ siteId: options.siteId,
+ };
+
+ if (!options.pageIndex) {
+ // Retrieve the index.
+ const pages = await this.getPages(lessonId, {
+ cmId: options.cmId,
+ password: options.password,
+ readingStrategy: CoreSitesReadingStrategy.PreferCache,
+ siteId: options.siteId,
+ });
+
+ options.pageIndex = this.createPagesIndex(pages);
+ }
+
+ // Get the list of question pages attempted.
+ let viewedPagesIds = await this.getPagesIdsWithQuestionAttempts(lessonId, retake, commonOptions);
+
+ // Get the list of viewed content pages.
+ const viewedContentPagesIds = await this.getContentPagesViewedIds(lessonId, retake, commonOptions);
+
+ const validPages = {};
+ let pageId = accessInfo.firstpageid;
+
+ viewedPagesIds = CoreUtils.instance.mergeArraysWithoutDuplicates(viewedPagesIds, viewedContentPagesIds);
+
+ // Filter out the following pages:
+ // - End of Cluster
+ // - End of Branch
+ // - Pages found inside of Clusters
+ // Do not filter out Cluster Page(s) because we count a cluster as one.
+ // By keeping the cluster page, we get our 1.
+ while (pageId) {
+ pageId = this.validPageAndView(options.pageIndex, options.pageIndex[pageId], validPages, viewedPagesIds);
+ }
+
+ // Progress calculation as a percent.
+ return CoreTextUtils.instance.roundToDecimals(viewedPagesIds.length / Object.keys(validPages).length, 2) * 100;
+ }
+
+ /**
+ * Check if the answer provided by the user is correct or not and return the result object.
+ * This method is based on the check_answer implementation of all page types (Moodle).
+ *
+ * @param lesson Lesson.
+ * @param pageData Page data.
+ * @param data Data containing the user answer.
+ * @param jumps Possible jumps.
+ * @param pageIndex Object containing all the pages indexed by ID.
+ * @return Result.
+ */
+ protected checkAnswer(
+ lesson: AddonModLessonLessonWSData,
+ pageData: AddonModLessonGetPageDataWSResponse,
+ data: Record,
+ jumps: AddonModLessonPossibleJumps,
+ pageIndex: Record,
+ ): AddonModLessonCheckAnswerResult {
+ // Default result.
+ const result: AddonModLessonCheckAnswerResult = {
+ answerid: 0,
+ noanswer: false,
+ correctanswer: false,
+ isessayquestion: false,
+ response: '',
+ newpageid: 0,
+ studentanswer: '',
+ userresponse: '',
+ feedback: '',
+ nodefaultresponse: false,
+ inmediatejump: false,
+ };
+
+ switch (pageData.page!.qtype) {
+ case AddonModLessonProvider.LESSON_PAGE_BRANCHTABLE:
+ // Load the new page immediately.
+ result.inmediatejump = true;
+ result.newpageid = this.getNewPageId(pageData.page!.id, data.jumpto, jumps);
+ break;
+
+ case AddonModLessonProvider.LESSON_PAGE_ESSAY:
+ this.checkAnswerEssay(pageData, data, result);
+ break;
+
+ case AddonModLessonProvider.LESSON_PAGE_MATCHING:
+ this.checkAnswerMatching(pageData, data, result);
+ break;
+
+ case AddonModLessonProvider.LESSON_PAGE_MULTICHOICE:
+ this.checkAnswerMultichoice(lesson, pageData, data, pageIndex, result);
+ break;
+
+ case AddonModLessonProvider.LESSON_PAGE_NUMERICAL:
+ this.checkAnswerNumerical(lesson, pageData, data, pageIndex, result);
+ break;
+
+ case AddonModLessonProvider.LESSON_PAGE_SHORTANSWER:
+ this.checkAnswerShort(lesson, pageData, data, pageIndex, result);
+ break;
+
+ case AddonModLessonProvider.LESSON_PAGE_TRUEFALSE:
+ this.checkAnswerTruefalse(lesson, pageData, data, pageIndex, result);
+ break;
+ default:
+ // Nothing to do.
+ }
+
+ return result;
+ }
+
+ /**
+ * Check an essay answer.
+ *
+ * @param pageData Page data.
+ * @param data Data containing the user answer.
+ * @param result Object where to store the result.
+ */
+ protected checkAnswerEssay(
+ pageData: AddonModLessonGetPageDataWSResponse,
+ data: Record,
+ result: AddonModLessonCheckAnswerResult,
+ ): void {
+ let studentAnswer;
+
+ result.isessayquestion = true;
+
+ if (!data) {
+ result.inmediatejump = true;
+ result.newpageid = pageData.page!.id;
+
+ return;
+ }
+
+ // The name was changed to "answer_editor" in 3.7. Before it was just "answer". Support both cases.
+ if (typeof data['answer_editor[text]'] != 'undefined') {
+ studentAnswer = data['answer_editor[text]'];
+ } else if (typeof data.answer_editor == 'object') {
+ studentAnswer = (<{text: string}> data.answer_editor).text;
+ } else if (typeof data['answer[text]'] != 'undefined') {
+ studentAnswer = data['answer[text]'];
+ } else if (typeof data.answer == 'object') {
+ studentAnswer = (<{text: string}> data.answer).text;
+ } else {
+ studentAnswer = data.answer;
+ }
+
+ if (!studentAnswer || studentAnswer.trim() === '') {
+ result.noanswer = true;
+
+ return;
+ }
+
+ // Essay pages should only have 1 possible answer.
+ pageData.answers.forEach((answer) => {
+ result.answerid = answer.id;
+ result.newpageid = answer.jumpto || 0;
+ });
+
+ result.userresponse = {
+ sent: 0,
+ graded: 0,
+ score: 0,
+ answer: studentAnswer,
+ answerformat: 1,
+ response: '',
+ responseformat: 1,
+ };
+ result.studentanswerformat = 1;
+ result.studentanswer = studentAnswer;
+ }
+
+ /**
+ * Check a matching answer.
+ *
+ * @param pageData Page data for the page to process.
+ * @param data Data containing the user answer.
+ * @param result Object where to store the result.
+ */
+ protected checkAnswerMatching(
+ pageData: AddonModLessonGetPageDataWSResponse,
+ data: Record,
+ result: AddonModLessonCheckAnswerResult,
+ ): void {
+ if (!data) {
+ result.inmediatejump = true;
+ result.newpageid = pageData.page!.id;
+
+ return;
+ }
+
+ const response = this.getUserResponseMatching(data);
+ const getAnswers = CoreUtils.instance.clone(pageData.answers);
+ const correct = getAnswers.shift();
+ const wrong = getAnswers.shift();
+ const answers: Record = {};
+
+ getAnswers.forEach((answer) => {
+ if (answer.answer !== '' || answer.response !== '') {
+ answers[answer.id] = answer;
+ }
+ });
+
+ // Get the user's exact responses for record keeping.
+ const userResponse: string[] = [];
+ let hits = 0;
+
+ result.studentanswer = '';
+ result.studentanswerformat = 1;
+
+ for (const id in response) {
+ let value = response[id];
+
+ if (!value) {
+ result.noanswer = true;
+
+ return;
+ }
+
+ value = CoreTextUtils.instance.decodeHTML(value);
+ userResponse.push(value);
+
+ if (typeof answers[id] != 'undefined') {
+ const answer = answers[id];
+
+ result.studentanswer += '
' + answer.answer + ' = ' + value;
+ if (answer.response && answer.response.trim() == value.trim()) {
+ hits++;
+ }
+ }
+ }
+
+ result.userresponse = userResponse.join(',');
+
+ if (hits == Object.keys(answers).length) {
+ result.correctanswer = true;
+ result.response = correct!.answer || '';
+ result.answerid = correct!.id;
+ result.newpageid = correct!.jumpto || 0;
+ } else {
+ result.correctanswer = false;
+ result.response = wrong!.answer || '';
+ result.answerid = wrong!.id;
+ result.newpageid = wrong!.jumpto || 0;
+ }
+ }
+
+ /**
+ * Check a multichoice answer.
+ *
+ * @param lesson Lesson.
+ * @param pageData Page data for the page to process.
+ * @param data Data containing the user answer.
+ * @param pageIndex Object containing all the pages indexed by ID.
+ * @param result Object where to store the result.
+ */
+ protected checkAnswerMultichoice(
+ lesson: AddonModLessonLessonWSData,
+ pageData: AddonModLessonGetPageDataWSResponse,
+ data: Record,
+ pageIndex: Record,
+ result: AddonModLessonCheckAnswerResult,
+ ): void {
+
+ if (!data) {
+ result.inmediatejump = true;
+ result.newpageid = pageData.page!.id;
+
+ return;
+ }
+
+ const answers = this.getUsedAnswersMultichoice(pageData);
+
+ if (pageData.page!.qoption) {
+ // Multianswer allowed, user's answer is an array.
+ const studentAnswers = this.getUserResponseMultichoice(data);
+
+ if (!studentAnswers || !Array.isArray(studentAnswers)) {
+ result.noanswer = true;
+
+ return;
+ }
+
+ // Get what the user answered.
+ result.userresponse = studentAnswers.join(',');
+
+ // Get the answers in a set order, the id order.
+ const studentAswersArray: string[] = [];
+ const responses: string[] = [];
+ let nHits = 0;
+ let nCorrect = 0;
+ let correctAnswerId = 0;
+ let wrongAnswerId = 0;
+ let correctPageId: number | undefined;
+ let wrongPageId: number | undefined;
+
+ // Store student's answers for displaying on feedback page.
+ result.studentanswer = '';
+ result.studentanswerformat = 1;
+ answers.forEach((answer) => {
+ for (const i in studentAnswers) {
+ const answerId = studentAnswers[i];
+
+ if (answerId == answer.id) {
+ studentAswersArray.push(answer.answer!);
+ responses.push(answer.response || '');
+ break;
+ }
+ }
+ });
+ result.studentanswer = studentAswersArray.join(AddonModLessonProvider.MULTIANSWER_DELIMITER);
+
+ // Iterate over all the possible answers.
+ answers.forEach((answer) => {
+ const correctAnswer = this.isAnswerCorrect(lesson, pageData.page!.id, answer, pageIndex);
+
+ // Iterate over all the student answers to check if he selected the current possible answer.
+ studentAnswers.forEach((answerId) => {
+ if (answerId == answer.id) {
+ if (correctAnswer) {
+ nHits++;
+ } else {
+ // Always use the first student wrong answer.
+ if (typeof wrongPageId == 'undefined') {
+ wrongPageId = answer.jumpto;
+ }
+ // Save the answer id for scoring.
+ if (!wrongAnswerId) {
+ wrongAnswerId = answer.id;
+ }
+ }
+ }
+ });
+
+ if (correctAnswer) {
+ nCorrect++;
+
+ // Save the first jumpto.
+ if (typeof correctPageId == 'undefined') {
+ correctPageId = answer.jumpto;
+ }
+ // Save the answer id for scoring.
+ if (!correctAnswerId) {
+ correctAnswerId = answer.id;
+ }
+ }
+ });
+
+ if (studentAnswers.length == nCorrect && nHits == nCorrect) {
+ result.correctanswer = true;
+ result.response = responses.join(AddonModLessonProvider.MULTIANSWER_DELIMITER);
+ result.newpageid = correctPageId || 0;
+ result.answerid = correctAnswerId;
+ } else {
+ result.correctanswer = false;
+ result.response = responses.join(AddonModLessonProvider.MULTIANSWER_DELIMITER);
+ result.newpageid = wrongPageId || 0;
+ result.answerid = wrongAnswerId;
+ }
+ } else {
+ // Only one answer allowed.
+ if (typeof data.answerid == 'undefined' || (!data.answerid && Number(data.answerid) !== 0)) {
+ result.noanswer = true;
+
+ return;
+ }
+
+ result.answerid = data.answerid;
+
+ // Search the answer.
+ for (const i in pageData.answers) {
+ const answer = pageData.answers[i];
+ if (answer.id == data.answerid) {
+ result.correctanswer = this.isAnswerCorrect(lesson, pageData.page!.id, answer, pageIndex);
+ result.newpageid = answer.jumpto || 0;
+ result.response = answer.response || '';
+ result.userresponse = result.studentanswer = answer.answer || '';
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Check a numerical answer.
+ *
+ * @param lesson Lesson.
+ * @param pageData Page data for the page to process.
+ * @param data Data containing the user answer.
+ * @param pageIndex Object containing all the pages indexed by ID.
+ * @param result Object where to store the result.
+ */
+ protected checkAnswerNumerical(
+ lesson: AddonModLessonLessonWSData,
+ pageData: AddonModLessonGetPageDataWSResponse,
+ data: Record,
+ pageIndex: Record,
+ result: AddonModLessonCheckAnswerResult,
+ ): void {
+
+ const parsedAnswer = parseFloat( data.answer);
+
+ // Set defaults.
+ result.response = '';
+ result.newpageid = 0;
+
+ if (!data.answer || isNaN(parsedAnswer)) {
+ result.noanswer = true;
+
+ return;
+ }
+
+ result.useranswer = parsedAnswer;
+ result.studentanswer = result.userresponse = String(result.useranswer);
+
+ // Find the answer.
+ for (const i in pageData.answers) {
+ const answer = pageData.answers[i];
+ let max: number;
+ let min: number;
+
+ if (answer.answer && answer.answer.indexOf(':') != -1) {
+ // There's a pair of values.
+ const split = answer.answer.split(':');
+ min = parseFloat(split[0]);
+ max = parseFloat(split[1]);
+ } else {
+ // Only one value.
+ min = parseFloat(answer.answer || '');
+ max = min;
+ }
+
+ if (parsedAnswer >= min && parsedAnswer <= max) {
+ result.newpageid = answer.jumpto || 0;
+ result.response = answer.response || '';
+ result.correctanswer = this.isAnswerCorrect(lesson, pageData.page!.id, answer, pageIndex);
+ result.answerid = answer.id;
+ break;
+ }
+ }
+
+ this.checkOtherAnswers(lesson, pageData, result);
+ }
+
+ /**
+ * Check a short answer.
+ *
+ * @param lesson Lesson.
+ * @param pageData Page data for the page to process.
+ * @param data Data containing the user answer.
+ * @param pageIndex Object containing all the pages indexed by ID.
+ * @param result Object where to store the result.
+ */
+ protected checkAnswerShort(
+ lesson: AddonModLessonLessonWSData,
+ pageData: AddonModLessonGetPageDataWSResponse,
+ data: Record,
+ pageIndex: Record,
+ result: AddonModLessonCheckAnswerResult,
+ ): void {
+
+ let studentAnswer = typeof data.answer == 'string' ? data.answer.trim() : false;
+ if (!studentAnswer) {
+ result.noanswer = true;
+
+ return;
+ }
+
+ // Search the answer in the list of possible answers.
+ for (const i in pageData.answers) {
+ const answer = pageData.answers[i];
+ const useRegExp = pageData.page!.qoption;
+ let expectedAnswer = answer.answer || '';
+ let isMatch = false;
+ let ignoreCase = '';
+
+ if (useRegExp) {
+ if (expectedAnswer.substr(-2) == '/i') {
+ expectedAnswer = expectedAnswer.substr(0, expectedAnswer.length - 2);
+ ignoreCase = 'i';
+ }
+ } else {
+ expectedAnswer = expectedAnswer.replace('*', '#####');
+ expectedAnswer = CoreTextUtils.instance.escapeForRegex(expectedAnswer);
+ expectedAnswer = expectedAnswer.replace('#####', '.*');
+ }
+
+ // See if user typed in any of the correct answers.
+ if (this.isAnswerCorrect(lesson, pageData.page!.id, answer, pageIndex)) {
+ if (!useRegExp) { // We are using 'normal analysis', which ignores case.
+ if (studentAnswer.match(new RegExp('^' + expectedAnswer + '$', 'i'))) {
+ isMatch = true;
+ }
+ } else {
+ if (studentAnswer.match(new RegExp('^' + expectedAnswer + '$', ignoreCase))) {
+ isMatch = true;
+ }
+ }
+ if (isMatch) {
+ result.correctanswer = true;
+ }
+ } else {
+ if (!useRegExp) {
+ // We are using 'normal analysis'.
+ // See if user typed in any of the wrong answers; don't worry about case.
+ if (studentAnswer.match(new RegExp('^' + expectedAnswer + '$', 'i'))) {
+ isMatch = true;
+ }
+ } else { // We are using regular expressions analysis.
+ const startCode = expectedAnswer.substr(0, 2);
+
+ switch (startCode){
+ // 1- Check for absence of required string in studentAnswer (coded by initial '--').
+ case '--':
+ expectedAnswer = expectedAnswer.substr(2);
+ if (!studentAnswer.match(new RegExp('^' + expectedAnswer + '$', ignoreCase))) {
+ isMatch = true;
+ }
+ break;
+
+ // 2- Check for code for marking wrong strings (coded by initial '++').
+ case '++': {
+ expectedAnswer = expectedAnswer.substr(2);
+
+ // Check for one or several matches.
+ const matches = studentAnswer.match(new RegExp(expectedAnswer, 'g' + ignoreCase));
+ if (matches) {
+ isMatch = true;
+ const nb = matches[0].length;
+ const original: string[] = [];
+ const marked: string[] = [];
+
+ for (let j = 0; j < nb; j++) {
+ original.push(matches[0][j]);
+ marked.push('' + matches[0][j] + '');
+ }
+
+ for (let j = 0; j < original.length; j++) {
+ studentAnswer = studentAnswer.replace(original[j], marked[j]);
+ }
+ }
+ break;
+ }
+ // 3- Check for wrong answers belonging neither to -- nor to ++ categories.
+ default:
+ if (studentAnswer.match(new RegExp('^' + expectedAnswer + '$', ignoreCase))) {
+ isMatch = true;
+ }
+ break;
+ }
+
+ result.correctanswer = false;
+ }
+ }
+
+ if (isMatch) {
+ result.newpageid = answer.jumpto || 0;
+ result.response = answer.response || '';
+ result.answerid = answer.id;
+ break; // Quit answer analysis immediately after a match has been found.
+ }
+ }
+
+ this.checkOtherAnswers(lesson, pageData, result);
+
+ result.userresponse = studentAnswer;
+ result.studentanswer = CoreTextUtils.instance.s(studentAnswer); // Clean student answer as it goes to output.
+ }
+
+ /**
+ * Check a truefalse answer.
+ *
+ * @param lesson Lesson.
+ * @param pageData Page data for the page to process.
+ * @param data Data containing the user answer.
+ * @param pageIndex Object containing all the pages indexed by ID.
+ * @param result Object where to store the result.
+ */
+ protected checkAnswerTruefalse(
+ lesson: AddonModLessonLessonWSData,
+ pageData: AddonModLessonGetPageDataWSResponse,
+ data: Record,
+ pageIndex: Record,
+ result: AddonModLessonCheckAnswerResult,
+ ): void {
+
+ if (!data.answerid) {
+ result.noanswer = true;
+
+ return;
+ }
+
+ result.answerid = data.answerid;
+
+ // Get the answer.
+ for (const i in pageData.answers) {
+ const answer = pageData.answers[i];
+ if (answer.id == data.answerid) {
+ // Answer found.
+ result.correctanswer = this.isAnswerCorrect(lesson, pageData.page!.id, answer, pageIndex);
+ result.newpageid = answer.jumpto || 0;
+ result.response = answer.response || '';
+ result.studentanswer = result.userresponse = answer.answer || '';
+ break;
+ }
+ }
+ }
+
+ /**
+ * Check the "other answers" value.
+ *
+ * @param lesson Lesson.
+ * @param pageData Page data for the page to process.
+ * @param result Object where to store the result.
+ */
+ protected checkOtherAnswers(
+ lesson: AddonModLessonLessonWSData,
+ pageData: AddonModLessonGetPageDataWSResponse,
+ result: AddonModLessonCheckAnswerResult,
+ ): void {
+ // We could check here to see if we have a wrong answer jump to use.
+ if (result.answerid == 0) {
+ // Use the all other answers jump details if it is set up.
+ const lastAnswer = pageData.answers[pageData.answers.length - 1] || {};
+
+ // Double check that this is the OTHER_ANSWERS answer.
+ if (typeof lastAnswer.answer == 'string' &&
+ lastAnswer.answer.indexOf(AddonModLessonProvider.LESSON_OTHER_ANSWERS) != -1) {
+ result.newpageid = lastAnswer.jumpto || 0;
+ result.response = lastAnswer.response || '';
+
+ if (lesson.custom) {
+ result.correctanswer = !!(lastAnswer.score && lastAnswer.score > 0);
+ }
+ result.answerid = lastAnswer.id;
+ }
+ }
+ }
+
+ /**
+ * Create a list of pages indexed by page ID based on a list of pages.
+ *
+ * @param pageList List of pages.
+ * @return Pages index.
+ */
+ protected createPagesIndex(pageList: AddonModLessonGetPagesPageWSData[]): Record {
+ // Index the pages by page ID.
+ const pages: Record = {};
+
+ pageList.forEach((pageData) => {
+ pages[pageData.page.id] = pageData.page;
+ });
+
+ return pages;
+ }
+
+ /**
+ * Finishes a retake.
+ *
+ * @param lesson Lesson.
+ * @param courseId Course ID the lesson belongs to.
+ * @param options Other options.
+ * @return Promise resolved with the result.
+ */
+ async finishRetake(
+ lesson: AddonModLessonLessonWSData,
+ courseId: number,
+ options: AddonModLessonFinishRetakeOptions = {},
+ ): Promise {
+
+ if (options.offline) {
+ return this.finishRetakeOffline(lesson, courseId, options);
+ }
+
+ const response = await this.finishRetakeOnline(lesson.id, options);
+
+ CoreEvents.trigger(AddonModLessonProvider.DATA_SENT_EVENT, {
+ lessonId: lesson.id,
+ type: 'finish',
+ courseId: courseId,
+ outOfTime: options.outOfTime,
+ review: options.review,
+ }, CoreSites.instance.getCurrentSiteId());
+
+ return response;
+ }
+
+ /**
+ * Finishes a retake. It will fail if offline or cannot connect.
+ *
+ * @param lessonId Lesson ID.
+ * @param options Other options.
+ * @return Promise resolved with the result.
+ */
+ async finishRetakeOnline(
+ lessonId: number,
+ options: AddonModLessonFinishRetakeOnlineOptions = {},
+ ): Promise {
+ const site = await CoreSites.instance.getSite(options.siteId);
+
+ const params: AddonModLessonFinishAttemptWSParams = {
+ lessonid: lessonId,
+ outoftime: !!options.outOfTime,
+ review: !!options.review,
+ };
+ if (typeof options.password == 'string') {
+ params.password = options.password;
+ }
+
+ const response = await site.write('mod_lesson_finish_attempt', params);
+
+ // Convert the data array into an object and decode the values.
+ const map: Record = {};
+
+ response.data.forEach((entry) => {
+ if (entry.value && typeof entry.value == 'string' && entry.value !== '1') {
+ // It's a JSON encoded object. Try to decode it.
+ entry.value = CoreTextUtils.instance.parseJSON(entry.value);
+ }
+
+ map[entry.name] = entry;
+ });
+
+ return Object.assign(response, { data: map });
+ }
+
+ /**
+ * Finishes a retake in offline.
+ *
+ * @param lesson Lesson.
+ * @param courseId Course ID the lesson belongs to.
+ * @param options Other options.
+ * @return Promise resolved with the result.
+ */
+ protected async finishRetakeOffline(
+ lesson: AddonModLessonLessonWSData,
+ courseId: number,
+ options: AddonModLessonFinishRetakeOptions = {},
+ ): Promise {
+ // First finish the retake offline.
+ const retake = options.accessInfo!.attemptscount;
+
+ await AddonModLessonOffline.instance.finishRetake(lesson.id, courseId, retake, true, options.outOfTime, options.siteId);
+
+ // Get the lesson grade.
+ const newOptions = {
+ cmId: lesson.coursemodule,
+ password: options.password,
+ review: options.review,
+ siteId: options.siteId,
+ };
+
+ const gradeInfo = await CoreUtils.instance.ignoreErrors(this.lessonGrade(lesson, retake, newOptions));
+
+ // Retake marked, now return the response.
+ return this.processEolPage(lesson, courseId, options, gradeInfo);
+ }
+
+ /**
+ * Create the data returned by finishRetakeOffline, to display the EOL page. It won't return all the possible data.
+ * This code is based in Moodle's process_eol_page.
+ *
+ * @param lesson Lesson.
+ * @param courseId Course ID the lesson belongs to.
+ * @param options Other options.
+ * @param gradeInfo Lesson grade info.
+ * @return Promise resolved with the data.
+ */
+ protected async processEolPage(
+ lesson: AddonModLessonLessonWSData,
+ courseId: number,
+ options: AddonModLessonFinishRetakeOptions = {},
+ gradeInfo: AddonModLessonGrade | undefined,
+ ): Promise {
+ if (!options.accessInfo) {
+ throw new CoreError('Access info not supplied to finishRetake.');
+ }
+
+ // This code is based in Moodle's process_eol_page.
+ const result: AddonModLessonFinishRetakeResponse = {
+ data: {},
+ messages: [],
+ warnings: [],
+ };
+ let gradeLesson = true;
+
+ this.addResultValueEolPage(result, 'offline', true); // Mark the result as offline.
+ this.addResultValueEolPage(result, 'gradeinfo', gradeInfo);
+
+ if (lesson.custom && !options.accessInfo.canmanage) {
+ /* Before we calculate the custom score make sure they answered the minimum number of questions.
+ We only need to do this for custom scoring as we can not get the miniumum score the user should achieve.
+ If we are not using custom scoring (so all questions are valued as 1) then we simply check if they
+ answered more than the minimum questions, if not, we mark it out of the number specified in the minimum
+ questions setting - which is done in lesson_grade(). */
+
+ // Get the number of answers given.
+ if (gradeInfo && lesson.minquestions && gradeInfo.nquestions < lesson.minquestions) {
+ gradeLesson = false;
+ this.addMessage(result.messages, 'addon.mod_lesson.numberofpagesviewednotice', {
+ $a: {
+ nquestions: gradeInfo.nquestions,
+ minquestions: lesson.minquestions,
+ },
+ });
+ }
+ }
+
+ if (!options.accessInfo.canmanage) {
+ if (gradeLesson) {
+ const progress = await this.calculateProgress(lesson.id, options.accessInfo, {
+ cmId: lesson.coursemodule,
+ password: options.password,
+ review: options.review,
+ siteId: options.siteId,
+ });
+
+ this.addResultValueEolPage(result, 'progresscompleted', progress);
+
+ if (gradeInfo?.attempts) {
+ // User has answered questions.
+ if (!lesson.custom) {
+ this.addResultValueEolPage(result, 'numberofpagesviewed', gradeInfo.nquestions, true);
+ if (lesson.minquestions) {
+ if (gradeInfo.nquestions < lesson.minquestions) {
+ this.addResultValueEolPage(result, 'youshouldview', lesson.minquestions, true);
+ }
+ }
+ this.addResultValueEolPage(result, 'numberofcorrectanswers', gradeInfo.earned, true);
+ }
+
+ const entryData: Record = {
+ score: gradeInfo.earned,
+ grade: gradeInfo.total,
+ };
+ if (gradeInfo.nmanual) {
+ entryData.tempmaxgrade = gradeInfo.total - gradeInfo.manualpoints;
+ entryData.essayquestions = gradeInfo.nmanual;
+ this.addResultValueEolPage(result, 'displayscorewithessays', entryData, true);
+ } else {
+ this.addResultValueEolPage(result, 'displayscorewithoutessays', entryData, true);
+ }
+
+ if (lesson.grade !== undefined && lesson.grade != CoreGradesProvider.TYPE_NONE) {
+ entryData.grade = CoreTextUtils.instance.roundToDecimals(gradeInfo.grade * lesson.grade / 100, 1);
+ entryData.total = lesson.grade;
+ this.addResultValueEolPage(result, 'yourcurrentgradeisoutof', entryData, true);
+ }
+
+ } else {
+ // User hasn't answered any question, only content pages.
+ if (lesson.timelimit) {
+ if (options.outOfTime) {
+ this.addResultValueEolPage(result, 'eolstudentoutoftimenoanswers', true, true);
+ }
+ } else {
+ this.addResultValueEolPage(result, 'welldone', true, true);
+ }
+ }
+ }
+ } else {
+ // Display for teacher.
+ if (lesson.grade != CoreGradesProvider.TYPE_NONE) {
+ this.addResultValueEolPage(result, 'displayofgrade', true, true);
+ }
+ }
+
+ if (lesson.modattempts && options.accessInfo.canmanage) {
+ this.addResultValueEolPage(result, 'modattemptsnoteacher', true, true);
+ }
+
+ if (gradeLesson) {
+ this.addResultValueEolPage(result, 'gradelesson', 1);
+ }
+
+ return result;
+ }
+
+ /**
+ * Get the access information of a certain lesson.
+ *
+ * @param lessonId Lesson ID.
+ * @param options Other options.
+ * @return Promise resolved with the access information.
+ */
+ async getAccessInformation(
+ lessonId: number,
+ options: CoreCourseCommonModWSOptions = {},
+ ): Promise {
+ const site = await CoreSites.instance.getSite(options.siteId);
+
+ const params: AddonModLessonGetAccessInformationWSParams = {
+ lessonid: lessonId,
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getAccessInformationCacheKey(lessonId),
+ updateFrequency: CoreSite.FREQUENCY_OFTEN,
+ component: AddonModLessonProvider.COMPONENT,
+ componentId: options.cmId,
+ ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+ };
+
+ return site.read('mod_lesson_get_lesson_access_information', params, preSets);
+ }
+
+ /**
+ * Get cache key for access information WS calls.
+ *
+ * @param lessonId Lesson ID.
+ * @return Cache key.
+ */
+ protected getAccessInformationCacheKey(lessonId: number): string {
+ return ROOT_CACHE_KEY + 'accessInfo:' + lessonId;
+ }
+
+ /**
+ * Get content pages viewed in online and offline.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number.
+ * @param options Other options.
+ * @return Promise resolved with an object with the online and offline viewed pages.
+ */
+ async getContentPagesViewed(
+ lessonId: number,
+ retake: number,
+ options: CoreCourseCommonModWSOptions = {},
+ ): Promise<{online: AddonModLessonWSContentPageViewed[]; offline: AddonModLessonPageAttemptRecord[]}> {
+ const type = AddonModLessonProvider.TYPE_STRUCTURE;
+
+ const [online, offline] = await Promise.all([
+ this.getContentPagesViewedOnline(lessonId, retake, options),
+ CoreUtils.instance.ignoreErrors(
+ AddonModLessonOffline.instance.getRetakeAttemptsForType(lessonId, retake, type, options.siteId),
+ ),
+ ]);
+
+ return {
+ online,
+ offline: offline || [],
+ };
+ }
+
+ /**
+ * Get cache key for get content pages viewed WS calls.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number.
+ * @return Cache key.
+ */
+ protected getContentPagesViewedCacheKey(lessonId: number, retake: number): string {
+ return this.getContentPagesViewedCommonCacheKey(lessonId) + ':' + retake;
+ }
+
+ /**
+ * Get common cache key for get content pages viewed WS calls.
+ *
+ * @param lessonId Lesson ID.
+ * @return Cache key.
+ */
+ protected getContentPagesViewedCommonCacheKey(lessonId: number): string {
+ return ROOT_CACHE_KEY + 'contentPagesViewed:' + lessonId;
+ }
+
+ /**
+ * Get IDS of content pages viewed in online and offline.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number.
+ * @param options Other options.
+ * @return Promise resolved with list of IDs.
+ */
+ async getContentPagesViewedIds(
+ lessonId: number,
+ retake: number,
+ options: CoreCourseCommonModWSOptions = {},
+ ): Promise {
+ const result = await this.getContentPagesViewed(lessonId, retake, options);
+
+ const ids: Record = {};
+ const pages = (<(AddonModLessonContentPageOrRecord)[]> result.online).concat(result.offline);
+
+ pages.forEach((page) => {
+ if (!ids[page.pageid]) {
+ ids[page.pageid] = true;
+ }
+ });
+
+ return Object.keys(ids).map((id) => Number(id));
+ }
+
+ /**
+ * Get the list of content pages viewed in the site for a certain retake.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number.
+ * @param options Other options.
+ * @return Promise resolved with the viewed pages.
+ */
+ async getContentPagesViewedOnline(
+ lessonId: number,
+ retake: number,
+ options: CoreCourseCommonModWSOptions = {},
+ ): Promise {
+
+ const site = await CoreSites.instance.getSite(options.siteId);
+
+ const params: AddonModLessonGetContentPagesViewedWSParams = {
+ lessonid: lessonId,
+ lessonattempt: retake,
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getContentPagesViewedCacheKey(lessonId, retake),
+ component: AddonModLessonProvider.COMPONENT,
+ componentId: options.cmId,
+ ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+ };
+
+ const result = await site.read(
+ 'mod_lesson_get_content_pages_viewed',
+ params,
+ preSets,
+ );
+
+ return result.pages;
+ }
+
+ /**
+ * Get the last content page viewed.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number.
+ * @param options Other options.
+ * @return Promise resolved with the last content page viewed.
+ */
+ async getLastContentPageViewed(
+ lessonId: number,
+ retake: number,
+ options: CoreCourseCommonModWSOptions = {},
+ ): Promise {
+ try {
+ const data = await this.getContentPagesViewed(lessonId, retake, options);
+
+ let lastPage: AddonModLessonContentPageOrRecord | undefined;
+ let maxTime = 0;
+
+ data.online.forEach((page) => {
+ if (page.timeseen > maxTime) {
+ lastPage = page;
+ maxTime = page.timeseen;
+ }
+ });
+
+ data.offline.forEach((page) => {
+ if (page.timemodified > maxTime) {
+ lastPage = page;
+ maxTime = page.timemodified;
+ }
+ });
+
+ return lastPage;
+ } catch {
+ // Error getting last page, don't return anything.
+ }
+ }
+
+ /**
+ * Get the last page seen.
+ * Based on Moodle's get_last_page_seen.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number.
+ * @param options Other options.
+ * @return Promise resolved with the last page seen.
+ */
+ async getLastPageSeen(
+ lessonId: number,
+ retake: number,
+ options: CoreCourseCommonModWSOptions = {},
+ ): Promise {
+ options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId();
+
+ let lastPageSeen: number | undefined;
+
+ // Get the last question answered.
+ const answer = await AddonModLessonOffline.instance.getLastQuestionPageAttempt(lessonId, retake, options.siteId);
+
+ if (answer) {
+ lastPageSeen = answer.newpageid;
+ }
+
+ // Now get the last content page viewed.
+ const page = await this.getLastContentPageViewed(lessonId, retake, options);
+
+ if (page) {
+ if (answer) {
+ const pageTime = 'timeseen' in page ? page.timeseen : page.timemodified;
+ if (pageTime > answer.timemodified) {
+ // This content page was viewed more recently than the question page.
+ lastPageSeen = ( page).newpageid || page.pageid;
+ }
+ } else {
+ // Has not answered any questions but has viewed a content page.
+ lastPageSeen = ( page).newpageid || page.pageid;
+ }
+ }
+
+ return lastPageSeen;
+ }
+
+ /**
+ * Get a Lesson by module ID.
+ *
+ * @param courseId Course ID.
+ * @param cmid Course module ID.
+ * @param options Other options.
+ * @return Promise resolved when the lesson is retrieved.
+ */
+ getLesson(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise {
+ return this.getLessonByField(courseId, 'coursemodule', cmId, options);
+ }
+
+ /**
+ * Get a Lesson with key=value. If more than one is found, only the first will be returned.
+ *
+ * @param courseId Course ID.
+ * @param key Name of the property to check.
+ * @param value Value to search.
+ * @param options Other options.
+ * @return Promise resolved when the lesson is retrieved.
+ */
+ protected async getLessonByField(
+ courseId: number,
+ key: string,
+ value: number,
+ options: CoreSitesCommonWSOptions = {},
+ ): Promise {
+
+ const site = await CoreSites.instance.getSite(options.siteId);
+
+ const params: AddonModLessonGetLessonsByCoursesWSParams = {
+ courseids: [courseId],
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getLessonDataCacheKey(courseId),
+ updateFrequency: CoreSite.FREQUENCY_RARELY,
+ component: AddonModLessonProvider.COMPONENT,
+ ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+ };
+
+ const response = await site.read(
+ 'mod_lesson_get_lessons_by_courses',
+ params,
+ preSets,
+ );
+
+ const currentLesson = response.lessons.find((lesson) => lesson[key] == value);
+
+ if (currentLesson) {
+ return currentLesson;
+ }
+
+ throw new CoreError('Lesson not found.');
+ }
+
+ /**
+ * Get a Lesson by lesson ID.
+ *
+ * @param courseId Course ID.
+ * @param id Lesson ID.
+ * @param options Other options.
+ * @return Promise resolved when the lesson is retrieved.
+ */
+ getLessonById(courseId: number, id: number, options: CoreSitesCommonWSOptions = {}): Promise {
+ return this.getLessonByField(courseId, 'id', id, options);
+ }
+
+ /**
+ * Get cache key for Lesson data WS calls.
+ *
+ * @param courseId Course ID.
+ * @return Cache key.
+ */
+ protected getLessonDataCacheKey(courseId: number): string {
+ return ROOT_CACHE_KEY + 'lesson:' + courseId;
+ }
+
+ /**
+ * Get a lesson protected with password.
+ *
+ * @param lessonId Lesson ID.
+ * @param options Other options.
+ * @return Promise resolved with the lesson.
+ */
+ async getLessonWithPassword(
+ lessonId: number,
+ options: AddonModLessonGetWithPasswordOptions = {},
+ ): Promise {
+ const validatePassword = options.validatePassword ?? true;
+
+ const site = await CoreSites.instance.getSite(options.siteId);
+
+ const params: AddonModLessonGetLessonWSParams = {
+ lessonid: lessonId,
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getLessonWithPasswordCacheKey(lessonId),
+ component: AddonModLessonProvider.COMPONENT,
+ componentId: options.cmId,
+ ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+ };
+
+ if (typeof options.password == 'string') {
+ params.password = options.password;
+ }
+
+ const response = await site.read('mod_lesson_get_lesson', params, preSets);
+
+ if (typeof response.lesson.ongoing != 'undefined') {
+ // Basic data not received, password is wrong. Remove stored password.
+ this.removeStoredPassword(lessonId, site.id);
+
+ if (validatePassword) {
+ // Invalidate the data and reject.
+ await CoreUtils.instance.ignoreErrors(this.invalidateLessonWithPassword(lessonId, site.id));
+
+ throw new CoreError(Translate.instance.instant('addon.mod_lesson.loginfail'));
+ }
+ }
+
+ return response.lesson;
+ }
+
+ /**
+ * Get cache key for get lesson with password WS calls.
+ *
+ * @param lessonId Lesson ID.
+ * @return Cache key.
+ */
+ protected getLessonWithPasswordCacheKey(lessonId: number): string {
+ return ROOT_CACHE_KEY + 'lessonWithPswrd:' + lessonId;
+ }
+
+ /**
+ * Given a page ID, a jumpto and all the possible jumps, calcualate the new page ID.
+ *
+ * @param pageId Current page ID.
+ * @param jumpTo The jumpto.
+ * @param jumps Possible jumps.
+ * @return New page ID.
+ */
+ protected getNewPageId(pageId: number, jumpTo: number, jumps: AddonModLessonPossibleJumps): number {
+ // If jump not found, return current jumpTo.
+ if (jumps && jumps[pageId] && jumps[pageId][jumpTo]) {
+ return jumps[pageId][jumpTo].calculatedjump;
+ } else if (!jumpTo) {
+ // Return current page.
+ return pageId;
+ }
+
+ return jumpTo;
+ }
+
+ /**
+ * Get the ongoing score message for the user (depending on the user permission and lesson settings).
+ *
+ * @param lesson Lesson.
+ * @param accessInfo Access info.
+ * @param options Other options.
+ * @return Promise resolved with the ongoing score message.
+ */
+ getOngoingScoreMessage(
+ lesson: AddonModLessonLessonWSData,
+ accessInfo: AddonModLessonGetAccessInformationWSResponse,
+ options: AddonModLessonGradeOptions = {},
+ ): Promise {
+
+ if (accessInfo.canmanage) {
+ return Promise.resolve(Translate.instance.instant('addon.mod_lesson.teacherongoingwarning'));
+ } else {
+ let retake = accessInfo.attemptscount;
+ if (options.review) {
+ retake--;
+ }
+
+ return this.lessonGrade(lesson, retake, options).then((gradeInfo) => {
+ if (lesson.custom) {
+ return Translate.instance.instant(
+ 'addon.mod_lesson.ongoingcustom',
+ { $a: { score: gradeInfo.earned, currenthigh: gradeInfo.total } },
+ );
+ } else {
+ return Translate.instance.instant(
+ 'addon.mod_lesson.ongoingnormal',
+ { $a: { correct: gradeInfo.earned, viewed: gradeInfo.attempts } },
+ );
+ }
+ });
+ }
+ }
+
+ /**
+ * Get the possible answers from a page.
+ *
+ * @param lesson Lesson.
+ * @param pageId Page ID.
+ * @param options Other options.
+ * @return Promise resolved with the list of possible answers.
+ */
+ protected async getPageAnswers(
+ lesson: AddonModLessonLessonWSData,
+ pageId: number,
+ options: AddonModLessonPwdReviewOptions = {},
+ ): Promise {
+ const data = await this.getPageData(lesson, pageId, {
+ includeContents: true,
+ ...options, // Include all options.
+ readingStrategy: options.readingStrategy || CoreSitesReadingStrategy.PreferCache,
+ includeOfflineData: false,
+ });
+
+ return data.answers;
+ }
+
+ /**
+ * Get all the possible answers from a list of pages, indexed by answerId.
+ *
+ * @param lesson Lesson.
+ * @param pageIds List of page IDs.
+ * @param options Other options.
+ * @return Promise resolved with an object containing the answers.
+ */
+ protected async getPagesAnswers(
+ lesson: AddonModLessonLessonWSData,
+ pageIds: number[],
+ options: AddonModLessonPwdReviewOptions = {},
+ ): Promise> {
+
+ const answers: Record = {};
+
+ await Promise.all(pageIds.map(async (pageId) => {
+ const pageAnswers = await this.getPageAnswers(lesson, pageId, options);
+
+ pageAnswers.forEach((answer) => {
+ // Include the pageid in each answer and add them to the final list.
+ answers[answer.id] = Object.assign(answer, { pageid: pageId });
+ });
+ }));
+
+ return answers;
+ }
+
+ /**
+ * Get page data.
+ *
+ * @param lesson Lesson.
+ * @param pageId Page ID.
+ * @param options Other options.
+ * @return Promise resolved with the page data.
+ */
+ async getPageData(
+ lesson: AddonModLessonLessonWSData,
+ pageId: number,
+ options: AddonModLessonGetPageDataOptions = {},
+ ): Promise {
+
+ const site = await CoreSites.instance.getSite(options.siteId);
+
+ const params: AddonModLessonGetPageDataWSParams = {
+ lessonid: lesson.id,
+ pageid: Number(pageId),
+ review: !!options.review,
+ returncontents: !!options.includeContents,
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getPageDataCacheKey(lesson.id, pageId),
+ component: AddonModLessonProvider.COMPONENT,
+ componentId: options.cmId,
+ ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+ };
+
+ if (typeof options.password == 'string') {
+ params.password = options.password;
+ }
+
+ if (options.review) {
+ // Force online mode in review.
+ preSets.getFromCache = false;
+ preSets.saveToCache = false;
+ preSets.emergencyCache = false;
+ }
+
+ const response = await site.read('mod_lesson_get_page_data', params, preSets);
+
+ if (preSets.omitExpires && options.includeOfflineData && response.page && options.accessInfo && options.jumps) {
+ // Offline mode and valid page. Calculate the data that might be affected.
+ const calcData = await this.calculateOfflineData(lesson, options);
+
+ Object.assign(response, calcData);
+
+ response.messages = await this.getPageViewMessages(lesson, options.accessInfo, response.page, options.jumps, {
+ password: options.password,
+ siteId: options.siteId,
+ });
+ }
+
+ return response;
+ }
+
+ /**
+ * Get cache key for get page data WS calls.
+ *
+ * @param lessonId Lesson ID.
+ * @param pageId Page ID.
+ * @return Cache key.
+ */
+ protected getPageDataCacheKey(lessonId: number, pageId: number): string {
+ return this.getPageDataCommonCacheKey(lessonId) + ':' + pageId;
+ }
+
+ /**
+ * Get common cache key for get page data WS calls.
+ *
+ * @param lessonId Lesson ID.
+ * @return Cache key.
+ */
+ protected getPageDataCommonCacheKey(lessonId: number): string {
+ return ROOT_CACHE_KEY + 'pageData:' + lessonId;
+ }
+
+ /**
+ * Get lesson pages.
+ *
+ * @param lessonId Lesson ID.
+ * @param options Other options.
+ * @return Promise resolved with the pages.
+ */
+ async getPages(lessonId: number, options: AddonModLessonPwdReviewOptions = {}): Promise {
+
+ const site = await CoreSites.instance.getSite(options.siteId);
+
+ const params: AddonModLessonGetPagesWSParams = {
+ lessonid: lessonId,
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getPagesCacheKey(lessonId),
+ updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
+ component: AddonModLessonProvider.COMPONENT,
+ componentId: options.cmId,
+ ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+ };
+
+ if (typeof options.password == 'string') {
+ params.password = options.password;
+ }
+
+ const response = await site.read('mod_lesson_get_pages', params, preSets);
+
+ return response.pages;
+ }
+
+ /**
+ * Get cache key for get pages WS calls.
+ *
+ * @param lessonId Lesson ID.
+ * @return Cache key.
+ */
+ protected getPagesCacheKey(lessonId: number): string {
+ return ROOT_CACHE_KEY + 'pages:' + lessonId;
+ }
+
+ /**
+ * Get possible jumps for a lesson.
+ *
+ * @param lessonId Lesson ID.
+ * @param options Other options.
+ * @return Promise resolved with the jumps.
+ */
+ async getPagesPossibleJumps(
+ lessonId: number,
+ options: CoreCourseCommonModWSOptions = {},
+ ): Promise {
+
+ const site = await CoreSites.instance.getSite(options.siteId);
+
+ const params: AddonModLessonGetPagesPossibleJumpsWSParams = {
+ lessonid: lessonId,
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getPagesPossibleJumpsCacheKey(lessonId),
+ component: AddonModLessonProvider.COMPONENT,
+ componentId: options.cmId,
+ ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+ };
+
+ const response = await site.read(
+ 'mod_lesson_get_pages_possible_jumps',
+ params,
+ preSets,
+ );
+
+ // Index the jumps by page and jumpto.
+ const jumps: AddonModLessonPossibleJumps = {};
+
+ response.jumps.forEach((jump) => {
+ if (typeof jumps[jump.pageid] == 'undefined') {
+ jumps[jump.pageid] = {};
+ }
+ jumps[jump.pageid][jump.jumpto] = jump;
+ });
+
+ return jumps;
+ }
+
+ /**
+ * Get cache key for get pages possible jumps WS calls.
+ *
+ * @param lessonId Lesson ID.
+ * @return Cache key.
+ */
+ protected getPagesPossibleJumpsCacheKey(lessonId: number): string {
+ return ROOT_CACHE_KEY + 'pagesJumps:' + lessonId;
+ }
+
+ /**
+ * Get different informative messages when processing a lesson page.
+ * Please try to use WS response messages instead of this function if possible.
+ * Based on Moodle's add_messages_on_page_process.
+ *
+ * @param lesson Lesson.
+ * @param accessInfo Access info.
+ * @param result Result of process page.
+ * @param review If the user wants to review just after finishing (1 hour margin).
+ * @param jumps Possible jumps.
+ * @return Array with the messages.
+ */
+ getPageProcessMessages(
+ lesson: AddonModLessonLessonWSData,
+ accessInfo: AddonModLessonGetAccessInformationWSResponse,
+ result: AddonModLessonProcessPageResponse,
+ review: boolean,
+ jumps: AddonModLessonPossibleJumps,
+ ): AddonModLessonMessageWSData[] {
+ const messages = [];
+
+ if (accessInfo.canmanage) {
+ // Warning for teachers to inform them that cluster and unseen does not work while logged in as a teacher.
+ if (this.lessonDisplayTeacherWarning(jumps)) {
+ this.addMessage(messages, 'addon.mod_lesson.teacherjumpwarning', {
+ $a: {
+ cluster: Translate.instance.instant('addon.mod_lesson.clusterjump'),
+ unseen: Translate.instance.instant('addon.mod_lesson.unseenpageinbranch'),
+ },
+ });
+ }
+
+ // Inform teacher that s/he will not see the timer.
+ if (lesson.timelimit) {
+ this.addMessage(messages, 'addon.mod_lesson.teachertimerwarning');
+ }
+ }
+ // Report attempts remaining.
+ if (result.attemptsremaining && result.attemptsremaining > 0 && lesson.review && !review) {
+ this.addMessage(messages, 'addon.mod_lesson.attemptsremaining', { $a: result.attemptsremaining });
+ }
+
+ return messages;
+ }
+
+ /**
+ * Get the IDs of all the pages that have at least 1 question attempt.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number.
+ * @param options Other options.
+ * @return Promise resolved with the IDs.
+ */
+ async getPagesIdsWithQuestionAttempts(
+ lessonId: number,
+ retake: number,
+ options: AddonModLessonGetPagesIdsWithAttemptsOptions = {},
+ ): Promise {
+
+ const result = await this.getQuestionsAttempts(lessonId, retake, options);
+
+ const ids: Record = {};
+ const attempts = ( result.online).concat(result.offline);
+
+ attempts.forEach((attempt) => {
+ if (!ids[attempt.pageid]) {
+ ids[attempt.pageid] = true;
+ }
+ });
+
+ return Object.keys(ids).map((id) => Number(id));
+ }
+
+ /**
+ * Get different informative messages when viewing a lesson page.
+ * Please try to use WS response messages instead of this function if possible.
+ * Based on Moodle's add_messages_on_page_view.
+ *
+ * @param lesson Lesson.
+ * @param accessInfo Access info. Required if offline is true.
+ * @param page Page loaded.
+ * @param jumps Possible jumps.
+ * @param options Other options.
+ * @return Promise resolved with the list of messages.
+ */
+ async getPageViewMessages(
+ lesson: AddonModLessonLessonWSData,
+ accessInfo: AddonModLessonGetAccessInformationWSResponse,
+ page: AddonModLessonPageWSData,
+ jumps: AddonModLessonPossibleJumps,
+ options: AddonModLessonGetPageViewMessagesOptions = {},
+ ): Promise {
+
+ const messages: AddonModLessonMessageWSData[] = [];
+
+ if (!accessInfo.canmanage) {
+ if (page.qtype == AddonModLessonProvider.LESSON_PAGE_BRANCHTABLE && lesson.minquestions) {
+ // Tell student how many questions they have seen, how many are required and their grade.
+ const retake = accessInfo.attemptscount;
+
+ const gradeInfo = await CoreUtils.instance.ignoreErrors(this.lessonGrade(lesson, retake, options));
+ if (gradeInfo?.attempts) {
+ if (gradeInfo.nquestions < lesson.minquestions) {
+ this.addMessage(messages, 'addon.mod_lesson.numberofpagesviewednotice', {
+ $a: {
+ nquestions: gradeInfo.nquestions,
+ minquestions: lesson.minquestions,
+ },
+ });
+ }
+
+ if (!options.review && !lesson.retake) {
+ this.addMessage(messages, 'addon.mod_lesson.numberofcorrectanswers', { $a: gradeInfo.earned });
+
+ if (lesson.grade !== undefined && lesson.grade != CoreGradesProvider.TYPE_NONE) {
+ this.addMessage(messages, 'addon.mod_lesson.yourcurrentgradeisoutof', { $a: {
+ grade: CoreTextUtils.instance.roundToDecimals(gradeInfo.grade * lesson.grade / 100, 1),
+ total: lesson.grade,
+ } });
+ }
+ }
+ }
+ }
+ } else {
+ if (lesson.timelimit) {
+ this.addMessage(messages, 'addon.mod_lesson.teachertimerwarning');
+ }
+
+ if (this.lessonDisplayTeacherWarning(jumps)) {
+ // Warning for teachers to inform them that cluster and unseen does not work while logged in as a teacher.
+ this.addMessage(messages, 'addon.mod_lesson.teacherjumpwarning', {
+ $a: {
+ cluster: Translate.instance.instant('addon.mod_lesson.clusterjump'),
+ unseen: Translate.instance.instant('addon.mod_lesson.unseenpageinbranch'),
+ },
+ });
+ }
+ }
+
+ return messages;
+ }
+
+ /**
+ * Get questions attempts, including offline attempts.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number.
+ * @param options Other options.
+ * @return Promise resolved with the questions attempts.
+ */
+ async getQuestionsAttempts(
+ lessonId: number,
+ retake: number,
+ options: AddonModLessonGetQuestionsAttemptsOptions = {},
+ ): Promise<{online: AddonModLessonQuestionAttemptWSData[]; offline: AddonModLessonPageAttemptRecord[]}> {
+
+ const [online, offline] = await Promise.all([
+ this.getQuestionsAttemptsOnline(lessonId, retake, options),
+ CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.getQuestionsAttempts(
+ lessonId,
+ retake,
+ options.correct,
+ options.pageId,
+ options.siteId,
+ )),
+ ]);
+
+ return {
+ online,
+ offline: offline || [],
+ };
+ }
+
+ /**
+ * Get cache key for get questions attempts WS calls.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number.
+ * @param userId User ID.
+ * @return Cache key.
+ */
+ protected getQuestionsAttemptsCacheKey(lessonId: number, retake: number, userId: number): string {
+ return this.getQuestionsAttemptsCommonCacheKey(lessonId) + ':' + userId + ':' + retake;
+ }
+
+ /**
+ * Get common cache key for get questions attempts WS calls.
+ *
+ * @param lessonId Lesson ID.
+ * @return Cache key.
+ */
+ protected getQuestionsAttemptsCommonCacheKey(lessonId: number): string {
+ return ROOT_CACHE_KEY + 'questionsAttempts:' + lessonId;
+ }
+
+ /**
+ * Get questions attempts from the site.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number.
+ * @param options Other options.
+ * @return Promise resolved with the questions attempts.
+ */
+ async getQuestionsAttemptsOnline(
+ lessonId: number,
+ retake: number,
+ options: AddonModLessonGetQuestionsAttemptsOptions = {},
+ ): Promise {
+
+ const site = await CoreSites.instance.getSite(options.siteId);
+
+ const userId = options.userId || site.getUserId();
+
+ // Don't pass "pageId" and "correct" params, they will be filtered locally.
+ const params: AddonModLessonGetQuestionsAttemptsWSParams = {
+ lessonid: lessonId,
+ attempt: retake,
+ userid: userId,
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getQuestionsAttemptsCacheKey(lessonId, retake, userId),
+ component: AddonModLessonProvider.COMPONENT,
+ componentId: options.cmId,
+ ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+ };
+
+ const response = await site.read(
+ 'mod_lesson_get_questions_attempts',
+ params,
+ preSets,
+ );
+
+ if (!options.pageId && !options.correct) {
+ return response.attempts;
+ }
+
+ // Filter the attempts.
+ return response.attempts.filter((attempt) => {
+ if (options.correct && !attempt.correct) {
+ return false;
+ }
+
+ if (options.pageId && attempt.pageid != options.pageId) {
+ return false;
+ }
+
+ return true;
+ });
+ }
+
+ /**
+ * Get the overview of retakes in a lesson (named "attempts overview" in Moodle).
+ *
+ * @param lessonId Lesson ID.
+ * @param options Other options.
+ * @return Promise resolved with the retakes overview, undefined if no attempts.
+ */
+ async getRetakesOverview(
+ lessonId: number,
+ options: AddonModLessonGroupOptions = {},
+ ): Promise {
+ const groupId = options.groupId || 0;
+
+ const site = await CoreSites.instance.getSite(options.siteId);
+
+ const params: AddonModLessonGetAttemptsOverviewWSParams = {
+ lessonid: lessonId,
+ groupid: groupId,
+ };
+ const preSets = {
+ cacheKey: this.getRetakesOverviewCacheKey(lessonId, groupId),
+ updateFrequency: CoreSite.FREQUENCY_OFTEN,
+ component: AddonModLessonProvider.COMPONENT,
+ componentId: options.cmId,
+ ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+ };
+
+ const response = await site.read(
+ 'mod_lesson_get_attempts_overview',
+ params,
+ preSets,
+ );
+
+ return response.data;
+ }
+
+ /**
+ * Get cache key for get retakes overview WS calls.
+ *
+ * @param lessonId Lesson ID.
+ * @param groupId Group ID.
+ * @return Cache key.
+ */
+ protected getRetakesOverviewCacheKey(lessonId: number, groupId: number): string {
+ return this.getRetakesOverviewCommonCacheKey(lessonId) + ':' + groupId;
+ }
+
+ /**
+ * Get common cache key for get retakes overview WS calls.
+ *
+ * @param lessonId Lesson ID.
+ * @return Cache key.
+ */
+ protected getRetakesOverviewCommonCacheKey(lessonId: number): string {
+ return ROOT_CACHE_KEY + 'retakesOverview:' + lessonId;
+ }
+
+ /**
+ * Get a password stored in DB.
+ *
+ * @param lessonId Lesson ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with password on success, rejected otherwise.
+ */
+ async getStoredPassword(lessonId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ const entry = await site.getDb().getRecord(PASSWORD_TABLE_NAME, { lessonid: lessonId });
+
+ return entry.password;
+ }
+
+ /**
+ * Finds all pages that appear to be a subtype of the provided pageId until an end point specified within "ends" is
+ * encountered or no more pages exist.
+ * Based on Moodle's get_sub_pages_of.
+ *
+ * @param pages Index of lesson pages, indexed by page ID. See createPagesIndex.
+ * @param pageId Page ID to get subpages of.
+ * @param end An array of LESSON_PAGE_* types that signify an end of the subtype.
+ * @return List of subpages.
+ */
+ getSubpagesOf(pages: Record, pageId: number, ends: number[]): AddonModLessonPageWSData[] {
+ const subPages: AddonModLessonPageWSData[] = [];
+
+ pageId = pages[pageId].nextpageid; // Move to the first page after the given page.
+ ends = ends || [];
+
+ // Search until there are no more pages or it reaches a page of the searched types.
+ while (pageId && ends.indexOf(pages[pageId].qtype) == -1) {
+ subPages.push(pages[pageId]);
+ pageId = pages[pageId].nextpageid;
+ }
+
+ return subPages;
+ }
+
+ /**
+ * Get lesson timers.
+ *
+ * @param lessonId Lesson ID.
+ * @param options Other options.
+ * @return Promise resolved with the pages.
+ */
+ async getTimers(lessonId: number, options: AddonModLessonUserOptions = {}): Promise {
+ const site = await CoreSites.instance.getSite(options.siteId);
+
+ const userId = options.userId || site.getUserId();
+ const params: ModLessonGetUserTimersWSParams = {
+ lessonid: lessonId,
+ userid: userId,
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getTimersCacheKey(lessonId, userId),
+ component: AddonModLessonProvider.COMPONENT,
+ componentId: options.cmId,
+ ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+ };
+
+ const response = await site.read('mod_lesson_get_user_timers', params, preSets);
+
+ return response.timers;
+ }
+
+ /**
+ * Get cache key for get timers WS calls.
+ *
+ * @param lessonId Lesson ID.
+ * @param userId User ID.
+ * @return Cache key.
+ */
+ protected getTimersCacheKey(lessonId: number, userId: number): string {
+ return this.getTimersCommonCacheKey(lessonId) + ':' + userId;
+ }
+
+ /**
+ * Get common cache key for get timers WS calls.
+ *
+ * @param lessonId Lesson ID.
+ * @return Cache key.
+ */
+ protected getTimersCommonCacheKey(lessonId: number): string {
+ return ROOT_CACHE_KEY + 'timers:' + lessonId;
+ }
+
+ /**
+ * Get the list of used answers (with valid answer) in a multichoice question page.
+ *
+ * @param pageData Page data for the page to process.
+ * @return List of used answers.
+ */
+ protected getUsedAnswersMultichoice(pageData: AddonModLessonGetPageDataWSResponse): AddonModLessonPageAnswerWSData[] {
+ const answers = CoreUtils.instance.clone(pageData.answers);
+
+ return answers.filter((entry) => entry.answer !== undefined && entry.answer !== '');
+ }
+
+ /**
+ * Get the user's response in a matching question page.
+ *
+ * @param data Data containing the user answer.
+ * @return User response.
+ */
+ protected getUserResponseMatching(data: Record): Record {
+ if (data.response) {
+ // The data is already stored as expected. Return it.
+ return > data.response;
+ }
+
+ // Data is stored in properties like 'response[379]'. Recreate the response object.
+ const response: Record = {};
+
+ for (const key in data) {
+ const match = key.match(/^response\[(\d+)\]/);
+
+ if (match && match.length > 1) {
+ response[match[1]] = data[key];
+ }
+ }
+
+ return response;
+ }
+
+ /**
+ * Get the user's response in a multichoice page if multiple answers are allowed.
+ *
+ * @param data Data containing the user answer.
+ * @return User response.
+ */
+ protected getUserResponseMultichoice(data: Record): number[] | null {
+ if (data.answer) {
+ // The data is already stored as expected. If it's valid, parse the values to int.
+ if (Array.isArray(data.answer)) {
+ return data.answer.map((value) => parseInt(value, 10));
+ }
+
+ return null;
+ }
+
+ // Data is stored in properties like 'answer[379]'. Recreate the answer array.
+ const answer: number[] = [];
+ for (const key in data) {
+ const match = key.match(/^answer\[(\d+)\]/);
+ if (match && match.length > 1) {
+ answer.push(parseInt(match[1], 10));
+ }
+ }
+
+ return answer;
+ }
+
+ /**
+ * Get a user's retake.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number
+ * @param options Other options.
+ * @return Promise resolved with the retake data.
+ */
+ async getUserRetake(
+ lessonId: number,
+ retake: number,
+ options: AddonModLessonUserOptions = {},
+ ): Promise {
+
+ const site = await CoreSites.instance.getSite(options.siteId);
+
+ const userId = options.userId || site.getUserId();
+ const params: AddonModLessonGetUserAttemptWSParams = {
+ lessonid: lessonId,
+ userid: userId,
+ lessonattempt: retake,
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getUserRetakeCacheKey(lessonId, userId, retake),
+ updateFrequency: CoreSite.FREQUENCY_SOMETIMES,
+ component: AddonModLessonProvider.COMPONENT,
+ componentId: options.cmId,
+ ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets.
+ };
+
+ return site.read('mod_lesson_get_user_attempt', params, preSets);
+ }
+
+ /**
+ * Get cache key for get user retake WS calls.
+ *
+ * @param lessonId Lesson ID.
+ * @param userId User ID.
+ * @param retake Retake number
+ * @return Cache key.
+ */
+ protected getUserRetakeCacheKey(lessonId: number, userId: number, retake: number): string {
+ return this.getUserRetakeUserCacheKey(lessonId, userId) + ':' + retake;
+ }
+
+ /**
+ * Get user cache key for get user retake WS calls.
+ *
+ * @param lessonId Lesson ID.
+ * @param userId User ID.
+ * @return Cache key.
+ */
+ protected getUserRetakeUserCacheKey(lessonId: number, userId: number): string {
+ return this.getUserRetakeLessonCacheKey(lessonId) + ':' + userId;
+ }
+
+ /**
+ * Get lesson cache key for get user retake WS calls.
+ *
+ * @param lessonId Lesson ID.
+ * @return Cache key.
+ */
+ protected getUserRetakeLessonCacheKey(lessonId: number): string {
+ return ROOT_CACHE_KEY + 'userRetake:' + lessonId;
+ }
+
+ /**
+ * Get the prevent access reason to display for a certain lesson.
+ *
+ * @param info Lesson access info.
+ * @param ignorePassword Whether password protected reason should be ignored (user already entered the password).
+ * @param isReview Whether user is reviewing a retake.
+ * @return Prevent access reason.
+ */
+ getPreventAccessReason(
+ info: AddonModLessonGetAccessInformationWSResponse,
+ ignorePassword?: boolean,
+ isReview?: boolean,
+ ): AddonModLessonPreventAccessReason | undefined {
+ if (!info?.preventaccessreasons) {
+ return;
+ }
+
+ let reason: AddonModLessonPreventAccessReason | undefined;
+ for (let i = 0; i < info.preventaccessreasons.length; i++) {
+ const entry = info.preventaccessreasons[i];
+
+ if (entry.reason == 'lessonopen' || entry.reason == 'lessonclosed') {
+ // Time restrictions are the most prioritary, return it.
+ return reason;
+ } else if (entry.reason == 'passwordprotectedlesson') {
+ if (!ignorePassword) {
+ // Treat password before all other reasons.
+ reason = entry;
+ }
+ } else if (entry.reason == 'noretake' && isReview) {
+ // Ignore noretake error when reviewing.
+ } else if (!reason) {
+ // Rest of cases, just return any of them.
+ reason = entry;
+ }
+ }
+
+ return reason;
+ }
+
+ /**
+ * Check if a jump is correct.
+ * Based in Moodle's jumpto_is_correct.
+ *
+ * @param pageId ID of the page from which you are jumping from.
+ * @param jumpTo The jumpto number.
+ * @param pageIndex Object containing all the pages indexed by ID. See createPagesIndex.
+ * @return Whether jump is correct.
+ */
+ jumptoIsCorrect(pageId: number, jumpTo: number, pageIndex: Record): boolean {
+ // First test the special values.
+ if (!jumpTo) {
+ // Same page
+ return false;
+ } else if (jumpTo == AddonModLessonProvider.LESSON_NEXTPAGE) {
+ return true;
+ } else if (jumpTo == AddonModLessonProvider.LESSON_UNSEENBRANCHPAGE) {
+ return true;
+ } else if (jumpTo == AddonModLessonProvider.LESSON_RANDOMPAGE) {
+ return true;
+ } else if (jumpTo == AddonModLessonProvider.LESSON_CLUSTERJUMP) {
+ return true;
+ } else if (jumpTo == AddonModLessonProvider.LESSON_EOL) {
+ return true;
+ }
+
+ let aPageId = pageIndex[pageId].nextpageid;
+ while (aPageId) {
+ if (jumpTo == aPageId) {
+ return true;
+ }
+
+ aPageId = pageIndex[aPageId].nextpageid;
+ }
+
+ return false;
+ }
+
+ /**
+ * Invalidates Lesson data.
+ *
+ * @param lessonId Lesson ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateAccessInformation(lessonId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getAccessInformationCacheKey(lessonId));
+ }
+
+ /**
+ * Invalidates content pages viewed for all retakes.
+ *
+ * @param lessonId Lesson ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateContentPagesViewed(lessonId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKeyStartingWith(this.getContentPagesViewedCommonCacheKey(lessonId));
+ }
+
+ /**
+ * Invalidates content pages viewed for a certain retake.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateContentPagesViewedForRetake(lessonId: number, retake: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getContentPagesViewedCacheKey(lessonId, retake));
+ }
+
+ /**
+ * Invalidates Lesson data.
+ *
+ * @param courseId Course ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateLessonData(courseId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getLessonDataCacheKey(courseId));
+ }
+
+ /**
+ * Invalidates lesson with password.
+ *
+ * @param lessonId Lesson ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateLessonWithPassword(lessonId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getLessonWithPasswordCacheKey(lessonId));
+ }
+
+ /**
+ * Invalidates page data for all pages.
+ *
+ * @param lessonId Lesson ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidatePageData(lessonId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKeyStartingWith(this.getPageDataCommonCacheKey(lessonId));
+ }
+
+ /**
+ * Invalidates page data for a certain page.
+ *
+ * @param lessonId Lesson ID.
+ * @param pageId Page ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidatePageDataForPage(lessonId: number, pageId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getPageDataCacheKey(lessonId, pageId));
+ }
+
+ /**
+ * Invalidates pages.
+ *
+ * @param lessonId Lesson ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidatePages(lessonId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getPagesCacheKey(lessonId));
+ }
+
+ /**
+ * Invalidates pages possible jumps.
+ *
+ * @param lessonId Lesson ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidatePagesPossibleJumps(lessonId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getPagesPossibleJumpsCacheKey(lessonId));
+ }
+
+ /**
+ * Invalidates questions attempts for all retakes.
+ *
+ * @param lessonId Lesson ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateQuestionsAttempts(lessonId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKeyStartingWith(this.getQuestionsAttemptsCommonCacheKey(lessonId));
+ }
+
+ /**
+ * Invalidates question attempts for a certain retake and user.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number.
+ * @param siteId Site ID. If not defined, current site..
+ * @param userId User ID. If not defined, site's user.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateQuestionsAttemptsForRetake(lessonId: number, retake: number, siteId?: string, userId?: number): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getQuestionsAttemptsCacheKey(lessonId, retake, userId || site.getUserId()));
+ }
+
+ /**
+ * Invalidates retakes overview for all groups in a lesson.
+ *
+ * @param lessonId Lesson ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateRetakesOverview(lessonId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKeyStartingWith(this.getRetakesOverviewCommonCacheKey(lessonId));
+ }
+
+ /**
+ * Invalidates retakes overview for a certain group in a lesson.
+ *
+ * @param lessonId Lesson ID.
+ * @param groupId Group ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateRetakesOverviewForGroup(lessonId: number, groupId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getRetakesOverviewCacheKey(lessonId, groupId));
+ }
+
+ /**
+ * Invalidates timers for all users in a lesson.
+ *
+ * @param lessonId Lesson ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateTimers(lessonId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKeyStartingWith(this.getTimersCommonCacheKey(lessonId));
+ }
+
+ /**
+ * Invalidates timers for a certain user.
+ *
+ * @param lessonId Lesson ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @param userId User ID. If not defined, site's current user.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateTimersForUser(lessonId: number, siteId?: string, userId?: number): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getTimersCacheKey(lessonId, userId || site.getUserId()));
+ }
+
+ /**
+ * Invalidates a certain retake for a certain user.
+ *
+ * @param lessonId Lesson ID.
+ * @param retake Retake number.
+ * @param userId User ID. Undefined for current user.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateUserRetake(lessonId: number, retake: number, userId?: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getUserRetakeCacheKey(lessonId, userId || site.getUserId(), retake));
+ }
+
+ /**
+ * Invalidates all retakes for all users in a lesson.
+ *
+ * @param lessonId Lesson ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateUserRetakesForLesson(lessonId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKeyStartingWith(this.getUserRetakeLessonCacheKey(lessonId));
+ }
+
+ /**
+ * Invalidates all retakes for a certain user in a lesson.
+ *
+ * @param lessonId Lesson ID.
+ * @param userId User ID. Undefined for current user.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateUserRetakesForUser(lessonId: number, userId?: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKeyStartingWith(this.getUserRetakeUserCacheKey(lessonId, userId || site.getUserId()));
+ }
+
+ /**
+ * Check if a page answer is correct.
+ *
+ * @param lesson Lesson.
+ * @param pageId The page ID.
+ * @param answer The answer to check.
+ * @param pageIndex Object containing all the pages indexed by ID.
+ * @return Whether the answer is correct.
+ */
+ protected isAnswerCorrect(
+ lesson: AddonModLessonLessonWSData,
+ pageId: number,
+ answer: AddonModLessonPageAnswerWSData,
+ pageIndex: Record,
+ ): boolean {
+ if (lesson.custom) {
+ // Custom scores. If score on answer is positive, it is correct.
+ return !!(answer.score && answer.score > 0);
+ } else {
+ return this.jumptoIsCorrect(pageId, answer.jumpto || 0, pageIndex);
+ }
+ }
+
+ /**
+ * Check if a lesson is enabled to be used in offline.
+ *
+ * @param lesson Lesson.
+ * @return Whether offline is enabled.
+ */
+ isLessonOffline(lesson: AddonModLessonLessonWSData): boolean {
+ return !!lesson.allowofflineattempts;
+ }
+
+ /**
+ * Check if a lesson is password protected based in the access info.
+ *
+ * @param info Lesson access info.
+ * @return Whether the lesson is password protected.
+ */
+ isPasswordProtected(info: AddonModLessonGetAccessInformationWSResponse): boolean {
+ if (!info || !info.preventaccessreasons) {
+ return false;
+ }
+
+ for (let i = 0; i < info.preventaccessreasons.length; i++) {
+ const entry = info.preventaccessreasons[i];
+
+ if (entry.reason == 'passwordprotectedlesson') {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Return whether or not the plugin is enabled in a certain site. Plugin is enabled if the lesson WS are available.
+ *
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise.
+ */
+ async isPluginEnabled(siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ // All WS were introduced at the same time so checking one is enough.
+ return site.wsAvailable('mod_lesson_get_lesson_access_information');
+ }
+
+ /**
+ * Check if a page is a question page or a content page.
+ *
+ * @param type Type of the page.
+ * @return True if question page, false if content page.
+ */
+ isQuestionPage(type: number): boolean {
+ return type == AddonModLessonProvider.TYPE_QUESTION;
+ }
+
+ /**
+ * Start or continue a retake.
+ *
+ * @param id Lesson ID.
+ * @param password Lesson password (if any).
+ * @param pageId Page id to continue from (only when continuing a retake).
+ * @param review If the user wants to review just after finishing (1 hour margin).
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the WS call is successful.
+ */
+ async launchRetake(
+ id: number,
+ password?: string,
+ pageId?: number,
+ review?: boolean,
+ siteId?: string,
+ ): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ const params: AddonModLessonLaunchAttemptWSParams = {
+ lessonid: id,
+ review: !!review,
+ };
+ if (typeof password == 'string') {
+ params.password = password;
+ }
+ if (typeof pageId == 'number') {
+ params.pageid = pageId;
+ }
+
+ const response = await site.write('mod_lesson_launch_attempt', params);
+
+ CoreEvents.trigger(AddonModLessonProvider.DATA_SENT_EVENT, {
+ lessonId: id,
+ type: 'launch',
+ }, CoreSites.instance.getCurrentSiteId());
+
+ return response;
+ }
+
+ /**
+ * Check if the user left during a timed session.
+ *
+ * @param info Lesson access info.
+ * @return True if left during timed, false otherwise.
+ */
+ leftDuringTimed(info?: AddonModLessonGetAccessInformationWSResponse): boolean {
+ return !!(info?.lastpageseen && info.lastpageseen != AddonModLessonProvider.LESSON_EOL && info.leftduringtimedsession);
+ }
+
+ /**
+ * Checks to see if a LESSON_CLUSTERJUMP or a LESSON_UNSEENBRANCHPAGE is used in a lesson.
+ * Based on Moodle's lesson_display_teacher_warning.
+ *
+ * @param jumps Possible jumps.
+ * @return Whether the lesson uses one of those jumps.
+ */
+ lessonDisplayTeacherWarning(jumps: AddonModLessonPossibleJumps): boolean {
+ if (!jumps) {
+ return false;
+ }
+
+ // Check if any jump is to cluster or unseen content page.
+ for (const pageId in jumps) {
+ for (const jumpto in jumps[pageId]) {
+ const jumptoNum = Number(jumpto);
+
+ if (jumptoNum == AddonModLessonProvider.LESSON_CLUSTERJUMP ||
+ jumptoNum == AddonModLessonProvider.LESSON_UNSEENBRANCHPAGE) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Calculates a user's grade for a lesson.
+ * Based on Moodle's lesson_grade.
+ *
+ * @param lesson Lesson.
+ * @param retake Retake number.
+ * @param options Other options.
+ * @return Promise resolved with the grade data.
+ */
+ async lessonGrade(
+ lesson: AddonModLessonLessonWSData,
+ retake: number,
+ options: AddonModLessonGradeOptions = {},
+ ): Promise {
+
+ const result: AddonModLessonGrade = {
+ nquestions: 0,
+ attempts: 0,
+ total: 0,
+ earned: 0,
+ grade: 0,
+ nmanual: 0,
+ manualpoints: 0,
+ };
+
+ // Get the questions attempts for the user.
+ const attemptsData = await this.getQuestionsAttempts(lesson.id, retake, {
+ cmId: lesson.coursemodule,
+ siteId: options.siteId,
+ userId: options.userId,
+ });
+
+ const attempts = ( attemptsData.online).concat(attemptsData.offline);
+
+ if (!attempts.length) {
+ // No attempts.
+ return result;
+ }
+
+ // Create the pageIndex if it isn't provided.
+ if (!options.pageIndex) {
+ const pages = await this.getPages(lesson.id, {
+ password: options.password,
+ cmId: lesson.coursemodule,
+ readingStrategy: CoreSitesReadingStrategy.PreferCache,
+ siteId: options.siteId,
+ });
+
+ options.pageIndex = this.createPagesIndex(pages);
+ }
+
+ const attemptSet: Record = {};
+ const pageIds: number[] = [];
+
+ // Group each try with its page.
+ attempts.forEach((attempt) => {
+ if (!attemptSet[attempt.pageid]) {
+ attemptSet[attempt.pageid] = [];
+ pageIds.push(attempt.pageid);
+ }
+ attemptSet[attempt.pageid].push(attempt);
+ });
+
+ if (lesson.maxattempts && lesson.maxattempts > 0) {
+ // Drop all attempts that go beyond max attempts for the lesson.
+ for (const pageId in attemptSet) {
+ // Sort the list by time in ascending order.
+ const attempts = attemptSet[pageId].sort((a, b) =>
+ ('timeseen' in a ? a.timeseen : a.timemodified) - ('timeseen' in b ? b.timeseen : b.timemodified));
+
+ attemptSet[pageId] = attempts.slice(0, lesson.maxattempts);
+ }
+ }
+
+ // Get all the answers from the pages the user answered.
+ const answers = await this.getPagesAnswers(lesson, pageIds, options);
+
+ // Number of pages answered.
+ result.nquestions = Object.keys(attemptSet).length;
+
+ for (const pageId in attemptSet) {
+ const attempts = attemptSet[pageId];
+ const lastAttempt = attempts[attempts.length - 1];
+
+ if (lesson.custom) {
+ // If essay question, handle it, otherwise add to score.
+ if (options.pageIndex[lastAttempt.pageid].qtype == AddonModLessonProvider.LESSON_PAGE_ESSAY) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const score: number | undefined = ( lastAttempt.useranswer)?.score;
+ if (typeof score != 'undefined') {
+ result.earned += score;
+ }
+ result.nmanual++;
+ result.manualpoints += answers[lastAttempt.answerid!].score || 0;
+ } else if (lastAttempt.answerid) {
+ result.earned += answers[lastAttempt.answerid!].score || 0;
+ }
+ } else {
+ attempts.forEach((attempt) => {
+ result.earned += attempt.correct ? 1 : 0;
+ });
+
+ // If essay question, increase numbers.
+ if (options.pageIndex[lastAttempt.pageid].qtype == AddonModLessonProvider.LESSON_PAGE_ESSAY) {
+ result.nmanual++;
+ result.manualpoints++;
+ }
+ }
+
+ // Number of times answered.
+ result.attempts += attempts.length;
+ }
+
+ if (lesson.custom) {
+ const bestScores: Record = {};
+
+ // Find the highest possible score per page to get our total.
+ for (const answerId in answers) {
+ const answer = answers[answerId];
+
+ if (typeof bestScores[answer.pageid] == 'undefined') {
+ bestScores[answer.pageid] = answer.score || 0;
+ } else if (bestScores[answer.pageid] < (answer.score || 0)) {
+ bestScores[answer.pageid] = answer.score || 0;
+ }
+ }
+
+ // Sum all the scores.
+ for (const pageId in bestScores) {
+ result.total += bestScores[pageId];
+ }
+ } else {
+ // Check to make sure the student has answered the minimum questions.
+ if (lesson.minquestions && result.nquestions < lesson.minquestions) {
+ // Nope, increase number viewed by the amount of unanswered questions.
+ result.total = result.attempts + (lesson.minquestions - result.nquestions);
+ } else {
+ result.total = result.attempts;
+ }
+ }
+
+ if (result.total) { // Not zero.
+ result.grade = CoreTextUtils.instance.roundToDecimals(result.earned * 100 / result.total, 5);
+ }
+
+ return result;
+ }
+
+ /**
+ * Report a lesson as being viewed.
+ *
+ * @param id Module ID.
+ * @param password Lesson password (if any).
+ * @param name Name of the assign.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the WS call is successful.
+ */
+ async logViewLesson(id: number, password?: string, name?: string, siteId?: string): Promise {
+ const params: AddonModLessonViewLessonWSParams = {
+ lessonid: id,
+ };
+
+ if (typeof password == 'string') {
+ params.password = password;
+ }
+
+ await CoreCourseLogHelper.instance.logSingle(
+ 'mod_lesson_view_lesson',
+ params,
+ AddonModLessonProvider.COMPONENT,
+ id,
+ name,
+ 'lesson',
+ {},
+ siteId,
+ );
+ }
+
+ /**
+ * Process a lesson page, saving its data.
+ *
+ * @param lesson Lesson.
+ * @param courseId Course ID the lesson belongs to.
+ * @param pageData Page data for the page to process.
+ * @param data Data to save.
+ * @param options Other options.
+ * @return Promise resolved when done.
+ */
+ async processPage(
+ lesson: AddonModLessonLessonWSData,
+ courseId: number,
+ pageData: AddonModLessonGetPageDataWSResponse,
+ data: Record,
+ options: AddonModLessonProcessPageOptions = {},
+ ): Promise {
+
+ options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId();
+ if (!pageData.page) {
+ throw new CoreError('Page data not supplied.');
+ }
+
+ const page = pageData.page;
+ const pageId = page.id;
+
+ if (!options.offline) {
+ const response = await this.processPageOnline(lesson.id, pageId, data, options);
+
+ CoreEvents.trigger(AddonModLessonProvider.DATA_SENT_EVENT, {
+ lessonId: lesson.id,
+ type: 'process',
+ courseId: courseId,
+ pageId: pageId,
+ review: options.review,
+ }, CoreSites.instance.getCurrentSiteId());
+
+ response.sent = true;
+
+ return response;
+ }
+
+ if (!options.accessInfo || !options.jumps) {
+ throw new CoreError('Access info or jumps not supplied to processPage.');
+ }
+
+ // Get the list of pages of the lesson.
+ const pages = await this.getPages(lesson.id, {
+ cmId: lesson.coursemodule,
+ password: options.password,
+ readingStrategy: CoreSitesReadingStrategy.PreferCache,
+ siteId: options.siteId,
+ });
+
+ const pageIndex = this.createPagesIndex(pages);
+ const result: AddonModLessonProcessPageResponse = {
+ newpageid: data.newpageid,
+ inmediatejump: false,
+ nodefaultresponse: false,
+ feedback: '',
+ attemptsremaining: null,
+ correctanswer: false,
+ noanswer: false,
+ isessayquestion: false,
+ maxattemptsreached: false,
+ response: '',
+ studentanswer: '',
+ userresponse: '',
+ reviewmode: false,
+ ongoingscore: '',
+ progress: null,
+ displaymenu: false,
+ messages: [],
+ };
+
+ if (pageData.answers.length) {
+ const recordAttemptResult = await this.recordAttempt(
+ lesson,
+ courseId,
+ pageData,
+ data,
+ !!options.review,
+ options.accessInfo,
+ options.jumps,
+ pageIndex,
+ options.siteId,
+ );
+
+ Object.assign(result, recordAttemptResult);
+ } else {
+ // If no answers, progress to the next page (as set by newpageid).
+ result.nodefaultresponse = true;
+ }
+
+ result.newpageid = this.getNewPageId(pageData.page.id, result.newpageid, options.jumps);
+
+ // Calculate some needed offline data.
+ const calculatedData = await this.calculateOfflineData(lesson, {
+ accessInfo: options.accessInfo,
+ password: options.password,
+ review: options.review,
+ pageIndex,
+ siteId: options.siteId,
+ });
+
+ // Add some default data to match the WS response.
+ return {
+ ...result,
+ ...calculatedData,
+ displaymenu: pageData.displaymenu, // Keep the same value since we can't calculate it in offline.
+ messages: this.getPageProcessMessages(lesson, options.accessInfo, result, !!options.review, options.jumps),
+ warnings: [],
+ sent: false,
+ };
+ }
+
+ /**
+ * Process a lesson page, saving its data. It will fail if offline or cannot connect.
+ *
+ * @param lessonId Lesson ID.
+ * @param pageId Page ID.
+ * @param data Data to save.
+ * @param options Other options.
+ * @return Promise resolved in success, rejected otherwise.
+ */
+ async processPageOnline(
+ lessonId: number,
+ pageId: number,
+ data: Record,
+ options: AddonModLessonProcessPageOnlineOptions = {},
+ ): Promise {
+
+ const site = await CoreSites.instance.getSite(options.siteId);
+
+ const params: AddonModLessonProcessPageWSParams = {
+ lessonid: lessonId,
+ pageid: pageId,
+ data: CoreUtils.instance.objectToArrayOfObjects(data, 'name', 'value', true),
+ review: !!options.review,
+ };
+
+ if (typeof options.password == 'string') {
+ params.password = options.password;
+ }
+
+ return site.write('mod_lesson_process_page', params);
+ }
+
+ /**
+ * Records an attempt on a certain page.
+ * Based on Moodle's record_attempt.
+ *
+ * @param lesson Lesson.
+ * @param courseId Course ID the lesson belongs to.
+ * @param pageData Page data for the page to process.
+ * @param data Data to save.
+ * @param review If the user wants to review just after finishing (1 hour margin).
+ * @param accessInfo Access info.
+ * @param jumps Possible jumps.
+ * @param pageIndex Object containing all the pages indexed by ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with the result.
+ */
+ protected async recordAttempt(
+ lesson: AddonModLessonLessonWSData,
+ courseId: number,
+ pageData: AddonModLessonGetPageDataWSResponse,
+ data: Record,
+ review: boolean,
+ accessInfo: AddonModLessonGetAccessInformationWSResponse,
+ jumps: AddonModLessonPossibleJumps,
+ pageIndex: Record,
+ siteId?: string,
+ ): Promise {
+
+ if (!pageData.page) {
+ throw new CoreError('Page data not supplied.');
+ }
+
+ // Check the user answer. Each page type has its own implementation.
+ const result: AddonModLessonRecordAttemptResult = this.checkAnswer(lesson, pageData, data, jumps, pageIndex);
+ const retake = accessInfo.attemptscount;
+
+ // Processes inmediate jumps.
+ if (result.inmediatejump) {
+ if (pageData.page?.qtype == AddonModLessonProvider.LESSON_PAGE_BRANCHTABLE) {
+ // Store the content page data. In Moodle this is stored in a separate table, during checkAnswer.
+ await AddonModLessonOffline.instance.processPage(
+ lesson.id,
+ courseId,
+ retake,
+ pageData.page,
+ data,
+ result.newpageid,
+ result.answerid,
+ false,
+ result.userresponse,
+ siteId,
+ );
+
+ return result;
+ }
+
+ return result;
+ }
+
+ let nAttempts: number | undefined;
+ result.attemptsremaining = 0;
+ result.maxattemptsreached = false;
+
+ if (result.noanswer) {
+ result.newpageid = pageData.page.id; // Display same page again.
+ result.feedback = Translate.instance.instant('addon.mod_lesson.noanswer');
+
+ return result;
+ }
+
+ if (!accessInfo.canmanage) {
+ // Get the number of attempts that have been made on this question for this student and retake.
+ const attempts = await this.getQuestionsAttempts(lesson.id, retake, {
+ cmId: lesson.coursemodule,
+ pageId: pageData.page.id,
+ siteId,
+ });
+
+ nAttempts = attempts.online.length + attempts.offline.length;
+
+ // Check if they have reached (or exceeded) the maximum number of attempts allowed.
+ if (lesson.maxattempts && lesson.maxattempts > 0 && nAttempts >= lesson.maxattempts) {
+ result.maxattemptsreached = true;
+ result.feedback = Translate.instance.instant('addon.mod_lesson.maximumnumberofattemptsreached');
+ result.newpageid = AddonModLessonProvider.LESSON_NEXTPAGE;
+
+ return result;
+ }
+
+ // Only insert a record if we are not reviewing the lesson.
+ if (!review && (lesson.retake || (!lesson.retake && !retake))) {
+ // Store the student's attempt and increase the number of attempts made.
+ // Calculate and store the new page ID to prevent having to recalculate it later.
+ const newPageId = this.getNewPageId(pageData.page.id, result.newpageid, jumps);
+
+ await AddonModLessonOffline.instance.processPage(
+ lesson.id,
+ courseId,
+ retake,
+ pageData.page,
+ data,
+ newPageId,
+ result.answerid,
+ result.correctanswer,
+ result.userresponse,
+ siteId,
+ );
+
+ nAttempts++;
+ }
+
+ // Check if "number of attempts remaining" message is needed.
+ if (!result.correctanswer && !result.newpageid) {
+ // Retreive the number of attempts left counter.
+ if (lesson.maxattempts && lesson.maxattempts > 0 && nAttempts >= lesson.maxattempts) {
+ if (lesson.maxattempts > 1) { // Don't bother with message if only one attempt.
+ result.maxattemptsreached = true;
+ }
+ result.newpageid = AddonModLessonProvider.LESSON_NEXTPAGE;
+ } else if (lesson.maxattempts && lesson.maxattempts > 1) { // Don't show message if only one attempt or unlimited.
+ result.attemptsremaining = lesson.maxattempts - nAttempts;
+ }
+ }
+ }
+
+ // Determine default feedback if necessary.
+ if (!result.response) {
+ if (!lesson.feedback && !result.noanswer && !(lesson.review && !result.correctanswer && !result.isessayquestion)) {
+ // These conditions have been met:
+ // 1. The lesson manager has not supplied feedback to the student.
+ // 2. Not displaying default feedback.
+ // 3. The user did provide an answer.
+ // 4. We are not reviewing with an incorrect answer (and not reviewing an essay question).
+ result.nodefaultresponse = true;
+ } else if (result.isessayquestion) {
+ result.response = Translate.instance.instant('addon.mod_lesson.defaultessayresponse');
+ } else if (result.correctanswer) {
+ result.response = Translate.instance.instant('addon.mod_lesson.thatsthecorrectanswer');
+ } else {
+ result.response = Translate.instance.instant('addon.mod_lesson.thatsthewronganswer');
+ }
+ }
+
+ if (!result.response) {
+ return result;
+ }
+
+ if (lesson.review && !result.correctanswer && !result.isessayquestion) {
+ // Calculate the number of question attempt in the page if it isn't calculated already.
+ if (typeof nAttempts == 'undefined') {
+ const result = await this.getQuestionsAttempts(lesson.id, retake, {
+ cmId: lesson.coursemodule,
+ pageId: pageData.page.id,
+ siteId,
+ });
+
+ nAttempts = result.online.length + result.offline.length;
+ }
+
+ const messageId = nAttempts == 1 ? 'firstwrong' : 'secondpluswrong';
+
+ result.feedback = '' + Translate.instance.instant('addon.mod_lesson.' + messageId) + '
';
+ } else {
+ result.feedback = '';
+ }
+
+ let className = 'response';
+ if (result.correctanswer) {
+ className += ' correct';
+ } else if (!result.isessayquestion) {
+ className += ' incorrect';
+ }
+
+ result.feedback += '' + pageData.page.contents + '
';
+ result.feedback += '' +
+ Translate.instance.instant('addon.mod_lesson.youranswer') + ' : ' +
+ '
';
+
+ // Create a table containing the answers and responses.
+ if (pageData.page.qoption) {
+ // Multianswer allowed.
+ const studentAnswerArray = result.studentanswer ?
+ result.studentanswer.split(AddonModLessonProvider.MULTIANSWER_DELIMITER) : [];
+ const responseArray = result.response ? result.response.split(AddonModLessonProvider.MULTIANSWER_DELIMITER) : [];
+
+ // Add answers and responses to the table.
+ for (let i = 0; i < studentAnswerArray.length; i++) {
+ result.feedback = this.addAnswerAndResponseToFeedback(
+ result.feedback,
+ studentAnswerArray[i],
+ result.studentanswerformat || 1,
+ responseArray[i],
+ className,
+ );
+ }
+ } else {
+ // Only 1 answer, add it to the table.
+ result.feedback = this.addAnswerAndResponseToFeedback(
+ result.feedback,
+ result.studentanswer,
+ result.studentanswerformat || 1,
+ result.response,
+ className,
+ );
+ }
+
+ result.feedback += '
';
+
+ return result;
+ }
+
+ /**
+ * Remove a password stored in DB.
+ *
+ * @param lessonId Lesson ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when removed.
+ */
+ async removeStoredPassword(lessonId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.getDb().deleteRecords(PASSWORD_TABLE_NAME, { lessonid: lessonId });
+ }
+
+ /**
+ * Store a password in DB.
+ *
+ * @param lessonId Lesson ID.
+ * @param password Password to store.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when stored.
+ */
+ async storePassword(lessonId: number, password: string, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ const entry: AddonModLessonPasswordDBRecord = {
+ lessonid: lessonId,
+ password: password,
+ timemodified: Date.now(),
+ };
+
+ await site.getDb().insertRecord(PASSWORD_TABLE_NAME, entry);
+ }
+
+ /**
+ * Function to determine if a page is a valid page. It will add the page to validPages if valid. It can also
+ * modify the list of viewedPagesIds for cluster pages.
+ * Based on Moodle's valid_page_and_view.
+ *
+ * @param pages Index of lesson pages, indexed by page ID. See createPagesIndex.
+ * @param page Page to check.
+ * @param validPages Valid pages, indexed by page ID.
+ * @param viewedPagesIds List of viewed pages IDs.
+ * @return Next page ID.
+ */
+ validPageAndView(
+ pages: Record,
+ page: AddonModLessonPageWSData,
+ validPages: Record,
+ viewedPagesIds: number[],
+ ): number {
+
+ if (page.qtype != AddonModLessonProvider.LESSON_PAGE_ENDOFCLUSTER &&
+ page.qtype != AddonModLessonProvider.LESSON_PAGE_ENDOFBRANCH) {
+ // Add this page as a valid page.
+ validPages[page.id] = 1;
+ }
+
+ if (page.qtype == AddonModLessonProvider.LESSON_PAGE_CLUSTER) {
+ // Get list of pages in the cluster.
+ const subPages = this.getSubpagesOf(pages, page.id, [AddonModLessonProvider.LESSON_PAGE_ENDOFCLUSTER]);
+
+ subPages.forEach((subPage) => {
+ const position = viewedPagesIds.indexOf(subPage.id);
+ if (position == -1) {
+ return;
+ }
+
+ delete viewedPagesIds[position]; // Remove it.
+
+ // Since the user did see one page in the cluster, add the cluster pageid to the viewedPagesIds.
+ if (viewedPagesIds.indexOf(page.id) == -1) {
+ viewedPagesIds.push(page.id);
+ }
+ });
+ }
+
+ return page.nextpageid;
+ }
+
+}
+
+export class AddonModLesson extends makeSingleton(AddonModLessonProvider) {}
+
+/**
+ * Result of check answer.
+ */
+export type AddonModLessonCheckAnswerResult = {
+ answerid: number;
+ noanswer: boolean;
+ correctanswer: boolean;
+ isessayquestion: boolean;
+ response: string;
+ newpageid: number;
+ studentanswer: string;
+ userresponse: unknown;
+ feedback?: string;
+ nodefaultresponse?: boolean;
+ inmediatejump?: boolean;
+ studentanswerformat?: number;
+ useranswer?: unknown;
+};
+
+/**
+ * Result of record attempt.
+ */
+export type AddonModLessonRecordAttemptResult = AddonModLessonCheckAnswerResult & {
+ attemptsremaining?: number;
+ maxattemptsreached?: boolean;
+};
+
+/**
+ * Result of lesson grade.
+ */
+export type AddonModLessonGrade = {
+ /**
+ * Number of questions answered.
+ */
+ nquestions: number;
+
+ /**
+ * Number of question attempts.
+ */
+ attempts: number;
+
+ /**
+ * Max points possible.
+ */
+ total: number;
+
+ /**
+ * Points earned by the student.
+ */
+ earned: number;
+
+ /**
+ * Calculated percentage grade.
+ */
+ grade: number;
+
+ /**
+ * Numer of manually graded questions.
+ */
+ nmanual: number;
+
+ /**
+ * Point value for manually graded questions.
+ */
+ manualpoints: number;
+};
+
+/**
+ * Common options including a group ID.
+ */
+export type AddonModLessonGroupOptions = CoreCourseCommonModWSOptions & {
+ groupId?: number; // The group to get. If not defined, all participants.
+};
+
+/**
+ * Common options including a group ID.
+ */
+export type AddonModLessonUserOptions = CoreCourseCommonModWSOptions & {
+ userId?: number; // User ID. If not defined, site's current user.
+};
+
+/**
+ * Common options including a password.
+ */
+export type AddonModLessonPasswordOptions = CoreCourseCommonModWSOptions & {
+ password?: string; // Lesson password (if any).
+};
+
+/**
+ * Common options including password and review.
+ */
+export type AddonModLessonPwdReviewOptions = AddonModLessonPasswordOptions & {
+ review?: boolean; // If the user wants to review just after finishing (1 hour margin).
+};
+
+/**
+ * Options to pass to get lesson with password.
+ */
+export type AddonModLessonGetWithPasswordOptions = AddonModLessonPasswordOptions & {
+ validatePassword?: boolean; // Defauls to true. If true, the function will fail if the password is wrong.
+};
+
+/**
+ * Options to pass to calculateProgress.
+ */
+export type AddonModLessonCalculateProgressBasicOptions = {
+ password?: string; // Lesson password (if any).
+ review?: boolean; // If the user wants to review just after finishing (1 hour margin).
+ pageIndex?: Record; // Page index. If not provided, it will be calculated.
+ siteId?: string; // Site ID. If not defined, current site.
+};
+
+/**
+ * Options to pass to calculateProgress.
+ */
+export type AddonModLessonCalculateProgressOptions = AddonModLessonCalculateProgressBasicOptions & {
+ cmId?: number; // Module ID.
+};
+
+/**
+ * Options to pass to lessonGrade.
+ */
+export type AddonModLessonGradeOptions = AddonModLessonCalculateProgressBasicOptions & {
+ userId?: number; // User ID. If not defined, site's user.
+};
+
+/**
+ * Options to pass to calculateOfflineData.
+ */
+export type AddonModLessonCalculateOfflineDataOptions = AddonModLessonCalculateProgressBasicOptions & {
+ accessInfo?: AddonModLessonGetAccessInformationWSResponse; // Access info.
+};
+
+/**
+ * Options to pass to get page data.
+ */
+export type AddonModLessonGetPageDataOptions = AddonModLessonPwdReviewOptions & {
+ includeContents?: boolean; // Include the page rendered contents.
+ includeOfflineData?: boolean; // Whether to include calculated offline data. Only when ignoring cache.
+ accessInfo?: AddonModLessonGetAccessInformationWSResponse; // Access info. Required if includeOfflineData is true.
+ jumps?: AddonModLessonPossibleJumps; // Possible jumps. Required if includeOfflineData is true.
+};
+
+/**
+ * Options to pass to get page data.
+ */
+export type AddonModLessonGetPageViewMessagesOptions = {
+ password?: string; // Lesson password (if any).
+ review?: boolean; // If the user wants to review just after finishing (1 hour margin).
+ siteId?: string; // Site ID. If not defined, current site.
+};
+
+/**
+ * Options to pass to get questions attempts.
+ */
+export type AddonModLessonGetQuestionsAttemptsOptions = CoreCourseCommonModWSOptions & {
+ correct?: boolean; // True to only fetch correct attempts, false to get them all.
+ pageId?: number; // If defined, only get attempts on this page.
+ userId?: number; // User ID. If not defined, site's user.
+};
+
+/**
+ * Options to pass to getPagesIdsWithQuestionAttempts.
+ */
+export type AddonModLessonGetPagesIdsWithAttemptsOptions = CoreCourseCommonModWSOptions & {
+ correct?: boolean; // True to only fetch correct attempts, false to get them all.
+ userId?: number; // User ID. If not defined, site's user.
+};
+
+/**
+ * Options to pass to processPageOnline.
+ */
+export type AddonModLessonProcessPageOnlineOptions = {
+ password?: string; // Lesson password (if any).
+ review?: boolean; // If the user wants to review just after finishing (1 hour margin).
+ siteId?: string; // Site ID. If not defined, current site.
+};
+
+/**
+ * Options to pass to processPage.
+ */
+export type AddonModLessonProcessPageOptions = AddonModLessonProcessPageOnlineOptions & {
+ offline?: boolean; // Whether it's offline mode.
+ accessInfo?: AddonModLessonGetAccessInformationWSResponse; // Access info. Required if offline is true.
+ jumps?: AddonModLessonPossibleJumps; // Possible jumps. Required if offline is true.
+};
+
+/**
+ * Options to pass to finishRetakeOnline.
+ */
+export type AddonModLessonFinishRetakeOnlineOptions = {
+ password?: string; // Lesson password (if any).
+ outOfTime?: boolean; // Whether the user ran out of time.
+ review?: boolean; // If the user wants to review just after finishing (1 hour margin).
+ siteId?: string; // Site ID. If not defined, current site.
+};
+
+/**
+ * Options to pass to finishRetake.
+ */
+export type AddonModLessonFinishRetakeOptions = AddonModLessonFinishRetakeOnlineOptions & {
+ offline?: boolean; // Whether it's offline mode.
+ accessInfo?: AddonModLessonGetAccessInformationWSResponse; // Access info. Required if offline is true.
+};
+
+/**
+ * Params of mod_lesson_get_lesson_access_information WS.
+ */
+export type AddonModLessonGetAccessInformationWSParams = {
+ lessonid: number; // Lesson instance id.
+};
+
+/**
+ * Data returned by mod_lesson_get_lesson_access_information WS.
+ */
+export type AddonModLessonGetAccessInformationWSResponse = {
+ canmanage: boolean; // Whether the user can manage the lesson or not.
+ cangrade: boolean; // Whether the user can grade the lesson or not.
+ canviewreports: boolean; // Whether the user can view the lesson reports or not.
+ reviewmode: boolean; // Whether the lesson is in review mode for the current user.
+ attemptscount: number; // The number of attempts done by the user.
+ lastpageseen: number; // The last page seen id.
+ leftduringtimedsession: boolean; // Whether the user left during a timed session.
+ firstpageid: number; // The lesson first page id.
+ preventaccessreasons: AddonModLessonPreventAccessReason[];
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Prevent access reason returned by mod_lesson_get_lesson_access_information.
+ */
+export type AddonModLessonPreventAccessReason = {
+ reason: string; // Reason lang string code.
+ data: string; // Additional data.
+ message: string; // Complete html message.
+};
+
+/**
+ * Params of mod_lesson_get_content_pages_viewed WS.
+ */
+export type AddonModLessonGetContentPagesViewedWSParams = {
+ lessonid: number; // Lesson instance id.
+ lessonattempt: number; // Lesson attempt number.
+ userid?: number; // The user id (empty for current user).
+};
+
+/**
+ * Data returned by mod_lesson_get_content_pages_viewed WS.
+ */
+export type AddonModLessonGetContentPagesViewedWSResponse = {
+ pages: AddonModLessonWSContentPageViewed[];
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Page data returned in mod_lesson_get_content_pages_viewed WS.
+ */
+export type AddonModLessonWSContentPageViewed = {
+ id: number; // The attempt id.
+ lessonid: number; // The lesson id.
+ pageid: number; // The page id.
+ userid: number; // The user who viewed the page.
+ retry: number; // The lesson attempt number.
+ flag: number; // 1 if the next page was calculated randomly.
+ timeseen: number; // The time the page was seen.
+ nextpageid: number; // The next page chosen id.
+};
+
+/**
+ * Params of mod_lesson_get_lessons_by_courses WS.
+ */
+export type AddonModLessonGetLessonsByCoursesWSParams = {
+ courseids?: number[]; // Array of course ids.
+};
+
+/**
+ * Data returned by mod_lesson_get_lessons_by_courses WS.
+ */
+export type AddonModLessonGetLessonsByCoursesWSResponse = {
+ lessons: AddonModLessonLessonWSData[];
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Lesson data returned by WS.
+ */
+export type AddonModLessonLessonWSData = {
+ id: number; // Standard Moodle primary key.
+ course: number; // Foreign key reference to the course this lesson is part of.
+ coursemodule: number; // Course module id.
+ name: string; // Lesson name.
+ intro?: string; // Lesson introduction text.
+ introformat?: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
+ practice?: boolean; // Practice lesson?.
+ modattempts?: boolean; // Allow student review?.
+ usepassword?: boolean; // Password protected lesson?.
+ password?: string; // Password.
+ dependency?: number; // Dependent on (another lesson id).
+ conditions?: string; // Conditions to enable the lesson.
+ grade?: number; // The total that the grade is scaled to be out of.
+ custom?: boolean; // Custom scoring?.
+ ongoing?: boolean; // Display ongoing score?.
+ usemaxgrade?: number; // How to calculate the final grade.
+ maxanswers?: number; // Maximum answers per page.
+ maxattempts?: number; // Maximum attempts.
+ review?: boolean; // Provide option to try a question again.
+ nextpagedefault?: number; // Action for a correct answer.
+ feedback?: boolean; // Display default feedback.
+ minquestions?: number; // Minimum number of questions.
+ maxpages?: number; // Number of pages to show.
+ timelimit?: number; // Time limit.
+ retake?: boolean; // Re-takes allowed.
+ activitylink?: number; // Id of the next activity to be linked once the lesson is completed.
+ mediafile?: string; // Local file path or full external URL.
+ mediaheight?: number; // Popup for media file height.
+ mediawidth?: number; // Popup for media with.
+ mediaclose?: number; // Display a close button in the popup?.
+ slideshow?: boolean; // Display lesson as slideshow.
+ width?: number; // Slideshow width.
+ height?: number; // Slideshow height.
+ bgcolor?: string; // Slideshow bgcolor.
+ displayleft?: boolean; // Display left pages menu?.
+ displayleftif?: number; // Minimum grade to display menu.
+ progressbar?: boolean; // Display progress bar?.
+ available?: number; // Available from.
+ deadline?: number; // Available until.
+ timemodified?: number; // Last time settings were updated.
+ completionendreached?: number; // Require end reached for completion?.
+ completiontimespent?: number; // Student must do this activity at least for.
+ allowofflineattempts: boolean; // Whether to allow the lesson to be attempted offline in the mobile app.
+ introfiles?: { // Introfiles.
+ filename?: string; // File name.
+ filepath?: string; // File path.
+ filesize?: number; // File size.
+ fileurl: string; // Downloadable file url.
+ timemodified?: number; // Time modified.
+ mimetype?: string; // File mime type.
+ isexternalfile?: number; // Whether is an external file.
+ repositorytype?: string; // The repository type for the external files.
+ }[];
+ mediafiles?: { // Mediafiles.
+ filename?: string; // File name.
+ filepath?: string; // File path.
+ filesize?: number; // File size.
+ fileurl: string; // Downloadable file url.
+ timemodified?: number; // Time modified.
+ mimetype?: string; // File mime type.
+ isexternalfile?: number; // Whether is an external file.
+ repositorytype?: string; // The repository type for the external files.
+ }[];
+};
+
+/**
+ * Params of mod_lesson_get_lesson WS.
+ */
+export type AddonModLessonGetLessonWSParams = {
+ lessonid: number; // Lesson instance id.
+ password?: string; // Lesson password.
+};
+
+/**
+ * Data returned by mod_lesson_get_lesson WS.
+ */
+export type AddonModLessonGetLessonWSResponse = {
+ lesson: AddonModLessonLessonWSData;
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Params of mod_lesson_get_page_data WS.
+ */
+export type AddonModLessonGetPageDataWSParams = {
+ lessonid: number; // Lesson instance id.
+ pageid: number; // The page id.
+ password?: string; // Optional password (the lesson may be protected).
+ review?: boolean; // If we want to review just after finishing (1 hour margin).
+ returncontents?: boolean; // If we must return the complete page contents once rendered.
+};
+
+/**
+ * Data returned by mod_lesson_get_page_data WS.
+ */
+export type AddonModLessonGetPageDataWSResponse = {
+ page?: AddonModLessonPageWSData; // Page fields.
+ newpageid: number; // New page id (if a jump was made).
+ pagecontent?: string; // Page html content.
+ ongoingscore: string; // The ongoing score message.
+ progress: number; // Progress percentage in the lesson.
+ contentfiles: CoreWSExternalFile[];
+ answers: AddonModLessonPageAnswerWSData[];
+ messages: AddonModLessonMessageWSData[];
+ displaymenu: boolean; // Whether we should display the menu or not in this page.
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Page data returned by several WS.
+ */
+export type AddonModLessonPageWSData = {
+ id: number; // The id of this lesson page.
+ lessonid: number; // The id of the lesson this page belongs to.
+ prevpageid: number; // The id of the page before this one.
+ nextpageid: number; // The id of the next page in the page sequence.
+ qtype: number; // Identifies the page type of this page.
+ qoption: number; // Used to record page type specific options.
+ layout: number; // Used to record page specific layout selections.
+ display: number; // Used to record page specific display selections.
+ timecreated: number; // Timestamp for when the page was created.
+ timemodified: number; // Timestamp for when the page was last modified.
+ title?: string; // The title of this page.
+ contents?: string; // The contents of this page.
+ contentsformat?: number; // Contents format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
+ displayinmenublock: boolean; // Toggles display in the left menu block.
+ type: number; // The type of the page [question | structure].
+ typeid: number; // The unique identifier for the page type.
+ typestring: string; // The string that describes this page type.
+};
+
+/**
+ * Page answer data returned by mod_lesson_get_page_data.
+ */
+export type AddonModLessonPageAnswerWSData = {
+ id: number; // The ID of this answer in the database.
+ answerfiles: CoreWSExternalFile[];
+ responsefiles: CoreWSExternalFile[];
+ jumpto?: number; // Identifies where the user goes upon completing a page with this answer.
+ grade?: number; // The grade this answer is worth.
+ score?: number; // The score this answer will give.
+ flags?: number; // Used to store options for the answer.
+ timecreated?: number; // A timestamp of when the answer was created.
+ timemodified?: number; // A timestamp of when the answer was modified.
+ answer?: string; // Possible answer text.
+ answerformat?: number; // Answer format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
+ response?: string; // Response text for the answer.
+ responseformat?: number; // Response format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
+};
+
+/**
+ * Page answer data with some calculated data.
+ */
+export type AddonModLessonPageAnswerData = AddonModLessonPageAnswerWSData & {
+ pageid: number;
+};
+
+/**
+ * Message data returned by several WS.
+ */
+export type AddonModLessonMessageWSData = {
+ message: string; // Message.
+ type: string; // Message type: usually a CSS identifier like: success, info, warning, error, ...
+};
+
+/**
+ * Params of mod_lesson_get_pages WS.
+ */
+export type AddonModLessonGetPagesWSParams = {
+ lessonid: number; // Lesson instance id.
+ password?: string; // Optional password (the lesson may be protected).
+};
+
+/**
+ * Data returned by mod_lesson_get_pages WS.
+ */
+export type AddonModLessonGetPagesWSResponse = {
+ pages: AddonModLessonGetPagesPageWSData[];
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Data for each page returned by mod_lesson_get_pages WS.
+ */
+export type AddonModLessonGetPagesPageWSData = {
+ page: AddonModLessonPageWSData; // Page fields.
+ answerids: number[]; // List of answers ids (empty for content pages in Moodle 1.9).
+ jumps: number[]; // List of possible page jumps.
+ filescount: number; // The total number of files attached to the page.
+ filessizetotal: number; // The total size of the files.
+};
+
+/**
+ * Params of mod_lesson_get_pages_possible_jumps WS.
+ */
+export type AddonModLessonGetPagesPossibleJumpsWSParams = {
+ lessonid: number; // Lesson instance id.
+};
+
+/**
+ * Data returned by mod_lesson_get_pages_possible_jumps WS.
+ */
+export type AddonModLessonGetPagesPossibleJumpsWSResponse = {
+ jumps: AddonModLessonPossibleJumpWSData[];
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Data for each jump returned by mod_lesson_get_pages_possible_jumps WS.
+ */
+export type AddonModLessonPossibleJumpWSData = {
+ pageid: number; // The page id.
+ answerid: number; // The answer id.
+ jumpto: number; // The jump (page id or type of jump).
+ calculatedjump: number; // The real page id (or EOL) to jump.
+};
+
+/**
+ * Lesson possible jumps, indexed by page and jumpto.
+ */
+export type AddonModLessonPossibleJumps = Record>;
+
+/**
+ * Params of mod_lesson_get_questions_attempts WS.
+ */
+export type AddonModLessonGetQuestionsAttemptsWSParams = {
+ lessonid: number; // Lesson instance id.
+ attempt: number; // Lesson attempt number.
+ correct?: boolean; // Only fetch correct attempts.
+ pageid?: number; // Only fetch attempts at the given page.
+ userid?: number; // Only fetch attempts of the given user.
+};
+
+/**
+ * Data returned by mod_lesson_get_questions_attempts WS.
+ */
+export type AddonModLessonGetQuestionsAttemptsWSResponse = {
+ attempts: AddonModLessonQuestionAttemptWSData[];
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Data for each attempt returned by mod_lesson_get_questions_attempts WS.
+ */
+export type AddonModLessonQuestionAttemptWSData = {
+ id: number; // The attempt id.
+ lessonid: number; // The attempt lessonid.
+ pageid: number; // The attempt pageid.
+ userid: number; // The user who did the attempt.
+ answerid: number; // The attempt answerid.
+ retry: number; // The lesson attempt number.
+ correct: number; // If it was the correct answer.
+ useranswer: string; // The complete user answer.
+ timeseen: number; // The time the question was seen.
+};
+
+/**
+ * Params of mod_lesson_get_attempts_overview WS.
+ */
+export type AddonModLessonGetAttemptsOverviewWSParams = {
+ lessonid: number; // Lesson instance id.
+ groupid?: number; // Group id, 0 means that the function will determine the user group.
+};
+
+/**
+ * Data returned by mod_lesson_get_attempts_overview WS.
+ */
+export type AddonModLessonGetAttemptsOverviewWSResponse = {
+ data?: AddonModLessonAttemptsOverviewWSData; // Attempts overview data (empty for no attemps).
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Overview data returned by mod_lesson_get_attempts_overview WS.
+ */
+export type AddonModLessonAttemptsOverviewWSData = {
+ lessonscored: boolean; // True if the lesson was scored.
+ numofattempts: number; // Number of attempts.
+ avescore: number; // Average score.
+ highscore: number; // High score.
+ lowscore: number; // Low score.
+ avetime: number; // Average time (spent in taking the lesson).
+ hightime: number; // High time.
+ lowtime: number; // Low time.
+ students?: AddonModLessonAttemptsOverviewsStudentWSData[]; // Students data, including attempts.
+};
+
+/**
+ * Student data returned by mod_lesson_get_attempts_overview WS.
+ */
+export type AddonModLessonAttemptsOverviewsStudentWSData = {
+ id: number; // User id.
+ fullname: string; // User full name.
+ bestgrade: number; // Best grade.
+ attempts: AddonModLessonAttemptsOverviewsAttemptWSData[];
+};
+
+/**
+ * Attempt data returned by mod_lesson_get_attempts_overview WS.
+ */
+export type AddonModLessonAttemptsOverviewsAttemptWSData = {
+ try: number; // Attempt number.
+ grade: number; // Attempt grade.
+ timestart: number; // Attempt time started.
+ timeend: number; // Attempt last time continued.
+ end: number; // Attempt time ended.
+};
+
+/**
+ * Params of mod_lesson_get_user_timers WS.
+ */
+export type ModLessonGetUserTimersWSParams = {
+ lessonid: number; // Lesson instance id.
+ userid?: number; // The user id (empty for current user).
+};
+
+/**
+ * Data returned by mod_lesson_get_user_timers WS.
+ */
+export type ModLessonGetUserTimersWSResponse = {
+ timers: AddonModLessonUserTimerWSData[];
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Data for each timer returned by mod_lesson_get_user_timers WS.
+ */
+export type AddonModLessonUserTimerWSData = {
+ id: number; // The attempt id.
+ lessonid: number; // The lesson id.
+ userid: number; // The user id.
+ starttime: number; // First access time for a new timer session.
+ lessontime: number; // Last access time to the lesson during the timer session.
+ completed: number; // If the lesson for this timer was completed.
+ timemodifiedoffline: number; // Last modified time via webservices.
+};
+
+/**
+ * Params of mod_lesson_get_user_attempt WS.
+ */
+export type AddonModLessonGetUserAttemptWSParams = {
+ lessonid: number; // Lesson instance id.
+ userid: number; // The user id. 0 for current user.
+ lessonattempt: number; // The attempt number.
+};
+
+/**
+ * Data returned by mod_lesson_get_user_attempt WS.
+ */
+export type AddonModLessonGetUserAttemptWSResponse = {
+ answerpages: AddonModLessonUserAttemptAnswerPageWSData[];
+ userstats: {
+ grade: number; // Attempt final grade.
+ completed: number; // Time completed.
+ timetotake: number; // Time taken.
+ gradeinfo?: AddonModLessonAttemptGradeWSData; // Attempt grade.
+ };
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Answer page data returned by mod_lesson_get_user_attempt.
+ */
+export type AddonModLessonUserAttemptAnswerPageWSData = {
+ page?: AddonModLessonPageWSData; // Page fields.
+ title: string; // Page title.
+ contents: string; // Page contents.
+ qtype: string; // Identifies the page type of this page.
+ grayout: number; // If is required to apply a grayout.
+ answerdata?: {
+ score: string; // The score (text version).
+ response: string; // The response text.
+ responseformat: number; // Response. format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
+ answers?: string[][]; // User answers.
+ }; // Answer data (empty in content pages created in Moodle 1.x).
+};
+
+/**
+ * Attempt grade returned by several WS.
+ */
+export type AddonModLessonAttemptGradeWSData = {
+ nquestions: number; // Number of questions answered.
+ attempts: number; // Number of question attempts.
+ total: number; // Max points possible.
+ earned: number; // Points earned by student.
+ grade: number; // Calculated percentage grade.
+ nmanual: number; // Number of manually graded questions.
+ manualpoints: number; // Point value for manually graded questions.
+};
+
+/**
+ * Params of mod_lesson_finish_attempt WS.
+ */
+export type AddonModLessonFinishAttemptWSParams = {
+ lessonid: number; // Lesson instance id.
+ password?: string; // Optional password (the lesson may be protected).
+ outoftime?: boolean; // If the user run out of time.
+ review?: boolean; // If we want to review just after finishing (1 hour margin).
+};
+
+/**
+ * Data returned by mod_lesson_finish_attempt WS.
+ */
+export type AddonModLessonFinishAttemptWSResponse = {
+ data: AddonModLessonEOLPageWSDataEntry[]; // The EOL page information data.
+ messages: AddonModLessonMessageWSData[];
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * EOL page data entry returned by mod_lesson_finish_attempt WS.
+ */
+export type AddonModLessonEOLPageWSDataEntry = {
+ name: string; // Data name.
+ value: string; // Data value.
+ message: string; // Data message (translated string).
+};
+
+/**
+ * Finish retake response.
+ */
+export type AddonModLessonFinishRetakeResponse = Omit & {
+ data: Record;
+};
+
+/**
+ * Parsed EOL page data.
+ */
+export type AddonModLessonEOLPageDataEntry = {
+ name: string; // Data name.
+ value: unknown; // Data value.
+ message: string; // Data message (translated string).
+};
+
+/**
+ * Params of mod_lesson_view_lesson WS.
+ */
+export type AddonModLessonViewLessonWSParams = {
+ lessonid: number; // Lesson instance id.
+ password?: string; // Lesson password.
+};
+
+/**
+ * Params of mod_lesson_process_page WS.
+ */
+export type AddonModLessonProcessPageWSParams = {
+ lessonid: number; // Lesson instance id.
+ pageid: number; // The page id.
+ data: ProcessPageData[]; // The data to be saved.
+ password?: string; // Optional password (the lesson may be protected).
+ review?: boolean; // If we want to review just after finishing (1 hour margin).
+};
+
+type ProcessPageData = {
+ name: string; // Data name.
+ value: string; // Data value.
+};
+
+/**
+ * Data returned by mod_lesson_process_page WS.
+ */
+export type AddonModLessonProcessPageWSResponse = {
+ newpageid: number; // New page id (if a jump was made).
+ inmediatejump: boolean; // Whether the page processing redirect directly to anoter page.
+ nodefaultresponse: boolean; // Whether there is not a default response.
+ feedback: string; // The response feedback.
+ attemptsremaining: number | null; // Number of attempts remaining.
+ correctanswer: boolean; // Whether the answer is correct.
+ noanswer: boolean; // Whether there aren't answers.
+ isessayquestion: boolean; // Whether is a essay question.
+ maxattemptsreached: boolean; // Whether we reachered the max number of attempts.
+ response: string; // The response.
+ studentanswer: string; // The student answer.
+ userresponse: string; // The user response.
+ reviewmode: boolean; // Whether the user is reviewing.
+ ongoingscore: string; // The ongoing message.
+ progress: number | null; // Progress percentage in the lesson.
+ displaymenu: boolean; // Whether we should display the menu or not in this page.
+ messages: AddonModLessonMessageWSData[];
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Result of process page.
+ */
+export type AddonModLessonProcessPageResponse = AddonModLessonProcessPageWSResponse & {
+ sent?: boolean; // Whether the data was sent to server.
+};
+
+/**
+ * Params of mod_lesson_launch_attempt WS.
+ */
+export type AddonModLessonLaunchAttemptWSParams = {
+ lessonid: number; // Lesson instance id.
+ password?: string; // Optional password (the lesson may be protected).
+ pageid?: number; // Page id to continue from (only when continuing an attempt).
+ review?: boolean; // If we want to review just after finishing.
+};
+
+/**
+ * Data returned by mod_lesson_launch_attempt WS.
+ */
+export type AddonModLessonLaunchAttemptWSResponse = {
+ messages: AddonModLessonMessageWSData[];
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Attempt data, either online or offline attempt.
+ */
+export type AddonModLessonAnyAttemptData = AddonModLessonQuestionAttemptWSData | AddonModLessonPageAttemptRecord;
+
+/**
+ * Either content page data or page attempt offline record.
+ */
+export type AddonModLessonContentPageOrRecord = AddonModLessonWSContentPageViewed | AddonModLessonPageAttemptRecord;
+
+/**
+ * Data passed to DATA_SENT_EVENT event.
+ */
+export type AddonModLessonDataSentData = CoreEventSiteData & {
+ lessonId: number;
+ type: string;
+ courseId?: number;
+ outOfTime?: boolean;
+ review?: boolean;
+ pageId?: number;
+};
diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts
new file mode 100644
index 000000000..fad22f4d3
--- /dev/null
+++ b/src/addons/mod/mod.module.ts
@@ -0,0 +1,27 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { NgModule } from '@angular/core';
+
+import { AddonModLessonModule } from './lesson/lesson.module';
+
+@NgModule({
+ declarations: [],
+ imports: [
+ AddonModLessonModule,
+ ],
+ providers: [],
+ exports: [],
+})
+export class AddonModModule { }