MERGE: dev ino feat/service-layer

This commit is contained in:
Gabriellvl 2025-03-13 17:42:04 +01:00
commit 6404335040
220 changed files with 12582 additions and 10400 deletions

View file

@ -0,0 +1,23 @@
import { getAttachmentRepository } from '../../data/repositories.js';
import { Attachment } from '../../entities/content/attachment.entity.js';
import { LearningObjectIdentifier } from '../../interfaces/learning-content.js';
const attachmentService = {
getAttachment(learningObjectId: LearningObjectIdentifier, attachmentName: string): Promise<Attachment | null> {
const attachmentRepo = getAttachmentRepository();
if (learningObjectId.version) {
return attachmentRepo.findByLearningObjectIdAndName(
{
hruid: learningObjectId.hruid,
language: learningObjectId.language,
version: learningObjectId.version,
},
attachmentName
);
}
return attachmentRepo.findByMostRecentVersionOfLearningObjectAndName(learningObjectId.hruid, learningObjectId.language, attachmentName);
},
};
export default attachmentService;

View file

@ -0,0 +1,115 @@
import { LearningObjectProvider } from './learning-object-provider.js';
import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js';
import { getLearningObjectRepository, getLearningPathRepository } from '../../data/repositories.js';
import { Language } from '../../entities/content/language.js';
import { LearningObject } from '../../entities/content/learning-object.entity.js';
import { getUrlStringForLearningObject } from '../../util/links.js';
import processingService from './processing/processing-service.js';
import { NotFoundError } from '@mikro-orm/core';
import learningObjectService from './learning-object-service.js';
import { getLogger, Logger } from '../../logging/initalize.js';
const logger: Logger = getLogger();
function convertLearningObject(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> {
const learningObjectRepo = getLearningObjectRepository();
return learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language as Language);
}
/**
* Service providing access to data about learning objects from the database
*/
const databaseLearningObjectProvider: LearningObjectProvider = {
/**
* Fetches a single learning object by its HRUID
*/
async getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> {
const learningObject = await findLearningObjectEntityById(id);
return convertLearningObject(learningObject);
},
/**
* Obtain a HTML-rendering of the learning object with the given identifier (as a string).
*/
async getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> {
const learningObjectRepo = getLearningObjectRepository();
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 learningPathRepo = getLearningPathRepository();
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 learningPathRepo = getLearningPathRepository();
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 = learningObjectService.getLearningObjectById({
hruid: it.learningObjectHruid,
language: it.language,
version: it.version,
});
if (learningObject === null) {
logger.warn(`WARN: Learning object corresponding with node ${it} not found!`);
}
return learningObject;
})
);
return learningObjects.filter((it) => it !== null);
},
};
export default databaseLearningObjectProvider;

View file

@ -0,0 +1,138 @@
import { DWENGO_API_BASE } from '../../config.js';
import { fetchWithLogging } from '../../util/apiHelper.js';
import {
FilteredLearningObject,
LearningObjectIdentifier,
LearningObjectMetadata,
LearningObjectNode,
LearningPathIdentifier,
LearningPathResponse,
} from '../../interfaces/learning-content.js';
import dwengoApiLearningPathProvider from '../learning-paths/dwengo-api-learning-path-provider.js';
import { LearningObjectProvider } from './learning-object-provider.js';
import { getLogger, Logger } from '../../logging/initalize.js';
const logger: Logger = getLogger();
/**
* Helper function to convert the learning object metadata retrieved from the API to a FilteredLearningObject which
* our API should return.
* @param data
*/
function filterData(data: LearningObjectMetadata): FilteredLearningObject {
return {
key: data.hruid, // Hruid learningObject (not path)
_id: data._id,
uuid: data.uuid,
version: data.version,
title: data.title,
htmlUrl: `/learningObject/${data.hruid}/html?language=${data.language}&version=${data.version}`, // Url to fetch html content
language: data.language,
difficulty: data.difficulty,
estimatedTime: data.estimated_time,
available: data.available,
teacherExclusive: data.teacher_exclusive,
educationalGoals: data.educational_goals, // List with learningObjects
keywords: data.keywords, // For search
description: data.description, // For search (not an actual description)
targetAges: data.target_ages,
contentType: data.content_type, // Markdown, image, audio, etc.
contentLocation: data.content_location, // If content type extern
skosConcepts: data.skos_concepts,
returnValue: data.return_value, // Callback response information
};
}
/**
* Generic helper function to fetch all learning objects from a given path (full data or just HRUIDs)
*/
async function fetchLearningObjects(learningPathId: LearningPathIdentifier, full: boolean): Promise<FilteredLearningObject[] | string[]> {
try {
const learningPathResponse: LearningPathResponse = await dwengoApiLearningPathProvider.fetchLearningPaths(
[learningPathId.hruid],
learningPathId.language,
`Learning path for HRUID "${learningPathId.hruid}"`
);
if (!learningPathResponse.success || !learningPathResponse.data?.length) {
logger.warn(`⚠️ WARNING: Learning path "${learningPathId.hruid}" exists but contains no learning objects.`);
return [];
}
const nodes: LearningObjectNode[] = learningPathResponse.data[0].nodes;
if (!full) {
return nodes.map((node) => node.learningobject_hruid);
}
const objects = await Promise.all(
nodes.map(async (node) =>
dwengoApiLearningObjectProvider.getLearningObjectById({
hruid: node.learningobject_hruid,
language: learningPathId.language,
})
)
);
return objects.filter((obj): obj is FilteredLearningObject => obj !== null);
} catch (error) {
logger.error('❌ Error fetching learning objects:', error);
return [];
}
}
const dwengoApiLearningObjectProvider: LearningObjectProvider = {
/**
* Fetches a single learning object by its HRUID
*/
async getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> {
const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata`;
const metadata = await fetchWithLogging<LearningObjectMetadata>(
metadataUrl,
`Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`,
{
params: id,
}
);
if (!metadata || typeof metadata !== 'object') {
logger.warn(`⚠️ WARNING: Learning object "${id.hruid}" not found.`);
return null;
}
return filterData(metadata);
},
/**
* Fetch full learning object data (metadata)
*/
async getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]> {
return (await fetchLearningObjects(id, true)) as FilteredLearningObject[];
},
/**
* Fetch only learning object HRUIDs
*/
async getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]> {
return (await fetchLearningObjects(id, false)) as string[];
},
/**
* Obtain a HTML-rendering of the learning object with the given identifier (as a string). For learning objects
* from the Dwengo API, this means passing through the HTML rendering from there.
*/
async getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> {
const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw`;
const html = await fetchWithLogging<string>(htmlUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, {
params: id,
});
if (!html) {
logger.warn(`⚠️ WARNING: Learning object "${id.hruid}" not found.`);
return null;
}
return html;
},
};
export default dwengoApiLearningObjectProvider;

