blob: fb0353850607bb87abe4f5964b66a5b0ee03bd62 [file] [log] [blame]
// Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
'use strict';
/**
* MessageManager class handles internationalized strings.
*
* Note: chrome.i18n isn't sufficient because...
* 1. There's a bug in chrome that makes it unavailable in iframes:
* https://crbug.com/130200
* 2. The client code may not be packaged in a Chrome extension.
* 3. The client code may be part of a library packaged in a third-party
* Chrome extension.
*
* @param {!Array<string>} languages List of languages to load, in the order
* they are preferred. The first language found will be used. 'en' is
* automatically added as the last language if it is not already present.
* @param {boolean=} useCrlf If true, '\n' in messages are substituted for
* '\r\n'. This fixes the translation process which discards '\r'
* characters.
* @constructor
*/
lib.MessageManager = function(languages, useCrlf = false) {
/**
* @private {!Array<string>}
* @const
*/
this.languages_ = [];
let stop = false;
for (let i = 0; i < languages.length && !stop; i++) {
for (const lang of lib.i18n.resolveLanguage(languages[i])) {
// There is no point having any language with lower priorty than 'en'
// since 'en' always contains all messages.
if (lang == 'en') {
stop = true;
break;
}
if (!this.languages_.includes(lang)) {
this.languages_.push(lang);
}
}
}
// Always have 'en' as last fallback.
this.languages_.push('en');
this.useCrlf = useCrlf;
/**
* @private {!Object<string, string>}
* @const
*/
this.messages_ = {};
};
/**
* @typedef {!Object<string, {
* message: string,
* description: (string|undefined),
* placeholders: ({content: string, example: string}|undefined),
* }>}
*/
lib.MessageManager.Messages;
/**
* Add message definitions to the message manager.
*
* This takes an object of the same format of a Chrome messages.json file. See
* <https://developer.chrome.com/extensions/i18n-messages>.
*
* @param {!lib.MessageManager.Messages} defs The message to add to the
* database.
*/
lib.MessageManager.prototype.addMessages = function(defs) {
for (const key in defs) {
const def = defs[key];
if (!def.placeholders) {
// Upper case key into this.messages_ since our translated
// bundles are lower case, but we request msg as upper.
this.messages_[key.toUpperCase()] = def.message;
} else {
// Replace "$NAME$" placeholders with "$1", etc.
this.messages_[key.toUpperCase()] =
def.message.replace(/\$([a-z][^\s$]+)\$/ig, function(m, name) {
return defs[key].placeholders[name.toUpperCase()].content;
});
}
}
};
/**
* Load language message bundles. Loads in reverse order so that higher
* priority languages overwrite lower priority.
*
* @param {string} pattern A url pattern containing a "$1" where the locale
* name should go.
*/
lib.MessageManager.prototype.findAndLoadMessages = async function(pattern) {
if (lib.i18n.browserSupported()) {
return;
}
for (let i = this.languages_.length - 1; i >= 0; i--) {
const lang = this.languages_[i];
const url = lib.i18n.replaceReferences(pattern, lang);
try {
await this.loadMessages(url);
} catch (e) {
console.warn(
`Error fetching ${lang} messages at ${url}`, e,
'Trying all languages in reverse order:', this.languages_);
}
}
};
/**
* Load messages from a messages.json file.
*
* @param {string} url The URL to load the messages from.
* @return {!Promise<void>}
*/
lib.MessageManager.prototype.loadMessages = function(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onload = () => {
try {
this.addMessages(/** @type {!lib.MessageManager.Messages} */ (
JSON.parse(xhr.responseText)));
resolve();
} catch (e) {
// Error parsing JSON.
reject(e);
}
};
xhr.onerror = () => reject(xhr);
xhr.open('GET', url);
xhr.send();
});
};
/**
* Get a message by name, optionally replacing arguments too.
*
* @param {string} msgname String containing the name of the message to get.
* @param {!Array<string>=} args Optional array containing the argument values.
* @param {string=} fallback Optional value to return if the msgname is not
* found. Returns the message name by default.
* @return {string} The formatted translation.
*/
lib.MessageManager.prototype.get = function(msgname, args, fallback) {
// First try the integrated browser getMessage. We prefer that over any
// registered messages as only the browser supports translations.
let message = lib.i18n.getMessage(msgname, args);
if (!message) {
// Look it up in the registered cache next.
message = this.messages_[msgname];
if (!message) {
console.warn('Unknown message: ' + msgname);
message = fallback === undefined ? msgname : fallback;
// Register the message with the default to avoid multiple warnings.
this.messages_[msgname] = message;
}
message = lib.i18n.replaceReferences(message, args);
}
if (this.useCrlf) {
message = message.replace(/\n/g, '\r\n');
}
return message;
};
/**
* Process all of the "i18n" html attributes found in a given element.
*
* The real work happens in processI18nAttribute.
*
* @param {!HTMLDocument|!Element} node The element whose nodes will be
* translated.
*/
lib.MessageManager.prototype.processI18nAttributes = function(node) {
const nodes = node.querySelectorAll('[i18n]');
for (let i = 0; i < nodes.length; i++) {
this.processI18nAttribute(nodes[i]);
}
};
/**
* Process the "i18n" attribute in the specified node.
*
* The i18n attribute should contain a JSON object. The keys are taken to
* be attribute names, and the values are message names.
*
* If the JSON object has a "_" (underscore) key, its value is used as the
* textContent of the element.
*
* Message names can refer to other attributes on the same element with by
* prefixing with a dollar sign. For example...
*
* <button id='send-button'
* i18n='{"aria-label": "$id", "_": "SEND_BUTTON_LABEL"}'
* ></button>
*
* The aria-label message name will be computed as "SEND_BUTTON_ARIA_LABEL".
* Notice that the "id" attribute was appended to the target attribute, and
* the result converted to UPPER_AND_UNDER style.
*
* @param {!Element} node The element to translate.
*/
lib.MessageManager.prototype.processI18nAttribute = function(node) {
// Convert the "lower-and-dashes" attribute names into
// "UPPER_AND_UNDER" style.
const thunk = (str) => str.replace(/-/g, '_').toUpperCase();
let i18n = node.getAttribute('i18n');
if (!i18n) {
return;
}
try {
i18n = JSON.parse(i18n);
} catch (ex) {
console.error('Can\'t parse ' + node.tagName + '#' + node.id + ': ' + i18n);
throw ex;
}
// Load all the messages specified in the i18n attributes.
for (let key in i18n) {
// The node attribute we'll be setting.
const attr = key;
let msgname = i18n[key];
// For "=foo", re-use the referenced message name.
if (msgname.startsWith('=')) {
key = msgname.substr(1);
msgname = i18n[key];
}
// For "$foo", calculate the message name.
if (msgname.startsWith('$')) {
msgname = thunk(node.getAttribute(msgname.substr(1)) + '_' + key);
}
// Finally load the message.
const msg = this.get(msgname);
if (attr == '_') {
node.textContent = msg;
} else {
node.setAttribute(attr, msg);
}
}
};