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;
      }
   }
}