feat(backend): DatabaseLearningObjectProvider geïmplementeerd.

This commit is contained in:
Gerald Schmittinger 2025-03-08 13:42:28 +01:00
parent 463c8c9fc0
commit bbcf22e4ea
8 changed files with 157 additions and 32 deletions

View file

@ -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[];

View file

@ -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;

View file

@ -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 {

View file

@ -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;

View file

@ -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

View file

@ -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
View 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()!!);
}

View file

@ -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}`;