View file

@ -0,0 +1,23 @@
import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js';
export interface LearningObjectProvider {
/**
* Fetches a single learning object by its HRUID
*/
getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null>;
/**
* Fetch full learning object data (metadata)
*/
getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]>;
/**
* Fetch only learning object HRUIDs
*/
getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]>;
/**
* Obtain a HTML-rendering of the learning object with the given identifier (as a string).
*/
getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null>;
}

View file

@ -0,0 +1,47 @@
import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js';
import dwengoApiLearningObjectProvider from './dwengo-api-learning-object-provider.js';
import { LearningObjectProvider } from './learning-object-provider.js';
import { EnvVars, getEnvVar } from '../../util/envvars.js';
import databaseLearningObjectProvider from './database-learning-object-provider.js';
function getProvider(id: LearningObjectIdentifier): LearningObjectProvider {
if (id.hruid.startsWith(getEnvVar(EnvVars.UserContentPrefix))) {
return databaseLearningObjectProvider;
}
return dwengoApiLearningObjectProvider;
}
/**
* Service providing access to data about learning objects from the appropriate data source (database or Dwengo-api)
*/
const learningObjectService = {
/**
* Fetches a single learning object by its HRUID
*/
getLearningObjectById(id: LearningObjectIdentifier): Promise<FilteredLearningObject | null> {
return getProvider(id).getLearningObjectById(id);
},
/**
* Fetch full learning object data (metadata)
*/
getLearningObjectsFromPath(id: LearningPathIdentifier): Promise<FilteredLearningObject[]> {
return getProvider(id).getLearningObjectsFromPath(id);
},
/**
* Fetch only learning object HRUIDs
*/
getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise<string[]> {
return getProvider(id).getLearningObjectIdsFromPath(id);
},
/**
* Obtain a HTML-rendering of the learning object with the given identifier (as a string).
*/
getLearningObjectHTML(id: LearningObjectIdentifier): Promise<string | null> {
return getProvider(id).getLearningObjectHTML(id);
},
};
export default learningObjectService;

View file

@ -0,0 +1,25 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/audio/audio_processor.js
*
* WARNING: The support for audio learning objects is currently still experimental.
*/
import DOMPurify from 'isomorphic-dompurify';
import { type } from 'node:os';
import { DwengoContentType } from '../content-type.js';
import { StringProcessor } from '../string-processor.js';
class AudioProcessor extends StringProcessor {
constructor() {
super(DwengoContentType.AUDIO_MPEG);
}
protected renderFn(audioUrl: string): string {
return DOMPurify.sanitize(`<audio controls>
<source src="${audioUrl}" type=${type}>
Your browser does not support the audio element.
</audio>`);
}
}
export default AudioProcessor;

View file

@ -0,0 +1,18 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/content_type.js
*/
enum DwengoContentType {
TEXT_PLAIN = 'text/plain',
TEXT_MARKDOWN = 'text/markdown',
IMAGE_BLOCK = 'image/image-block',
IMAGE_INLINE = 'image/image',
AUDIO_MPEG = 'audio/mpeg',
APPLICATION_PDF = 'application/pdf',
EXTERN = 'extern',
BLOCKLY = 'blockly',
GIFT = 'text/gift',
CT_SCHEMA = 'text/ct-schema',
}
export { DwengoContentType };

View file

