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.

\n

Your {{$a.essayquestions}} essay question(s) will be graded and added into your final score at a later date.

\n

Your 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 = ''; + } 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 { }