I18nWorker.java

/*
** Module   : I18nWorker.java
** Abstract : i18n helper
**
** Copyright (c) 2021-2023, Golden Code Development Corporation.
**
** -#- -I- --Date-- -------------------------------------------Description-----------------------------------
** 001 GES 20211003 Created initial version.
**     TJD 20220504 Upgrade do Java 11
** 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.convert;

import com.goldencode.p2j.cfg.*;
import com.goldencode.p2j.pattern.*;
import com.goldencode.p2j.util.*;

import java.io.*;
import java.util.*;
import java.util.logging.*;


/**
 * A pattern worker whose purpose is to provide helpers for conversion of localizable strings.
 */
public class I18nWorker
extends AbstractPatternWorker
{
   /** Logger */
   private static ConversionStatus LOG = ConversionStatus.get(I18nWorker.class);

   /** map of languages of procedure file name to list of translations mapped by source messages */
   private Map<String, Map<String, HashMap<String, List<Translation>>>> translations = new HashMap<>();

   /** Flag that indicates TM translations load state */
   private boolean loaded = false;

   /** String expansion ratio */
   private double expansionRatio = 0;

   /**
    * Default ctor.
    */
   public I18nWorker()
   {
      String expStr = Configuration.getParameter("text-expansion", null);
      if (expStr != null)
      {
         String errMsg = "Failed to load i18n configuration, 'text-expansion' must be a positive double " +
                         "value.";
         try
         {
            double percent = Double.parseDouble(expStr);
            if (percent < 0)
            {
               LOG.log(Level.WARNING, errMsg);
            }
            else
            {
               expansionRatio = percent / 100.0;
            }
         }
         catch (NumberFormatException ex)
         {
            LOG.log(Level.WARNING, errMsg, ex);
         }
      }

      setLibrary(new Library());
   }

   /**
    * The library.
    */
   public class Library
   {
      /**
       * Loads Translation Manager translations data exported in CSV. The method reads {@code tm-translations}
       * configuration value, which holds comma separated list of CSV files with the exported translations.
       * All translations are kept in memory for subsequent calls of {@link #getTargetTMLanguages()} and
       * {@link #resolveTMTranslation(String, String, int, String)}.
       *
       * @throws   IOException
       *           For any IO error during reading the CSV files.
       */
      public void loadTMTranslations()
      throws IOException
      {
         // load only once
         if (loaded)
         {
            return;
         }

         loaded = true;

         String tmFiles = Configuration.getParameter("tm-translations", null);
         if (tmFiles == null || tmFiles.trim().isEmpty())
         {
            return;
         }

         String[] files = tmFiles.split(",");

         for (String file : files)
         {
            try (Reader r = new InputStreamReader(new FileInputStream(file)))
            {
               Map<String, HashMap<String, List<Translation>>> translations = new HashMap<>();

               HashMap<String, List<Translation>> procTranslations = null;
               String lastProc = null;

               // first line holds target language and name of Translation Manager project,
               // second line has csv columns
               List<String> line = Utils.readCSVLine(r);
               String targetLanguage = TranslationManager.getIdentifier(line.get(1).trim());

               // skip columns
               Utils.readCSVLine(r);

               while ((line = Utils.readCSVLine(r)) != null)
               {
                  String proc = line.get(0).replace('\\', '/');
                  Translation tr = new Translation();
                  tr.source = line.get(3);
                  tr.target = line.get(4);
                  tr.line = -1;
                  try
                  {
                     tr.line = Integer.parseInt(line.get(6));
                  }
                  catch (NumberFormatException ex)
                  {
                     // skip
                  }

                  if (procTranslations == null || !Objects.equals(lastProc, proc))
                  {
                     procTranslations = translations.get(proc);
                     if (procTranslations == null)
                     {
                        procTranslations = new HashMap<>();
                        translations.put(proc, procTranslations);
                     }
                     lastProc = proc;
                  }

                  List<Translation> lineTranslations = procTranslations.get(tr.source);
                  if (lineTranslations == null)
                  {
                     lineTranslations = new ArrayList<>();
                     procTranslations.put(tr.source, lineTranslations);
                  }

                  lineTranslations.add(tr);
               }

               I18nWorker.this.translations.put(targetLanguage, translations);
            }
         }

         // second pass - deduplicate translations which map all source messages to the same target message,
         // such entries are not subject of disambiguation during translation resolution
         for (Map<String, HashMap<String, List<Translation>>> proc : I18nWorker.this.translations.values())
         {
            for (HashMap<String, List<Translation>> procTranslations : proc.values())
            {
               for (List<Translation> sourceTranslations : procTranslations.values())
               {
                  Translation firstTr = null;
                  for (Translation tr : sourceTranslations)
                  {
                     if (firstTr == null)
                     {
                        firstTr = tr;
                        continue;
                     }

                     if (Objects.equals(firstTr.target, tr.target))
                     {
                        continue;
                     }

                     firstTr = null;
                     break;
                  }

                  // the list of translations all translate the source to the same target, keep only
                  // the first translation
                  if (firstTr != null)
                  {
                     firstTr.line = -1;
                     sourceTranslations.clear();
                     sourceTranslations.add(firstTr);
                  }
               }
            }
         }
      }

      /**
       * Returns the list of target languages as specified in the exported Translation Manager translation
       * data loaded by {@link #loadTMTranslations()}.
       *
       * @return  See above.
       */
      public List<String> getTargetTMLanguages()
      {
         return new ArrayList<>(translations.keySet());
      }

      /**
       * Resolves the translation for the supplied target language, procedure and source message. The
       * resolution is performed on the data loaded by {@link #loadTMTranslations()}. If the translation is
       * found it is returned by the method, otherwise the method returns {@code null}.
       *
       * @param   targetLanguage
       *          The target language.
       * @param   proc
       *          Path of the procedure which is the origin of the source message to translate.
       * @param   line
       *          The line of the source message in the specified procedure.
       * @param   source
       *          The source message to translate.
       *
       * @return  See above.
       */
      public String resolveTMTranslation(String targetLanguage, String proc, int line, String source)
      {
         if (translations.isEmpty())
         {
            return null;
         }

         Translation foundTranslation = null;

         Map<String, HashMap<String, List<Translation>>> translations =
            I18nWorker.this.translations.get(targetLanguage);

         if (translations != null)
         {
            proc = proc.trim();
            // TODO: will this work on Windows?
            if (proc.startsWith("./"))
            {
               proc = proc.substring(2);
            }

            HashMap<String, List<Translation>> procTranslations = translations.get(proc);

            if (procTranslations != null)
            {
               List<Translation> targetTranslations = procTranslations.get(source);
               if (targetTranslations != null)
               {
                  Translation noLineTr = null;
                  for (Translation tr : targetTranslations)
                  {
                     if (tr.line == -1)
                     {
                        noLineTr = tr;
                        continue;
                     }

                     if (tr.line == line)
                     {
                        foundTranslation = tr;
                        break;
                     }
                  }

                  if (foundTranslation == null)
                  {
                     foundTranslation = noLineTr;
                  }
               }
            }
         }

         if (foundTranslation == null)
         {
            LOG.log(Level.WARNING, "Failed to resolve TM translation for '" + targetLanguage + "' '" +
                           source + "' in '" + proc + ":" + line + "'.");
            return null;
         }
         else
         {
            return foundTranslation.target;
         }
      }

      /**
       * Expands the supplied string.
       *
       * The string is expanded with empty space characters. The number of characters is calculated from
       * the expansion {@linkplain #expansionRatio} and the expansion table held by
       * {@link TranslationManager}, for more details see
       * {@link TranslationManager#getExpandedLength(String, double)}.
       *
       * @param   str
       *          String to be expanded.
       *
       * @return  The expanded string.
       */
      public String expandString(String str)
      {
         if (expansionRatio > 0)
         {
            int length = TranslationManager.getExpandedLength(str, expansionRatio);
            I18nString i18nString = new I18nString(str, null, length);
            return i18nString.getTranslatedExpanded();
         }
         else
         {
            return str;
         }
      }
   }

   /**
    * Holds a single translation entry.
    */
   private static class Translation
   {
      /** Location of the source message in the source procedure */
      public int line;
      // TODO: TM doesn't export column number
      public int column;

      /** The source message */
      public String source;

      /** The target message */
      public String target;
   }
}