@ -0,0 +1,40 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/extern/extern_processor.js
*
* WARNING: The support for external content is currently still experimental.
*/
import DOMPurify from 'isomorphic-dompurify';
import { ProcessingError } from '../processing-error.js';
import { isValidHttpUrl } from '../../../../util/links.js';
import { DwengoContentType } from '../content-type.js';
import { StringProcessor } from '../string-processor.js';
class ExternProcessor extends StringProcessor {
constructor() {
super(DwengoContentType.EXTERN);
}
override renderFn(externURL: string) {
if (!isValidHttpUrl(externURL)) {
throw new ProcessingError('The url is not valid: ' + externURL);
}
// If a seperate youtube-processor would be added, this code would need to move to that processor
// Converts youtube urls to youtube-embed urls
const match = /(.*youtube.com\/)watch\?v=(.*)/.exec(externURL);
if (match) {
externURL = match[1] + 'embed/' + match[2];
}
return DOMPurify.sanitize(
`
<div class="iframe-container">
<iframe src="${externURL}" allowfullscreen></iframe>
</div>`,
{ ADD_TAGS: ['iframe'], ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'] }
);
}
}
export default ExternProcessor;

View file

@ -0,0 +1,61 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/gift/gift_processor.js
*/
import DOMPurify from 'isomorphic-dompurify';
import { GIFTQuestion, parse } from 'gift-pegjs';
import { DwengoContentType } from '../content-type.js';
import { GIFTQuestionRenderer } from './question-renderers/gift-question-renderer.js';
import { MultipleChoiceQuestionRenderer } from './question-renderers/multiple-choice-question-renderer.js';
import { CategoryQuestionRenderer } from './question-renderers/category-question-renderer.js';
import { DescriptionQuestionRenderer } from './question-renderers/description-question-renderer.js';
import { EssayQuestionRenderer } from './question-renderers/essay-question-renderer.js';
import { MatchingQuestionRenderer } from './question-renderers/matching-question-renderer.js';
import { NumericalQuestionRenderer } from './question-renderers/numerical-question-renderer.js';
import { ShortQuestionRenderer } from './question-renderers/short-question-renderer.js';
import { TrueFalseQuestionRenderer } from './question-renderers/true-false-question-renderer.js';
import { StringProcessor } from '../string-processor.js';
class GiftProcessor extends StringProcessor {
private renderers: RendererMap = {
Category: new CategoryQuestionRenderer(),
Description: new DescriptionQuestionRenderer(),
Essay: new EssayQuestionRenderer(),
Matching: new MatchingQuestionRenderer(),
Numerical: new NumericalQuestionRenderer(),
Short: new ShortQuestionRenderer(),
TF: new TrueFalseQuestionRenderer(),
MC: new MultipleChoiceQuestionRenderer(),
};
constructor() {
super(DwengoContentType.GIFT);
}
override renderFn(giftString: string) {
const quizQuestions: GIFTQuestion[] = parse(giftString);
let html = "<div class='learning-object-gift'>\n";
let i = 1;
for (const question of quizQuestions) {
html += ` <div class='gift-question' id='gift-q${i}'>\n`;
html += ' ' + this.renderQuestion(question, i).replaceAll(/\n(.+)/g, '\n $1'); // Replace for indentation.
html += ` </div>\n`;
i++;
}
html += '</div>\n';
return DOMPurify.sanitize(html);
}
private renderQuestion<T extends GIFTQuestion>(question: T, questionNumber: number): string {
const renderer = this.renderers[question.type] as GIFTQuestionRenderer<T>;
return renderer.render(question, questionNumber);
}
}
type RendererMap = {
[K in GIFTQuestion['type']]: GIFTQuestionRenderer<Extract<GIFTQuestion, { type: K }>>;
};
export default GiftProcessor;

View file

@ -0,0 +1,9 @@
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
import { Category } from 'gift-pegjs';
import { ProcessingError } from '../../processing-error.js';
export class CategoryQuestionRenderer extends GIFTQuestionRenderer<Category> {
render(question: Category, questionNumber: number): string {
throw new ProcessingError("The question type 'Category' is not supported yet!");
}
}

View file

@ -0,0 +1,9 @@
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
import { Description } from 'gift-pegjs';
import { ProcessingError } from '../../processing-error.js';
export class DescriptionQuestionRenderer extends GIFTQuestionRenderer<Description> {
render(question: Description, questionNumber: number): string {
throw new ProcessingError("The question type 'Description' is not supported yet!");
}
}

View file

@ -0,0 +1,16 @@
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
import { Essay } from 'gift-pegjs';
export class EssayQuestionRenderer extends GIFTQuestionRenderer<Essay> {
render(question: Essay, questionNumber: number): string {
let renderedHtml = '';
if (question.title) {
renderedHtml += `<h2 class='gift-title' id='gift-q${questionNumber}-title'>${question.title}</h2>\n`;
}
if (question.stem) {
renderedHtml += `<p class='gift-stem' id='gift-q${questionNumber}-stem'>${question.stem.text}</p>\n`;
}
renderedHtml += `<textarea class='gift-essay-answer' id='gift-q${questionNumber}-answer'></textarea>\n`;
return renderedHtml;
}
}

View file

@ -0,0 +1,14 @@
import { GIFTQuestion } from 'gift-pegjs';
/**
* Subclasses of this class are renderers which can render a specific type of GIFT questions to HTML.
*/
export abstract class GIFTQuestionRenderer<T extends GIFTQuestion> {
/**
* Render the given question to HTML.
* @param question The question.
* @param questionNumber The index number of the question.
* @returns The question rendered as HTML.
*/
abstract render(question: T, questionNumber: number): string;
}

View file

@ -0,0 +1,9 @@
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
import { Matching } from 'gift-pegjs';
import { ProcessingError } from '../../processing-error.js';
export class MatchingQuestionRenderer extends GIFTQuestionRenderer<Matching> {
render(question: Matching, questionNumber: number): string {
throw new ProcessingError("The question type 'Matching' is not supported yet!");
}
}

View file

@ -0,0 +1,23 @@
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
import { MultipleChoice } from 'gift-pegjs';
export class MultipleChoiceQuestionRenderer extends GIFTQuestionRenderer<MultipleChoice> {
render(question: MultipleChoice, questionNumber: number): string {
let renderedHtml = '';
if (question.title) {
renderedHtml += `<h2 class='gift-title' id='gift-q${questionNumber}-title'>${question.title}</h2>\n`;
}
if (question.stem) {
renderedHtml += `<p class='gift-stem' id='gift-q${questionNumber}-stem'>${question.stem.text}</p>\n`;
}
let i = 0;
for (const choice of question.choices) {
renderedHtml += `<div class="gift-choice-div">\n`;
renderedHtml += ` <input type='radio' id='gift-q${questionNumber}-choice-${i}' name='gift-q${questionNumber}-choices' value="${i}"/>\n`;
renderedHtml += ` <label for='gift-q${questionNumber}-choice-${i}'>${choice.text}</label>\n`;
renderedHtml += `</div>\n`;
i++;
}
return renderedHtml;
}
}

View file

@ -0,0 +1,9 @@
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
import { Numerical } from 'gift-pegjs';
import { ProcessingError } from '../../processing-error.js';
export class NumericalQuestionRenderer extends GIFTQuestionRenderer<Numerical> {
render(question: Numerical, questionNumber: number): string {
throw new ProcessingError("The question type 'Numerical' is not supported yet!");
}
}

View file

@ -0,0 +1,9 @@
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
import { ShortAnswer } from 'gift-pegjs';
import { ProcessingError } from '../../processing-error.js';
export class ShortQuestionRenderer extends GIFTQuestionRenderer<ShortAnswer> {
render(question: ShortAnswer, questionNumber: number): string {
throw new ProcessingError("The question type 'ShortAnswer' is not supported yet!");
}
}

View file

@ -0,0 +1,9 @@
import { GIFTQuestionRenderer } from './gift-question-renderer.js';
import { TrueFalse } from 'gift-pegjs';
import { ProcessingError } from '../../processing-error.js';
export class TrueFalseQuestionRenderer extends GIFTQuestionRenderer<TrueFalse> {
render(question: TrueFalse, questionNumber: number): string {
throw new ProcessingError("The question type 'TrueFalse' is not supported yet!");
}
}

View file

@ -0,0 +1,19 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/image/block_image_processor.js
*/
import InlineImageProcessor from './inline-image-processor.js';
import DOMPurify from 'isomorphic-dompurify';
class BlockImageProcessor extends InlineImageProcessor {
constructor() {
super();
}
override renderFn(imageUrl: string) {
const inlineHtml = super.render(imageUrl);
return DOMPurify.sanitize(`<div>${inlineHtml}</div>`);
}
}
export default BlockImageProcessor;

View file

@ -0,0 +1,24 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/image/inline_image_processor.js
*/
import DOMPurify from 'isomorphic-dompurify';
import { DwengoContentType } from '../content-type.js';
import { ProcessingError } from '../processing-error.js';
import { isValidHttpUrl } from '../../../../util/links.js';
import { StringProcessor } from '../string-processor.js';
class InlineImageProcessor extends StringProcessor {
constructor(contentType: DwengoContentType = DwengoContentType.IMAGE_INLINE) {
super(contentType);
}
override renderFn(imageUrl: string) {
if (!isValidHttpUrl(imageUrl)) {
throw new ProcessingError(`Image URL is invalid: ${imageUrl}`);
}
return DOMPurify.sanitize(`<img src="${imageUrl}" alt="">`);
}
}
export default InlineImageProcessor;

View file

@ -0,0 +1,109 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/markdown/learing_object_markdown_renderer.js [sic!]
*/
import PdfProcessor from '../pdf/pdf-processor.js';
import AudioProcessor from '../audio/audio-processor.js';
import ExternProcessor from '../extern/extern-processor.js';
import InlineImageProcessor from '../image/inline-image-processor.js';
import * as marked from 'marked';
import { getUrlStringForLearningObjectHTML, isValidHttpUrl } from '../../../../util/links.js';
import { ProcessingError } from '../processing-error.js';
import { LearningObjectIdentifier } from '../../../../interfaces/learning-content.js';
import { Language } from '../../../../entities/content/language.js';
import Image = marked.Tokens.Image;
import Heading = marked.Tokens.Heading;
import Link = marked.Tokens.Link;
import RendererObject = marked.RendererObject;
const prefixes = {
learningObject: '@learning-object',
pdf: '@pdf',
audio: '@audio',
extern: '@extern',
video: '@youtube',
notebook: '@notebook',
blockly: '@blockly',
};
function extractLearningObjectIdFromHref(href: string): LearningObjectIdentifier {
const [hruid, language, version] = href.split(/\/(.+)/, 2)[1].split('/');
return {
hruid,
language: language as Language,
version: parseInt(version),
};
}
/**
* An extension for the renderer of the Marked Markdown renderer which adds support for
* - a custom heading,
* - links to other learning objects,
* - embeddings of other learning objects.
*/
const dwengoMarkedRenderer: RendererObject = {
heading(heading: Heading): string {
const text = heading.text;
const level = heading.depth;
const escapedText = text.toLowerCase().replace(/[^\w]+/g, '-');
return (
`<h${level}>\n` +
` <a name="${escapedText}" class="anchor" href="#${escapedText}">\n` +
` <span class="header-link"></span>\n` +
` </a>\n` +
` ${text}\n` +
`</h${level}>\n`
);
},
// When the syntax for a link is used => [text](href "title")
// Render a custom link when the prefix for a learning object is used.
link(link: Link): string {
const href = link.href;
const title = link.title || '';
const text = marked.parseInline(link.text); // There could for example be an image in the link.
if (href.startsWith(prefixes.learningObject)) {
// Link to learning-object
const learningObjectId = extractLearningObjectIdFromHref(href);
return `<a href="${getUrlStringForLearningObjectHTML(learningObjectId)}" target="_blank" title="${title}">${text}</a>`;
}
// Any other link
if (!isValidHttpUrl(href)) {
throw new ProcessingError('Link is not a valid HTTP URL!');
}
//<a href="https://kiks.ilabt.imec.be/hub/tmplogin?id=0101" title="Notebooks Werking"><img src="Knop.png" alt="" title="Knop"></a>
return `<a href="${href}" target="_blank" title="${title}">${text}</a>`;
},
// When the syntax for an image is used => ![text](href "title")
// Render a learning object, pdf, audio or video if a prefix is used.
image(img: Image): string {
const href = img.href;
if (href.startsWith(prefixes.learningObject)) {
// Embedded learning-object
const learningObjectId = extractLearningObjectIdFromHref(href);
return `
<learning-object hruid="${learningObjectId.hruid}" language="${learningObjectId.language}" version="${learningObjectId.version}"/>
`; // Placeholder for the learning object since we cannot fetch its HTML here (this has to be a sync function!)
} else if (href.startsWith(prefixes.pdf)) {
// Embedded pdf
const proc = new PdfProcessor();
return proc.render(href.split(/\/(.+)/, 2)[1]);
} else if (href.startsWith(prefixes.audio)) {
// Embedded audio
const proc = new AudioProcessor();
return proc.render(href.split(/\/(.+)/, 2)[1]);
} else if (href.startsWith(prefixes.extern) || href.startsWith(prefixes.video) || href.startsWith(prefixes.notebook)) {
// Embedded youtube video or notebook (or other extern content)
const proc = new ExternProcessor();
return proc.render(href.split(/\/(.+)/, 2)[1]);
}
// Embedded image
const proc = new InlineImageProcessor();
return proc.render(href);
},
};
export default dwengoMarkedRenderer;

View file

@ -0,0 +1,39 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/markdown/markdown_processor.js
*/
import { marked } from 'marked';
import InlineImageProcessor from '../image/inline-image-processor.js';
import { DwengoContentType } from '../content-type.js';
import dwengoMarkedRenderer from './dwengo-marked-renderer.js';
import { StringProcessor } from '../string-processor.js';
import { ProcessingError } from '../processing-error.js';
class MarkdownProcessor extends StringProcessor {
constructor() {
super(DwengoContentType.TEXT_MARKDOWN);
}
override renderFn(mdText: string) {
let html = '';
try {
marked.use({ renderer: dwengoMarkedRenderer });
html = marked(mdText, { async: false });
html = this.replaceLinks(html); // Replace html image links path
} catch (e: any) {
throw new ProcessingError(e.message);
}
return html;
}
replaceLinks(html: string) {
const proc = new InlineImageProcessor();
html = html.replace(
/<img.*?src="(.*?)".*?(alt="(.*?)")?.*?(title="(.*?)")?.*?>/g,
(match: string, src: string, alt: string, altText: string, title: string, titleText: string) => proc.render(src)
);
return html;
}
}
export { MarkdownProcessor };

View file

@ -0,0 +1,32 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/pdf/pdf_processor.js
*
* WARNING: The support for PDF learning objects is currently still experimental.
*/
import DOMPurify from 'isomorphic-dompurify';
import { DwengoContentType } from '../content-type.js';
import { isValidHttpUrl } from '../../../../util/links.js';
import { ProcessingError } from '../processing-error.js';
import { StringProcessor } from '../string-processor.js';
class PdfProcessor extends StringProcessor {
constructor() {
super(DwengoContentType.APPLICATION_PDF);
}
override renderFn(pdfUrl: string) {
if (!isValidHttpUrl(pdfUrl)) {
throw new ProcessingError(`PDF URL is invalid: ${pdfUrl}`);
}
return DOMPurify.sanitize(
`
<embed src="${pdfUrl}" type="application/pdf" width="100%" height="800px"/>
`,
{ ADD_TAGS: ['embed'] }
);
}
}
export default PdfProcessor;

View file

@ -0,0 +1,5 @@
export class ProcessingError extends Error {
constructor(error: string) {
super(error);
}
}

View file

@ -0,0 +1,83 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/processing_proxy.js
*/
import BlockImageProcessor from './image/block-image-processor.js';
import InlineImageProcessor from './image/inline-image-processor.js';
import { MarkdownProcessor } from './markdown/markdown-processor.js';
import TextProcessor from './text/text-processor.js';
import AudioProcessor from './audio/audio-processor.js';
import PdfProcessor from './pdf/pdf-processor.js';
import ExternProcessor from './extern/extern-processor.js';
import GiftProcessor from './gift/gift-processor.js';
import { LearningObject } from '../../../entities/content/learning-object.entity.js';
import Processor from './processor.js';
import { DwengoContentType } from './content-type.js';
import { LearningObjectIdentifier } from '../../../interfaces/learning-content.js';
import { Language } from '../../../entities/content/language.js';
import { replaceAsync } from '../../../util/async.js';
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>>;
constructor() {
const processors = [
new InlineImageProcessor(),
new BlockImageProcessor(),
new MarkdownProcessor(),
new TextProcessor(),
new AudioProcessor(),
new PdfProcessor(),
new ExternProcessor(),
new GiftProcessor(),
];
this.processors = new Map(processors.map((processor) => [processor.contentType, processor]));
}
/**
* Render the given learning object.
* @param learningObject The learning object to render
* @param fetchEmbeddedLearningObjects A function which takes a learning object identifier as an argument and
* returns the corresponding learning object. This is used to fetch learning
* objects embedded into this one.
* If this argument is omitted, embedded learning objects will be represented
* by placeholders.
* @returns Rendered HTML for this LearningObject as a string.
*/
async render(
learningObject: LearningObject,
fetchEmbeddedLearningObjects?: (loId: LearningObjectIdentifier) => Promise<LearningObject | null>
): Promise<string> {
const html = this.processors.get(learningObject.contentType)!.renderLearningObject(learningObject);
if (fetchEmbeddedLearningObjects) {
// Replace all embedded learning objects.
return replaceAsync(
html,
EMBEDDED_LEARNING_OBJECT_PLACEHOLDER,
async (_, hruid: string, language: string, version: string): Promise<string> => {
// Fetch the embedded learning object...
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);
}
);
}
return html;
}
}
export default new ProcessingService();

