Skip to content

Commit

Permalink
feat(h5p-server): hub now localizable (#1200)
Browse files Browse the repository at this point in the history
* feat(h5p-server): support for Hub locales

* feat(h5p-server): added auto-translated Hub locale and tests

* feat(h5p-server): added localization to h5p-rest-example

* feat(h5p-server): added title to hub locale

* feat(h5p-server): hub localization works for untranslated content types
  • Loading branch information
sr258 committed Mar 19, 2021
1 parent 8ed0c33 commit 2d8505c
Show file tree
Hide file tree
Showing 14 changed files with 993 additions and 18 deletions.
6 changes: 3 additions & 3 deletions docs/advanced/localization.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ Some places of H5P cannot be localized at this time (this must be changed by
Joubel):

* Some strings in libraries that are hard-coded
* The H5P Hub content list and description

### Changing the language of the editor

Expand All @@ -35,17 +34,18 @@ shows where this must be done:
| 2. notify H5P editor client | Call `H5PEditor.render(contentId, language, ...)` with the language code you need. |
| 3. properties of IIntegration | Pass a valid `translationCallback` of type `ITranslationFunction` to the constructor of `H5PEditor` |
| 4. error messages emitted by @lumieducation/h5p-server | Catch errors of types `H5PError` and `AggregateH5PError` and localize the message property yourself. |
| 5. H5P Hub | When constructing `H5PEditor` set the option `enableHubLocalization` to true and load the namespace `hub` in your localization system. Call `H5PEditor.getContentTypeCache()` with a language or make sure that `req.language` is set in the get AJAX route when using `h5p-express`. |

The [Express example](/packages/h5p-examples/src/express.ts) demonstrates how to
do 1,2 and 3. The [Express adapter for the Ajax endpoints](/packages/h5p-express/src/H5PAjaxRouter/H5PAjaxExpressRouter.ts)
do 1,2 and 3. The [Express adapter for the Ajax endpoints](/packages/h5p-express/src/H5PAjaxRouter/H5PAjaxExpressRouter.ts)
already implements 4 but requires the `t(...)` function to be added to the `req`
object.

The language strings used by @lumieducation/h5p-server all follow the
conventions of [i18next](https://www.npmjs.com/package/i18next) and it is a good
library to perform the translation for cases 3 and 4. However, you are free to
use whatever translation library you want as long as you make sure to pass a
valid `translationCallback` to `H5PEditor` (case 3) and add the required
valid `translationCallback` to `H5PEditor` (case 3+5) and add the required
`t(...)` function to `req` (case 4).

### Initializing the JavaScript H5P client (in the browser)
Expand Down
6 changes: 5 additions & 1 deletion packages/h5p-examples/src/createH5PEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,11 @@ export default async function createH5PEditor(
: new H5P.fsImplementations.DirectoryTemporaryFileStorage(
localTemporaryPath
),
translationCallback
translationCallback,
undefined,
{
enableHubLocalization: true
}
);

// Set bucket lifecycle configuration for S3 temporary storage to make
Expand Down
1 change: 1 addition & 0 deletions packages/h5p-examples/src/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const start = async (): Promise<void> => {
ns: [
'client',
'copyright-semantics',
'hub',
'metadata-semantics',
'mongo-s3-content-storage',
's3-temporary-storage',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default class H5PAjaxExpressController {
req.query.machineName as string,
req.query.majorVersion as string,
req.query.minorVersion as string,
req.query.language as string,
(req as any).language ?? (req.query.language as string),
req.user
);
res.status(200).send(result);
Expand Down
6 changes: 5 additions & 1 deletion packages/h5p-rest-example-server/src/createH5PEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,11 @@ export default async function createH5PEditor(
: new H5P.fsImplementations.DirectoryTemporaryFileStorage(
localTemporaryPath
),
translationCallback
translationCallback,
undefined,
{
enableHubLocalization: true
}
);

// Set bucket lifecycle configuration for S3 temporary storage to make
Expand Down
1 change: 1 addition & 0 deletions packages/h5p-rest-example-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const start = async (): Promise<void> => {
ns: [
'client',
'copyright-semantics',
'hub',
'metadata-semantics',
'mongo-s3-content-storage',
's3-temporary-storage',
Expand Down
325 changes: 325 additions & 0 deletions packages/h5p-server/assets/translations/hub/de.json

Large diffs are not rendered by default.

403 changes: 403 additions & 0 deletions packages/h5p-server/assets/translations/hub/en.json

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions packages/h5p-server/scripts/create-hub-base-locale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import fsExtra from 'fs-extra';
import path from 'path';

const hubData = fsExtra.readJSONSync(
path.resolve(
path.join(
__dirname,
'../../../test/data/content-type-cache/real-content-types.json'
)
)
);

const reducedHubData = hubData.contentTypes.reduce((prev, ct) => {
prev[ct.id.replace('.', '_')] = {
title: `${ct.title} (${ct.title})`,
summary: ct.summary,
description: ct.description,
keywords: ct.keywords?.reduce((prev, curr) => {
prev[curr.replace(' ', '_')] = curr;
return prev;
}, {})
};
return prev;
}, {});

fsExtra.writeJSONSync(
path.resolve(path.join(__dirname, '../assets/translations/hub/en.json')),
reducedHubData,
{
spaces: 4
}
);
96 changes: 92 additions & 4 deletions packages/h5p-server/src/ContentTypeInformationRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
IHubInfo,
IInstalledLibrary,
ILibraryInstallResult,
ITranslationFunction,
IUser
} from './types';

Expand All @@ -36,21 +37,35 @@ export default class ContentTypeInformationRepository {
* @param contentTypeCache
* @param libraryManager
* @param config
* @param translationCallback (optional) if passed in, the object will try
* to localize content type information (if a language is passed to the
* `get(...)` method). You can safely leave it out if you don't want to
* localize hub information.
*/
constructor(
private contentTypeCache: ContentTypeCache,
private libraryManager: LibraryManager,
private config: IH5PConfig
private config: IH5PConfig,
private translationCallback?: ITranslationFunction
) {
log.info(`initialize`);
}

/**
* Gets the information about available content types with all the extra information as listed in the class description.
* Gets the information about available content types with all the extra
* information as listed in the class description.
*/
public async get(user: IUser): Promise<IHubInfo> {
public async get(user: IUser, language?: string): Promise<IHubInfo> {
log.info(`getting information about available content types`);
const cachedHubInfo = await this.contentTypeCache.get();
let cachedHubInfo = await this.contentTypeCache.get();
if (
this.translationCallback &&
language &&
language.toLowerCase() !== 'en' && // We don't localize English as the base strings already are in English
!language.toLowerCase().startsWith('en-')
) {
cachedHubInfo = this.localizeHubInfo(cachedHubInfo, language);
}
let hubInfoWithLocalInfo = await this.addUserAndInstallationSpecificInfo(
cachedHubInfo,
user
Expand Down Expand Up @@ -294,4 +309,77 @@ export default class ContentTypeInformationRepository {
}
return library.restricted;
}

/**
* Returns a transformed list of content type information in which the
* visible strings are localized into the desired language. Only works if
* the namespace 'hub' has been initialized and populated by the i18n
* system.
* @param contentTypes
* @param language
* @returns the transformed list of content types
*/
private localizeHubInfo(
contentTypes: IHubContentType[],
language: string
): IHubContentType[] {
if (!this.translationCallback) {
throw new Error(
'You need to instantiate ContentTypeInformationRepository with a translationCallback if you want to localize Hub information.'
);
}

return contentTypes.map((ct) => {
const cleanMachineName = ct.machineName.replace('.', '_');
return {
...ct,
summary: this.tryLocalize(
`${cleanMachineName}.summary`,
ct.summary,
language
),
description: this.tryLocalize(
`${cleanMachineName}.description`,
ct.description,
language
),
keywords: ct.keywords.map((kw) =>
this.tryLocalize(
`${ct.machineName.replace(
'.',
'_'
)}.keywords.${kw.replace('_', ' ')}`,
kw,
language
)
),
title: this.tryLocalize(
`${cleanMachineName}.title`,
ct.title,
language
)
};
});
}

/**
* Tries localizing the entry of the content type information. If it fails
* (indicated by the fact that the key is part of the localized string), it
* will return the original source string.
* @param key the key to look up the translation in the i18n data
* @param sourceString the original English string received from the Hub
* @param language the desired language
* @returns the localized string or the original English source string
*/
private tryLocalize(
key: string,
sourceString: string,
language: string
): string {
const localized = this.translationCallback(`hub:${key}`, language);
if (localized.includes(key)) {
return sourceString;
}
return localized;
}
}
8 changes: 5 additions & 3 deletions packages/h5p-server/src/H5PAjaxEndpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export default class H5PAjaxEndpoint {
'You must specify a user when calling getAjax(...).'
);
}
return this.h5pEditor.getContentTypeCache(user);
return this.h5pEditor.getContentTypeCache(user, language);
case 'libraries':
if (
machineName === undefined ||
Expand Down Expand Up @@ -650,7 +650,8 @@ export default class H5PAjaxEndpoint {
).length;

const contentTypeCache = await this.h5pEditor.getContentTypeCache(
user
user,
language
);
return new AjaxSuccessResponse(
contentTypeCache,
Expand Down Expand Up @@ -685,7 +686,8 @@ export default class H5PAjaxEndpoint {
).length;

const contentTypes = await this.h5pEditor.getContentTypeCache(
user
user,
language
);
return new AjaxSuccessResponse(
{
Expand Down
15 changes: 10 additions & 5 deletions packages/h5p-server/src/H5PEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ export default class H5PEditor {
this.contentTypeRepository = new ContentTypeInformationRepository(
this.contentTypeCache,
this.libraryManager,
config
config,
options?.enableHubLocalization ? translationCallback : undefined
);
this.temporaryFileManager = new TemporaryFileManager(
temporaryStorage,
Expand Down Expand Up @@ -305,12 +306,16 @@ export default class H5PEditor {
}

/**
* Returns the content type cache for a specific user. This includes all available content types for the user (some
* might be restricted) and what the user can do with them (update, install from Hub).
* Returns the content type cache for a specific user. This includes all
* available content types for the user (some might be restricted) and what
* the user can do with them (update, install from Hub).
*/
public getContentTypeCache(user: IUser): Promise<IHubInfo> {
public getContentTypeCache(
user: IUser,
language?: string
): Promise<IHubInfo> {
log.info(`getting content type cache`);
return this.contentTypeRepository.get(user);
return this.contentTypeRepository.get(user, language);
}

/**
Expand Down
15 changes: 15 additions & 0 deletions packages/h5p-server/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1835,6 +1835,21 @@ export interface IH5PEditorOptions {
styles?: string[];
};
};
/**
* If true, the system will localize the information about content types
* displayed in the H5P Hub. It will use the translationCallback that is
* passed to H5PEditor for this by getting translations from the namespace
* 'hub'. It will try to localize these language strings:
* hub:H5P_Example.description
* hub:H5P_Example.summary
* hub:H5P_Example.keywords.key_word1
* hub:H5P_Example.keywords.key_word2
* hub:H5P_Example.keywords. ...
* Note that "H5P_Example" is a transformed version of the machineName of
* the content type main library, in which . is replaced by _. In the key
* words whitespaces are replaced by _.
*/
enableHubLocalization?: boolean;
}

/**
Expand Down
Loading

0 comments on commit 2d8505c

Please sign in to comment.