TranslationManager.java
/*
** Module : TranslationManager.java
** Abstract : i18n support.
**
** Copyright (c) 2021-2023, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description---------------------------------------
** 001 HC 20211001 Initial version.
** SBI 20221121 Added getBundles and parseLanguages to reuse the code for the web client
** SBI 20221124 Changed getBundles to provide language identifier with its translation resources.
** SBI 20221202 Added getDefaultLanguage() and implemented the logic related to the special language value
** given by the "?" question mark.
** 002 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
*/
/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
** GNU Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
**
** Additional terms under GNU Affero GPL version 3 section 7:
**
** Under Section 7 of the GNU Affero GPL version 3, the following additional
** terms apply to the works covered under the License. These additional terms
** are non-permissive additional terms allowed under Section 7 of the GNU
** Affero GPL version 3 and may not be removed by you.
**
** 0. Attribution Requirement.
**
** You must preserve all legal notices or author attributions in the covered
** work or Appropriate Legal Notices displayed by works containing the covered
** work. You may not remove from the covered work any author or developer
** credit already included within the covered work.
**
** 1. No License To Use Trademarks.
**
** This license does not grant any license or rights to use the trademarks
** Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
** of Golden Code Development Corporation. You are not authorized to use the
** name Golden Code, FWD, or the names of any author or contributor, for
** publicity purposes without written authorization.
**
** 2. No Misrepresentation of Affiliation.
**
** You may not represent yourself as Golden Code Development Corporation or FWD.
**
** You may not represent yourself for publicity purposes as associated with
** Golden Code Development Corporation, FWD, or any author or contributor to
** the covered work, without written authorization.
**
** 3. No Misrepresentation of Source or Origin.
**
** You may not represent the covered work as solely your work. All modified
** versions of the covered work must be marked in a reasonable way to make it
** clear that the modified work is not originating from Golden Code Development
** Corporation or FWD. All modified versions must contain the notices of
** attribution required in this license.
*/
package com.goldencode.p2j.util;
import com.goldencode.p2j.directory.*;
import com.goldencode.p2j.security.*;
import com.goldencode.p2j.util.logging.*;
import java.util.*;
import java.util.Map.*;
import java.util.AbstractMap.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
/**
* Implements internationalization tasks, i.e. language bundle resolution, keeping track of current
* language and the message translation itself.
*/
public class TranslationManager
{
/**
* Synthetic field names generated in the converted java classes for business logic, frames and legacy
* classes.
*/
public static final String REFERENT_ID_FIELD_NAME = "__trid";
public static final String TR_MANAGER_FIELD_NAME = "__tm";
/** Logger **/
private static CentralLogger LOG = CentralLogger.get(TranslationManager.class.getSimpleName());
/** Context local data */
private static final ContextLocal<TranslationManager> instance = new ContextLocal<TranslationManager>()
{
@Override
protected synchronized TranslationManager initialValue()
{
return new TranslationManager();
}
};
/**
* Expansion table:
* Even indexes contain the minimum number of chars for the expansion value to take effect. These entries
* must be sorted.
* Odd indexes contain the expansion values. The value is a percentage value the target string
* will be expanded to.
*/
private static int[] expansionTable = new int[] {10, 200, 11, 100, 21, 80, 31, 60, 51, 40, 71, 30};
/** Next referent id */
private final AtomicLong nextReferentId = new AtomicLong();
/** Map of referent ids to resource budles */
private final Map<Long, ResourceBundle> referentBundleMap = new ConcurrentHashMap<>();
/** Map of referent ids and their referent classes */
private final Map<Long, Class<?>> referentMap = new ConcurrentHashMap<>();
/** Map of referent classes and their corresponding referent ids */
private final Map<Class<?>, Long> referentIdMap = new ConcurrentHashMap<>();
/** Currently set current languages */
private volatile List<String> currentLanguages;
/** Custom bundle control */
private BundleControl bundleControl = new BundleControl();
/** String expansion ratio */
private double expansionRatio = 0;
/**
* Returns the context local instance.
*
* @return See above.
*/
public static TranslationManager getInstance()
{
return instance.get();
}
/**
* Converts the supplied string to a valid Java identifier by skipping the unsupported chars from the
* result.
*
* @param str
* The input string.
*
* @return Valid Java identifier.
*/
public static String getIdentifier(String str)
{
StringBuilder sb = new StringBuilder();
for (int i = 0; i < str.length(); i++)
{
if ((i == 0 && Character.isJavaIdentifierStart(str.charAt(i))) ||
(i > 0 && Character.isJavaIdentifierPart(str.charAt(i))))
{
sb.append(str.charAt(i));
}
}
return sb.toString();
}
/**
* Calculates the expanded length of the supplied char. The expansion is calculated from the supplied
* expansionRatio and the expansion table {@linkplain #expansionTable}.
* The length is calculates as follows:
* str.length + round(((expansionTableEntry * expansionRatio) / 100.0) * len).
*
* @param str
* The string to be expanded.
* @param expansionRatio
* The expansion ratio, must be 0 or greater.
*
* @return The calculated expanded length. If the supplied str is {@code null} or empty, or
* {@code expansionRatio} is less than 0, the returned value is 0.
*/
public static int getExpandedLength(String str, double expansionRatio)
{
if (expansionRatio <= 0 || str == null || str.isEmpty())
{
return 0;
}
int len = str.length();
int entry = 0;
for (int i = 0; i < expansionTable.length; i+=2)
{
if (len >= expansionTable[i])
{
entry = i;
}
else
{
break;
}
}
double effectiveExpansion = (expansionTable[entry + 1] * expansionRatio) / 100.0;
int newLen = len + (int) Math.round(effectiveExpansion * len);
return newLen;
}
/**
* Converts the given string of comma separated list of language identifiers to the list of
* language identifiers.
*
* @param languages
* The given string of comma separated list of language identifiers
* @param computeDefaultLanguage
* True value indicates that the default language is computed if languages value is
* given as "?".
*
* @return The list of language identifiers
*/
public static List<String> parseLanguages(String languages, boolean computeDefaultLanguage)
{
if (computeDefaultLanguage && "?".equals(languages))
{
languages = getDefaultLanguage();
}
return languages == null ? Collections.emptyList() : Arrays.asList(languages.split(","));
}
/**
* Get the JVM default language in the form of language code + _ + country code, for example, en_US.
*
* @return The language identifier
*/
public static String getDefaultLanguage()
{
Locale clientLocale = Locale.getDefault();
StringBuilder buf = new StringBuilder(clientLocale.getLanguage());
buf.append("_").append(clientLocale.getCountry());
return buf.toString();
}
/**
* Returns the iterator over the collection of i18n resources for the given bundle base name and
* the given list of language identifiers.
*
* @param bundleBaseName
* The given bundle base name
* @param languages
* The given list of language identifier
*
* @return The target iterator over the collection of i18n resources
*/
public static Iterator<Entry<String, String>> getBundles(String bundleBaseName,
List<String> languages)
{
return languages.stream().<Entry<String, String>>map(
(lang) -> new SimpleImmutableEntry<String, String>(lang,
bundleBaseName + "_" + getIdentifier(lang)))
.iterator();
}
/**
* Default constructor.
*/
public TranslationManager()
{
setCurrentLanguages(EnvironmentOps.getCurrentLanguageString());
Directory dir = DirectoryManager.getInstance();
int percent = dir.getInt(Directory.ID_RELATIVE, "text-expansion", 0);
expansionRatio = percent / 100.0;
}
/**
* Initializes supplied class (aka referent class) for the purpose of i18n. The method returns a unique
* identifier associated with the supplied referent class. Any further i18n requests must contain this
* unique id.
* By convention a referent class has associated set of resource bundles, one for each target language.
* The resource bundles reside in the same Java package as the referent class, whereas the resource
* bundle class name is in the form [referent class simple name]_[target language].
* The current target language is determined by the assigned value to {@code CURRENT-LANGUAGE@} session
* variable.
*
* @param referentClass
* The referent class.
*
* @return Unique id of to the initialized referent class. Any further i18n requests must contain this
* id.
*/
public long initReferent(Class<?> referentClass)
{
long id = nextReferentId.incrementAndGet();
referentMap.put(id, referentClass);
referentIdMap.put(referentClass, id);
return id;
}
/**
* Translates the supplied message to the current target language. If no current language is set or
* no bundle of the target language can be resolved or the resolved bundle doesn't contain translation
* for the supplied message, the method just returns the supplied message.
*
* @param referentId
* Referent class id returned by {@link #initReferent(Class)}.
* @param msg
* A message to translate.
*
* @return Translated message or {@code msg} if the message can't be translated.
*/
public String translate(long referentId, String msg)
{
return translate(referentId, -1, msg);
}
/**
* Translates the supplied message to the current target language. If no current language is set or
* no bundle of the target language can be resolved or the resolved bundle doesn't contain translation
* for the supplied message, the method just returns the supplied message.
*
* @param referentId
* Referent class id returned by {@link #initReferent(Class)}.
* @param ctxt
* A context identifier. It is used to disambiguate messages of the same value but appearing in
* different locations in the source.
* @param msg
* A message to translate.
*
* @return Translated message or {@code msg} if the message can't be translated.
*/
public String translate(long referentId, long ctxt, String msg)
{
if (msg == null || msg.isEmpty())
{
return msg;
}
ResourceBundle bundle = referentBundleMap.get(referentId);
if (bundle == null)
{
return msg;
}
String key = msg;
if (ctxt > 0)
{
key = ctxt + "\u0004" + msg;
}
try
{
return bundle.getString(key);
}
catch (MissingResourceException ex)
{
return msg;
}
}
/**
* Translates the supplied string for the supplied referent and calculates the expansion length (see
* {@link #getExpandedLength(String, double)} for more details).
*
* @param referentId
* Id of the referent.
* @param msg
* The string to be translated.
*
* @return The translates string with the calculated expansion length.
*/
public I18nString translateExpand(long referentId, String msg)
{
return translateExpand(referentId, -1, msg);
}
/**
* Translates the supplied string for the supplied referent and calculates the expansion length (see
* {@link #getExpandedLength(String, double)} for more details).
*
* @param referentId
* Id of the referent.
* @param ctxt
* Context id.
* @param msg
* The string to be translated.
*
* @return The translates string with the calculated expansion length.
*/
public I18nString translateExpand(long referentId, long ctxt, String msg)
{
if (msg == null || msg.isEmpty())
{
return new I18nString(msg, msg, 0);
}
int expandedLength = getExpandedLength(msg, expansionRatio);
ResourceBundle bundle = referentBundleMap.get(referentId);
if (bundle == null)
{
return new I18nString(msg, msg, expandedLength);
}
String key = msg;
if (ctxt > 0)
{
key = ctxt + "\u0004" + msg;
}
String translated = null;
try
{
translated = bundle.getString(key);
}
catch (MissingResourceException ex)
{
translated = msg;
}
return new I18nString(translated, msg, expandedLength);
}
/**
* Sets the current languages. The supplied value may be {@code null} a comma separated string.
* When {@code null} the effective current language will be cleared and messages will not be translated.
* When multiple languages are given, they are used during language bundle resolution such that the
* current languages are iterated until a corresponding resource bundle is found.
*
* @param languages
* List of languages to assign as current languages. The value is a comma separated string.
*/
public void setCurrentLanguages(String languages)
{
currentLanguages = parseLanguages(languages, false);
}
/**
* Resolves a resource bundle based on the current languages set with {@link #setCurrentLanguages(String)}
* and associates it with the supplied referent. Any further translation requests for the referent will be
* performed against the resolved resource bundle.
*
* @param referentId
* The referent id.
*/
public void applyLanguage(long referentId)
{
Class refClass = referentMap.get(referentId);
if (refClass == null)
{
LOG.warning("Failed to apply language to referent. The referent was not initialized.");
return;
}
applyLanguage(refClass);
}
/**
* Resolves a resource bundle based on the current languages set with {@link #setCurrentLanguages(String)}
* and associates it with the supplied referent class. Any further translation requests for the referent
* class will be performed against the resolved resource bundle.
*
* @param referentClass
* The referent class.
*
* @return {@code true} if the language was successfully applied to the referent, {@code false}
* otherwise.
*/
public boolean applyLanguage(Class<?> referentClass)
{
Long refId = referentIdMap.get(referentClass);
if (refId == null)
{
LOG.fine("Failed to apply language for referent '" + referentClass.getName() +
"'. The referent wasn't initialized.");
return false;
}
ResourceBundle bundle = resolveBundle(referentClass);
if (bundle == null)
{
LOG.fine("Failed to apply language for referent '" + referentClass.getName() +
"'. The bundle for the current language(s) '" + currentLanguages + "' was not found.");
return false;
}
referentBundleMap.put(refId, bundle);
return true;
}
/**
* Resolves resource bundle for the supplied reference.
*
* @param referentClass
* The referent class.
*
* @return The resolved resource bundle.
*/
private ResourceBundle resolveBundle(Class<?> referentClass)
{
List<String> currentLanguages = this.currentLanguages;
if (currentLanguages == null || currentLanguages.isEmpty())
{
return null;
}
String bundleBaseName = referentClass.getName();
Iterator<Entry<String, String>> bundles = getBundles(bundleBaseName, currentLanguages);
while (bundles.hasNext())
{
try
{
return ResourceBundle.getBundle(bundles.next().getValue(), bundleControl);
}
catch (MissingResourceException ex)
{
continue;
}
}
return null;
}
/**
* Custom bundle control to override the bundle name logic.
*/
private static class BundleControl
extends ResourceBundle.Control
{
/**
* {@inheritDoc}
*/
@Override
public String toBundleName(String baseName, Locale locale)
{
return baseName;
}
}
}