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 { Attachment } from './attachment.entity.js'; | ||||||
| import { Teacher } from '../users/teacher.entity.js'; | import { Teacher } from '../users/teacher.entity.js'; | ||||||
| import {DwengoContentType} from "../../services/learning-objects/processing/content-type"; | import {DwengoContentType} from "../../services/learning-objects/processing/content-type"; | ||||||
|  | import {v4} from "uuid"; | ||||||
| 
 | 
 | ||||||
| @Entity() | @Entity() | ||||||
| export class LearningObject { | export class LearningObject { | ||||||
|  | @ -21,8 +22,11 @@ export class LearningObject { | ||||||
|     @Enum({ items: () => Language, primary: true }) |     @Enum({ items: () => Language, primary: true }) | ||||||
|     language!: Language; |     language!: Language; | ||||||
| 
 | 
 | ||||||
|     @PrimaryKey({ type: 'string' }) |     @PrimaryKey({ type: 'number' }) | ||||||
|     version: string = '1'; |     version: number = 1; | ||||||
|  | 
 | ||||||
|  |     @Property({type: 'uuid', unique: true}) | ||||||
|  |     uuid = v4(); | ||||||
| 
 | 
 | ||||||
|     @ManyToMany({ entity: () => Teacher }) |     @ManyToMany({ entity: () => Teacher }) | ||||||
|     admins!: Teacher[]; |     admins!: Teacher[]; | ||||||
|  |  | ||||||
|  | @ -43,8 +43,8 @@ export class LearningPathNode { | ||||||
|     @Enum({ items: () => Language }) |     @Enum({ items: () => Language }) | ||||||
|     language!: Language; |     language!: Language; | ||||||
| 
 | 
 | ||||||
|     @Property({ type: 'string' }) |     @Property({ type: 'number' }) | ||||||
|     version!: string; |     version!: number; | ||||||
| 
 | 
 | ||||||
|     @Property({ type: 'longtext' }) |     @Property({ type: 'longtext' }) | ||||||
|     instruction!: string; |     instruction!: string; | ||||||
|  |  | ||||||
|  | @ -14,7 +14,7 @@ export interface Transition { | ||||||
| export interface LearningObjectIdentifier { | export interface LearningObjectIdentifier { | ||||||
|     hruid: string; |     hruid: string; | ||||||
|     language: Language; |     language: Language; | ||||||
|     version?: string; |     version?: number; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export interface LearningObjectNode { | export interface LearningObjectNode { | ||||||
|  |  | ||||||
|  | @ -4,6 +4,51 @@ import { | ||||||
|     LearningObjectIdentifier, |     LearningObjectIdentifier, | ||||||
|     LearningPathIdentifier |     LearningPathIdentifier | ||||||
| } from "../../interfaces/learning-content"; | } 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 |  * 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 |      * Fetches a single learning object by its HRUID | ||||||
|      */ |      */ | ||||||
|     getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> { |     async getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> { | ||||||
|         return Promise.resolve(null); // TODO
 |         const learningObject = await findLearningObjectEntityById(id); | ||||||
|     }, |         return filter(learningObject); | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * 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
 |  | ||||||
|     }, |     }, | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Obtain a HTML-rendering of the learning object with the given identifier (as a string). |      * Obtain a HTML-rendering of the learning object with the given identifier (as a string). | ||||||
|      */ |      */ | ||||||
|     getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]> { |     async getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> { | ||||||
|         return Promise.resolve([]); // TODO
 |         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; | export default databaseLearningObjectProvider; | ||||||
|  |  | ||||||
|  | @ -3,7 +3,7 @@ import AudioProcessor from "../audio/audio-processor.js"; | ||||||
| import ExternProcessor from "../extern/extern-processor.js"; | import ExternProcessor from "../extern/extern-processor.js"; | ||||||
| import InlineImageProcessor from "../image/inline-image-processor.js"; | import InlineImageProcessor from "../image/inline-image-processor.js"; | ||||||
| import {RendererObject, Tokens} from "marked"; | import {RendererObject, Tokens} from "marked"; | ||||||
| import {getUrlStringForLearningObject, isValidHttpUrl} from "../../../../util/links"; | import {getUrlStringForLearningObjectHTML, isValidHttpUrl} from "../../../../util/links"; | ||||||
| import {ProcessingError} from "../processing-error"; | import {ProcessingError} from "../processing-error"; | ||||||
| import {LearningObjectIdentifier} from "../../../../interfaces/learning-content"; | import {LearningObjectIdentifier} from "../../../../interfaces/learning-content"; | ||||||
| import {Language} from "../../../../entities/content/language"; | import {Language} from "../../../../entities/content/language"; | ||||||
|  | @ -58,7 +58,7 @@ function extractLearningObjectIdFromHref(href: string): LearningObjectIdentifier | ||||||
|             // link to learning-object
 |             // link to learning-object
 | ||||||
|             const learningObjectId = extractLearningObjectIdFromHref(href); |             const learningObjectId = extractLearningObjectIdFromHref(href); | ||||||
|             return ` |             return ` | ||||||
|             <a href="${getUrlStringForLearningObject(learningObjectId)}]" target="_blank" title="${title}">${text}</a> |             <a href="${getUrlStringForLearningObjectHTML(learningObjectId)}]" target="_blank" title="${title}">${text}</a> | ||||||
|         `;
 |         `;
 | ||||||
|         } else { |         } else { | ||||||
|             // any other link
 |             // any other link
 | ||||||
|  |  | ||||||
|  | @ -15,8 +15,10 @@ import Processor from "./processor"; | ||||||
| import {DwengoContentType} from "./content-type"; | import {DwengoContentType} from "./content-type"; | ||||||
| import {LearningObjectIdentifier} from "../../../interfaces/learning-content"; | import {LearningObjectIdentifier} from "../../../interfaces/learning-content"; | ||||||
| import {Language} from "../../../entities/content/language"; | import {Language} from "../../../entities/content/language"; | ||||||
|  | import {replaceAsync} from "../../../util/async"; | ||||||
| 
 | 
 | ||||||
| const EMBEDDED_LEARNING_OBJECT_PLACEHOLDER = /<learning-object hruid="([^"]+)" language="([^"]+)" version="([^"]+)"\/>/g; | 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 { | class ProcessingService { | ||||||
|     private processors!: Map<DwengoContentType, Processor<any>>; |     private processors!: Map<DwengoContentType, Processor<any>>; | ||||||
|  | @ -48,15 +50,28 @@ class ProcessingService { | ||||||
|      *                                     by placeholders. |      *                                     by placeholders. | ||||||
|      * @returns Rendered HTML for this LearningObject as a string. |      * @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); |         let html = this.processors.get(learningObject.contentType)!.renderLearningObject(learningObject); | ||||||
|         if (fetchEmbeddedLearningObjects) { |         if (fetchEmbeddedLearningObjects) { | ||||||
|             // Replace all embedded learning objects.
 |             // Replace all embedded learning objects.
 | ||||||
|             return html.replace( |             return replaceAsync( | ||||||
|  |                 html, | ||||||
|                 EMBEDDED_LEARNING_OBJECT_PLACEHOLDER, |                 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...
 |                     // 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.
 |                     // ... and render it.
 | ||||||
|                     return this.render(learningObject); |                     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}`; |     let url = `/learningObject/${learningObjectIdentifier.hruid}/html?language=${learningObjectIdentifier.language}`; | ||||||
|     if (learningObjectIdentifier.version) { |     if (learningObjectIdentifier.version) { | ||||||
|         url += `&version=${learningObjectIdentifier.version}`; |         url += `&version=${learningObjectIdentifier.version}`; | ||||||
|  |  | ||||||
		Reference in a new issue
	
	 Gerald Schmittinger
						Gerald Schmittinger