View file

@ -0,0 +1,61 @@
import { LearningObject } from '../../../entities/content/learning-object.entity.js';
import { ProcessingError } from './processing-error.js';
import { DwengoContentType } from './content-type.js';
/**
* Abstract base class for all processors.
* Each processor is responsible for a specific format a learning object can be in, which i tcan render to HTML.
*
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/processor.js
*/
abstract class Processor<T> {
protected constructor(public contentType: DwengoContentType) {}
/**
* Render the given object.
*
* @param toRender Object which has to be rendered to HTML. This object has to be in the format for which this
* Processor is responsible.
* @return Rendered HTML-string
* @throws ProcessingError if the rendering fails.
*/
render(toRender: T): string {
return this.renderFn(toRender);
}
/**
* Render a learning object with the content type for which this processor is responsible.
* @param toRender
*/
renderLearningObject(toRender: LearningObject): string {
if (toRender.contentType !== this.contentType) {
throw new ProcessingError(
`Unsupported content type: ${toRender.contentType}.
This processor is only responsible for content of type ${this.contentType}.`
);
}
return this.renderLearningObjectFn(toRender);
}
/**
* Function which actually renders the content.
*
* @param toRender Content to be rendered
* @return Rendered HTML as a string
* @protected
*/
protected abstract renderFn(toRender: T): string;
/**
* Function which actually executes the rendering of a learning object.
*
* When implementing this function, we may assume that we are responsible for the content type of the learning
* object.
*
* @param toRender Learning object to render
* @protected
*/
protected abstract renderLearningObjectFn(toRender: LearningObject): string;
}
export default Processor;

