blob: 9d757c18517f7766428022bd7386f9f5d07221d4 [file] [log] [blame]
// Copyright 2015 The Vanadium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package io.v.v23.i18n;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import io.v.v23.verror.VException;
/**
* Catalog maps language names and message identifiers to message format strings. The intent is
* to provide a primitive form of String.format() in a way where the format string can depend upon
* the language.
* <p>
* Languages are specified in the IETF language tag format. Language tags can be obtained easily
* by calling the {@link java.util.Locale#toLanguageTag} method. Empty string indicates that the
* default locale's language should be used.
* <p>
* Message identifier is a string that identifies a set of message format strings that have the
* same meaning, but may be available in multiple languages.
* <p>
* A message format string is a string containing substrings of the form {@code {<number>}}
* which are replaced by the corresponding position parameter (numbered from 1), or
* {@code {_}}, which is replaced by all otherwise unused parameters. If a substring is of the
* form {@code {:<number>}}, {@code {<number>:}}, {@code {:<number>:}}, {@code {:_}}, {@code {_:}},
* or {@code {:_:}} and the corresponding parameters are not the empty string, the parameter is
* preceded by {@code ": "} or followed by {@code ":"} or both, respectively. For example, if the
* format:
* <p><blockquote><pre>
* {3:} foo {2} bar{:_} ({3})
* </pre></blockquote><p>
* is used with arguments:
* <p><blockquote><pre>
* "1st", "2nd", "3rd", "4th"
* </pre></blockquote><p>
* it yields:
* <p><blockquote><pre>
* 3rd: foo 2nd bar: 1st 4th (3rd)
* </pre></blockquote><p>
*
* The positional parameters may have any type and are printed in their default formatting.
* If a particular formatting is desired, the parameter should be converted to a string first.
* In principle, the default formating for a parameter may depend on a language tag.
*
* A typical usage pattern of this class goes like:
* <p><blockquote><pre>
* Catalog cat = Language.getDefaultCatalog();
* String output = cat.format(language, msgID, "1st", "2nd", "3rd", "4th");
* </pre></blockquote><p>
*/
public class Catalog {
private static native String nativeFormatParams(String format, String[] params)
throws VException;
/**
* Returns a copy of format with instances of {@code {1}}, {@code {2}}, etc replaced by the
* default string representation of {@code params[0]}, {@code params[1]}, etc. The last
* instance of the string {@code {_}} is replaced with a space-separated list of positional
* parameters unused by other {@code {...}} sequences. Missing parameters are replaced
* with {@code "?"}.
*
* @param format message format
* @param params message parameters
* @return the result of applying the parameters to the given format
*/
public static String formatParams(String format, Object... params) {
try {
return nativeFormatParams(format, convertParamsToStr(params));
} catch (VException e) {
throw new RuntimeException("Couldn't format params.", e);
}
}
private static String[] convertParamsToStr(Object... params) {
String[] ret = new String[params.length];
for (int i = 0; i < params.length; ++i) {
ret[i] = "" + params[i];
}
return ret;
}
// language -> (msgID -> format)
private final Map<String, Map<String, String>> formats;
private final ReadWriteLock lock;
/**
* Creates a new empty {@link Catalog}.
*/
public Catalog() {
this.formats = new HashMap<String, Map<String, String>>();
this.lock = new ReentrantReadWriteLock();
}
/**
* Returns the format corresponding to a given language and message identifier.
* If no such message is known, any message for a base language is retrieved.
* If no such message exists, empty string is returned.
*
* @param language language that is looked up
* @param msgID message identifier that is looked up
* @return a format corresponding to the given language and message identifier
*/
public String lookup(String language, String msgID) {
this.lock.readLock().lock();
String fmt = lookupUnlocked(language, msgID);
if (fmt.isEmpty()) {
fmt = lookupUnlocked(Language.baseLanguage(language), msgID);
}
this.lock.readLock().unlock();
return fmt;
}
private String lookupUnlocked(String language, String msgID) {
Map<String, String> msgFmtMap = this.formats.get(language);
if (msgFmtMap == null) {
return "";
}
String fmt = msgFmtMap.get(msgID);
return fmt == null ? "" : fmt;
}
/**
* Finds the format corresponding to a given language and message identifier and then applies
* {@link #formatParams formatParams} to it with the given params. If the format lookup fails,
* the result is the text of the message followed by a colon and then the parameters.
*
* @param language language used for the format lookup
* @param msgID message identifier used for the format lookup
* @param params message parameters
* @return the result of applying the parameters to the looked-up format
*/
public String format(String language, String msgID, Object... params) {
String formatStr = lookup(language, msgID);
if (formatStr.isEmpty()) {
formatStr = msgID;
if (params.length > 0) {
formatStr += "{:_}";
}
}
return formatParams(formatStr, params);
}
/**
* Sets the format corresponding to the given language and message identifier. If the format
* string is empty, the corresponding entry is removed. Any previous format string is returned.
*
* @param language language to be associated with the format
* @param msgID message identifier to be associated with the format
* @param newFormat format assigned to the given language and message identifier
* @return previous format associated with the given language and message identifier
*/
public String set(String language, String msgID, String newFormat) {
this.lock.writeLock().lock();
String oldFormat = setUnlocked(language, msgID, newFormat);
this.lock.writeLock().unlock();
return oldFormat;
}
/**
* Just like {@link #set set} but additionaly sets the format for the base language if not
* already set. Note that if the new format is empty, the entry for the base language WILL NOT
* be removed.
*
* @param language language to be associated with the format
* @param msgID message identifier to be associated with the format
* @param newFormat format assigned to the given language and message identifier
* @return previous format associated with the given language and message identifier
*/
public String setWithBase(String language, String msgID, String newFormat) {
this.lock.writeLock().lock();
String oldFormat = setUnlocked(language, msgID, newFormat);
String baseLang = Language.baseLanguage(language);
String baseFmt = lookupUnlocked(baseLang, msgID);
if (baseFmt.isEmpty() && !newFormat.isEmpty() && !baseLang.equals(language)) {
setUnlocked(baseLang, msgID, newFormat);
}
this.lock.writeLock().unlock();
return oldFormat;
}
private String setUnlocked(String language, String msgID, String newFormat) {
Map<String, String> msgFmtMap = this.formats.get(language);
if (msgFmtMap == null) {
msgFmtMap = new HashMap<String, String>();
this.formats.put(language, msgFmtMap);
}
String oldFormat = msgFmtMap.get(msgID);
if (newFormat != null && !newFormat.isEmpty()) {
msgFmtMap.put(msgID, newFormat);
} else {
msgFmtMap.remove(msgID);
if (msgFmtMap.isEmpty()) {
this.formats.remove(language);
}
}
return oldFormat == null ? "" : oldFormat;
}
/**
* Merges the data in the lines from the provided input stream into the catalog.
* Each line from the input stream is expected to be in the following format:
* <p><blockquote><pre>
* {@literal <language> <message ID> "<escaped format string>"}
* </pre></blockquote><p>
* If a line starts with a {@code #} or cannot be parsed, the line is ignored.
*
* @param in input stream containing lines in the above format
* @throws IOException if there was an error reading the input stream
*/
public void merge(InputStream in) throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
String line = null;
Pattern pattern =
Pattern.compile("^\\s*([^\\s\"]+)\\s+([^\\s\"]+)\\s+\"((?:[^\"]|\\\")*)\".*$");
while ((line = reader.readLine()) != null) {
Matcher matcher = pattern.matcher(line);
if (matcher.matches() &&
matcher.groupCount() == 3 && !matcher.group(1).startsWith("#")) {
String language = matcher.group(1);
String msgID = matcher.group(2);
String format = matcher.group(3);
set(language, msgID, format);
}
}
reader.close();
}
/**
* Emits the contents of the catalog into the provided output stream, in the format
* expected by {@link #merge merge}.
*
* @param out output stream into which the catalog is emitted
* @throws IOException if there was an error writing to the output stream
*/
public void output(OutputStream out) throws IOException {
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out));
this.lock.readLock().lock();
for (Map.Entry<String, Map<String, String>> entry : this.formats.entrySet()) {
String language = entry.getKey();
for (Map.Entry<String, String> idFmt : entry.getValue().entrySet()) {
String msgID = idFmt.getKey();
String format = idFmt.getValue();
writer.write(String.format("%s %s \"%s\"\n", language, msgID, format));
}
}
this.lock.readLock().unlock();
writer.close();
}
}