MOBILE-3648 lesson: Implement base services
This commit is contained in:
		
							parent
							
								
									3796654443
								
							
						
					
					
						commit
						2ff5883026
					
				| @ -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 {} | ||||
|  | ||||
							
								
								
									
										86
									
								
								src/addons/mod/lesson/lang.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								src/addons/mod/lesson/lang.json
									
									
									
									
									
										Normal file
									
								
							| @ -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": "<p>You earned {{$a.score}} out of {{$a.tempmaxgrade}} for the automatically graded questions.</p>\n<p>Your {{$a.essayquestions}} essay question(s) will be graded and added into your final score at a later date.</p>\n<p>Your current grade without the essay question(s) is {{$a.score}} out of {{$a.grade}}.</p>", | ||||
|     "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.<br />Please click on Continue to restart the lesson.", | ||||
|     "leftduringtimednoretake": "You have left during a timed lesson and you are<br />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.<br />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}}" | ||||
| } | ||||
							
								
								
									
										31
									
								
								src/addons/mod/lesson/lesson.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/addons/mod/lesson/lesson.module.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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 {} | ||||
							
								
								
									
										185
									
								
								src/addons/mod/lesson/services/database/lesson.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								src/addons/mod/lesson/services/database/lesson.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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; | ||||
| }; | ||||
							
								
								
									
										723
									
								
								src/addons/mod/lesson/services/lesson-helper.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										723
									
								
								src/addons/mod/lesson/services/lesson-helper.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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 = <HTMLInputElement> 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 = <HTMLInputElement | HTMLButtonElement> 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 = <HTMLElement> element.querySelector('.fcontainer'); | ||||
| 
 | ||||
|         // Get hidden inputs and add their data to the form group.
 | ||||
|         const hiddenInputs = <HTMLInputElement[]> 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 = <HTMLInputElement> 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 = <AddonModLessonMultichoiceQuestion> { | ||||
|             ...question, | ||||
|             template: 'multichoice', | ||||
|             options: [], | ||||
|             multi: false, | ||||
|         }; | ||||
| 
 | ||||
|         // Get all the inputs. Search radio first.
 | ||||
|         let inputs = <HTMLInputElement[]> 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 = <HTMLInputElement[]> 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 = <AddonModLessonInputQuestion> question; | ||||
|         inputQuestion.template = 'shortanswer'; | ||||
| 
 | ||||
|         // Get the input.
 | ||||