View file

@ -0,0 +1,19 @@
import Processor from './processor.js';
import { LearningObject } from '../../../entities/content/learning-object.entity.js';
export abstract class StringProcessor extends Processor<string> {
/**
* Function which actually executes the rendering of a learning object.
* By default, this just means rendering the content in the content property of the learning object (interpreted
* as string)
*
* When implementing this function, we may assume that we are responsible for the content type of the learning
* object.
*
* @param toRender Learning object to render
* @protected
*/
protected renderLearningObjectFn(toRender: LearningObject): string {
return this.render(toRender.content.toString('ascii'));
}
}

View file

@ -0,0 +1,20 @@
/**
* Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/text/text_processor.js
*/
import DOMPurify from 'isomorphic-dompurify';
import { DwengoContentType } from '../content-type.js';
import { StringProcessor } from '../string-processor.js';
class TextProcessor extends StringProcessor {
constructor() {
super(DwengoContentType.TEXT_PLAIN);
}
override renderFn(text: string) {
// Sanitize plain text to prevent xss.
return DOMPurify.sanitize(text);
}
}
export default TextProcessor;

View file

@ -1,61 +0,0 @@
import { fetchWithLogging } from '../util/api-helper.js';
import { DWENGO_API_BASE } from '../config.js';
import {
LearningPath,
LearningPathResponse,
} from '../interfaces/learning-path.js';
export async function fetchLearningPaths(
hruids: string[],
language: string,
source: string
): Promise<LearningPathResponse> {
if (hruids.length === 0) {
return {
success: false,
source,
data: null,
message: `No HRUIDs provided for ${source}.`,
};
}
const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`;
const params = { pathIdList: JSON.stringify({ hruids }), language };
const learningPaths = await fetchWithLogging<LearningPath[]>(
apiUrl,
`Learning paths for ${source}`,
params
);
if (!learningPaths || learningPaths.length === 0) {
console.error(`⚠️ WARNING: No learning paths found for ${source}.`);
return {
success: false,
source,
data: [],
message: `No learning paths found for ${source}.`,
};
}
return {
success: true,
source,
data: learningPaths,
};
}
export async function searchLearningPaths(
query: string,
language: string
): Promise<LearningPath[]> {
const apiUrl = `${DWENGO_API_BASE}/learningPath/search`;
const params = { all: query, language };
const searchResults = await fetchWithLogging<LearningPath[]>(
apiUrl,
`Search learning paths with query "${query}"`,
params
);
return searchResults ?? [];
}

View file

@ -0,0 +1,190 @@
import { LearningPathProvider } from './learning-path-provider.js';
import { FilteredLearningObject, LearningObjectNode, LearningPath, LearningPathResponse, Transition } from '../../interfaces/learning-content.js';
import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js';
import { getLearningPathRepository } from '../../data/repositories.js';
import { Language } from '../../entities/content/language.js';
import learningObjectService from '../learning-objects/learning-object-service.js';
import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js';
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
import { getLastSubmissionForCustomizationTarget, isTransitionPossible, PersonalizationTarget } from './learning-path-personalization-util.js';
/**
* Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its
* corresponding learning object.
* @param nodes The nodes to find the learning object for.
*/
async function getLearningObjectsForNodes(nodes: LearningPathNode[]): Promise<Map<LearningPathNode, FilteredLearningObject>> {
// Fetching the corresponding learning object for each of the nodes and creating a map that maps each node to
// Its corresponding learning object.
const nullableNodesToLearningObjects = new Map<LearningPathNode, FilteredLearningObject | null>(
await Promise.all(
nodes.map((node) =>
learningObjectService
.getLearningObjectById({
hruid: node.learningObjectHruid,
version: node.version,
language: node.language,
})
.then((learningObject) => <[LearningPathNode, FilteredLearningObject | null]>[node, learningObject])
)
)
);
if (nullableNodesToLearningObjects.values().some((it) => it === null)) {
throw new Error('At least one of the learning objects on this path could not be found.');
}
return nullableNodesToLearningObjects as Map<LearningPathNode, FilteredLearningObject>;
}
/**
* Convert the given learning path entity to an object which conforms to the learning path content.
*/
async function convertLearningPath(learningPath: LearningPathEntity, order: number, personalizedFor?: PersonalizationTarget): Promise<LearningPath> {
const nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> = await getLearningObjectsForNodes(learningPath.nodes);
const targetAges = nodesToLearningObjects
.values()
.flatMap((it) => it.targetAges || [])
.toArray();
const keywords = nodesToLearningObjects
.values()
.flatMap((it) => it.keywords || [])
.toArray();
const image = learningPath.image ? learningPath.image.toString('base64') : undefined;
const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor);
return {
_id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API.
__order: order,
hruid: learningPath.hruid,
language: learningPath.language,
description: learningPath.description,
image: image,
title: learningPath.title,
nodes: convertedNodes,
num_nodes: learningPath.nodes.length,
num_nodes_left: convertedNodes.filter((it) => !it.done).length,
keywords: keywords.join(' '),
target_ages: targetAges,
max_age: Math.max(...targetAges),
min_age: Math.min(...targetAges),
};
}
/**
* Helper function converting pairs of learning path nodes (as represented in the database) and the corresponding
* learning objects into a list of learning path nodes as they should be represented in the API.
* @param nodesToLearningObjects
* @param personalizedFor
*/
async function convertNodes(
nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>,
personalizedFor?: PersonalizationTarget
): Promise<LearningObjectNode[]> {
const nodesPromise = nodesToLearningObjects
.entries()
.map(async (entry) => {
const [node, learningObject] = entry;
const lastSubmission = personalizedFor ? await getLastSubmissionForCustomizationTarget(node, personalizedFor) : null;
return {
_id: learningObject.uuid,
language: learningObject.language,
start_node: node.startNode,
created_at: node.createdAt.toISOString(),
updatedAt: node.updatedAt.toISOString(),
learningobject_hruid: node.learningObjectHruid,
version: learningObject.version,
transitions: node.transitions
.filter(
(trans) => !personalizedFor || isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)) // If we want a personalized learning path, remove all transitions that aren't possible.
)
.map((trans, i) => convertTransition(trans, i, nodesToLearningObjects)), // Then convert all the transition
};
})
.toArray();
return await Promise.all(nodesPromise);
}
/**
* Helper method to convert a json string to an object, or null if it is undefined.
*/
function optionalJsonStringToObject(jsonString?: string): object | null {
if (!jsonString) {
return null;
}
return JSON.parse(jsonString);
}
/**
* Helper function which converts a transition in the database representation to a transition in the representation
* the Dwengo API uses.
*
* @param transition
* @param index
* @param nodesToLearningObjects
*/
function convertTransition(
transition: LearningPathTransition,
index: number,
nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>
): Transition {
const nextNode = nodesToLearningObjects.get(transition.next);
if (!nextNode) {
throw new Error(`Learning object ${transition.next.learningObjectHruid}/${transition.next.language}/${transition.next.version} not found!`);
} else {
return {
_id: '' + index, // Retained for backwards compatibility. The index uniquely identifies the transition within the learning path.
default: false, // We don't work with default transitions but retain this for backwards compatibility.
next: {
_id: nextNode._id + index, // Construct a unique ID for the transition for backwards compatibility.
hruid: transition.next.learningObjectHruid,
language: nextNode.language,
version: nextNode.version,
},
};
}
}
/**
* Service providing access to data about learning paths from the database.
*/
const databaseLearningPathProvider: LearningPathProvider = {
/**
* Fetch the learning paths with the given hruids from the database.
*/
async fetchLearningPaths(
hruids: string[],
language: Language,
source: string,
personalizedFor?: PersonalizationTarget
): Promise<LearningPathResponse> {
const learningPathRepo = getLearningPathRepository();
const learningPaths = (await Promise.all(hruids.map((hruid) => learningPathRepo.findByHruidAndLanguage(hruid, language)))).filter(
(learningPath) => learningPath !== null
);
const filteredLearningPaths = await Promise.all(
learningPaths.map((learningPath, index) => convertLearningPath(learningPath, index, personalizedFor))
);
return {
success: filteredLearningPaths.length > 0,
data: await Promise.all(filteredLearningPaths),
source,
};
},
/**
* Search learning paths in the database using the given search string.
*/
async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]> {
const learningPathRepo = getLearningPathRepository();
const searchResults = await learningPathRepo.findByQueryStringAndLanguage(query, language);
return await Promise.all(searchResults.map((result, index) => convertLearningPath(result, index, personalizedFor)));
},
};
export default databaseLearningPathProvider;

View file

@ -0,0 +1,50 @@
import { fetchWithLogging } from '../../util/apiHelper.js';
import { DWENGO_API_BASE } from '../../config.js';
import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js';
import { LearningPathProvider } from './learning-path-provider.js';
import { getLogger, Logger } from '../../logging/initalize.js';
const logger: Logger = getLogger();
const dwengoApiLearningPathProvider: LearningPathProvider = {
async fetchLearningPaths(hruids: string[], language: string, source: string): Promise<LearningPathResponse> {
if (hruids.length === 0) {
return {
success: false,
source,
data: null,
message: `No HRUIDs provided for ${source}.`,
};
}
const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`;
const params = { pathIdList: JSON.stringify({ hruids }), language };
const learningPaths = await fetchWithLogging<LearningPath[]>(apiUrl, `Learning paths for ${source}`, { params });
if (!learningPaths || learningPaths.length === 0) {
logger.warn(`⚠️ WARNING: No learning paths found for ${source}.`);
return {
success: false,
source,
data: [],
message: `No learning paths found for ${source}.`,
};
}
return {
success: true,
source,
data: learningPaths,
};
},
async searchLearningPaths(query: string, language: string): Promise<LearningPath[]> {
const apiUrl = `${DWENGO_API_BASE}/learningPath/search`;
const params = { all: query, language };
const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params });
return searchResults ?? [];
},
};
export default dwengoApiLearningPathProvider;

