Files
openstapps/packages/core/src/translator.ts

386 lines
14 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
/*
* Copyright (C) 2019-2022 Open StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import equal from 'fast-deep-equal/es6/index.js';
import clone from 'rfdc';
import {SCLanguageCode} from './general/i18n.js';
import {isThing} from './guards.js';
import {SCClasses} from './meta.js';
import {SCThing, SCThingType} from './things/abstract/thing.js';
// eslint disable @typescript-eslint/no-explicit-any
const standardCacheSize = 200;
/**
* SCThingTranslator class
*/
export class SCThingTranslator {
/**
* Getter for language property
*/
get language(): SCLanguageCode {
return this._language;
}
/**
* Setter for language property. Also flushes translation cache
* @param language The language the translator instance will use from now on
*/
set language(language: SCLanguageCode) {
if (language !== this._language) {
this.cache.flush();
}
this._language = language;
}
/**
* Property representing the translators target language
*/
private _language: SCLanguageCode;
/**
* LRU cache containing already translated SCThings
*/
private readonly cache: LRUCache<SCThing>;
/**
* Property providing a mapping from a SCThingType to its known own meta class
*/
private readonly metaClasses: typeof SCClasses;
/**
* LRU cache containing SCThings translations have been provided for
*/
private readonly sourceCache: LRUCache<SCThing>;
/**
* @example
* // returns translator instance for german
* new SCThingTranslator('de');
*/
constructor(language: SCLanguageCode, cacheCapacity: number = standardCacheSize) {
this.cache = new LRUCache(cacheCapacity);
this.sourceCache = new LRUCache(cacheCapacity);
this._language = language;
this.metaClasses = SCClasses;
// Initalize all meta classes once
if (typeof (this.metaClasses as any)[Object.keys(this.metaClasses)[0]] === 'function') {
for (const metaClass of Object.keys(this.metaClasses)) {
(this.metaClasses as any)[metaClass] = new (SCClasses as any)[metaClass]();
}
}
}
/**
* Get field value translation recursively
* @param data The intermediate object / primitive returned by the Proxys get() method
* @returns a T object allowing for access to translations or a translated value(s)
*/
private deeptranslate<T>(data: T): T {
return typeof data === 'object'
? (new Proxy(data as never, {
get: (target, key) => this.deeptranslate(target[key as never]),
}) as unknown as T)
: data;
}
/**
* Applies only known field translations of the given SCThings meta class to an instance
* @param thingType The type of thing that will be translated
* @param language The language the thing property values are translated to
* @returns The thing with all known meta values translated
*/
private getAllMetaFieldTranslations(thingType: SCThingType, language: SCLanguageCode): object | undefined {
const fieldTranslations = {};
const metaClass = this.getMetaClassInstance(thingType);
if (metaClass === undefined) {
return undefined;
}
// Assigns every property in fieldTranslations to the known base language translation
if (metaClass.fieldTranslations.en !== undefined) {
for (const key of Object.keys(metaClass.fieldTranslations.en)) {
(fieldTranslations as any)[key] = metaClass.fieldTranslations.en[key];
}
}
// Assigns every property in fieldTranslations to the known translation in given language
if (metaClass.fieldTranslations[language] !== undefined) {
for (const key of Object.keys(metaClass.fieldTranslations[language])) {
(fieldTranslations as any)[key] = metaClass.fieldTranslations[language][key];
}
}
return fieldTranslations;
}
/**
* Returns meta class needed for translations given a SCThingType
* @param thingType Type of the thing
* @returns An instance of the metaclass
*/
private getMetaClassInstance(thingType: SCThingType): any {
if (thingType in this.metaClasses) {
return this.metaClasses[thingType];
}
return undefined;
}
/**
* Applies known field value translations of the given SCThings meta class to an instance
* Translated values overwrite current values inplace (destructive)
* @param instance The thing / object that will be translated
* @param language The language the thing / object is translated to
* @returns The thing with translated meta field values
*/
private replaceAvailableMetaFieldValueTranslations(instance: any, language: SCLanguageCode): any {
const metaClass = this.getMetaClassInstance(instance.type);
if (metaClass === undefined) {
return instance;
}
if (metaClass.fieldValueTranslations[language] !== undefined) {
for (const key of Object.keys(metaClass.fieldValueTranslations[language])) {
if (
metaClass.fieldValueTranslations[language][key] instanceof Object &&
(instance as any)[key] instanceof Object
) {
// Assigns known translations of subproperties to property in given language (e.g. categories)
for (const subKey of Object.keys((instance as any)[key])) {
(instance as any)[key][subKey] =
metaClass.fieldValueTranslations[language][key][(instance as any)[key][subKey]];
}
} else if (
metaClass.fieldValueTranslations[language][key] instanceof Object &&
typeof (instance as any)[key] === 'string'
) {
// Assigns known translations of enum to property in given language (e.g. SCSettingInputType)
(instance as any)[key] = metaClass.fieldValueTranslations[language][key][(instance as any)[key]];
} else {
// Assigns property to known translation of fieldValueTranslations in given language
(instance as any)[key] = metaClass.fieldValueTranslations[language][key];
}
}
}
return instance;
}
/**
* Recursively translates the given object in-place
* Translated values overwrite current values (destructive)
* @param instance The thing / object that will be translated
* @returns The thing translated
*/
private translateThingInPlaceDestructively<T>(instance: T): T {
const targetLanguage = this.language;
let nextInstance = instance as any;
// Recursively call this function on all nested SCThings, arrays and objects
for (const key of Object.keys(nextInstance)) {
if (
isThing(nextInstance[key]) ||
Array.isArray(nextInstance[key]) ||
nextInstance[key] instanceof Object
) {
nextInstance[key] = this.translateThingInPlaceDestructively(nextInstance[key]);
}
}
// Spread variable translations given by the connector into thing
if (nextInstance.translations?.[targetLanguage] !== undefined) {
nextInstance = {...nextInstance, ...nextInstance.translations![targetLanguage]} as T;
}
// Spread known translations from meta classes into (partly) translated thing
this.replaceAvailableMetaFieldValueTranslations(nextInstance, targetLanguage);
return nextInstance;
}
/**
* Recursively translates the given object in-place
* Translated values overwrite current values (destructive)
* @param thing The thing / object that will be translated
* @returns The thing translated
*/
public translate<T extends SCThing>(thing: T): T {
if (equal(this.sourceCache.get(thing), thing)) {
const cachedInstance = this.cache.get(thing);
if (cachedInstance !== undefined) {
return cachedInstance as T;
}
}
const translatedInstance = this.translateThingInPlaceDestructively(clone()(thing));
delete translatedInstance.translations;
this.cache.putObject(translatedInstance);
this.sourceCache.putObject(thing);
return translatedInstance as T;
}
/**
* Get field value translation recursively
* @example
* const dish: SCDish = {...};
* translator.translate(dish).offers[0].inPlace.categories[1]());
* // or
* const dishTranslatedAccess = translator.translate(dish);
* dishTranslatedAccess.offers[0].inPlace.categories[1]();
* // undoing the TSOCType<T>
* const dishAsBefore: SCDish = dishTranslatedAccess()!;
* @param thing Top level object that gets passed through the recursion
* @returns an TSOCType<T> object allowing for access to translations or a translated value(s)
*/
public translatedAccess<T extends SCThing>(thing: T): T {
return new Proxy(thing, {
get: (target, key) => {
const object: any = target;
if (equal(this.sourceCache.get(thing), thing)) {
const objectTranslatedFromCache = this.cache.get(thing);
if (objectTranslatedFromCache !== undefined) {
return this.deeptranslate((objectTranslatedFromCache as any)[key]);
}
}
const objectTranslated = this.translateThingInPlaceDestructively(clone()(object));
this.cache.putObject(objectTranslated);
this.sourceCache.putObject(thing);
return this.deeptranslate(objectTranslated[key]);
},
});
}
/**
* Given a SCThingType this function returns an object with the same basic structure as the corresponding SCThing
* All the values will be set to the known translations of the property/key name
* @example
* const translatedMetaDish = translator.translatedPropertyNames<SCCourseOfStudy>(SCThingType.CourseOfStudy);
* @param type The type whose property names will be translated
* @returns An object with the properties of the SCThingType where the values are the known property tranlations
*/
public translatedPropertyNames<T extends SCThing>(type: SCThingType): T | undefined {
return this.getAllMetaFieldTranslations(type, this.language) as T;
}
/**
* Given a SCThingType and a corresponding property name it returns the known property value translation
* Access pattern to the meta object containing the translation can be thought of as type.field[key] with key being optional
* @example
* const singleValueTranslation = translator.translatedPropertyValue(SCThingType.Dish, 'categories', 'main dish');
* @param type The type for whose property values a translation is required
* @param field The property for which a translation is required
* @param key If specified tries to access the field with this key
* @returns Known translation for the property
*/
public translatedPropertyValue(type: SCThingType, field: string, key?: string): string | undefined {
const fieldValueTranslations =
this.getMetaClassInstance(type).fieldValueTranslations[this.language] ??
this.getMetaClassInstance(type).fieldValueTranslations.en;
const fieldTranslation = fieldValueTranslations?.[field];
return fieldTranslation?.[key ?? ''] ?? key ?? fieldTranslation;
}
}
/**
* LRUCache class
* Small last recently used cache intended to get used by SCThingTranslator
*/
class LRUCache<T> {
/**
* Map property that manages cached content
*/
private readonly entries: Map<string, T> = new Map<string, T>();
/**
* Property representing cache maximum capacity
*/
private readonly maxEntries: number;
/**
* @example
* // returns LRUCache instance with a maximum capacity of 500
* new LRUCache(500);
*/
constructor(maxEntries: number) {
this.maxEntries = maxEntries;
}
/**
* Flushes cache / removes all entries
*/
public flush() {
this.entries.clear();
}
/**
* Get content from cache by key
*/
public get(somethingOrKey: string): T | undefined;
/**
* Get content from cache by another objects uid
*/
public get<U extends SCThing>(something: U): T | undefined;
/**
* Get content from cache by key or by another objects uid
* @param somethingOrKey The key which maps to the cached content or an object for which content has been cached
* @returns If available the content connected to the key or somethingOrKey.uid property
*/
public get<U extends SCThing>(somethingOrKey: string | U): T | undefined {
let key: string;
if (typeof somethingOrKey === 'string') {
key = somethingOrKey;
} else if (isThing(somethingOrKey)) {
key = somethingOrKey.uid;
} else {
throw new Error(`Passed argument ${somethingOrKey} cannot be key in LRUCache`);
}
const entry = this.entries.get(key);
if (entry !== undefined) {
// LRU behavior
this.entries.delete(key);
this.entries.set(key, entry);
}
return entry;
}
/**
* Place content in cache by key
* @param key The key for which content should be cached
* @param content The content that should be cached
*/
public put(key: string, content: T) {
if (this.entries.size >= this.maxEntries) {
// LRU behavior
const keyToDelete = this.entries.keys().next().value;
this.entries.delete(keyToDelete);
}
this.entries.set(key, content);
}
/**
* Place content in cache by another objects uid
* @param something The object that should be cached under something.uid
*/
public putObject<U extends SCThing>(something: U) {
this.put(something.uid, something as any as T);
}
}