|         const input = <HTMLInputElement> 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 = <AddonModLessonEssayQuestion> 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 = <AddonModLessonMatchingQuestion> { | ||||
|             ...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 = <HTMLInputElement> 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 = <HTMLInputElement> 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<string, unknown>): Record<string, unknown> { | ||||
|         if (question.template == 'essay') { | ||||
|             const textarea = (<AddonModLessonEssayQuestion> question).textarea; | ||||
| 
 | ||||
|             // Add some HTML to the answer if needed.
 | ||||
|             if (textarea) { | ||||
|                 data[textarea.name] = CoreTextUtils.instance.formatHtmlLines(<string> data[textarea.name]); | ||||
|             } | ||||
|         } else if (question.template == 'multichoice' && (<AddonModLessonMultichoiceQuestion> 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<string, string>; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * 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; | ||||
							
								
								
									
										565
									
								
								src/addons/mod/lesson/services/lesson-offline.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										565
									
								
								src/addons/mod/lesson/services/lesson-offline.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<void> { | ||||
|         const site = await CoreSites.instance.getSite(siteId); | ||||
| 
 | ||||
|         await site.getDb().deleteRecords(PAGE_ATTEMPTS_TABLE_NAME, <Partial<AddonModLessonPageAttemptDBRecord>> { | ||||
|             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<void> { | ||||
|         const site = await CoreSites.instance.getSite(siteId); | ||||
| 
 | ||||
|         await site.getDb().deleteRecords(RETAKES_TABLE_NAME, <Partial<AddonModLessonRetakeDBRecord>> { 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<void> { | ||||
|         const site = await CoreSites.instance.getSite(siteId); | ||||
| 
 | ||||
|         await site.getDb().deleteRecords(PAGE_ATTEMPTS_TABLE_NAME, <Partial<AddonModLessonPageAttemptDBRecord>> { | ||||
|             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<void> { | ||||
| 
 | ||||
|         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<AddonModLessonPageAttemptRecord[]> { | ||||
|         const db = await CoreSites.instance.getSiteDb(siteId); | ||||
| 
 | ||||
|         const attempts = await db.getAllRecords<AddonModLessonPageAttemptDBRecord>(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<AddonModLessonLessonStoredData[]> { | ||||
|         const lessons: Record<number, AddonModLessonLessonStoredData> = {}; | ||||
| 
 | ||||
|         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<AddonModLessonRetakeDBRecord[]> { | ||||
|         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<AddonModLessonPageAttemptRecord | undefined> { | ||||
|         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<AddonModLessonPageAttemptRecord[]> { | ||||
|         const site = await CoreSites.instance.getSite(siteId); | ||||
| 
 | ||||
|         const attempts = await site.getDb().getRecords<AddonModLessonPageAttemptDBRecord>( | ||||
|             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<number, AddonModLessonLessonStoredData>, | ||||
|         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<AddonModLessonPageAttemptRecord[]> { | ||||
|         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<AddonModLessonRetakeDBRecord> { | ||||
|         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<AddonModLessonPageAttemptRecord[]> { | ||||
|         const site = await CoreSites.instance.getSite(siteId); | ||||
| 
 | ||||
|         const attempts = await site.getDb().getRecords<AddonModLessonPageAttemptDBRecord>( | ||||
|             PAGE_ATTEMPTS_TABLE_NAME, | ||||
|             <Partial<AddonModLessonPageAttemptDBRecord>> { | ||||
|                 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<AddonModLessonPageAttemptRecord[]> { | ||||
|         const site = await CoreSites.instance.getSite(siteId); | ||||
| 
 | ||||
|         const attempts = await site.getDb().getRecords<AddonModLessonPageAttemptDBRecord>( | ||||
|             PAGE_ATTEMPTS_TABLE_NAME, | ||||
|             <Partial<AddonModLessonPageAttemptDBRecord>> { | ||||
|                 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<AddonModLessonPageAttemptRecord[]> { | ||||
|         const site = await CoreSites.instance.getSite(siteId); | ||||
| 
 | ||||
|         const attempts = await site.getDb().getRecords<AddonModLessonPageAttemptDBRecord>( | ||||
|             PAGE_ATTEMPTS_TABLE_NAME, | ||||
|             <Partial<AddonModLessonPageAttemptDBRecord>> { | ||||
|                 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<AddonModLessonRetakeDBRecord> { | ||||
|         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<boolean> { | ||||
|         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<boolean> { | ||||
|         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<boolean> { | ||||
|         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<string, unknown>, | ||||
|         newPageId: number, | ||||
|         answerId?: number, | ||||
|         correct?: boolean, | ||||
|         userAnswer?: unknown, | ||||
|         siteId?: string, | ||||
|     ): Promise<void> { | ||||
| 
 | ||||
|         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<void> { | ||||
|         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<AddonModLessonPageAttemptDBRecord, 'data'|'useranswer'> & { | ||||
|     data: Record<string, unknown> | null; | ||||
|     useranswer: unknown | null; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Lesson data stored in DB. | ||||
|  */ | ||||
| export type AddonModLessonLessonStoredData = { | ||||
|     id: number; | ||||
|     courseId: number; | ||||
| }; | ||||
							
								
								
									
										4231
									
								
								src/addons/mod/lesson/services/lesson.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4231
									
								
								src/addons/mod/lesson/services/lesson.ts
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										27
									
								
								src/addons/mod/mod.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/addons/mod/mod.module.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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 { } | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user