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