import {
deflateObject,
inflateObject,
NestedRecord,
mergeObjects,
isNestedRecord,
assert,
camel2Underscore
} from '@rcc/core'
import { Translator } from '../translator.class'
import {
TranslationResult,
isFinal
} from '../interfaces'
// ### Interfaces and ultilities:
export type TranslationMap = NestedRecord<string>
export function isTranslationMap(x:unknown): x is TranslationMap {
return isNestedRecord(x, 'string')
}
export interface ScopedTranslationMap {
scope: string | null
translationMap: { [lang: string] : TranslationMap }
}
export type TranslationKey = string & { __isTranslationKey?: true}
export type TranslationTemplate = string & { __isTranslationTemplate?: true}
export function isTranslationKey(key: string): key is TranslationKey {
if(typeof key != 'string') return false
if(key == '') return false
if(key.match(/^[A-Z0-9_.-]+$/)) return true
return false
}
export function isTranslationTemplate(str: string): str is TranslationTemplate {
if(typeof str != 'string') return false
if(str == '') return false
if(str.match(/{{[^{}]+}}/)) return true
return false
}
/**
* Strips everything, that is not a capital letter, underscore, dot, a digit or a curley bracket, off a given string.
* If the result would be an empty string (thus no translation key) '_EMPTY_KEY' is returned instead and a warning logged.
**/
export function normalizeTranslationKey(str:string) : TranslationKey {
const normalized_str = camel2Underscore(str).toUpperCase().replace(/[^A-Z0-9_.]/g,"")
if(isTranslationKey(normalized_str)) return normalized_str
console.warn(`normalizeTranslationKey() results in empty key for '${str}'.`)
return '_EMPTY_KEY'
}
/**
* This function is meant for basic replacements in translation strings.
* Occurences of %_n_ , with _n_ being some integer, will be replaced by the _n_-th paramter, starting at 1.
* Occurences of %_s_ with _s_ being a single lowercase letter, will be replaced by the parameters in the order of their appearance;
* same lower case letters will be replaced by different values.
* The replacement text will only include capital letters, digits, dots and underscores, everything else will be stripped off:
* ```
* extendTranslationKey('SOME_SCOPE.%s' | fill: paused ? 'player_running':'player_paused') == 'SOME_SCOPE.PLAYER_PAUSED' //true iff pause is truthy.
*
* extendTranslationKey('SOME_SCOPE.GROUP_%2.LABEL_%1' | fill : 'long', 1) == 'SOME_SCOPE.GROUP_1.LABEL_LONG' //true
*
* extendTranslationKey('SOME_SCOPE.GROUP_%s.LABEL_%t' | fill : 'long', 1) == 'SOME_SCOPE.GROUP_LONG.LABEL_1' //true
*
* ```
**/
export function extendTranslationKey(key: TranslationKey, ...content: (string|number)[] ): TranslationKey {
if(!key) return ''
let pos = 0
key = key.replace(/%[a-z]+/g, () => String(content[pos++] || '') )
content.forEach( (item, i) => {
key = key.replace(new RegExp(`%${i+1}`,'g'), String(item))
})
return normalizeTranslationKey(key)
}
// ### Translator class:
/**
* Replaces translation keys with prior provided translations for a given language.
**/
export class StringTranslator extends Translator {
translationMaps : { [index:string] : TranslationMap } = {}
constructor(
scopedTranslationMaps : ScopedTranslationMap[],
){
super()
// Without any translation maps this translator cannot do anything.
assert(scopedTranslationMaps, "StringTranslator.constructor(): missing scopedTranslationsMaps.")
// Setup the central dictionary for all string translations:
scopedTranslationMaps.forEach( scopedTranslationMap => {
const { scope, translationMap } = scopedTranslationMap
for(const language in translationMap ){
this.addTranslation(translationMap[language], language, scope)
}
})
}
get compatibleLanguages() : string[] {
const languages = Object.keys(this.translationMaps)
return languages.length == 0
// This can happens if no translationMap was ever provided,
// so nothing needs to be translated and this translator is
// compatible with all langauges. -.OO.-
? null
: languages
}
match(x: unknown): number {
if(typeof x != 'string') return -1
if(x == '') return -1
if(isTranslationKey(x)) return 1
if(isTranslationTemplate(x)) return 1
return 0
}
/**
* Looks up the entry for key and language in the central dictionary and returns it.
* A translation may have replaceable substrings. Those substrings can be a property name
* `{{somePropertyName}}` or another translation key `{{SOME_SCOPE.MY_KEY}}`.
* If such a substring matches a property of param, the substring will be replaced by that property.
* If a substring matches a translation key, the substring will be replaced by the respective translation.
* Params take prescedence.
*
* ```js
* let stringTranslator = new StringTranslator()
*
* stringTranslator.translate('SOME_SCOPE.GREETING', 'en')
* // May give something like 'Hello {{address}}!'
*
* stringTranslator.translate('SOME_SCOPE.GREETING', 'en', {address:'world'})
* // Will then give 'Hello world!'
*
*
* ```
**/
translate(input: string, language: string, param?: Record<string, unknown>, recursionCount = 0): TranslationResult {
//recursive
// If we can't handle the input with this translator:
if(this.match(input) < 0) return null
//TODO: maybe look for a way not to call match on every recursion call
if(recursionCount > 20) {
console.warn('StringTranslator.translate() recursionCount greater than 20; stopping recursion: '+input)
return null
}
// Check if input is a translation key, that is registered in the central dictionary:
if(isTranslationKey(input)) {
const preliminary_translation = this.deflateMap(input, this.translationMaps[language])
if(typeof preliminary_translation != 'string') return null //no matching translation found
return this.translate(preliminary_translation, language, param, recursionCount++) //maybe the preliminary translation includes new translation keys...
}
// Check if the string is a translation template. (if so it probably comes from a recursion step)
if(isTranslationTemplate(input)){
const translation = input.replace(
/{{([^}]+)}}/g,
(match: string, sub_match: string) => {
sub_match = sub_match.trim()
const param_value = deflateObject(param, sub_match)
const sub_value = param_value !== undefined
? String(param_value) // use params to fill in variables
: sub_match // use dictionary to fill in variables
const sub_translation = this.translate(sub_value, language, param, recursionCount++)
return isFinal(sub_translation)
? sub_translation.final
: sub_value
}
)
return { final: translation }
}
return { final: input }
}
/**
* Add new translations to the central dictionary for a specific language.
* Specifiying a scope will prepend the scope to all translation keys.
**/
addTranslation(translationMap : TranslationMap, language: string, scope : string = '') : void {
if(!scope && typeof translationMap == 'string'){
console.warn('StringTranslator.addTranslation() scope must not be empty, if translationMap is a string;', translationMap, language, scope)
return;
}
const root_table = scope
? this.inflateMap(scope, translationMap)
: translationMap
const mergedTable = mergeObjects(this.translationMaps[language] || {}, root_table)
this.translationMaps[language] = mergedTable
}
/**
* Splits the key into its parts and feeds them recursively into the
* tanslationMap returning a sub map, a translation or null if no matching sub map or translation exist.
* ```js
* let key = 'SOME_SCOPE.SECTION.MY_KEY'
* let stringTranslator = new StringTranslator()
*
*
* stringTranslator.deflateMap(key, translationMap)
* // yields: translationMap['SOME_SCOPE']['SECTION']['MY_KEY']
* ```
**/
deflateMap(key: TranslationKey, translationMap: TranslationMap): string | TranslationMap | null {
return deflateObject(translationMap, key) || null
}
/**
* Splits the key into its parts and recursively prepends them to the given translation map or string.
*
* ```js
* let key = 'SOME_SCOPE.SECTION.MY_KEY'
*
* stringTranslator.inflateMap(key, 'Hello World!')
* // yields: { 'SOME_SCOPE': {'SECTION': { 'MY_KEY': 'Hello World"'} } } }
*
* stringTranslator.inflateMap(key, {'GREETING':Hello World!'} )
* // yields: { 'SOME_SCOPE': {'SECTION': { 'MY_KEY': {'GREETING':Hello World!'}} } } }
* ```
**/
inflateMap(key: TranslationKey, value: string | TranslationMap): TranslationMap {
return inflateObject(value, key)
}
}