StanzaIni.java

/*
** Module   : StanzaIni.java
** Abstract : Progress 4GL compatible Windows INI files related methods
**
** Copyright (c) 2013-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------Description----------------------------------
** 001 EVL 20130604 Created initial version.
** 002 EVL 20140403 The file separator value should be taken from client side JVM, not from
**                  EnvironmentOps class.  Because StanzaIni is the code running only in Windows
**                  we can use system File.separator value here.
** 003 CA  20140702 Added getEnvironmentName API.
** 004 MAG 20140908 Make section names case insensitive. Added getEnvironmentType API.
**                  Added read only options when INI files are deployed on jar files.
** 005 OM  20180606 Avoid duplicating .ini extension. Return null/unknown when entry not found.
** 006 CA  20180523 Fixed environment name for stanza - this is case sensitive.
** 007 RFB 20200609 The getFullIniFileName and openReader methods were not normalizing the workingDir
**                  and fileName.
**     RFB 20200610 openReader needs to check for file existing before attempting to open to prevent NPE.
**     RFB 20200612 Enhanced the error reporting in openReader by maintaining the filename that fails to open.
** 008 HC  20230427 Implemented server-side file-system resource. File operations performed on
**                  client can be optionally performed directly on server without the expensive
**                  remote call.
** 009 GBB 20240826 Moving OSResourceManager to osresource package.
*/

/*
** 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 java.io.*;
import java.util.*;

import com.goldencode.p2j.ui.chui.ThinClient;
import com.goldencode.p2j.util.osresource.*;

// TODO: Implement PrintWriter supporting charset other than default one.

/**
 * A class implementing the INI file based environment methods. The INI file is the plain text
 * file containing the inside the [] braces and key=value pairs within every section.
 * <p>
 * The INI file like other INI files in Windows allow to use the comment and empty strings to
 * be added into.  Every line beginning with ';' char is considered as comment line.  The INI
 * file allows section or key-value pair duplications.  Only first encountered section or key-value
 * pair will be meaningful in this case, the other will be ignored.  This is important to know
 * because after deletion the section or key the modified INI file is reloading and previously
 * ignored section or key-value pair become active.
 * <p>
 * Another note is some functionality is very strange for CHUI client mode on Windows.  It does
 * not follow the description in Progress ABL command reference book.  All such 'bugs' are
 * duplicated here.  It is getting the key or section list and removing the key-value pair or
 * section.
 *
 * @author    EVL
 */
