i18n: Fix incorrect chain generation

This commit is contained in:
Michael Fabian 'Xaymar' Dirks
2018-11-29 02:36:26 +01:00
committed by Michael Fabian Dirks
parent c296e7cc6b
commit f92f52bafa
+301 -325
View File
@@ -65,86 +65,87 @@ This cache is refreshed when first attempting to translate to that language,
*/ */
class I18n { class I18n {
/** Create a new object, ready to be used. /** Create a new object, ready to be used.
* *
* @param {string} language Initial base language to base all translations on. * @param {string} language Initial base language to base all translations on.
* @param {string} baseLanguageKey Key to use for base language overrides. (Default = _base) * @param {string} baseLanguageKey Key to use for base language overrides. (Default = _base)
* @throws Exception on invalid parameters. * @throws Exception on invalid parameters.
*/ */
constructor(language, baseLanguageKey = '_base') { constructor(language, baseLanguageKey = '_base') {
this._sanitizeLanguage(language); this._sanitizeLanguage(language);
this._verifyKey(baseLanguageKey); this._verifyKey(baseLanguageKey);
this.languages = new Map(); this.languages = new Map();
this.chains = new Map(); this.chains = new Map();
this.baseLanguage = language; this.baseLanguage = language;
this.baseLanguageKey = baseLanguageKey; this.baseLanguageKey = baseLanguageKey;
this.dirtyTs = performance.now(); this.dirtyTs = performance.now();
this.chainsTs = performance.now(); this.chainsTs = performance.now();
} }
_verifyLanguageKnown(language) { _verifyLanguageKnown(language) {
if (!this.languages.has(language)) { if (!this.languages.has(language)) {
throw 'language unknown'; throw 'language unknown';
} }
} }
_verifyKey(key) { _verifyKey(key) {
if (typeof (key) == 'string') { if (typeof (key) == 'string') {
return; return;
} }
throw 'key must be of type string'; throw 'key must be of type string';
} }
_verifyKeyKnown(language, key) { _verifyKeyKnown(language, key) {
this._verifyLanguageKnown(language); this._verifyLanguageKnown(language);
this._verifyKey(key); this._verifyKey(key);
if (!this.languages.get(language).has(key)) { if (!this.languages.get(language).has(key)) {
throw 'key unknown in language'; throw 'key unknown in language';
} }
} }
_verifyData(data) { _verifyData(data) {
if (typeof (data) == 'string') { if (typeof (data) == 'string') {
return; return;
} }
if (typeof (data) == 'object') { if (typeof (data) == 'object') {
return; return;
} }
if (data instanceof File) { if (data instanceof File) {
return; return;
} }
if (data instanceof Blob) { if (data instanceof Blob) {
return; return;
} }
throw 'data must be of type string, object, File or Blob'; throw 'data must be of type string, object, File or Blob';
} }
_sanitizeLanguage(language) { _sanitizeLanguage(language) {
try { try {
this._verifyKey(language); this._verifyKey(language);
} catch (e) { } catch (e) {
throw 'language must be of type string'; throw 'language must be of type string';
} }
return language.toLowerCase(); return language.toLowerCase();
} }
_defaultResolver(language, key, element) { _defaultResolver(language, key, element) {
return this.translate(key, language); element;
} return this.translate(key, language);
}
_defaultApplier(language, key, translation, element) { _defaultApplier(language, key, translation, element) {
try { try {
element.textContent = translation; element.textContent = translation;
return true; return true;
} catch (e) { } catch (e) {
return false; return false;
} }
} }
/** Caches the necessary language chain for translation. /** Caches the necessary language chain for translation.
* *
* Creates and returns a language chain required for translation, avoiding * Creates and returns a language chain required for translation, avoiding
* recursive loops that never end in the process. The chain will not * recursive loops that never end in the process. The chain will not
@@ -166,127 +167,100 @@ class I18n {
* @param {string} language * @param {string} language
* @returns {array} Translation chain * @returns {array} Translation chain
*/ */
_cacheChain(language) { _cacheChain(language) {
// This caches a chain so that we do not have to rebuild this every // This caches a chain so that we do not have to rebuild this every
// lookup. It is important that there are no recursive loops in this // lookup. It is important that there are no recursive loops in this
// code, which means we can't rely on this function to work until the // code, which means we can't rely on this function to work until the
// cache is completed. // cache is completed.
// Have there been changes to the timestamp? // Have there been changes to the timestamp?
if (this.chainsTs < this.dirtyTs) { if (this.chainsTs < this.dirtyTs) {
// If yes, clear all chains for rebuilding. // If yes, clear all chains for rebuilding.
this.chainsTs = this.dirtyTs; this.chainsTs = this.dirtyTs;
this.chains.clear(); this.chains.clear();
} }
// Do we have a chain cached? // Do we have a chain cached?
if (this.chains.has(language)) { if (this.chains.has(language)) {
// Yes, return the cached chain and go back to translation. // Yes, return the cached chain and go back to translation.
return this.chains.get(language); return this.chains.get(language);
} }
// Create a new chain without relying on our own function. // Create a new chain without relying on our own function.
let chain = [language]; // Chains always contain the language itself. let chain = new Array();
chain.push(language); // Chains always contain the language itself.
// Now we walk through the chain manually, modifying it as we go. // Now we walk through the chain manually, modifying it as we go.
for (let pos = 0; pos < chain.length; pos++) { for (let pos = 0; pos < chain.length; pos++) {
// Check if the language is loaded, if not skip it. // Check if the language is loaded, if not skip it.
if (!this.languages.has(chain[pos])) { if (!this.languages.has(chain[pos])) {
continue; continue;
} }
let languageMap = this.languages.get(chain[pos]);
let languageMap = this.languages.get(chain[pos]); // Check if there is a base language override.
if (!languageMap.has(this.baseLanguageKey)) {
continue;
}
let baseLanguages = languageMap.get(this.baseLanguageKey);
// Check if there is a base language override. // Convert to array for for...in.
if (!languageMap.has(this.baseLanguageKey)) { if (typeof (baseLanguages) == 'string') {
continue; baseLanguages = [baseLanguages];
} } else if (!(baseLanguages instanceof Array)) {
continue;
}
// If yes, walk it. for (let base of baseLanguages) {
let baseLanguages = languageMap.get(this.baseLanguageKey); base = this._sanitizeLanguage(base);
if (typeof (baseLanguages) == 'string') { if (!chain.includes(base) && (this.languages.has(base))) {
baseLanguages = [baseLanguages]; chain.push(base);
} else if (typeof (baseLanguages) == 'array') { }
}
} else { // Append the global base languages if there are no other languages left.
continue; if (pos == (chain.length - 1)) {
} let baseLanguages = this.baseLanguage;
if (typeof(this.baseLanguage) == 'string') {
baseLanguages = [this.baseLanguage];
}
for (let base of baseLanguages) {
if (!chain.includes(base) && (this.languages.has(base))) {
chain.push(base);
}
}
}
}
for (let baseLanguage in baseLanguages) { // Store.
baseLanguage = this._sanitizeLanguage(baseLanguage); this.chains.set(language, chain);
if (!chain.includes(baseLanguage) && (this.languages.has(baseLanguage))) {
chain = chain.push(baseLanguage);
}
}
}
// We are now through with the actual language chain, so we now have to check for the base language chain. // Return.
// The logic for this is identical to the above. return chain;
let baseChain = this.baseLanguage; }
if (typeof (this.baseLanguage) == 'string') {
baseChain = [baseChain];
}
for (let pos = 0; pos < baseChain.length; pos++) {
// Check if the language is loaded, if not skip it.
if (!this.languages.has(baseChain[pos])) {
continue;
}
let languageMap = this.languages.get(baseChain[pos]); /** Check if a language is known.
// Check if there is a base language override.
if (!languageMap.has(this.baseLanguageKey)) {
continue;
}
// If yes, walk it.
let baseLanguages = languageMap.get(this.baseLanguageKey);
if (typeof (baseLanguages) == 'string') {
baseLanguages = [baseLanguages];
} else if (typeof (baseLanguages) == 'array') {
} else {
continue;
}
for (let baseLanguage in baseLanguages) {
baseLanguage = this._sanitizeLanguage(baseLanguage);
if ((!chain.includes(baseLanguage)) && (!baseChain.includes(baseLanguage)) && (this.languages.has(baseLanguage))) {
baseChain = baseChain.push(baseLanguage);
chain.push(baseLanguage);
}
}
}
// Store.
this.chains.set(language, chain);
// Return.
return chain;
}
/** Check if a language is known.
* *
* @param {string} language Name of the language * @param {string} language Name of the language
* @returns {bool} true if known. * @returns {bool} true if known.
*/ */
hasLanguage(language) { hasLanguage(language) {
language = this._sanitizeLanguage(language); language = this._sanitizeLanguage(language);
return this.languages.has(language); return this.languages.has(language);
} }
/** Create a new language. /** Create a new language.
* *
* @param {string} language Name of the language. * @param {string} language Name of the language.
* @throws Exception on invalid parameters. * @throws Exception on invalid parameters.
*/ */
createLanguage(language) { createLanguage(language) {
language = this._sanitizeLanguage(language); language = this._sanitizeLanguage(language);
this.languages.set(language, new Map()); this.languages.set(language, new Map());
this.dirtyTs = performance.now(); this.dirtyTs = performance.now();
} }
/** Load a new language. /** Load a new language.
* *
* @param {string} language Name of the language. * @param {string} language Name of the language.
* @param {File/Blob/object/string} data Data containing a JSON representation of the language. * @param {File/Blob/object/string} data Data containing a JSON representation of the language.
@@ -294,119 +268,121 @@ class I18n {
* @returns {Promise} * @returns {Promise}
* @throws Exception on invalid parameters or invalid data. * @throws Exception on invalid parameters or invalid data.
*/ */
loadLanguage(language, data, encoding = 'utf-8') { loadLanguage(language, data, encoding = 'utf-8') {
// Verify input. // Verify input.
language = this._sanitizeLanguage(language); language = this._sanitizeLanguage(language);
this._verifyData(data); this._verifyData(data);
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
try { try {
let json_data; let json_data;
// Decode File, Blob and string to JSON object. // Decode File, Blob and string to JSON object.
if ((data instanceof File) || (data instanceof Blob)) { if ((data instanceof File) || (data instanceof Blob)) {
await new Promise((resolve2, reject2) => { let freader = new FileReader();
let freader = new FileReader(); await new Promise((resolve2, reject2) => {
freader.onload((ev) => { freader.onload(() => {
resolve2(); resolve2();
}); });
freader.onabort((ev) => { freader.onabort((ev) => {
reject2(ev); reject2(ev);
}); });
freader.onerror((ev) => { freader.onerror((ev) => {
reject2(ev); reject2(ev);
}); });
freader.readAsText(data, encoding); freader.readAsText(data, encoding);
}); });
json_data = JSON.parse(freader.result); json_data = JSON.parse(freader.result);
} else if (typeof (data) == 'string') { } else if (typeof (data) == 'string') {
json_data = JSON.parse(data); json_data = JSON.parse(data);
} else if (typeof (data) == 'object') { } else if (typeof (data) == 'object') {
json_data = data; json_data = data;
} }
let language_map = new Map(); let language_map = new Map();
for (let key in json_data) { for (let key in json_data) {
language_map.set(key, json_data[key]); language_map.set(key, json_data[key]);
} }
this.languages.set(language, language_map); this.languages.set(language, language_map);
this.dirtyTs = performance.now(); this.dirtyTs = performance.now();
resolve(language); resolve(language);
} catch (e) { } catch (e) {
reject(e); reject(e);
return; return;
} }
}); });
} }
/** Save a language. /** Save a language.
* *
* @param {string} language Name of the language. * @param {string} language Name of the language.
* @returns {Promise} Promise that eventually returns the JSON data of the language. * @returns {Promise} Promise that eventually returns the JSON data of the language.
* @throws Exception on invalid parameters and missing language. * @throws Exception on invalid parameters and missing language.
*/ */
saveLanguage(language) { saveLanguage(language) {
language = this._sanitizeLanguage(language); language = this._sanitizeLanguage(language);
this._verifyLanguageKnown(language); this._verifyLanguageKnown(language);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this._verifyLanguageKnown(language); reject;
this._verifyLanguageKnown(language);
let language_data = {}; let language_data = {};
let language_map = this.languages.get(language); let language_map = this.languages.get(language);
language_map.forEach((value, key, map) => { language_map.forEach((value, key, map) => {
language_data[key] = value; map;
}); language_data[key] = value;
let json_data = JSON.stringify(language_data); });
resolve(json_data); let json_data = JSON.stringify(language_data);
}); resolve(json_data);
} });
}
/** Destroy/unload a language. /** Destroy/unload a language.
* *
* @param {string} language Name of the language. * @param {string} language Name of the language.
* @throws Exception on invalid parameters and missing language. * @throws Exception on invalid parameters and missing language.
*/ */
destroyLanguage(language) { destroyLanguage(language) {
language = this._sanitizeLanguage(language); language = this._sanitizeLanguage(language);
this._verifyLanguageKnown(language); this._verifyLanguageKnown(language);
this.languages.delete(language); this.languages.delete(language);
this.dirtyTs = performance.now(); this.dirtyTs = performance.now();
} }
/** Clear a key from a language. /** Clear a key from a language.
* *
* @param {string} language Language to edit. * @param {string} language Language to edit.
* @param {string} key Key to clear. * @param {string} key Key to clear.
* @return {bool} true on success. * @return {bool} true on success.
* @throws Exception on invalid parameters and missing language. * @throws Exception on invalid parameters and missing language.
*/ */
clearKey(language, key) { clearKey(language, key) {
// Verify and sanitize input. // Verify and sanitize input.
language = this._sanitizeLanguage(language); language = this._sanitizeLanguage(language);
this._verifyKey(key); this._verifyKey(key);
// Check if language exists. // Check if language exists.
this._verifyLanguageKnown(language); this._verifyLanguageKnown(language);
// Delete key if exists. // Delete key if exists.
let language_map = this.languages.get(language); let language_map = this.languages.get(language);
if (!language_map.has(key)) { if (!language_map.has(key)) {
return false; return false;
} }
language_map.delete(key); language_map.delete(key);
// If the key was the base language key, set dirty timestamp. // If the key was the base language key, set dirty timestamp.
if (key == this.baseLanguageKey) { if (key == this.baseLanguageKey) {
this.dirtyTs = performance.now(); this.dirtyTs = performance.now();
} }
return true; return true;
} }
/** Set a key in a language /** Set a key in a language
* *
* @param {string} language Language to edit. * @param {string} language Language to edit.
* @param {string} key Key to set. * @param {string} key Key to set.
@@ -415,73 +391,73 @@ class I18n {
* @return {bool} true on success. * @return {bool} true on success.
* @throws Exception on invalid parameters and missing language. * @throws Exception on invalid parameters and missing language.
*/ */
setKey(language, key, value, force = true) { setKey(language, key, value, force = true) {
// Verify and sanitize input. // Verify and sanitize input.
language = this._sanitizeLanguage(language); language = this._sanitizeLanguage(language);
this._verifyKey(key); this._verifyKey(key);
// Check if language exists. // Check if language exists.
this._verifyLanguageKnown(language); this._verifyLanguageKnown(language);
// Set key. // Set key.
let language_map = this.languages.get(language); let language_map = this.languages.get(language);
if ((language_map.has(key)) && !force) { if ((language_map.has(key)) && !force) {
return false; return false;
} }
language_map.set(key, value); language_map.set(key, value);
// If the key was the base language key, set dirty timestamp. // If the key was the base language key, set dirty timestamp.
if (key == this.baseLanguageKey) { if (key == this.baseLanguageKey) {
this.dirtyTs = performance.now(); this.dirtyTs = performance.now();
} }
return true; return true;
} }
/** Get a key in a language /** Get a key in a language
* *
* @param {string} language * @param {string} language
* @param {string} key * @param {string} key
* @return {*} the value * @return {*} the value
* @throws Exception on invalid parameters, missing language and missing key. * @throws Exception on invalid parameters, missing language and missing key.
*/ */
getKey(language, key) { getKey(language, key) {
// Verify and sanitize input. // Verify and sanitize input.
language = this._sanitizeLanguage(language); language = this._sanitizeLanguage(language);
this._verifyKeyKnown(language, key); this._verifyKeyKnown(language, key);
// Get Key // Get Key
let language_map = this.languages.get(language); let language_map = this.languages.get(language);
return language_map.get(key); return language_map.get(key);
} }
/** Translate a single string to any loaded language. /** Translate a single string to any loaded language.
* *
* @param {string} key String to translate * @param {string} key String to translate
* @param {string} language Language to translate to * @param {string} language Language to translate to
* @return {string} Translated string, or if failed the string plus the language appended. * @return {string} Translated string, or if failed the string plus the language appended.
* @throws Exception on invalid parameters. * @throws Exception on invalid parameters.
*/ */
translate(key, language) { translate(key, language) {
// Verify and sanitize input. // Verify and sanitize input.
language = this._sanitizeLanguage(language); language = this._sanitizeLanguage(language);
this._verifyKey(key); this._verifyKey(key);
// Translate using the translation chain. // Translate using the translation chain.
let chain = this._cacheChain(language); let chain = this._cacheChain(language);
let translated = key; let translated = key;
for (let language of chain) { for (let language of chain) {
let languageMap = this.languages.get(language); let languageMap = this.languages.get(language);
if (languageMap.has(key)) { if (languageMap.has(key)) {
translated = languageMap.get(key); translated = languageMap.get(key);
break; break;
} }
} }
return translated; return translated;
} }
/** Automatically translate the entire page using the specified property on elements. /** Automatically translate the entire page using the specified property on elements.
* *
* @param {string} language Language to translate to * @param {string} language Language to translate to
* @param {string} property Property to search for (default 'data-i18n') * @param {string} property Property to search for (default 'data-i18n')
@@ -490,42 +466,42 @@ class I18n {
* @returns {Promise} resolved when successful, rejected if applier returns false. * @returns {Promise} resolved when successful, rejected if applier returns false.
* @throws Exception on invalid parameters. * @throws Exception on invalid parameters.
*/ */
domTranslate(language, property = 'data-i18n', resolver = undefined, applier = undefined) { domTranslate(language, property = 'data-i18n', resolver = undefined, applier = undefined) {
language = this._sanitizeLanguage(language); language = this._sanitizeLanguage(language);
if ((resolver != undefined) && (typeof (resolver) != 'function')) { if ((resolver != undefined) && (typeof (resolver) != 'function')) {
throw 'resolver must be a function'; throw 'resolver must be a function';
} else if (resolver == undefined) { } else if (resolver == undefined) {
let self = this; let self = this;
resolver = (language, key, el) => { resolver = (language, key, el) => {
return self._defaultResolver(language, key, el); return self._defaultResolver(language, key, el);
}; };
} }
if ((applier != undefined) && (typeof (applier) != 'function')) { if ((applier != undefined) && (typeof (applier) != 'function')) {
throw 'applier must be a function'; throw 'applier must be a function';
} else if (applier == undefined) { } else if (applier == undefined) {
let self = this; let self = this;
applier = (language, key, translation, el) => { applier = (language, key, translation, el) => {
return self._defaultApplier(language, key, translation, el); return self._defaultApplier(language, key, translation, el);
}; };
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let els = document.querySelectorAll(`[${property}]`); let els = document.querySelectorAll(`[${property}]`);
for (let el of els) { for (let el of els) {
let key = el.getAttribute(property); let key = el.getAttribute(property);
if (!applier(language, key, resolver(language, key, el), el)) { if (!applier(language, key, resolver(language, key, el), el)) {
reject(); reject();
return; return;
} }
} }
resolve(); resolve();
}); });
} }
} }
// Compatible with Node.js and Browsers // Compatible with Node.js and Browsers
if (typeof (module) != 'undefined') { if (typeof (module) != 'undefined') {
module.exports = exports = { module.exports = exports = {
'I18n': I18n 'I18n': I18n
} };
} }