/** * A helper class which assists with localization and string translation * @param {string} serverLanguage The default language configuration setting for the server */ class Localization { constructor(serverLanguage) { // Obtain the default language from application settings const [defaultLanguage, defaultModule] = (serverLanguage || "en.core").split("."); /** * The target language for localization * @type {string} */ this.lang = defaultLanguage; /** * The package authorized to provide default language configurations * @type {string} */ this.defaultModule = defaultModule; /** * The translation dictionary for the target language * @type {Object} */ this.translations = {}; /** * Fallback translations if the target keys are not found * @type {Object} */ this._fallback = {}; } /* -------------------------------------------- */ /** * Cached store of Intl.ListFormat instances. * @type {Record} */ #formatters = {}; /* -------------------------------------------- */ /** * Initialize the Localization module * Discover available language translations and apply the current language setting * @returns {Promise} A Promise which resolves once languages are initialized */ async initialize() { const clientLanguage = await game.settings.get("core", "language") || this.lang; // Discover which modules available to the client this._discoverSupportedLanguages(); // Activate the configured language if ( clientLanguage !== this.lang ) this.defaultModule = "core"; await this.setLanguage(clientLanguage || this.lang); // Define type labels if ( game.system ) { for ( let [documentName, types] of Object.entries(game.documentTypes) ) { const config = CONFIG[documentName]; config.typeLabels = config.typeLabels || {}; for ( const t of types ) { if ( config.typeLabels[t] ) continue; const key = t === CONST.BASE_DOCUMENT_TYPE ? "TYPES.Base" :`TYPES.${documentName}.${t}`; config.typeLabels[t] = key; /** @deprecated since v11 */ const legacyKey = `${documentName.toUpperCase()}.Type${t.titleCase()}`; if ( !this.has(key) && this.has(legacyKey) ) { foundry.utils.logCompatibilityWarning( `You are using the '${legacyKey}' localization key which has been deprecated. ` + `Please define a '${key}' key instead.`, {since: 11, until: 13} ); config.typeLabels[t] = legacyKey; } } } } // Pre-localize data models Localization.#localizeDataModels(); Hooks.callAll("i18nInit"); } /* -------------------------------------------- */ /* Data Model Localization */ /* -------------------------------------------- */ /** * Perform one-time localization of the fields in a DataModel schema, translating their label and hint properties. * @param {typeof DataModel} model The DataModel class to localize * @param {object} options Options which configure how localization is performed * @param {string[]} [options.prefixes] An array of localization key prefixes to use. If not specified, prefixes * are learned from the DataModel.LOCALIZATION_PREFIXES static property. * @param {string} [options.prefixPath] A localization path prefix used to prefix all field names within this * model. This is generally not required. * * @example * JavaScript class definition and localization call. * ```js * class MyDataModel extends foundry.abstract.DataModel { * static defineSchema() { * return { * foo: new foundry.data.fields.StringField(), * bar: new foundry.data.fields.NumberField() * }; * } * static LOCALIZATION_PREFIXES = ["MYMODULE.MYDATAMODEL"]; * } * * Hooks.on("i18nInit", () => { * Localization.localizeDataModel(MyDataModel); * }); * ``` * * JSON localization file * ```json * { * "MYMODULE": { * "MYDATAMODEL": { * "FIELDS" : { * "foo": { * "label": "Foo", * "hint": "Instructions for foo" * }, * "bar": { * "label": "Bar", * "hint": "Instructions for bar" * } * } * } * } * } * ``` */ static localizeDataModel(model, {prefixes, prefixPath}={}) { prefixes ||= model.LOCALIZATION_PREFIXES; Localization.#localizeSchema(model.schema, prefixes, {prefixPath}); } /* -------------------------------------------- */ /** * Perform one-time localization of data model definitions which localizes their label and hint properties. */ static #localizeDataModels() { for ( const document of Object.values(foundry.documents) ) { const cls = document.implementation; Localization.localizeDataModel(cls); for ( const model of Object.values(CONFIG[cls.documentName].dataModels ?? {}) ) { Localization.localizeDataModel(model, {prefixPath: "system."}); } } } /* -------------------------------------------- */ /** * Localize the "label" and "hint" properties for all fields in a data schema. * @param {SchemaField} schema * @param {string[]} prefixes * @param {object} [options] * @param {string} [options.prefixPath] */ static #localizeSchema(schema, prefixes=[], {prefixPath=""}={}) { const getRules = prefixes => { const rules = {}; for ( const prefix of prefixes ) { if ( game.i18n.lang !== "en" ) { const fallback = foundry.utils.getProperty(game.i18n._fallback, `${prefix}.FIELDS`); Object.assign(rules, fallback); } Object.assign(rules, foundry.utils.getProperty(game.i18n.translations, `${prefix}.FIELDS`)); } return rules; }; const rules = getRules(prefixes); // Apply localization to fields of the model schema.apply(function() { // Inner models may have prefixes which take precedence if ( this instanceof foundry.data.fields.EmbeddedDataField ) { if ( this.model.LOCALIZATION_PREFIXES.length ) { foundry.utils.setProperty(rules, this.fieldPath, getRules(this.model.LOCALIZATION_PREFIXES)); } } // Localize model fields let k = this.fieldPath; if ( prefixPath ) k = k.replace(prefixPath, ""); const field = foundry.utils.getProperty(rules, k); if ( field?.label ) this.label = game.i18n.localize(field.label); if ( field?.hint ) this.hint = game.i18n.localize(field.hint); }); } /* -------------------------------------------- */ /** * Set a language as the active translation source for the session * @param {string} lang A language string in CONFIG.supportedLanguages * @returns {Promise} A Promise which resolves once the translations for the requested language are ready */ async setLanguage(lang) { if ( !Object.keys(CONFIG.supportedLanguages).includes(lang) ) { console.error(`Cannot set language ${lang}, as it is not in the supported set. Falling back to English`); lang = "en"; } this.lang = lang; document.documentElement.setAttribute("lang", this.lang); // Load translations and English fallback strings this.translations = await this._getTranslations(lang); if ( lang !== "en" ) this._fallback = await this._getTranslations("en"); } /* -------------------------------------------- */ /** * Discover the available supported languages from the set of packages which are provided * @returns {object} The resulting configuration of supported languages * @private */ _discoverSupportedLanguages() { const sl = CONFIG.supportedLanguages; // Define packages const packages = Array.from(game.modules.values()); if ( game.world ) packages.push(game.world); if ( game.system ) packages.push(game.system); if ( game.worlds ) packages.push(...game.worlds.values()); if ( game.systems ) packages.push(...game.systems.values()); // Registration function const register = pkg => { if ( !pkg.languages.size ) return; for ( let l of pkg.languages ) { if ( !sl.hasOwnProperty(l.lang) ) sl[l.lang] = l.name; } }; // Register core translation languages first for ( let m of game.modules ) { if ( m.coreTranslation ) register(m); } // Discover and register languages for ( let p of packages ) { if ( p.coreTranslation || ((p.type === "module") && !p.active) ) continue; register(p); } return sl; } /* -------------------------------------------- */ /** * Prepare the dictionary of translation strings for the requested language * @param {string} lang The language for which to load translations * @returns {Promise} The retrieved translations object * @private */ async _getTranslations(lang) { const translations = {}; const promises = []; // Include core supported translations if ( CONST.CORE_SUPPORTED_LANGUAGES.includes(lang) ) { promises.push(this._loadTranslationFile(`lang/${lang}.json`)); } // Game system translations if ( game.system ) { this._filterLanguagePaths(game.system, lang).forEach(path => { promises.push(this._loadTranslationFile(path)); }); } // Module translations for ( let module of game.modules.values() ) { if ( !module.active && (module.id !== this.defaultModule) ) continue; this._filterLanguagePaths(module, lang).forEach(path => { promises.push(this._loadTranslationFile(path)); }); } // Game world translations if ( game.world ) { this._filterLanguagePaths(game.world, lang).forEach(path => { promises.push(this._loadTranslationFile(path)); }); } // Merge translations in load order and return the prepared dictionary await Promise.all(promises); for ( let p of promises ) { let json = await p; foundry.utils.mergeObject(translations, json, {inplace: true}); } return translations; } /* -------------------------------------------- */ /** * Reduce the languages array provided by a package to an array of file paths of translations to load * @param {object} pkg The package data * @param {string} lang The target language to filter on * @returns {string[]} An array of translation file paths * @private */ _filterLanguagePaths(pkg, lang) { return pkg.languages.reduce((arr, l) => { if ( l.lang !== lang ) return arr; let checkSystem = !l.system || (game.system && (l.system === game.system.id)); let checkModule = !l.module || game.modules.get(l.module)?.active; if (checkSystem && checkModule) arr.push(l.path); return arr; }, []); } /* -------------------------------------------- */ /** * Load a single translation file and return its contents as processed JSON * @param {string} src The translation file path to load * @returns {Promise} The loaded translation dictionary * @private */ async _loadTranslationFile(src) { // Load the referenced translation file let err; const resp = await fetch(src).catch(e => { err = e; return {}; }); if ( resp.status !== 200 ) { const msg = `Unable to load requested localization file ${src}`; console.error(`${vtt} | ${msg}`); if ( err ) Hooks.onError("Localization#_loadTranslationFile", err, {msg, src}); return {}; } // Parse and expand the provided translation object let json; try { json = await resp.json(); console.log(`${vtt} | Loaded localization file ${src}`); json = foundry.utils.expandObject(json); } catch(err) { Hooks.onError("Localization#_loadTranslationFile", err, { msg: `Unable to parse localization file ${src}`, log: "error", src }); json = {}; } return json; } /* -------------------------------------------- */ /* Localization API */ /* -------------------------------------------- */ /** * Return whether a certain string has a known translation defined. * @param {string} stringId The string key being translated * @param {boolean} [fallback] Allow fallback translations to count? * @returns {boolean} */ has(stringId, fallback=true) { let v = foundry.utils.getProperty(this.translations, stringId); if ( typeof v === "string" ) return true; if ( !fallback ) return false; v = foundry.utils.getProperty(this._fallback, stringId); return typeof v === "string"; } /* -------------------------------------------- */ /** * Localize a string by drawing a translation from the available translations dictionary, if available * If a translation is not available, the original string is returned * @param {string} stringId The string ID to translate * @returns {string} The translated string * * @example Localizing a simple string in JavaScript * ```js * { * "MYMODULE.MYSTRING": "Hello, this is my module!" * } * game.i18n.localize("MYMODULE.MYSTRING"); // Hello, this is my module! * ``` * * @example Localizing a simple string in Handlebars * ```hbs * {{localize "MYMODULE.MYSTRING"}} * ``` */ localize(stringId) { let v = foundry.utils.getProperty(this.translations, stringId); if ( typeof v === "string" ) return v; v = foundry.utils.getProperty(this._fallback, stringId); return typeof v === "string" ? v : stringId; } /* -------------------------------------------- */ /** * Localize a string including variable formatting for input arguments. * Provide a string ID which defines the localized template. * Variables can be included in the template enclosed in braces and will be substituted using those named keys. * * @param {string} stringId The string ID to translate * @param {object} data Provided input data * @returns {string} The translated and formatted string * * @example Localizing a formatted string in JavaScript * ```js * { * "MYMODULE.GREETING": "Hello {name}, this is my module!" * } * game.i18n.format("MYMODULE.GREETING" {name: "Andrew"}); // Hello Andrew, this is my module! * ``` * * @example Localizing a formatted string in Handlebars * ```hbs * {{localize "MYMODULE.GREETING" name="Andrew"}} * ``` */ format(stringId, data={}) { let str = this.localize(stringId); const fmt = /{[^}]+}/g; str = str.replace(fmt, k => { return data[k.slice(1, -1)]; }); return str; } /* -------------------------------------------- */ /** * Retrieve list formatter configured to the world's language setting. * @see [Intl.ListFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/ListFormat/ListFormat) * @param {object} [options] * @param {ListFormatStyle} [options.style=long] The list formatter style, either "long", "short", or "narrow". * @param {ListFormatType} [options.type=conjunction] The list formatter type, either "conjunction", "disjunction", * or "unit". * @returns {Intl.ListFormat} */ getListFormatter({style="long", type="conjunction"}={}) { const key = `${style}${type}`; this.#formatters[key] ??= new Intl.ListFormat(this.lang, {style, type}); return this.#formatters[key]; } /* -------------------------------------------- */ /** * Sort an array of objects by a given key in a localization-aware manner. * @param {object[]} objects The objects to sort, this array will be mutated. * @param {string} key The key to sort the objects by. This can be provided in dot-notation. * @returns {object[]} */ sortObjects(objects, key) { const collator = new Intl.Collator(this.lang); objects.sort((a, b) => { return collator.compare(foundry.utils.getProperty(a, key), foundry.utils.getProperty(b, key)); }); return objects; } }