feat(backend): DatabaseLearningObjectProvider geïmplementeerd.
This commit is contained in:
		
							parent
							
								
									463c8c9fc0
								
							
						
					
					
						commit
						bbcf22e4ea
					
				
					 8 changed files with 157 additions and 32 deletions
				
			
		|  | @ -12,6 +12,7 @@ import { Language } from './language.js'; | |||
| import { Attachment } from './attachment.entity.js'; | ||||
| import { Teacher } from '../users/teacher.entity.js'; | ||||
| import {DwengoContentType} from "../../services/learning-objects/processing/content-type"; | ||||
| import {v4} from "uuid"; | ||||
| 
 | ||||
| @Entity() | ||||
| export class LearningObject { | ||||
|  | @ -21,8 +22,11 @@ export class LearningObject { | |||
|     @Enum({ items: () => Language, primary: true }) | ||||
|     language!: Language; | ||||
| 
 | ||||
|     @PrimaryKey({ type: 'string' }) | ||||
|     version: string = '1'; | ||||
|     @PrimaryKey({ type: 'number' }) | ||||
|     version: number = 1; | ||||
| 
 | ||||
|     @Property({type: 'uuid', unique: true}) | ||||
|     uuid = v4(); | ||||
| 
 | ||||
|     @ManyToMany({ entity: () => Teacher }) | ||||
|     admins!: Teacher[]; | ||||
|  |  | |||
|  | @ -43,8 +43,8 @@ export class LearningPathNode { | |||
|     @Enum({ items: () => Language }) | ||||
|     language!: Language; | ||||
| 
 | ||||
|     @Property({ type: 'string' }) | ||||
|     version!: string; | ||||
|     @Property({ type: 'number' }) | ||||
|     version!: number; | ||||
| 
 | ||||
|     @Property({ type: 'longtext' }) | ||||
|     instruction!: string; | ||||
|  |  | |||
|  | @ -14,7 +14,7 @@ export interface Transition { | |||
| export interface LearningObjectIdentifier { | ||||
|     hruid: string; | ||||
|     language: Language; | ||||
|     version?: string; | ||||
|     version?: number; | ||||
| } | ||||
| 
 | ||||
| export interface LearningObjectNode { | ||||
|  |  | |||
|  | @ -4,6 +4,51 @@ import { | |||
|     LearningObjectIdentifier, | ||||
|     LearningPathIdentifier | ||||
| } from "../../interfaces/learning-content"; | ||||
| import {getLearningObjectRepository, getLearningPathRepository} from "../../data/repositories"; | ||||
| import {Language} from "../../entities/content/language"; | ||||
| import {LearningObject} from "../../entities/content/learning-object.entity"; | ||||
| import {getUrlStringForLearningObject} from "../../util/links"; | ||||
| import processingService from "./processing/processing-service"; | ||||
| import {NotFoundError} from "@mikro-orm/core"; | ||||
| 
 | ||||
| const learningObjectRepo = getLearningObjectRepository(); | ||||
| const learningPathRepo = getLearningPathRepository(); | ||||
| 
 | ||||
| function filter(learningObject: LearningObject | null): FilteredLearningObject | null { | ||||
|     if (!learningObject) { | ||||
|         return null; | ||||
|     } | ||||
|     return { | ||||
|         key: learningObject.hruid, | ||||
|         _id: learningObject.uuid, // For backwards compatibility with the original Dwengo API, we also populate the _id field.
 | ||||
|         uuid: learningObject.uuid, | ||||
|         language: learningObject.language, | ||||
|         version: learningObject.version, | ||||
|         title: learningObject.title, | ||||
|         description: learningObject.description, | ||||
|         htmlUrl: getUrlStringForLearningObject(learningObject), | ||||
|         available: learningObject.available, | ||||
|         contentType: learningObject.contentType, | ||||
|         contentLocation: learningObject.contentLocation, | ||||
|         difficulty: learningObject.difficulty || 1, | ||||
|         estimatedTime: learningObject.estimatedTime, | ||||
|         keywords: learningObject.keywords, | ||||
|         educationalGoals: learningObject.educationalGoals, | ||||
|         returnValue: { | ||||
|             callback_url: learningObject.returnValue.callbackUrl, | ||||
|             callback_schema: JSON.parse(learningObject.returnValue.callbackSchema) | ||||
|         }, | ||||
|         skosConcepts: learningObject.skosConcepts, | ||||
|         targetAges: learningObject.targetAges || [], | ||||
|         teacherExclusive: learningObject.teacherExclusive | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| function findLearningObjectEntityById(id: LearningObjectIdentifier): Promise<LearningObject | null> { | ||||
|     return learningObjectRepo.findLatestByHruidAndLanguage( | ||||
|         id.hruid, id.language as Language | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Service providing access to data about learning objects from the database | ||||
|  | @ -12,31 +57,61 @@ const databaseLearningObjectProvider: LearningObjectProvider = { | |||
|     /** | ||||
|      * Fetches a single learning object by its HRUID | ||||
|      */ | ||||
|     getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> { | ||||
|         return Promise.resolve(null); // TODO
 | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch full learning object data (metadata) | ||||
|      */ | ||||
|     getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> { | ||||
|         return Promise.resolve(null); // TODO
 | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch only learning object HRUIDs | ||||
|      */ | ||||
|     getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]> { | ||||
|         return Promise.resolve([]);// TODO
 | ||||
|     async getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> { | ||||
|         const learningObject = await findLearningObjectEntityById(id); | ||||
|         return filter(learningObject); | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * Obtain a HTML-rendering of the learning object with the given identifier (as a string). | ||||
|      */ | ||||
|     getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]> { | ||||
|         return Promise.resolve([]); // TODO
 | ||||
|     async getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> { | ||||
|         const learningObject  = await learningObjectRepo.findLatestByHruidAndLanguage( | ||||
|             id.hruid, id.language as Language | ||||
|         ); | ||||
|         if (!learningObject) { | ||||
|             return null; | ||||
|         } | ||||
|         return await processingService.render( | ||||
|             learningObject, | ||||
|             (id) => findLearningObjectEntityById(id) | ||||
|         ); | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch the HRUIDs of all learning objects on this path. | ||||
|      */ | ||||
|     async getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]> { | ||||
|         const learningPath = await learningPathRepo.findByHruidAndLanguage(id.hruid, id.language); | ||||
|         if (!learningPath) { | ||||
|             throw new NotFoundError("The learning path with the given ID could not be found."); | ||||
|         } | ||||
|         return learningPath.nodes.map(it => it.learningObjectHruid); // TODO: Determine this based on the submissions of the user.
 | ||||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch the full metadata of all learning objects on this path. | ||||
|      */ | ||||
|     async getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]> { | ||||
|         const learningPath = await learningPathRepo.findByHruidAndLanguage(id.hruid, id.language); | ||||
|         if (!learningPath) { | ||||
|             throw new NotFoundError("The learning path with the given ID could not be found."); | ||||
|         } | ||||
|         const learningObjects = await Promise.all( | ||||
|             learningPath.nodes.map(it => { | ||||
|                 const learningObject = this.getLearningObjectById({ | ||||
|                     hruid: it.learningObjectHruid, | ||||
|                     language: it.language, | ||||
|                     version: it.version | ||||
|                 }) | ||||
|                 if (learningObject === null) { | ||||
|                     console.log(`WARN: Learning object corresponding with node ${it} not found!`); | ||||
|                 } | ||||
|                 return learningObject; | ||||
|             }) | ||||
|         ); | ||||
|         return learningObjects.filter(it => it !== null); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| export default databaseLearningObjectProvider; | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ import AudioProcessor from "../audio/audio-processor.js"; | |||
| import ExternProcessor from "../extern/extern-processor.js"; | ||||
| import InlineImageProcessor from "../image/inline-image-processor.js"; | ||||
| import {RendererObject, Tokens} from "marked"; | ||||
| import {getUrlStringForLearningObject, isValidHttpUrl} from "../../../../util/links"; | ||||
| import {getUrlStringForLearningObjectHTML, isValidHttpUrl} from "../../../../util/links"; | ||||
| import {ProcessingError} from "../processing-error"; | ||||
| import {LearningObjectIdentifier} from "../../../../interfaces/learning-content"; | ||||
| import {Language} from "../../../../entities/content/language"; | ||||
|  | @ -58,7 +58,7 @@ function extractLearningObjectIdFromHref(href: string): LearningObjectIdentifier | |||
|             // link to learning-object
 | ||||
|             const learningObjectId = extractLearningObjectIdFromHref(href); | ||||
|             return ` | ||||
|             <a href="${getUrlStringForLearningObject(learningObjectId)}]" target="_blank" title="${title}">${text}</a> | ||||
|             <a href="${getUrlStringForLearningObjectHTML(learningObjectId)}]" target="_blank" title="${title}">${text}</a> | ||||
|         `;
 | ||||
|         } else { | ||||
|             // any other link
 | ||||
|  |  | |||
|  | @ -15,8 +15,10 @@ import Processor from "./processor"; | |||
| import {DwengoContentType} from "./content-type"; | ||||
| import {LearningObjectIdentifier} from "../../../interfaces/learning-content"; | ||||
| import {Language} from "../../../entities/content/language"; | ||||
| import {replaceAsync} from "../../../util/async"; | ||||
| 
 | ||||
| const EMBEDDED_LEARNING_OBJECT_PLACEHOLDER = /<learning-object hruid="([^"]+)" language="([^"]+)" version="([^"]+)"\/>/g; | ||||
| const LEARNING_OBJECT_DOES_NOT_EXIST = "<div class='non-existing-learning-object' />"; | ||||
| 
 | ||||
| class ProcessingService { | ||||
|     private processors!: Map<DwengoContentType, Processor<any>>; | ||||
|  | @ -48,15 +50,28 @@ class ProcessingService { | |||
|      *                                     by placeholders. | ||||
|      * @returns Rendered HTML for this LearningObject as a string. | ||||
|      */ | ||||
|     render(learningObject: LearningObject, fetchEmbeddedLearningObjects?: (loId: LearningObjectIdentifier) => LearningObject): string { | ||||
|     async render( | ||||
|         learningObject: LearningObject, | ||||
|         fetchEmbeddedLearningObjects?: (loId: LearningObjectIdentifier) => Promise<LearningObject | null> | ||||
|     ): Promise<string> { | ||||
|         let html = this.processors.get(learningObject.contentType)!.renderLearningObject(learningObject); | ||||
|         if (fetchEmbeddedLearningObjects) { | ||||
|             // Replace all embedded learning objects.
 | ||||
|             return html.replace( | ||||
|             return replaceAsync( | ||||
|                 html, | ||||
|                 EMBEDDED_LEARNING_OBJECT_PLACEHOLDER, | ||||
|                 (_, hruid: string, language: string, version: string): string => { | ||||
|                 async (_, hruid: string, language: string, version: string): Promise<string> => { | ||||
|                     // Fetch the embedded learning object...
 | ||||
|                     const learningObject = fetchEmbeddedLearningObjects({hruid, language: language as Language, version}) | ||||
|                     const learningObject = await fetchEmbeddedLearningObjects({ | ||||
|                         hruid, | ||||
|                         language: language as Language, | ||||
|                         version: parseInt(version) | ||||
|                     }); | ||||
| 
 | ||||
|                     // If it does not exist, replace it by a placeholder.
 | ||||
|                     if (!learningObject) { | ||||
|                         return LEARNING_OBJECT_DOES_NOT_EXIST; | ||||
|                     } | ||||
| 
 | ||||
|                     // ... and render it.
 | ||||
|                     return this.render(learningObject); | ||||
|  | @ -67,4 +82,4 @@ class ProcessingService { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| export default ProcessingService; | ||||
| export default new ProcessingService(); | ||||
|  |  | |||
							
								
								
									
										23
									
								
								backend/src/util/async.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								backend/src/util/async.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,23 @@ | |||
| /** | ||||
|  * Replace all occurrences of regex in str with the result of asyncFn called with the matching snippet and each of | ||||
|  * the parts matched by a group in the regex as arguments. | ||||
|  * | ||||
|  * @param str The string where to replace the occurrences | ||||
|  * @param regex | ||||
|  * @param replacementFn | ||||
|  */ | ||||
| export async function replaceAsync(str: string, regex: RegExp, replacementFn: (match: string, ...args: string[]) => Promise<string>) { | ||||
|     const promises: Promise<string>[] = []; | ||||
| 
 | ||||
|     // First run through matches: add all Promises resulting from the replacement function
 | ||||
|     str.replace(regex, (full, ...args) => { | ||||
|         promises.push(replacementFn(full, ...args)); | ||||
|         return full; | ||||
|     }); | ||||
| 
 | ||||
|     // Wait for the replacements to get loaded. Reverse them so when popping them, we work in a FIFO manner.
 | ||||
|     const replacements: string[] = (await Promise.all(promises)); | ||||
| 
 | ||||
|     // Second run through matches: Replace them by their previously computed replacements.
 | ||||
|     return str.replace(regex, () => replacements.pop()!!); | ||||
| } | ||||
|  | @ -9,7 +9,15 @@ export function isValidHttpUrl(url: string): boolean { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| export function getUrlStringForLearningObject(learningObjectIdentifier: LearningObjectIdentifier): string { | ||||
| export function getUrlStringForLearningObject(learningObjectId: LearningObjectIdentifier) { | ||||
|     let url = `/learningObject/${learningObjectId.hruid}/html?language=${learningObjectId.language}`; | ||||
|     if (learningObjectId.version) { | ||||
|         url += `&version=${learningObjectId.version}`; | ||||
|     } | ||||
|     return url; | ||||
| } | ||||
| 
 | ||||
| export function getUrlStringForLearningObjectHTML(learningObjectIdentifier: LearningObjectIdentifier): string { | ||||
|     let url = `/learningObject/${learningObjectIdentifier.hruid}/html?language=${learningObjectIdentifier.language}`; | ||||
|     if (learningObjectIdentifier.version) { | ||||
|         url += `&version=${learningObjectIdentifier.version}`; | ||||
|  |  | |||
		Reference in a new issue
	
	 Gerald Schmittinger
						Gerald Schmittinger