blob: 451468184e142601c828e20359d97a99c256f458 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.sis.util.iso;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;
import java.util.Set;
import java.util.Map;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Collections;
import java.util.Iterator;
import java.util.Locale;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import org.opengis.util.InternationalString;
import org.apache.sis.util.Locales;
import org.apache.sis.util.ArgumentChecks;
import org.apache.sis.util.logging.Logging;
import org.apache.sis.util.resources.Errors;
import org.apache.sis.util.resources.Messages;
import org.apache.sis.util.collection.Containers;
import org.apache.sis.internal.system.Modules;
/**
* An international string using a map of strings for different locales.
* Strings for new locales can be {@linkplain #add(Locale, String) added},
* but existing strings can not be removed or modified.
* This behavior is a compromise between making constructions easier, and being suitable for
* use in immutable objects.
*
* <h2>Thread safety</h2>
* Instances of {@code DefaultInternationalString} are thread-safe. While those instances are not strictly immutable,
* SIS typically references them as if they were immutable because of their <cite>add-only</cite> behavior.
*
* @author Martin Desruisseaux (IRD, Geomatys)
* @version 0.3
*
* @see Types#toInternationalString(Map, String)
*
* @since 0.3
* @module
*/
public class DefaultInternationalString extends AbstractInternationalString implements Serializable {
/**
* Serial number for inter-operability with different versions.
*/
private static final long serialVersionUID = 3663160836923279819L;
/**
* The string values in different locales (never {@code null}).
*/
private Map<Locale,String> localeMap;
/**
* An unmodifiable view of the entry set in {@link #localeMap}. This is the set of locales
* defined in this international string. Will be constructed only when first requested.
*/
private transient Set<Locale> localeSet;
/**
* Creates an initially empty international string. Localized strings can been added
* using one of {@link #add add(…)} methods.
*/
public DefaultInternationalString() {
localeMap = Collections.emptyMap();
}
/**
* Creates an international string initialized with the given string.
* Additional localized strings can been added using one of {@link #add add(…)} methods.
* The string specified to this constructor is the one that will be returned if no localized
* string is found for the {@code Locale} argument in a call to {@link #toString(Locale)}.
*
* @param string the string in no specific locale, or {@code null} if none.
*/
public DefaultInternationalString(final String string) {
if (string != null) {
localeMap = Collections.singletonMap(Locale.ROOT, string);
} else {
localeMap = Collections.emptyMap();
}
}
/**
* Creates an international string initialized with the given localized strings.
* The content of the given map is copied, so changes to that map after construction
* will not be reflected into this international string.
*
* @param strings the strings in various locales, or {@code null} if none.
*
* @see Types#toInternationalString(Map, String)
*/
public DefaultInternationalString(final Map<Locale,String> strings) {
if (Containers.isNullOrEmpty(strings)) {
localeMap = Collections.emptyMap();
} else {
final Iterator<Map.Entry<Locale,String>> it = strings.entrySet().iterator();
final Map.Entry<Locale,String> entry = it.next();
if (!it.hasNext()) {
localeMap = Collections.singletonMap(entry.getKey(), entry.getValue());
} else {
localeMap = new LinkedHashMap<>(strings);
// If HashMap is replaced by an other type, please revisit 'getLocales()'.
}
}
final boolean nullMapKey = localeMap.containsKey(null);
if (nullMapKey || localeMap.containsValue(null)) {
throw new IllegalArgumentException(Errors.format(nullMapKey
? Errors.Keys.NullMapKey : Errors.Keys.NullMapValue));
}
}
/**
* Adds a string for the given locale.
*
* @param locale the locale for the {@code string} value.
* @param string the localized string.
* @throws IllegalArgumentException if a different string value was already set for the given locale.
*/
public synchronized void add(final Locale locale, final String string) throws IllegalArgumentException {
ArgumentChecks.ensureNonNull("locale", locale);
ArgumentChecks.ensureNonNull("string", string);
switch (localeMap.size()) {
case 0: {
localeMap = Collections.singletonMap(locale, string);
localeSet = null;
defaultValue = null; // Will be recomputed when first needed.
return;
}
case 1: {
// If HashMap is replaced by an other type, please revisit 'getLocales()'.
localeMap = new LinkedHashMap<>(localeMap);
localeSet = null;
break;
}
}
final String old = localeMap.put(locale, string);
if (old != null) {
localeMap.put(locale, old);
if (string.equals(old)) {
return;
}
throw new IllegalArgumentException(Errors.format(
Errors.Keys.ValueAlreadyDefined_1, locale));
}
defaultValue = null; // Will be recomputed when first needed.
}
/**
* Adds the given character sequence. If the given sequence is an other {@link InternationalString} instance,
* then only the string for the given locale is added. This method is for {@link Types} internal usage only.
*
* @param locale the locale for the {@code string} value.
* @param string the character sequence to add.
* @throws IllegalArgumentException if a different string value was already set for the given locale.
*/
final void add(final Locale locale, final CharSequence string) throws IllegalArgumentException {
final boolean i18n = (string instanceof InternationalString);
add(locale, i18n ? ((InternationalString) string).toString(locale) : string.toString());
if (i18n && !(string instanceof SimpleInternationalString)) {
/*
* If the string may have more than one locale, log a warning telling that some locales
* may have been ignored. We declare Types.toInternationalString(…) as the source since
* it is the public facade invoking this method. We declare the source class using only
* its name rather than Types.class in order to avoid unnecessary real dependency.
*/
final LogRecord record = Messages.getResources(null).getLogRecord(Level.WARNING, Messages.Keys.LocalesDiscarded);
record.setSourceClassName("org.apache.sis.util.iso.Types");
record.setSourceMethodName("toInternationalString");
record.setLoggerName(Modules.UTILITIES);
Logging.getLogger(Modules.UTILITIES).log(record);
}
}
/**
* Returns the set of locales defined in this international string.
*
* @return the set of locales.
*
* @todo Current implementation does not return a synchronized set. We should synchronize
* on the same lock than the one used for accessing the internal locale map.
*/
public synchronized Set<Locale> getLocales() {
Set<Locale> locales = localeSet;
if (locales == null) {
locales = localeMap.keySet();
if (localeMap instanceof HashMap<?,?>) {
locales = Collections.unmodifiableSet(locales);
}
localeSet = locales;
}
return locales;
}
/**
* Returns a string in the specified locale. If there is no string for that {@code locale},
* then this method search for a locale without the {@linkplain Locale#getVariant() variant}
* part. If no string are found, then this method search for a locale without the
* {@linkplain Locale#getCountry() country} part. If none are found, then this method returns
* {@code null}.
*
* @param locale the locale to look for, or {@code null}.
* @return the string in the specified locale, or {@code null} if none was found.
*/
private String getString(Locale locale) {
while (locale != null) {
final String text = localeMap.get(locale);
if (text != null) {
return text;
}
final String language = locale.getLanguage();
final String country = locale.getCountry ();
final String variant = locale.getVariant ();
if (!variant.isEmpty()) {
locale = new Locale(language, country);
continue;
}
if (!country.isEmpty()) {
locale = new Locale(language);
continue;
}
break;
}
return null;
}
/**
* Returns a string in the specified locale. If there is no string for that {@code locale},
* then this method searches for a locale without the {@linkplain Locale#getVariant() variant}
* part. If no string are found, then this method searches for a locale without the
* {@linkplain Locale#getCountry() country} part. For example if the {@code "fr_CA"} locale
* was requested but not found, then this method looks for the {@code "fr"} locale.
* The {@linkplain Locale#ROOT root locale} is tried last.
*
* <h4>Handling of {@code Locale.ROOT} argument value</h4>
* {@link Locale#ROOT} can be given to this method for requesting a "unlocalized" string,
* typically some programmatic values like enumerations or identifiers.
* While identifiers often look like English words, {@code Locale.ROOT} is not considered
* synonymous to {@link Locale#ENGLISH} because the values may differ in the way numbers and
* dates are formatted (e.g. using the ISO 8601 standard for dates instead than English conventions).
* In order to produce a value close to the common practice, this method handles {@code Locale.ROOT}
* as below:
*
* <ul>
* <li>If a string has been explicitly {@linkplain #add(Locale, String) added} for
* {@code Locale.ROOT}, then that string is returned.</li>
* <li>Otherwise, acknowledging that UML identifiers in OGC/ISO specifications are primarily
* expressed in the English language, this method looks for strings associated to
* {@link Locale#US} as an approximation of "unlocalized" strings.</li>
* <li>If no English string was found, then this method looks for a string for the
* {@linkplain Locale#getDefault() system default locale}.</li>
* <li>If none of the above steps found a string, then this method returns
* an arbitrary string.</li>
* </ul>
*
* <h4>Handling of {@code null} argument value</h4>
* In the default implementation, the {@code null} locale is handled as a synonymous of
* {@code Locale.ROOT}. However subclasses are free to use a different fallback. Client
* code are encouraged to specify only non-null values for more determinist behavior.
*
* @param locale the desired locale for the string to be returned.
* @return the string in the given locale if available, or in an
* implementation-dependent fallback locale otherwise.
*/
@Override
public synchronized String toString(final Locale locale) {
String text = getString(locale);
if (text == null) {
/*
* No localized string for the requested locale.
* Try fallbacks in the following order:
*
* 1) Locale.ROOT
* 2) Locale.US, as an approximation of "unlocalized" strings.
* 3) Locale.getDefault()
*
* Locale.getDefault() must be last because the i18n string is often constructed with
* an English sentence for the 'ROOT' locale (the unlocalized text), without explicit
* entry for the English locale since the 'ROOT' locale is the fallback. If we were
* looking for the default locale first on a system having French as the default locale,
* we would get a sentence in French when the user asked for a sentence in English or
* any language not explicitly declared.
*/
text = localeMap.get(Locale.ROOT);
if (text == null) {
Locale fallback = Locale.US; // The fallback language for "unlocalized" string.
if (fallback != locale) { // Avoid requesting the same locale twice (optimization).
text = getString(fallback);
if (text != null) {
return text;
}
}
fallback = Locale.getDefault();
if (fallback != locale && fallback != Locale.US) {
text = getString(fallback);
if (text != null) {
return text;
}
}
/*
* Every else failed; pickup a random string.
* This behavior may change in future versions.
*/
final Iterator<String> it = localeMap.values().iterator();
if (it.hasNext()) {
text = it.next();
}
}
}
return text;
}
/**
* Returns {@code true} if all localized texts stored in this international string are
* contained in the specified object. More specifically:
*
* <ul>
* <li>If {@code candidate} is an instance of {@link InternationalString}, then this method
* returns {@code true} if, for all <var>{@linkplain Locale locale}</var>-<var>{@linkplain
* String string}</var> pairs contained in {@code this}, <code>candidate.{@linkplain
* InternationalString#toString(Locale) toString}(locale)</code> returns a string
* {@linkplain String#equals equals} to {@code string}.</li>
*
* <li>If {@code candidate} is an instance of {@link CharSequence}, then this method
* returns {@code true} if {@link #toString(Locale)} returns a string {@linkplain
* String#equals equals} to <code>candidate.{@linkplain CharSequence#toString()
* toString()}</code> for all locales.</li>
*
* <li>If {@code candidate} is an instance of {@link Map}, then this methods returns
* {@code true} if all <var>{@linkplain Locale locale}</var>-<var>{@linkplain String
* string}</var> pairs are contained into {@code candidate}.</li>
*
* <li>Otherwise, this method returns {@code false}.</li>
* </ul>
*
* @param candidate the object which may contains this international string.
* @return {@code true} if the given object contains all localized strings found in this international string.
*/
public synchronized boolean isSubsetOf(final Object candidate) {
if (candidate instanceof InternationalString) {
final InternationalString string = (InternationalString) candidate;
for (final Map.Entry<Locale,String> entry : localeMap.entrySet()) {
final Locale locale = entry.getKey();
final String text = entry.getValue();
if (!text.equals(string.toString(locale))) {
return false;
}
}
} else if (candidate instanceof CharSequence) {
final String string = candidate.toString();
for (final String text : localeMap.values()) {
if (!text.equals(string)) {
return false;
}
}
} else if (candidate instanceof Map<?,?>) {
final Map<?,?> map = (Map<?,?>) candidate;
return map.entrySet().containsAll(localeMap.entrySet());
} else {
return false;
}
return true;
}
/**
* Compares this international string with the specified object for equality.
*
* @param object the object to compare with this international string.
* @return {@code true} if the given object is equal to this string.
*/
@Override
public boolean equals(final Object object) {
if (object != null && object.getClass() == getClass()) {
final DefaultInternationalString that = (DefaultInternationalString) object;
return Objects.equals(this.localeMap, that.localeMap);
}
return false;
}
/**
* Returns a hash code value for this international text.
*
* @return a hash code value for this international text.
*/
@Override
public synchronized int hashCode() {
return localeMap.hashCode() ^ (int) serialVersionUID;
}
/**
* Canonicalize the locales after deserialization.
*
* @param in the input stream from which to deserialize an international string.
* @throws IOException if an I/O error occurred while reading or if the stream contains invalid data.
* @throws ClassNotFoundException if the class serialized on the stream is not on the classpath.
*/
private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
final int size = localeMap.size();
if (size == 0) {
return;
}
@SuppressWarnings({"unchecked","rawtypes"}) // Generic array creation.
Map.Entry<Locale,String>[] entries = new Map.Entry[size];
entries = localeMap.entrySet().toArray(entries);
if (size == 1) {
final Map.Entry<Locale,String> entry = entries[0];
localeMap = Collections.singletonMap(Locales.unique(entry.getKey()), entry.getValue());
} else {
localeMap.clear();
for (final Map.Entry<Locale,String> entry : entries) {
localeMap.put(Locales.unique(entry.getKey()), entry.getValue());
}
}
}
}