View file

@ -0,0 +1,90 @@
import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js';
import { Student } from '../../entities/users/student.entity.js';
import { Group } from '../../entities/assignments/group.entity.js';
import { Submission } from '../../entities/assignments/submission.entity.js';
import { getClassRepository, getGroupRepository, getStudentRepository, getSubmissionRepository } from '../../data/repositories.js';
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
import { JSONPath } from 'jsonpath-plus';
export type PersonalizationTarget = { type: 'student'; student: Student } | { type: 'group'; group: Group };
/**
* Shortcut function to easily create a PersonalizationTarget object for a student by his/her username.
* @param username Username of the student we want to generate a personalized learning path for.
* If there is no student with this username, return undefined.
*/
export async function personalizedForStudent(username: string): Promise<PersonalizationTarget | undefined> {
const student = await getStudentRepository().findByUsername(username);
if (student) {
return {
type: 'student',
student: student,
};
}
return undefined;
}
/**
* Shortcut function to easily create a PersonalizationTarget object for a group by class name, assignment number and
* group number.
* @param classId Id of the class in which this group was created
* @param assignmentNumber Number of the assignment for which this group was created
* @param groupNumber Number of the group for which we want to personalize the learning path.
*/
export async function personalizedForGroup(
classId: string,
assignmentNumber: number,
groupNumber: number
): Promise<PersonalizationTarget | undefined> {
const clazz = await getClassRepository().findById(classId);
if (!clazz) {
return undefined;
}
const group = await getGroupRepository().findOne({
assignment: {
within: clazz,
id: assignmentNumber,
},
groupNumber: groupNumber,
});
if (group) {
return {
type: 'group',
group: group,
};
}
return undefined;
}
/**
* Returns the last submission for the learning object associated with the given node and for the student or group
*/
export async function getLastSubmissionForCustomizationTarget(node: LearningPathNode, pathFor: PersonalizationTarget): Promise<Submission | null> {
const submissionRepo = getSubmissionRepository();
const learningObjectId: LearningObjectIdentifier = {
hruid: node.learningObjectHruid,
language: node.language,
version: node.version,
};
if (pathFor.type === 'group') {
return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor.group);
}
return await submissionRepo.findMostRecentSubmissionForStudent(learningObjectId, pathFor.student);
}
/**
* Checks whether the condition of the given transaction is fulfilled by the given submission.
* @param transition
* @param submitted
*/
export function isTransitionPossible(transition: LearningPathTransition, submitted: object | null): boolean {
if (transition.condition === 'true' || !transition.condition) {
return true; // If the transition is unconditional, we can go on.
}
if (submitted === null) {
return false; // If the transition is not unconditional and there was no submission, the transition is not possible.
}
const match = JSONPath({ path: transition.condition, json: { submission: submitted } });
return match.length === 1;
}

