diff --git a/backend/src/entities/content/learning-object.entity.ts b/backend/src/entities/content/learning-object.entity.ts index 1f888a19..4563da25 100644 --- a/backend/src/entities/content/learning-object.entity.ts +++ b/backend/src/entities/content/learning-object.entity.ts @@ -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[]; diff --git a/backend/src/entities/content/learning-path.entity.ts b/backend/src/entities/content/learning-path.entity.ts index f426cdfe..f3a30ad6 100644 --- a/backend/src/entities/content/learning-path.entity.ts +++ b/backend/src/entities/content/learning-path.entity.ts @@ -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; diff --git a/backend/src/interfaces/learning-content.ts b/backend/src/interfaces/learning-content.ts index 7fd3a55d..861996f5 100644 --- a/backend/src/interfaces/learning-content.ts +++ b/backend/src/interfaces/learning-content.ts @@ -14,7 +14,7 @@ export interface Transition { export interface LearningObjectIdentifier { hruid: string; language: Language; - version?: string; + version?: number; } export interface LearningObjectNode { diff --git a/backend/src/services/learning-objects/database-learning-object-provider.ts b/backend/src/services/learning-objects/database-learning-object-provider.ts index 85b97614..388bd8da 100644 --- a/backend/src/services/learning-objects/database-learning-object-provider.ts +++ b/backend/src/services/learning-objects/database-learning-object-provider.ts @@ -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 { + 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 { - return Promise.resolve(null); // TODO - }, - - /** - * Fetch full learning object data (metadata) - */ - getLearningObjectHTML(id: LearningObjectIdentifier): Promise { - return Promise.resolve(null); // TODO - }, - - /** - * Fetch only learning object HRUIDs - */ - getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise { - return Promise.resolve([]);// TODO + async getLearningObjectById(id: LearningObjectIdentifier): Promise { + 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 { - return Promise.resolve([]); // TODO - } + async getLearningObjectHTML(id: LearningObjectIdentifier): Promise { + 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 { + 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 { + 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; diff --git a/backend/src/services/learning-objects/processing/markdown/learning-object-markdown-renderer.ts b/backend/src/services/learning-objects/processing/markdown/learning-object-markdown-renderer.ts index dbcd7067..318beee4 100644 --- a/backend/src/services/learning-objects/processing/markdown/learning-object-markdown-renderer.ts +++ b/backend/src/services/learning-objects/processing/markdown/learning-object-markdown-renderer.ts @@ -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 ` - ${text} + ${text} `; } else { // any other link diff --git a/backend/src/services/learning-objects/processing/processing-service.ts b/backend/src/services/learning-objects/processing/processing-service.ts index 444ef353..1e2fc3c9 100644 --- a/backend/src/services/learning-objects/processing/processing-service.ts +++ b/backend/src/services/learning-objects/processing/processing-service.ts @@ -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 = //g; +const LEARNING_OBJECT_DOES_NOT_EXIST = "
"; class ProcessingService { private processors!: Map>; @@ -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 + ): Promise { 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 => { // 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(); diff --git a/backend/src/util/async.ts b/backend/src/util/async.ts new file mode 100644 index 00000000..f37f53a0 --- /dev/null +++ b/backend/src/util/async.ts @@ -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) { + const promises: Promise[] = []; + + // 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()!!); +} diff --git a/backend/src/util/links.ts b/backend/src/util/links.ts index ff5504af..c58ac5f1 100644 --- a/backend/src/util/links.ts +++ b/backend/src/util/links.ts @@ -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}`;