public class StanzaIni
implements EnvironmentReader
{
   /** The name of the INI file that holds the environment variables. */
   private String name = null;
   
   /** The name of the environment. */
   private String envName = null;

   /** Working directory value to search the INI file. */
   private String workingDirectory = null;
   
   /** The flag indicating whether we need to create new file instead of opening existed one. */
   private boolean isNew = false;
   
   /** Flag indicating the type of the client running, either CHUI or Windows graphical UI. */
   private boolean isChui = false;

   /** INI files read only property */
   private boolean isReadOnly = false;
   
   /** The ordered list of INI file content. */
   private List<Content> contList = null;
   
   /** The map that caches the INI file sections without any comments. */
   private Map<String, Content> sectMap = null;
   
   /**
    * Constructs an instance of the environment with INI file as backend.
    *
    * @param env 
    *        The environment to create or load. The name without extension of the initialization
    *        file.
    * @param directory 
    *        The directory path for existing initialization file or place to create
    *        initialization file.
    * @param flagNew
    *        If <code>true</code> - the new file will be created overwriting the possibly
    *        existed one.
    * @param readOnly
    *        If <code>true</code> INI files are loaded from server jar files otherwise from
    *        client file system. 
    */
   public StanzaIni(String env, String directory, boolean flagNew, boolean readOnly)
   {
      contList = new LinkedList<Content>();
      sectMap = new TreeMap<String, Content>(String.CASE_INSENSITIVE_ORDER);
      
      // get driver type and read only property.
      ThinClient tc = ThinClient.getInstance();
      isChui = tc.isChui();      

      if (env.indexOf('.') < 0)
      {
         env = env + ".ini";
      }

      name = env;
      if (directory != null)
      {
         workingDirectory = directory;
      }
      isNew = flagNew;
      
      isReadOnly = readOnly;
      
      // environment name should not contain .ini file extension
      envName = env;
   }

   /**
    * Creates application defaults implementing Progress LOAD statement.
    */
   public boolean load()
   {
      return (!isNew) ? loadIniFile() : createEmptyIniFile();
   }

   /**
    * Adds, modifies and deletes keys in current environment.  The real work it performs is
    * controlled by the input parameters.  In a simplest case when all three inputs have the real
    * not null and not empty string the method changes the value of the given key in the given
    * section of the currently loaded INI file.
    * <p>
    * The other possible cases are("" means the parameter is null or empty string if the
    * difference is not important):
    * key == "" deletes the given section with all it's content from the INI file.
    * value == "" removes the key-value pair from the given section of the current INI file.
    * 
    * @param section 
    *        The name of the section containing the key to modify.
    * @param key 
    *        The name of the key key to modify or default key if not specified.
    * @param value 
    *        The new value of the key under modification.
    */
   public void setKeyValue(String section, String key, String value)
   {
      // The 4GL does not allow to put extra spaces for sections and key names into INI file
      String sectionAsString = section.trim();
      String keyAsString = key.trim();
      // The 4GL does allow to put extra spaces for value string into INI file
      String valueAsString = value;
      
      if (keyAsString == null || keyAsString.isEmpty())
      {
         // In this case whole section and all key-value pairs in this section be removed
         removeSection(sectionAsString);
      }
      else if (valueAsString == null || valueAsString.isEmpty())
      {
         // In this case the specific key-value pair should be removed
         removeKey(sectionAsString, keyAsString);
      }
      else
      {
         // We have all options presented, do usual PUT-KEY-VALUE work 
         SectionContent sectionEntry = getSection(sectionAsString);
         if (sectionEntry == null)
         {
            // This is the first key=value pair for new section
            sectionEntry = new SectionContent(sectionAsString);
            sectionEntry.store(keyAsString, valueAsString);
            contList.add(sectionEntry);
         }
         else
         {
            // Refresh already existed section
            int entryNdx = contList.indexOf(sectionEntry);
            sectionEntry.store(keyAsString, valueAsString);
            contList.set(entryNdx, sectionEntry);
         }
         // And refresh the section map too
         sectMap.put(sectionAsString, sectionEntry);
      
         commitIniFile();
      }
   }
   
   /**
    * Getting the key value from the current environment.  The real work it performs is
    * controlled by the input parameters.  In a simplest case when all two inputs have the real
    * not null and not empty string the method gets the value of the given key.
    * <p>
    * The other possible cases are("" means the parameter is null or empty string if the
    * difference is not important):
    * section == "" gets the comma separated list of all sections for the current INI file.
    * key == "" gets the comma separated list of all keys for the given section for the current
    *           INI file.
    * 
    * @param section 
    *        The name of the section containing the key to get.
    * @param key 
    *        The name of the key to get or default key if not specified.
    * 
    * @return The value of the key.
    */
   public String getKeyValue(String section, String key)
   {
      String sectionAsString = section.trim();
      String keyAsString = key.trim();
      String valueAsString = null;
      
      if (sectionAsString == null || sectionAsString.isEmpty())
      {
         // In this case we need to return the comma-separated list of the section in given file
         return getSections();
      }
      else if (keyAsString == null || keyAsString.isEmpty())
      {
         // In this case we need to return the comma-separated list of the keys in given section
         return getKeys(sectionAsString);
      }
      else
      {
         // We have all options presented, do usual GET-KEY-VALUE work 
         SectionContent sectionEntry = getSection(sectionAsString);
         if (sectionEntry != null)
         {
            valueAsString = sectionEntry.getValueForKey(keyAsString);
         }
         // For missing entries 4GL returns empty string
         if (valueAsString == null)
         {
            return null;
         }
         else
         {
            // For statement GET-KEY-VALUE 4GL does not allow to return extra spaces unlike to
            // PUT-KEY-VALUE. Pretty strange behavior but was tested on real Windows 4GL system.
            return valueAsString.trim();
         }
      }
   }

   /**
    * Unloads specifies environment from the current one.
    */
   public void unload()
   {
      // Clean up memory allocated for this instance internals
      sectMap.clear();
      sectMap = null;
      contList.clear();
      contList = null;
   }

   /**
    * Get the environment's name.
    * 
    * @return   See above.
    */
   public String getEnvironmentName()
   {
      return envName;
   }

   /**
    * Get the environment's type.
    * 
    * @return   See above.
    */
   @Override
   public String getEnvironmentType()
   {
      return TYPE_INI;
   }

   /**
    * Removing the given section with all key-value pairs from the current environment.
    * 
    * @param section 
    *        The name of the whole section to delete.
    */
   private void removeSection(String section)
   {
      if (section != null && sectMap.containsKey(section))
      {
         // Get the section object to remove from the list
         SectionContent sectionEntry = getSection(section);
         // Yes, the behaviour in CHUI and Graphical interface is different
         if (isChui)
         {
            // For now 4GL CHUI adds = char to the end of the meaningfull part of the
            // section as if it is kew-value pair
            sectionEntry.storeAsPseudoKey("=");
            // Write changes to the file system
            commitIniFile();
         }
         else
         {
            // Remove section from map
            sectMap.remove(section);
            // And from list as well
            contList.remove(sectionEntry);
            // After deletion we need to write new content on disk first
            commitIniFile();
            // And load them into internal maps. This is not absurd, the modified INI file can
            // have different interpretation than before, for example if we had two sections with
            // the same name and one of them was just removed 
            reloadIniFile();
         }
      }
   }

   /**
    * Removing the given key-value pair from the current environment in a given section.
    * 
    * @param section 
    *        The name of the section to delete the key-value pair.
    * @param key 
    *        The name of the key to delete with its value.
    */
   private void removeKey(String section, String key)
   {
      // Take the section object
      SectionContent sectionEntry = getSection(section);
      // Yes, the behaviour in CHUI and Graphical interface is different
      if (isChui)
      {
         // For now instead of delete the key value become just empty
         if (sectionEntry != null)
         {
            // Refresh already existed section by storing empty value
            int entryNdx = contList.indexOf(sectionEntry);
            sectionEntry.store(key, "");
            contList.set(entryNdx, sectionEntry);
         }
         // And refresh the section map too
         sectMap.put(section, sectionEntry);
         // Write changes to file system
         commitIniFile();
      }
      else
      {
         if (sectionEntry != null)
         {
            sectionEntry.remove(key);
         }
         // After deletion we need to write new content on disk first
         commitIniFile();
         // And load them into internal maps. This is not absurd, the modified INI file can have
         // different interpretation than before, for example if we had two keys with the same
         // name and one of them was just removed 
         reloadIniFile();
      }
   }

   /**
    * Getting the comma-separated list of the keys for the given section.
    * 
    * @return The String with comma-separated key names.
    */
   private String getSections()
   {
      StringBuilder sb = new StringBuilder();

      // Yes, the behaviour in CHUI and Graphical interface is different
      if (isChui)
      {
         // 4GL Returns unknown character, this looks like a 4GL bug
         return null;
      }
      else
      {
         // Enumerate ordering list
         Iterator<Content> iterSect = contList.iterator();
         int count = 0;
         while (iterSect.hasNext())
         {
            // Get next available Renderable object
            Content objToCheck = (Content)iterSect.next();
            // Check if it is a member of the section map
            if (sectMap.containsValue(objToCheck))
            {
               // This is the comma-separator if there is more than 1 name
               if (count > 0)
               {
                  sb.append(EnvironmentOps.PROPATH_SEPARATOR);
               }
               sb.append(((SectionContent)objToCheck).getName());
               count++;
            }
         }
      }
      
      return sb.toString();
   }
   
   /**
    * Getting the comma-separated list of the keys for the given section.
    * 
    * @param section 
    *        The name of the section to analyze.
    * 
    * @return The String with comma-separated key names.
    */
   private String getKeys(String section)
   {
      SectionContent sectionEntry = getSection(section);
      
      // Yes, the behaviour in CHUI and Graphical interface is different
      if (isChui)
      {
         // 4GL Returns unknown character, this looks like a 4GL bug
         return null;
      }
      else
      {
         if (sectionEntry != null)
         {
            return sectionEntry.getKeys();
         }
      }
      
      return null;
   }
   
   /**
    * Getting the object that holds the section from the previously formed environment.
    * 
    * @param name 
    *        The name of the section to look for.
    * 
    * @return The SectionContent object or null if the section was not found.
    */
   private SectionContent getSection(String name)
   {
      if (sectMap != null)
      {
         return (SectionContent)sectMap.get(name);
      }
      
      return null;
   }

   /**
    * Reconstructs full initial file name with possible path.
    */
   private String getFullIniFileName()
   {
      StringBuilder fullName = new StringBuilder();
      
      // We have non-empty directory value, add it first 
      if (workingDirectory != null && workingDirectory.length() > 0)
      {
         String wDir = FileSystemDaemon.normalizeFileSeparators(workingDirectory);
         fullName.append(wDir);
         if (!wDir.endsWith(File.separator))
         {
            fullName.append(File.separator);
         }
      }
      // And we have the non-empty file name, add it
      if (name != null && name.length() > 0)
      {
         fullName.append(name);
      }
      // If something was added, convert to String
      if (fullName.length() > 0)
      {
         return fullName.toString();
      }
      else // Otherwise return null
      {
         return null;
      }
   }
   
   /**
    * Open an INI file reader. By default INI files are read from file system. 
    * Read only INI files are read as resources from jar files.
    * 
    * @param   fileName
    *          INI File name.
    *          
    * @return  Buffered reader object.
    * 
    * @throws  FileNotFoundException if file not found
    */
   private BufferedReader openReader(String fileName) 
   throws FileNotFoundException
   {
      BufferedReader br = null;
      ThinClient tc = ThinClient.getInstance();
      FileSystem fs = OSResourceManager.getFileSystem();

      // Read only INI files are loaded from jar files as resources.
      if (isReadOnly)
      {
         // load INI file as resource from server
         byte[] data = tc.getServer().loadEnvironment(fileName);
         if (data == null)
         {
            throw new FileNotFoundException("Cannot load " + fileName + " from server.");
         }
         ByteArrayInputStream inp = new ByteArrayInputStream(data);
         br = new BufferedReader(new InputStreamReader(inp));               
      }
      else
      {
         // Normalize the filename
         String fN = FileSystemDaemon.search(null, fs.isCaseSensitive(), fileName, false);
         if (fN == null)
         {
            throw new FileNotFoundException("Cannot load " + fileName + " from server.");
         }
         
         // Open file stream for reading
         br = new BufferedReader(new FileReader(fN));
      }
      
      return br;
   }
   
   /**
    * Reads the current environment set from appropriate INI file in file system.
    * 
    * @return  <code>true</code> if loaded false otherwise.      
    */
   private boolean loadIniFile()
   {
      boolean loaded = false;
      
      String fileName = getFullIniFileName();
      
      if (fileName != null && fileName.length() > 0)
      {
         BufferedReader br = null;
         
         try
         {
            // open INI file reader
            br = openReader(fileName);
            
            String sectionName = null;
            Content contentEntry = null;
            
            String nextLine = br.readLine();
            // Read until EOF(nextLine == null)
            while (nextLine != null)
            {
               // Next INI file section should start here
               if (nextLine.length() > 2 && nextLine.charAt(0) == '[' &&
                   nextLine.charAt(nextLine.length() - 1) == ']')
               {
                  // The previously constructed section should be saved before starting to
                  // process the next section
                  if (sectionName != null && contentEntry != null && contentEntry.notEmpty())
                  {
                     contList.add(contentEntry);
                     sectMap.put(sectionName, contentEntry);
                  }
                  // The new section name candidate
                  sectionName = new String(nextLine.substring(1,nextLine.length() - 1));
                  // Purge the previous one if it was existed
                  if (contentEntry != null)
                  {
                     contentEntry = null;
                  }
                  // Check if the section with the same name already added to the map. In this
                  // case we have to completely ignore more than one section occurrence
                  if (sectMap.containsKey(sectionName))
                  {
                     // Decline section name candidate
                     sectionName = null;
                     // And add the line with section name to the ordered list as simple line
                     contentEntry = new LineContent(nextLine);
                     // Put the simple line into ordered list
                     contList.add(contentEntry);
                     // And mark this is not a section start
                     contentEntry = null;
                  }
               }
               // Section processing
               else
               {
                  // Key-value candidate, get the index of separator first
                  int separatorNdx = nextLine.indexOf('=');
                  // Something should exist before and after separator and
                  // line is not a comment and we have the section to add the key
                  if (separatorNdx > 0 && nextLine.length() > separatorNdx + 1 &&
                      nextLine.trim().indexOf(';') != 0 && sectionName != null)
                  {
                     if (contentEntry == null)
                     {
                        contentEntry = new SectionContent(sectionName);
                     }
                     // Construct key-value pair from line string
                     // Key name should be trimmed, need to check other cases
                     String keyName = new String(nextLine.substring(0,separatorNdx).trim());
                     // The value should remain as it is in INI file on reading stage.
                     String keyValue = new String(nextLine.substring(separatorNdx + 1));
                     // Store the next key-value pair into section content
                     if (!((SectionContent)contentEntry).alreadyInSection(keyName))
                     {
                        ((SectionContent)contentEntry).add(keyName, keyValue);
                     }
                     // This is the case when the key already exists so just ignore second or
                     // more occurrence of the same key considering the key-value as simple line
                     else
                     {
                        ((SectionContent)contentEntry).store(nextLine);
                     }
                  }
                  // Here we add artificial meaningless data including empty lines between
                  // sections
                  else
                  {
                     // There was no sections at this time, create simple line 
                     if (sectionName == null)
                     {
                        contentEntry = new LineContent(nextLine);
                        // Put the simple line into ordered list
                        contList.add(contentEntry);
                        // And mark this is not a section start
                        contentEntry = null;
                     }
                     // The line is a part of the section already created or just found
                     else
                     {
                        // This is the comment or space in the head of the section
                        if (contentEntry == null)
                        {
                           contentEntry = new SectionContent(sectionName);
                        }
                        // Store the simple line inside the section
                        ((SectionContent)contentEntry).store(nextLine);
                     }
                  }
               }
               
               // Read the next line from file
               nextLine = br.readLine();
               if (nextLine == null && sectionName != null &&
                   contentEntry != null && contentEntry.notEmpty())
               {
                  // Possible last section in the file
                  contList.add(contentEntry);
                  sectMap.put(sectionName, contentEntry);
               }
            }
            br.close();
            loaded = true;
         }
         catch (IOException ioex)
         {
         }
      }
      
      return loaded;
   }
   
   /**
    * Creates the empty INI file for current environment set in file system.
    */
   private boolean createEmptyIniFile()
   {
      boolean created = false;
      
      String fileName = getFullIniFileName();
      
      if (fileName != null && fileName.length() > 0)
      {
         PrintWriter pw = null;
         
         try
         {
            // Open file for writing
            pw = new PrintWriter(new FileWriter(fileName, false));
            created = true;
         }
         catch (IOException ioex)
         {
         }
         finally
         {
            // Do nothing and close it
            if (pw != null)
            {
               pw.close();
            }
         }
      }
      
      return created;
   }
   
   /**
    * Writes the current environment set to the appropriate INI file in file system.
    */
   private void commitIniFile()
   {
      String fileName = getFullIniFileName();
      
      if (fileName != null && fileName.length() > 0)
      {
         PrintWriter pw = null;
         
         try
         {
            // Open file for writing
            pw = new PrintWriter(new FileWriter(fileName, false));
            // Sections iterator
            Iterator<Content> iterEntry = contList.iterator();
            while (iterEntry.hasNext())
            {
               // Get next available content entry, section or ignoring line
               Content contEntry = (Content)iterEntry.next();
               // If entry is not empty write the content to file
               if (contEntry.notEmpty())
               {
                  contEntry.write(pw);
               }
            }
            // Commit the changes
            pw.flush();
         }
         catch (IOException ioex)
         {
         }
         finally
         {
            if (pw != null)
            {
               // And finally close the file
               pw.close();
            }
         }
      }
   }
   
   /**
    * Reloads the INI file replacing the given internal maps with the new ones from changed INI
    * file taken from file system.
    */
   private void reloadIniFile()
   {
      sectMap.clear();
      contList.clear();
      loadIniFile();
   }
}