/* This code is part of Freenet. It is distributed under the GNU General
* Public License, version 2 (or at your option any later version). See
* http://www.gnu.org/ for further details of the GPL. */
package freenet.l10n;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.MissingResourceException;
import freenet.clients.http.TranslationToadlet;
import freenet.support.HTMLEncoder;
import freenet.support.HTMLNode;
import freenet.support.Logger;
import freenet.support.SimpleFieldSet;
import freenet.support.io.Closer;
import freenet.support.io.FileUtil;
/**
* This is the core of all the localization stuff. This method can get
* localized strings from any SimpleFieldSet file, and can, if necessary,
* use a custom ClassLoader. The language can be changed at anytime.
*
* Note : do not use this class *as is*, use NodeL10n.getBase() or
* PluginL10n.getBase().
*
* Note : this class also supports using/saving/editing overriden translations.
* @author Florent Daignière <nextgens@freenetproject.org>
* @author Artefact2
*/
public class BaseL10n {
/**
* @see "http://www.omniglot.com/language/names.htm"
* @see "http://loc.gov/standards/iso639-2/php/code_list.php"
*/
public enum LANGUAGE {
// Windows language codes must be preceded with WINDOWS and be in upper case hex, 4 digits.
// See http://www.autohotkey.com/docs/misc/Languages.htm
ENGLISH("en", "English", "eng", new String[] { "WINDOWS0409", "WINDOWS0809", "WINDOWS0C09", "WINDOWS1009", "WINDOWS1409", "WINDOWS1809", "WINDOWS1C09", "WINDOWS2009", "WINDOWS2409", "WINDOWS2809", "WINDOWS2C09", "WINDOWS3009", "WINDOWS3409"}),
SPANISH("es", "Español", "spa", new String[] { "WINDOWS040A", "WINDOWS080A", "WINDOWS0C0A", "WINDOWS100A", "WINDOWS140A", "WINDOWS180A", "WINDOWS1C0A", "WINDOWS200A", "WINDOWS240A", "WINDOWS280A", "WINDOWS2C0A", "WINDOWS300A", "WINDOWS340A", "WINDOWS380A", "WINDOWS3C0A", "WINDOWS400A", "WINDOWS440A", "WINDOWS480A", "WINDOWS4C0A", "WINDOWS500A"}),
DANISH("da", "Dansk", "dan", new String[] { "WINDOWS0406" }),
DUTCH("nl", "Nederlands", "nld", new String[] { "WINDOWS0413", "WINDOWS0813"}),
GERMAN("de", "Deutsch", "deu", new String[] { "WINDOWS0407", "WINDOWS0807", "WINDOWS0C07", "WINDOWS1007", "WINDOWS1407"}),
FINNISH("fi", "Suomi", "fin", new String[] { "WINDOWS040B"}),
FRENCH("fr", "Français", "fra", new String[] { "WINDOWS040C", "WINDOWS080C", "WINDOWS0C0C", "WINDOWS100C", "WINDOWS140C", "WINDOWS180C"}),
ITALIAN("it", "Italiano", "ita", new String[] { "WINDOWS0410", "WINDOWS0810"}),
NORWEGIAN("no", "Norsk", "nor", new String[] { "WINDOWS0414", "WINDOWS0814"}),
POLISH("pl", "Polski", "pol", new String[] { "WINDOWS0415"}),
SWEDISH("sv", "Svenska", "swe", new String[] { "WINDOWS041D", "WINDOWS081D"}),
CHINESE("zh-cn", "中文(简体)", "chn", new String[] { "WINDOWS0804", "WINDOWS1004" }),
// simplified chinese, used on mainland, Singapore and Malaysia
CHINESE_TAIWAN("zh-tw", "中文(繁體)", "zh-tw", new String[] { "WINDOWS0404", "WINDOWS0C04", "WINDOWS1404" }),
// traditional chinese, used in Taiwan, Hong Kong and Macau
RUSSIAN("ru", "Русский", "rus", new String[] { "WINDOWS0419" }), // Just one variant for russian. Belorussian is separate, code page 423, speakers may or may not speak russian, I'm not including it.
JAPANESE("ja", "日本語", "jpn", new String[] { "WINDOWS0411" }),
BRAZILIAN_PORTUGUESE("pt-br", "Português do Brasil", "pt-br", new String[] { "WINDOWS0416" }),
UNLISTED("unlisted", "unlisted", "unlisted", new String[] {});
/** The identifier we use internally : MUST BE UNIQUE! */
public final String shortCode;
/** The identifier shown to the user */
public final String fullName;
/** The mapping with the installer's l10n (@see bug #2424); MUST BE UNIQUE! */
public final String isoCode;
public final String[] aliases;
private LANGUAGE(String shortCode, String fullName, String isoCode, String[] aliases) {
this.shortCode = shortCode;
this.fullName = fullName;
this.isoCode = isoCode;
this.aliases = aliases;
}
LANGUAGE(LANGUAGE l) {
this(l.shortCode, l.fullName, l.isoCode, l.aliases);
}
/**
* Create a new LANGUAGE object from either its short code, its full
* name or its ISO code.
* @param whatever Short code, full name or ISO code.
* @return LANGUAGE
*/
public static LANGUAGE mapToLanguage(String whatever) {
for (LANGUAGE currentLanguage : LANGUAGE.values()) {
if (currentLanguage.shortCode.equalsIgnoreCase(whatever) ||
currentLanguage.fullName.equalsIgnoreCase(whatever) ||
currentLanguage.isoCode.equalsIgnoreCase(whatever) ||
currentLanguage.toString().equalsIgnoreCase(whatever)) {
return currentLanguage;
}
if(currentLanguage.aliases != null) {
for(String s : currentLanguage.aliases)
if(whatever.equalsIgnoreCase(s)) return currentLanguage;
}
}
return null;
}
public static String[] valuesWithFullNames() {
LANGUAGE[] allValues = values();
String[] result = new String[allValues.length];
for (int i = 0; i < allValues.length; i++) {
result[i] = allValues[i].fullName;
}
return result;
}
public static LANGUAGE getDefault() {
return ENGLISH;
}
}
private LANGUAGE lang;
private String l10nFilesBasePath;
private String l10nFilesMask;
private String l10nOverrideFilesMask;
private SimpleFieldSet currentTranslation = null;
private SimpleFieldSet fallbackTranslation = null;
private SimpleFieldSet translationOverride;
private ClassLoader cl;
private static ClassLoader getClassLoaderFallback() {
ClassLoader _cl;
// getClassLoader() can return null on some implementations if the boot classloader was used.
_cl = BaseL10n.class.getClassLoader();
if (_cl == null) {
_cl = ClassLoader.getSystemClassLoader();
}
return _cl;
}
public BaseL10n(String l10nFilesBasePath, String l10nFilesMask, String l10nOverrideFilesMask) {
this(l10nFilesBasePath, l10nFilesMask, l10nOverrideFilesMask, LANGUAGE.getDefault());
}
public BaseL10n(String l10nFilesBasePath, String l10nFilesMask, String l10nOverrideFilesMask, final LANGUAGE lang) {
this(l10nFilesBasePath, l10nFilesMask, l10nOverrideFilesMask, lang, getClassLoaderFallback());
}
/**
* Create a new BaseL10n object.
*
* Note : you shouldn't have to run this yourself. Use PluginL10n or NodeL10n.
* @param l10nFilesBasePath Base path of the l10n files, ex. "com/mycorp/myproject/l10n"
* @param l10nFilesMask Mask of the l10n files, ex. "messages_${lang}.l10n"
* @param l10nOverrideFilesMask Same as l10nFilesMask, but for overriden messages.
* @param lang Language to use.
* @param cl ClassLoader to use.
*/
public BaseL10n(String l10nFilesBasePath, String l10nFilesMask, String l10nOverrideFilesMask, final LANGUAGE lang, final ClassLoader cl) {
if (!l10nFilesBasePath.endsWith("/")) {
l10nFilesBasePath += "/";
}
this.l10nFilesBasePath = l10nFilesBasePath;
this.l10nFilesMask = l10nFilesMask;
this.l10nOverrideFilesMask = l10nOverrideFilesMask;
this.cl = cl;
this.setLanguage(lang);
}
/**
* Get the full base name of the L10n file used by the current language.
* @return String
*/
public String getL10nFileName(LANGUAGE lang) {
return this.l10nFilesBasePath + this.l10nFilesMask.replace("${lang}", lang.shortCode);
}
/**
* Get the full base name of the L10n override file used by the current language.
* @return String
*/
public String getL10nOverrideFileName(LANGUAGE lang) {
return this.l10nOverrideFilesMask.replace("${lang}", lang.shortCode);
}
/**
* Use a new language, and load the SimpleFieldSets accordingly.
* @param selectedLanguage New language to use.
* @throws MissingResourceException If the l10n file could not be found.
*/
public void setLanguage(final LANGUAGE selectedLanguage) throws MissingResourceException {
if (selectedLanguage == null) {
throw new MissingResourceException("LANGUAGE given is null !", this.getClass().getName(), "");
}
this.lang = selectedLanguage;
Logger.normal(this.getClass(), "Changing the current language to : " + this.lang);
try {
this.loadOverrideFileOrBackup();
} catch (IOException e) {
this.translationOverride = null;
Logger.error(this, "IOError while accessing the file!" + e.getMessage(), e);
}
this.currentTranslation = this.loadTranslation(lang);
if (this.currentTranslation == null) {
Logger.error(this, "The translation file for " + lang + " is invalid. The node will load an empty template.");
this.currentTranslation = null;
}
}
/**
* Try loading the override file, or the backup override file if it
* exists.
* @throws IOException
*/
private void loadOverrideFileOrBackup() throws IOException {
final File tmpFile = new File(this.getL10nOverrideFileName(this.lang));
if (tmpFile.exists() && tmpFile.canRead() && tmpFile.length() > 0) {
Logger.normal(this, "Override file detected : let's try to load it");
this.translationOverride = SimpleFieldSet.readFrom(tmpFile, false, false);
} else {
// try to restore a backup
final File backup = new File(tmpFile.getParentFile(), tmpFile.getName() + ".bak");
if (backup.exists() && backup.length() > 0) {
Logger.normal(this, "Override-backup file detected : let's try to load it");
this.translationOverride = SimpleFieldSet.readFrom(backup, false, false);
} else {
this.translationOverride = null;
}
}
}
/**
* Load the l10n file for a custom language and return its parsed SimpleFieldSet.
* @param lang Language to use.
* @return SimpleFieldSet
*/
private SimpleFieldSet loadTranslation(LANGUAGE lang) {
SimpleFieldSet result = null;
InputStream in = null;
try {
// Returns null on lookup failures:
in = this.cl.getResourceAsStream(this.getL10nFileName(lang));
if (in != null) {
result = SimpleFieldSet.readFrom(in, false, false);
} else {
System.err.println("Could not get resource : " + this.getL10nFileName(lang));
}
} catch (Exception e) {
System.err.println("Error while loading the l10n file from " + this.getL10nFileName(lang) + " :" + e.getMessage());
e.printStackTrace();
result = null;
} finally {
Closer.close(in);
}
return result;
}
/**
* Load the fallback translation. Synchronized.
*/
private synchronized void loadFallback() {
if (this.fallbackTranslation == null) {
this.fallbackTranslation = loadTranslation(LANGUAGE.getDefault());
if(fallbackTranslation == null)
fallbackTranslation = new SimpleFieldSet(true);
}
}
/**
* Get the language currently used by this BaseL10n.
* @return LANGUAGE
*/
public LANGUAGE getSelectedLanguage() {
return this.lang;
}
/**
* Returns true if a key is overriden.
* @param key Key to check override status
* @return boolean
*/
public boolean isOverridden(String key) {
if (this.translationOverride == null) {
return false;
}
return this.translationOverride.get(key) != null;
}
/**
* Override a custom key with a new value.
* @param key Key to override.
* @param value New value of that key.
*/
public void setOverride(String key, String value) {
key = key.trim();
value = value.trim();
// Is the override already declared ? if not, create it.
if (this.translationOverride == null) {
this.translationOverride = new SimpleFieldSet(false);
}
// If there is no need to keep it in the override, remove it...
// unless the original/default is the same as the translation
if ("".equals(value) || (currentTranslation != null && value.equals(this.currentTranslation.get(key)))) {
this.translationOverride.removeValue(key);
} else {
value = value.replaceAll("(\r|\n|\t)+", "");
// Set the value of the override
this.translationOverride.putOverwrite(key, value);
Logger.normal(this.getClass(), "Got a new translation key: set the Override!");
}
// Save the file to disk
saveTranslationFile();
}
/**
* Save the SimpleFieldSet of overriden keys in a file.
*/
private void saveTranslationFile() {
FileOutputStream fos = null;
File finalFile = new File(this.getL10nOverrideFileName(this.lang));
try {
// We don't set deleteOnExit on it : if the save operation fails, we want a backup
File tempFile = File.createTempFile(finalFile.getName(), ".bak", finalFile.getParentFile());;
Logger.minor(this.getClass(), "The temporary filename is : " + tempFile);
fos = new FileOutputStream(tempFile);
this.translationOverride.writeToBigBuffer(fos);
fos.close();
fos = null;
FileUtil.renameTo(tempFile, finalFile);
Logger.normal(this.getClass(), "Override file saved successfully!");
} catch (IOException e) {
Logger.error(this.getClass(), "Error while saving the translation override: " + e.getMessage(), e);
} finally {
Closer.close(fos);
}
}
/**
* Get a copy of the currently used SimpleFieldSet.
* @return SimpleFieldSet
*/
public SimpleFieldSet getCurrentLanguageTranslation() {
return (this.currentTranslation == null ? null : new SimpleFieldSet(currentTranslation));
}
/**
* Get a copy of the currently used SimpleFieldSet (overriden messages).
* @return SimpleFieldSet
*/
public SimpleFieldSet getOverrideForCurrentLanguageTranslation() {
return (this.translationOverride == null ? null : new SimpleFieldSet(translationOverride));
}
/**
* Get the SimpleFieldSet of the default language (should be english).
* @return SimpleFieldSet
*/
public SimpleFieldSet getDefaultLanguageTranslation() {
this.loadFallback();
return new SimpleFieldSet(this.fallbackTranslation);
}
/**
* Get a localized string. Return "" (empty string) if it doesn't exist.
* @param key Key to search for.
* @return String
*/
public String getString(String key) {
return getString(key, false);
}
/**
* Get a localized string.
* @param key Key to search for.
* @param returnNullIfNotFound If this is true, will return null if the key is not found.
* @return String
*/
public String getString(String key, boolean returnNullIfNotFound) {
String result = null;
if (this.translationOverride != null) {
result = this.translationOverride.get(key);
}
if (result != null) {
return result;
}
if (this.currentTranslation != null) {
result = this.currentTranslation.get(key);
}
if (result != null) {
return result;
} else {
Logger.normal(this.getClass(), "The translation for " + key + " hasn't been found (" + this.getSelectedLanguage() + ")! please tell the maintainer.");
return (returnNullIfNotFound ? null : this.getDefaultString(key));
}
}
/**
* Get a localized string and put it in a HTMLNode for the translation page.
* @param values Values to replace patterns with.
* @return HTMLNode
*/
public HTMLNode getHTMLNode(String key) {
return getHTMLNode(key, null, null);
}
/**
* Get a localized string and put it in a HTMLNode for the translation page.
* @param key Key to search for.
* @param patterns Patterns to replace. May be null, if so values must also be null.
* @param values Values to replace patterns with.
* @return HTMLNode
*/
public HTMLNode getHTMLNode(String key, String[] patterns, String[] values) {
String value = this.getString(key, true);
if (value != null) {
if(patterns != null)
return new HTMLNode("#", getString(key, patterns, values));
else
return new HTMLNode("#", value);
}
HTMLNode translationField = new HTMLNode("span", "class", "translate_it");
if(patterns != null)
translationField.addChild("#", getDefaultString(key, patterns, values));
else
translationField.addChild("#", getDefaultString(key));
translationField.addChild("a", "href", TranslationToadlet.TOADLET_URL + "?translate=" + key).addChild("small", " (translate it in your native language!)");
return translationField;
}
/**
* Get the default value for a key.
* @param key Key to search for.
* @return String
*/
public String getDefaultString(String key) {
String result = null;
this.loadFallback();
result = this.fallbackTranslation.get(key);
if (result != null) {
return result;
}
Logger.error(this.getClass(), "The default translation for " + key + " hasn't been found!");
System.err.println("The default translation for " + key + " hasn't been found!");
new Exception().printStackTrace();
return key;
}
/**
* Get the default value for a key.
* @param key Key to search for.
* @return String
*/
public String getDefaultString(String key, String[] patterns, String[] values) {
assert (patterns.length == values.length);
String result = getDefaultString(key);
for (int i = 0; i < patterns.length; i++) {
result = result.replaceAll("\\$\\{" + patterns[i] + "\\}", quoteReplacement(values[i]));
}
return result;
}
/**
* Get a localized string, and replace on-the-fly some values.
* @param key Key to search for.
* @param patterns Patterns to replace, ${ and } are not included.
* @param values Replacement values.
* @return String
*/
public String getString(String key, String[] patterns, String[] values) {
assert (patterns.length == values.length);
String result = getString(key);
for (int i = 0; i < patterns.length; i++) {
result = result.replaceAll("\\$\\{" + patterns[i] + "\\}", quoteReplacement(values[i]));
}
return result;
}
/**
* Get a localized string, and replace on-the-fly a value.
* @param key Key to search for.
* @param pattern Pattern to replace, ${ and } not included.
* @param value Replacement value.
* @return String
*/
public String getString(String key, String pattern, String value) {
return getString(key, new String[]{pattern}, new String[]{value}); // FIXME code efficiently!
}
/**
* Escape null, $ and \.
* @param s String to parse
* @return String
*/
private String quoteReplacement(String s) {
if (s == null) {
return "(null)";
}
if ((s.indexOf('\\') == -1) && (s.indexOf('$') == -1)) {
return s;
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '\\') {
sb.append('\\');
sb.append('\\');
} else if (c == '$') {
sb.append('\\');
sb.append('$');
} else {
sb.append(c);
}
}
return sb.toString();
}
/**
* Parse a localized string and put the result in a HTMLNode.
* @param node The result will be put in this HTMLNode.
* @param key Key to search for.
* @param patterns Patterns to replace, ${ and } are not included.
* @param values Replacement values.
* @deprecated Use {@link #addL10nSubstitution(HTMLNode, String, String[], HTMLNode[])} instead.
*/
@Deprecated
public void addL10nSubstitution(HTMLNode node, String key, String[] patterns, String[] values) {
String result = HTMLEncoder.encode(getString(key));
assert (patterns.length == values.length);
for (int i = 0; i < patterns.length; i++) {
result = result.replaceAll("\\$\\{" + patterns[i] + "\\}", quoteReplacement(values[i]));
}
node.addChild("%", result);
}
/**
* Loads an L10n string, replaces variables such as ${link} or ${bold} in it with {@link HTMLNode}s
* and adds the result to the given HTMLNode.
*
* This is *much* safer than the deprecated {@link #addL10nSubstitution(HTMLNode, String, String[], String[])}.
* Callers won't accidentally pass in unencoded strings and cause vulnerabilities.
* Callers should try to reuse parameters if possible.
* We automatically close each tag: When a pattern ${name} is matched, we search for
* ${/name}. If we find it, we make the tag enclose everything between the two; if we
* can't find it, we just add it with no children. It is not possible to create an
* HTMLNode representing a tag closure, so callers will need to change their code to
* not pass in /link or similar, and in some cases will need to change the l10n
* strings themselves to always close the tag properly, rather than using a generic
* /link for multiple links as we use in some places.
*
* <p><b>Examples</b>:
* <p>TranslationLookup.string=This is a ${link}link${/link} about ${text}.</p>
* <p>
* <code>addL10nSubstitution(html, "TranslationLookup.string", new String[] { "link", "text" },
* new HTMLNode[] { HTMLNode.link("/KSK@gpl.txt"), HTMLNode.text("blah") });</code>
* </p>
* <br>
* <p>TranslationLookup.string=${bold}This${/bold} is a bold text.</p>
* <p>
* <code>addL10nSubstitution(html, "TranslationLookup.string", new String[] { "bold" },
* new HTMLNode[] { HTMLNode.STRONG });</code>
* </p>
*
* @param node The {@link HTMLNode} to which the L10n should be added after substitution was done.
* @param key The key of the L10n string which shall be used.
* @param patterns Specifies things such as ${link} which shall be replaced in the L10n string with {@link HTMLNode}s.
* @param values For each entry in the previous array parameter, this array specifies the {@link HTMLNode} with which it shall be replaced.
*/
public void addL10nSubstitution(HTMLNode node, String key, String[] patterns, HTMLNode[] values) {
String value = getString(key);
addL10nSubstitutionInner(node, key, value, patterns, values);
}
/**
* @see #addL10nSubstitution(HTMLNode, String, String[], HTMLNode[])
*/
private void addL10nSubstitutionInner(HTMLNode node, String key, String value, String[] patterns, HTMLNode[] values) {
int x;
while(!value.equals("") && (x = value.indexOf("${")) != -1) {
String before = value.substring(0, x);
if(before.length() > 0)
node.addChild("#", before);
value = value.substring(x);
int y = value.indexOf('}');
if(y == -1) {
Logger.error(this, "Unclosed braces in l10n value \""+value+"\" for "+key);
return;
}
String lookup = value.substring(2, y);
value = value.substring(y+1);
if(lookup.startsWith("/")) {
Logger.error(this, "Starts with / in "+key);
return;
}
HTMLNode subnode = null;
for(int i=0;i<patterns.length;i++) {
if(patterns[i].equals(lookup)) {
subnode = values[i];
break;
}
}
String searchFor = "${/"+lookup+"}";
x = value.indexOf(searchFor);
if(x == -1) {
// It goes up to the end of the tag. It has no contents.
if(subnode != null) {
node.addChild(subnode);
}
} else {
// It has contents. Must recurse.
String inner = value.substring(0, x);
String rest = value.substring(x + searchFor.length());
if(subnode != null) {
subnode = subnode.clone();
node.addChild(subnode);
addL10nSubstitutionInner(subnode, key, inner, patterns, values);
} else {
addL10nSubstitutionInner(node, key, inner, patterns, values);
}
value = rest;
}
}
if(!value.equals(""))
node.addChild("#", value);
}
public String[] getAllNamesWithPrefix(String prefix){
if(fallbackTranslation==null){
return new String[]{};
}
List<String> toReturn=new ArrayList<String>();
Iterator<String> it= fallbackTranslation.keyIterator();
while(it.hasNext()){
String key=it.next();
if(key.startsWith(prefix)){
toReturn.add(key);
}
}
return toReturn.toArray(new String[toReturn.size()]);
}
}