/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.ui.Localization');
goog.provide('shaka.ui.Localization.ConflictResolution');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.FakeEventTarget');
goog.require('shaka.util.Iterables');
goog.require('shaka.util.LanguageUtils');
// TODO: link to the design and usage documentation here
// b/117679670
/**
* Localization system provided by the shaka ui library.
* It can be used to store the various localized forms of
* strings that are expected to be displayed to the user.
* If a string is not available, it will return the localized
* form in the closest related locale.
*
* @implements {EventTarget}
* @final
* @export
*/
shaka.ui.Localization = class {
/**
* @param {string} fallbackLocale
* The fallback locale that should be used. It will be assumed that this
* locale should have entries for just about every request.
*/
constructor(fallbackLocale) {
/** @private {string} */
this.fallbackLocale_ = shaka.util.LanguageUtils.normalize(fallbackLocale);
/**
* The current mappings that will be used when requests are made. Since
* nothing has been loaded yet, there will be nothing in this map.
*
* @private {!Map.<string, string>}
*/
this.currentMap_ = new Map();
/**
* The locales that were used when creating |currentMap_|. Since we don't
* have anything when we first initialize, an empty set means "no
* preference".
*
* @private {!Set.<string>}
*/
this.currentLocales_ = new Set();
/**
* A map of maps where:
* - The outer map is a mapping from locale code to localizations.
* - The inner map is a mapping from id to localized text.
*
* @private {!Map.<string, !Map.<string, string>>}
*/
this.localizations_ = new Map();
/**
* The event target that we will wrap so that we can fire events
* without having to manage the listeners directly.
*
* @private {!EventTarget}
*/
this.events_ = new shaka.util.FakeEventTarget();
}
/**
* @override
* @export
*/
addEventListener(type, listener, options) {
this.events_.addEventListener(type, listener, options);
}
/**
* @override
* @export
*/
removeEventListener(type, listener, options) {
// Apparently Closure says we can be passed a null |option|, but we can't
// pass a null option, so if we get have a null-like |option|, force it to
// be undefined.
this.events_.removeEventListener(type, listener, options || undefined);
}
/**
* @override
* @export
*/
dispatchEvent(event) {
return this.events_.dispatchEvent(event);
}
/**
* Request the localization system to change which locale it serves. If any of
* of the preferred locales cannot be found, the localization system will fire
* an event identifying which locales it does not know. The localization
* system will then continue to operate using the closest matches it has.
*
* @param {!Iterable.<string>} locales
* The locale codes for the requested locales in order of preference.
* @export
*/
changeLocale(locales) {
const Class = shaka.ui.Localization;
// Normalize the locale so that matching will be easier. We need to reset
// our internal set of locales so that we have the same order as the new
// set.
this.currentLocales_.clear();
for (const locale of locales) {
this.currentLocales_.add(shaka.util.LanguageUtils.normalize(locale));
}
this.updateCurrentMap_();
// Check if we have support for the exact locale requested. Even through we
// will do our best to return the most relevant results, we need to tell
// app that some data may be missing.
const missing = shaka.util.Iterables.filter(
this.currentLocales_,
(locale) => !this.localizations_.has(locale));
if (missing.length) {
this.events_.dispatchEvent(new shaka.util.FakeEvent(
Class.UNKNOWN_LOCALES,
(new Map()).set('locales', missing)));
}
const found = shaka.util.Iterables.filter(
this.currentLocales_,
(locale) => this.localizations_.has(locale));
const data = (new Map()).set(
'locales', found.length ? found : [this.fallbackLocale_]);
this.events_.dispatchEvent(new shaka.util.FakeEvent(
Class.LOCALE_CHANGED,
data));
}
/**
* Insert a set of localizations for a single locale. This will amend the
* existing localizations for the given locale.
*
* @param {string} locale
* The locale that the localizations should be added to.
* @param {!Map.<string, string>} localizations
* A mapping of id to localized text that should used to modify the internal
* collection of localizations.
* @param {shaka.ui.Localization.ConflictResolution=} conflictResolution
* The strategy used to resolve conflicts when the id of an existing entry
* matches the id of a new entry. Default to |USE_NEW|, where the new
* entry will replace the old entry.
* @return {!shaka.ui.Localization}
* Returns |this| so that calls can be chained.
* @export
*/
insert(locale, localizations, conflictResolution) {
const Class = shaka.ui.Localization;
const ConflictResolution = shaka.ui.Localization.ConflictResolution;
const FakeEvent = shaka.util.FakeEvent;
// Normalize the locale so that matching will be easier.
locale = shaka.util.LanguageUtils.normalize(locale);
// Default |conflictResolution| to |USE_NEW| if it was not given. Doing it
// here because it would create too long of a parameter list.
if (conflictResolution === undefined) {
conflictResolution = ConflictResolution.USE_NEW;
}
// Make sure we have an entry for the locale because we are about to
// write to it.
const table = this.localizations_.get(locale) || new Map();
localizations.forEach((value, id) => {
// Set the value if we don't have an old value or if we are to replace
// the old value with the new value.
if (!table.has(id) || conflictResolution == ConflictResolution.USE_NEW) {
table.set(id, value);
}
});
this.localizations_.set(locale, table);
// The data we use to make our map may have changed, update the map we pull
// data from.
this.updateCurrentMap_();
this.events_.dispatchEvent(new FakeEvent(Class.LOCALE_UPDATED));
return this;
}
/**
* Set the value under each key in |dictionary| to the resolved value.
* Convenient for apps with some kind of data binding system.
*
* Equivalent to:
* for (const key of dictionary.keys()) {
* dictionary.set(key, localization.resolve(key));
* }
*
* @param {!Map.<string, string>} dictionary
* @export
*/
resolveDictionary(dictionary) {
for (const key of dictionary.keys()) {
// Since we are not changing what keys are in the map, it is safe to
// update the map while iterating it.
dictionary.set(key, this.resolve(key));
}
}
/**
* Request the localized string under the given id. If there is no localized
* version of the string, then the fallback localization will be given
* ("en" version). If there is no fallback localization, a non-null empty
* string will be returned.
*
* @param {string} id The id for the localization entry.
* @return {string}
* @export
*/
resolve(id) {
const Class = shaka.ui.Localization;
const FakeEvent = shaka.util.FakeEvent;
/** @type {string} */
const result = this.currentMap_.get(id);
// If we have a result, it means that it was found in either the current
// locale or one of the fall-backs.
if (result) {
return result;
}
// Since we could not find the result, it means it is missing from a large
// number of locales. Since we don't know which ones we actually checked,
// just tell them the preferred locale.
const data = new Map()
// Make a copy to avoid leaking references.
.set('locales', Array.from(this.currentLocales_))
.set('missing', id);
this.events_.dispatchEvent(new FakeEvent(Class.UNKNOWN_LOCALIZATION, data));
return '';
}
/**
* @private
*/
updateCurrentMap_() {
const LanguageUtils = shaka.util.LanguageUtils;
/** @type {!Map.<string, !Map.<string, string>>} */
const localizations = this.localizations_;
/** @type {string} */
const fallbackLocale = this.fallbackLocale_;
/** @type {!Iterable.<string>} */
const preferredLocales = this.currentLocales_;
/**
* We want to create a single map that gives us the best possible responses
* for the current locale. To do this, we will go through be loosest
* matching locales to the best matching locales. By the time we finish
* flattening the maps, the best result will be left under each key.
*
* Get the locales we should use in order of preference. For example with
* preferred locales of "elvish-WOODLAND" and "dwarfish-MOUNTAIN" and a
* fallback of "common-HUMAN", this would look like:
*
* new Set([
* // Preference 1
* 'elvish-WOODLAND',
* // Preference 1 Base
* 'elvish',
* // Preference 1 Siblings
* 'elvish-WOODLAND', 'elvish-WESTWOOD', 'elvish-MARSH,
* // Preference 2
* 'dwarfish-MOUNTAIN',
* // Preference 2 base
* 'dwarfish',
* // Preference 2 Siblings
* 'dwarfish-MOUNTAIN', 'dwarfish-NORTH', "dwarish-SOUTH",
* // Fallback
* 'common-HUMAN',
* ])
*
* @type {!Set.<string>}
*/
const localeOrder = new Set();
for (const locale of preferredLocales) {
localeOrder.add(locale);
localeOrder.add(LanguageUtils.getBase(locale));
const siblings = shaka.util.Iterables.filter(
localizations.keys(),
(other) => LanguageUtils.areSiblings(other, locale));
// Sort the siblings so that they will always appear in the same order
// regardless of the order of |localizations|.
siblings.sort();
for (const locale of siblings) {
localeOrder.add(locale);
}
const children = shaka.util.Iterables.filter(
localizations.keys(),
(other) => LanguageUtils.getBase(other) == locale);
// Sort the children so that they will always appear in the same order
// regardless of the order of |localizations|.
children.sort();
for (const locale of children) {
localeOrder.add(locale);
}
}
// Finally we add our fallback (something that should have all expected
// entries).
localeOrder.add(fallbackLocale);
// Add all the sibling maps.
/** @type {!Array.<!Map.<string, string>>} */
const mergeOrder = [];
for (const locale of localeOrder) {
const map = localizations.get(locale);
if (map) {
mergeOrder.push(map);
}
}
// We need to reverse the merge order. We build the order based on most
// preferred to least preferred. However, the merge will work in the
// opposite order so we must reverse our maps so that the most preferred
// options will be applied last.
mergeOrder.reverse();
// Merge all the options into our current map.
this.currentMap_.clear();
for (const map of mergeOrder) {
map.forEach((value, key) => {
this.currentMap_.set(key, value);
});
}
// Go through every key we have and see if any preferred locales are
// missing entries. This will allow app developers to find holes in their
// localizations.
/** @type {!Iterable.<string>} */
const allKeys = this.currentMap_.keys();
/** @type {!Set.<string>} */
const missing = new Set();
for (const locale of this.currentLocales_) {
// Make sure we have a non-null map. The diff will be easier that way.
const map = this.localizations_.get(locale) || new Map();
shaka.ui.Localization.findMissingKeys_(map, allKeys, missing);
}
if (missing.size > 0) {
const data = new Map()
// Make a copy of the preferred locales to avoid leaking references.
.set('locales', Array.from(preferredLocales))
// Because more people like arrays more than sets, convert the set to
// an array.
.set('missing', Array.from(missing));
this.events_.dispatchEvent(new shaka.util.FakeEvent(
shaka.ui.Localization.MISSING_LOCALIZATIONS,
data));
}
}
/**
* Go through a map and add all the keys that are in |keys| but not in
* |map| to |missing|.
*
* @param {!Map.<string, string>} map
* @param {!Iterable.<string>} keys
* @param {!Set.<string>} missing
* @private
*/
static findMissingKeys_(map, keys, missing) {
for (const key of keys) {
// Check if the value is missing so that we are sure that it does not
// have a value. We get the value and not just |has| so that a null or
// empty string will fail this check.
if (!map.get(key)) {
missing.add(key);
}
}
}
};
/**
* An enum for how the localization system should resolve conflicts between old
* translations and new translations.
*
* @enum {number}
* @export
*/
shaka.ui.Localization.ConflictResolution = {
'USE_OLD': 0,
'USE_NEW': 1,
};
/**
* The event name for when locales were requested, but we could not find any
* entries for them. The localization system will continue to use the closest
* matches it has.
*
* @const {string}
* @export
*/
shaka.ui.Localization.UNKNOWN_LOCALES = 'unknown-locales';
/**
* The event name for when an entry could not be found in the preferred locale,
* related locales, or the fallback locale.
*
* @const {string}
* @export
*/
shaka.ui.Localization.UNKNOWN_LOCALIZATION = 'unknown-localization';
/**
* The event name for when entries are missing from the user's preferred
* locale, but we were able to find an entry in a related locale or the fallback
* locale.
*
* @const {string}
* @export
*/
shaka.ui.Localization.MISSING_LOCALIZATIONS = 'missing-localizations';
/**
* The event name for when a new locale has been requested and any previously
* resolved values should be updated.
*
* @const {string}
* @export
*/
shaka.ui.Localization.LOCALE_CHANGED = 'locale-changed';
/**
* The event name for when |insert| was called and it changed entries that could
* affect previously resolved values.
*
* @const {string}
* @export
*/
shaka.ui.Localization.LOCALE_UPDATED = 'locale-updated';
/**
* @event shaka.ui.Localization.UnknownLocalesEvent
* @property {string} type
* 'unknown-locales'
* @property {!Array.<string>} locales
* The locales that the user wanted but could not be found.
* @exportDoc
*/
/**
* @event shaka.ui.Localization.MissingLocalizationsEvent
* @property {string} type
* 'unknown-localization'
* @property {!Array.<string>} locales
* The locales that the user wanted.
* @property {string} missing
* The id of the unknown entry.
* @exportDoc
*/
/**
* @event shaka.ui.Localization.MissingLocalizationsEvent
* @property {string} type
* 'missing-localizations'
* @property {string} locale
* The locale that the user wanted.
* @property {!Array.<string>} missing
* The ids of the missing entries.
* @exportDoc
*/
/**
* @event shaka.ui.Localization.LocaleChangedEvent
* @property {string} type
* 'locale-changed'
* @property {!Array.<string>} locales
* The new set of locales that user wanted,
* and that were successfully found.
* @exportDoc
*/