PropertiesStore.java
package org.opentrafficsim.swing.gui;
import java.awt.Color;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.djutils.exceptions.Throw;
/**
* Class that can be used within a program to load and save properties. This class adheres to the XDG Base Directory
* Specification regarding where setting files are stored.
* <p>
* Copyright (c) 2026-2026 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
* BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
* </p>
* @author Alexander Verbraeck
* @author Peter Knoppers
* @author Wouter Schakel
* @see <a href="https://specifications.freedesktop.org/basedir/latest/">XDG Base Directory Specification</a>
*/
public class PropertiesStore
{
/**
* Location within <code>${user.home}</code> where properties are stored to comply with XDG Base Directory Specification.
*/
private static final String CONFIG = ".config";
/** Enterprise folder. */
private static final String OTS = "ots";
/** Format to store dates with. Complies with {@link Date#toString} and {@link Properties}. */
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("EEE MMM dd HH:mm:ss zzz yyyy");
/** Properties. */
private final Properties properties;
/** Context. */
private final String context;
/** Description that is saved as a comment in the file that stores the properties. */
private final String description;
/** Cached colors. */
private final Map<String, Color> colorCache = new LinkedHashMap<>();
/** Cached ints. */
private final Map<String, Integer> intCache = new LinkedHashMap<>();
/** Cached booleans. */
private final Map<String, Boolean> booleanCache = new LinkedHashMap<>();
/** Maximum number of sub-contexts. */
private int maxSubContexts = 50;
/** Scheduler to delay limiting the contexts. */
private final ScheduledExecutorService limitContextScheduler;
/** Counter to skip intermediate context limitation requests. */
private final AtomicLong limitContextRequest = new AtomicLong(0);
/** Scheduler to delay saving. */
private final ScheduledExecutorService saveScheduler;
/** Counter to skip intermediate saving requests. */
private final AtomicLong saveRequest = new AtomicLong(0);
/**
* Constructor. To populate the default {@link Properties} use the various static {@code valueToString} methods.
* @param properties properties pre-loaded with defaults
* @param context context of the properties, e.g. {@code "appearance"} or {@code "editor"}
* @param description description of the properties, which is saved as a comment in the file that stores the properties
*/
public PropertiesStore(final Properties properties, final String context, final String description)
{
Throw.whenNull(context, "context");
Path path = Paths.get(System.getProperty("user.home"), CONFIG, OTS, safeContext(context));
Properties props = properties == null ? new Properties() : properties;
try
{
props.load(new FileReader(path.toFile()));
}
catch (IOException exception)
{
// ignore
}
this.properties = props;
this.context = context;
this.description = description; // can be null
this.limitContextScheduler = Executors
.newSingleThreadScheduledExecutor((runnable) -> new Thread(runnable, this.context + "-context-limiter"));
this.saveScheduler =
Executors.newSingleThreadScheduledExecutor((runnable) -> new Thread(runnable, this.context + "-saver"));
save(); // saves defaults on missing values or completely missing file
}
/**
* Sets the maximum number of sub-contexts. The default value is 50.
* @param maxSubContexts maximum number of sub-contexts
*/
public void setMaxSubContexts(final int maxSubContexts)
{
Throw.when(maxSubContexts <= 0, IllegalArgumentException.class, "Number of maximum sub-contexts should be at least 1.");
this.maxSubContexts = maxSubContexts;
limitContexts();
}
/**
* Saves properties.
*/
public void save()
{
long request = this.saveRequest.incrementAndGet();
this.saveScheduler.schedule(() ->
{
// If a newer request arrived, skip
if (request != this.saveRequest.get())
{
return;
}
File f = Paths.get(System.getProperty("user.home"), CONFIG, OTS, safeContext(this.context)).toFile();
f.getParentFile().mkdirs();
try
{
FileWriter writer = new FileWriter(f);
this.properties.store(writer, this.description);
}
catch (IOException exception)
{
// ignore
}
}, 500L, TimeUnit.MILLISECONDS);
}
/**
* Returns a lower case context that will append .ini if the context does not already end with .ini.
* @param context context
* @return save context
*/
private static String safeContext(final String context)
{
return context.toLowerCase().endsWith(".ini") ? context.toLowerCase() : context.toLowerCase() + ".ini";
}
/**
* Returns the property value. If the program has not saved any value, a default value should have been given via the input
* properties.
* @param key key
* @return property value
*/
public String getProperty(final String key)
{
Throw.whenNull(key, "key");
return this.properties.getProperty(key);
}
/**
* Returns property that might not be given.
* @param key key
* @return property that might not be given
*/
public Optional<String> getOptionalProperty(final String key)
{
return Optional.ofNullable(getProperty(key));
}
/**
* Returns the property value, or the provided default if there is no value mapped to the key. In the latter case, the
* default value will be stored as the property value.
* @param key key
* @param defaultValue default value
* @return property value, or the provided default if there is no value mapped to the key
*/
public String getPropertyOrDefault(final String key, final String defaultValue)
{
if (!this.properties.containsKey(key))
{
setProperty(key, defaultValue, true);
}
return getProperty(key);
}
/**
* Sets a property value.
* @param key key
* @param value value
* @param save whether to save the properties (typically yes, but only on last if multiple properties are set)
*/
public void setProperty(final String key, final String value, final boolean save)
{
Throw.whenNull(key, "key");
Throw.whenNull(value, "value");
this.properties.setProperty(key, value);
if (save)
{
save();
}
}
/**
* Sets a property value.
* @param key key
* @param value value
*/
public void setProperty(final String key, final String value)
{
setProperty(key, value, true);
}
/**
* Removes key from the store.
* @param key key
*/
public void clearProperty(final String key)
{
Throw.whenNull(key, "key");
this.properties.remove(key);
save();
}
/**
* Returns a key that complies to upper/lower case convention.
* @param key key
* @return key that complies to upper/lower case convention
*/
public static String key(final String key)
{
String s = key;
if (s.matches("^[A-Z]+$"))
{
return s.toLowerCase(Locale.ROOT);
}
Matcher m1 = Pattern.compile("^[A-Z]+(?=[A-Z][a-z])").matcher(s);
if (m1.find())
{
s = m1.replaceFirst(m1.group().toLowerCase(Locale.ROOT));
return s;
}
Matcher m2 = Pattern.compile("^[A-Z](?=[a-z])").matcher(s);
if (m2.find())
{
s = m2.replaceFirst(m2.group().toLowerCase(Locale.ROOT));
}
return s;
}
/**
* Returns a key specific for the sub-context. The resulting key is <code>context.{hashCode}.{key}</code>, using the hash
* code of the sub-context. This method also deals with limiting the number of saved sub-contexts.
* @param key key
* @param subContext sub-context
* @return contextual key
*/
public String contextKey(final String key, final Object subContext)
{
Throw.whenNull(key, "key");
Throw.whenNull(subContext, "context");
String keyPart = "context." + Integer.toString(subContext.hashCode());
setProperty(key + "_date", DATE_FORMAT.format(new Date())); // context.123456789_date
limitContexts();
return keyPart + "." + key;
}
/**
* Limit the number of contexts stored. This method is delayed. Intermediate invocations will cancel previous invocations.
*/
private void limitContexts()
{
long request = this.limitContextRequest.incrementAndGet();
this.limitContextScheduler.schedule(() ->
{
// If a newer request arrived, skip
if (request != this.limitContextRequest.get())
{
return;
}
limitContexts0();
}, 500L, TimeUnit.MILLISECONDS);
}
/**
* Performs the actual limiting of the number of contexts.
*/
private void limitContexts0()
{
// Gather sorted contexts
SortedMap<Date, String> contexts = new TreeMap<>();
Map<String, Set<String>> contextKeys = new LinkedHashMap<>();
Pattern pattern = Pattern.compile("context\\.(%d+)(\\.|_date).*=(.*)");
for (Object keyObj : PropertiesStore.this.properties.keySet())
{
String key = keyObj.toString();
Matcher matcher = pattern.matcher(key);
if (matcher.matches())
{
String subContext = matcher.group(0);
if ("_".equals(matcher.group(1)))
{
// date value
Date date;
try
{
date = DATE_FORMAT.parse(matcher.group(2));
contexts.put(date, subContext);
}
catch (ParseException exception)
{
// throw it away by assuming old time
contexts.put(new Date(0L), subContext);
}
}
else
{
contextKeys.computeIfAbsent(subContext, (s) -> new LinkedHashSet<>()).add(key);
}
}
}
// Clear old contexts
boolean removed = contexts.size() > PropertiesStore.this.maxSubContexts;
while (contexts.size() > PropertiesStore.this.maxSubContexts)
{
Date first = contexts.firstKey();
String oldContext = contexts.remove(first);
contextKeys.computeIfAbsent(oldContext, (d) -> new LinkedHashSet<>()).forEach((k) -> clearProperty(k));
}
// Save if any removed
if (removed)
{
save();
}
}
// ====== List ======
/**
* Returns list property.
* @param key key under which list is stored
* @return list (recent to old)
*/
public List<String> getList(final String key)
{
Throw.whenNull(key, "key");
List<String> out = new ArrayList<>();
if (this.properties.containsKey(key))
{
String[] values = ((String) this.properties.get(key)).split("\\|");
Arrays.stream(values).forEach(out::add);
}
return out;
}
/**
* Add value to list. If the value is already in the list, it is moved to the front. If the list does not exist it will be
* created. The resulting list is saved.
* @param key key under which list is stored
* @param value value to add to the list
* @param maxNumber maximum number of elements in the list
* @throws IllegalArgumentException when the value contains a '|'
*/
public void addToList(final String key, final String value, final int maxNumber)
{
Throw.whenNull(key, "key");
Throw.whenNull(value, "value");
Throw.when(value.contains("|"), IllegalArgumentException.class, "Value in a list may not contain '|'.");
List<String> files = getList(key);
if (files.contains(value))
{
if (files.get(0).equals(value))
{
return;
}
files.remove(value);
}
files.add(0, value);
setList(key, files, maxNumber);
}
/**
* Remove value from list. The resulting list is saved.
* @param key key
* @param value value
*/
public void removeFromList(final String key, final String value)
{
List<String> list = getList(key);
list.remove(value);
setList(key, list, list.size()); // size is ok, we shrink the list so we can't run in to the limit
}
/**
* Sets the list.
* @param key key
* @param list list
* @param maxNumber maximum number of elements in the list
*/
private void setList(final String key, final List<String> list, final int maxNumber)
{
StringBuilder str = new StringBuilder();
int n = Math.min(list.size(), maxNumber);
if (n > 0)
{
list.stream().limit(n - 1).forEach((f) -> str.append(f).append("|"));
str.append(list.get(n - 1));
setProperty(key, str.toString());
}
else
{
clearProperty(key);
}
}
// ====== Color ======
/**
* Returns color of given key.
* @param key key
* @return color
*/
public Color getColor(final String key)
{
Throw.whenNull(key, "key");
return this.colorCache.computeIfAbsent(key, (k) ->
{
String value = getProperty(k);
return value == null ? null : stringToColor(value);
});
}
/**
* Returns color that might not be given for given key.
* @param key key
* @return color that might not be given
*/
public Optional<Color> getOptionalColor(final String key)
{
return Optional.ofNullable(getColor(key));
}
/**
* Returns the property value, or the provided default if there is no value mapped to the key. In the latter case, the
* default value will be stored as the property value.
* @param key key
* @param defaultValue default value
* @return property value, or the provided default if there is no value mapped to the key
*/
public Color getColorOrDefault(final String key, final Color defaultValue)
{
if (!this.properties.containsKey(key))
{
setProperty(key, valueToString(defaultValue), true);
}
return getColor(key);
}
/**
* Returns color from string.
* @param colorString color as string
* @return color
*/
public static Color stringToColor(final String colorString)
{
Throw.whenNull(colorString, "colorString");
String value = colorString.replace(" ", "");
String[] channels = value.substring(1, value.length() - 1).split(",");
return new Color(Integer.valueOf(channels[0]), Integer.valueOf(channels[1]), Integer.valueOf(channels[2]));
}
/**
* Set color.
* @param key key
* @param color color
*/
public void setColor(final String key, final Color color)
{
Throw.whenNull(key, "key");
this.colorCache.put(key, color);
setProperty(key, valueToString(color));
}
/**
* Returns string from color.
* @param color color
* @return string from color
*/
public static String valueToString(final Color color)
{
Throw.whenNull(color, "color");
return String.format("[%d, %d, %d]", color.getRed(), color.getGreen(), color.getBlue());
}
// ====== int ======
/**
* Returns int for given key.
* @param key key
* @return int
*/
public Integer getInteger(final String key)
{
Throw.whenNull(key, "key");
return this.intCache.computeIfAbsent(key, (k) ->
{
String value = getProperty(k);
return value == null ? null : Integer.valueOf(value);
});
}
/**
* Returns int that might not be given for given key.
* @param key key
* @return int that might not be given
*/
public Optional<Integer> getOptionalInteger(final String key)
{
return Optional.ofNullable(getInteger(key));
}
/**
* Returns the property value, or the provided default if there is no value mapped to the key. In the latter case, the
* default value will be stored as the property value.
* @param key key
* @param defaultValue default value
* @return property value, or the provided default if there is no value mapped to the key
*/
public Integer getIntegerOrDefault(final String key, final int defaultValue)
{
if (!this.properties.containsKey(key))
{
setProperty(key, valueToString(defaultValue), true);
}
return getInteger(key);
}
/**
* Set int value.
* @param key key
* @param value value
*/
public void setInt(final String key, final int value)
{
Throw.whenNull(key, "key");
this.intCache.put(key, value);
setProperty(key, valueToString(value));
}
/**
* Converts int to String.
* @param value value
* @return string
*/
public static String valueToString(final int value)
{
return Integer.toString(value);
}
// ====== boolean ======
/**
* Returns boolean for given key.
* @param key key
* @return boolean
*/
public Boolean getBoolean(final String key)
{
Throw.whenNull(key, "key");
return this.booleanCache.computeIfAbsent(key, (k) ->
{
String value = getProperty(k);
return value == null ? null : Boolean.valueOf(value);
});
}
/**
* Returns boolean that might not be given for given key.
* @param key key
* @return boolean that might not be given
*/
public Optional<Boolean> getOptionalBoolean(final String key)
{
return Optional.ofNullable(getBoolean(key));
}
/**
* Returns the property value, or the provided default if there is no value mapped to the key. In the latter case, the
* default value will be stored as the property value.
* @param key key
* @param defaultValue default value
* @return property value, or the provided default if there is no value mapped to the key
*/
public Boolean getIntegerOrDefault(final String key, final boolean defaultValue)
{
if (!this.properties.containsKey(key))
{
setProperty(key, valueToString(defaultValue), true);
}
return getBoolean(key);
}
/**
* Set boolean value.
* @param key key
* @param value value
*/
public void setBoolean(final String key, final boolean value)
{
Throw.whenNull(key, "key");
this.booleanCache.put(key, value);
setProperty(key, valueToString(value));
}
/**
* Converts boolean to String.
* @param value value
* @return string
*/
public static String valueToString(final boolean value)
{
return Boolean.toString(value);
}
}