View file

@ -0,0 +1,18 @@
import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js';
import { Language } from '../../entities/content/language.js';
import { PersonalizationTarget } from './learning-path-personalization-util.js';
/**
* Generic interface for a service which provides access to learning paths from a data source.
*/
export interface LearningPathProvider {
/**
* Fetch the learning paths with the given hruids from the data source.
*/
fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: PersonalizationTarget): Promise<LearningPathResponse>;
/**
* Search learning paths in the data source using the given search string.
*/
searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]>;
}

View file

@ -0,0 +1,57 @@
import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js';
import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js';
import databaseLearningPathProvider from './database-learning-path-provider.js';
import { EnvVars, getEnvVar } from '../../util/envvars.js';
import { Language } from '../../entities/content/language.js';
import { PersonalizationTarget } from './learning-path-personalization-util.js';
const userContentPrefix = getEnvVar(EnvVars.UserContentPrefix);
const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider];
/**
* Service providing access to data about learning paths from the appropriate data source (database or Dwengo-api)
*/
const learningPathService = {
/**
* Fetch the learning paths with the given hruids from the data source.
* @param hruids For each of the hruids, the learning path will be fetched.
* @param language This is the language each of the learning paths will use.
* @param source
* @param personalizedFor If this is set, a learning path personalized for the given group or student will be returned.
*/
async fetchLearningPaths(
hruids: string[],
language: Language,
source: string,
personalizedFor?: PersonalizationTarget
): Promise<LearningPathResponse> {
const userContentHruids = hruids.filter((hruid) => hruid.startsWith(userContentPrefix));
const nonUserContentHruids = hruids.filter((hruid) => !hruid.startsWith(userContentPrefix));
const userContentLearningPaths = await databaseLearningPathProvider.fetchLearningPaths(userContentHruids, language, source, personalizedFor);
const nonUserContentLearningPaths = await dwengoApiLearningPathProvider.fetchLearningPaths(
nonUserContentHruids,
language,
source,
personalizedFor
);
const result = (userContentLearningPaths.data || []).concat(nonUserContentLearningPaths.data || []);
return {
data: result,
source: source,
success: userContentLearningPaths.success || nonUserContentLearningPaths.success,
};
},
/**
* Search learning paths in the data source using the given search string.
*/
async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise<LearningPath[]> {
const providerResponses = await Promise.all(allProviders.map((provider) => provider.searchLearningPaths(query, language, personalizedFor)));
return providerResponses.flat();
},
};
export default learningPathService;