View Javadoc
1   package org.opentrafficsim.swing.gui;
2   
3   import java.awt.Color;
4   import java.io.File;
5   import java.io.FileReader;
6   import java.io.FileWriter;
7   import java.io.IOException;
8   import java.nio.file.Path;
9   import java.nio.file.Paths;
10  import java.text.DateFormat;
11  import java.text.ParseException;
12  import java.text.SimpleDateFormat;
13  import java.util.ArrayList;
14  import java.util.Arrays;
15  import java.util.Date;
16  import java.util.LinkedHashMap;
17  import java.util.LinkedHashSet;
18  import java.util.List;
19  import java.util.Locale;
20  import java.util.Map;
21  import java.util.Optional;
22  import java.util.Properties;
23  import java.util.Set;
24  import java.util.SortedMap;
25  import java.util.TreeMap;
26  import java.util.concurrent.Executors;
27  import java.util.concurrent.ScheduledExecutorService;
28  import java.util.concurrent.TimeUnit;
29  import java.util.concurrent.atomic.AtomicLong;
30  import java.util.regex.Matcher;
31  import java.util.regex.Pattern;
32  
33  import org.djutils.exceptions.Throw;
34  
35  /**
36   * Class that can be used within a program to load and save properties. This class adheres to the XDG Base Directory
37   * Specification regarding where setting files are stored.
38   * <p>
39   * Copyright (c) 2026-2026 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
40   * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
41   * </p>
42   * @author Alexander Verbraeck
43   * @author Peter Knoppers
44   * @author Wouter Schakel
45   * @see <a href="https://specifications.freedesktop.org/basedir/latest/">XDG Base Directory Specification</a>
46   */
47  public class PropertiesStore
48  {
49  
50      /**
51       * Location within <code>${user.home}</code> where properties are stored to comply with XDG Base Directory Specification.
52       */
53      private static final String CONFIG = ".config";
54  
55      /** Enterprise folder. */
56      private static final String OTS = "ots";
57  
58      /** Format to store dates with. Complies with {@link Date#toString} and {@link Properties}. */
59      private static final DateFormat DATE_FORMAT = new SimpleDateFormat("EEE MMM dd HH:mm:ss zzz yyyy");
60  
61      /** Properties. */
62      private final Properties properties;
63  
64      /** Context. */
65      private final String context;
66  
67      /** Description that is saved as a comment in the file that stores the properties. */
68      private final String description;
69  
70      /** Cached colors. */
71      private final Map<String, Color> colorCache = new LinkedHashMap<>();
72  
73      /** Cached ints. */
74      private final Map<String, Integer> intCache = new LinkedHashMap<>();
75  
76      /** Cached booleans. */
77      private final Map<String, Boolean> booleanCache = new LinkedHashMap<>();
78  
79      /** Maximum number of sub-contexts. */
80      private int maxSubContexts = 50;
81  
82      /** Scheduler to delay limiting the contexts. */
83      private final ScheduledExecutorService limitContextScheduler;
84  
85      /** Counter to skip intermediate context limitation requests. */
86      private final AtomicLong limitContextRequest = new AtomicLong(0);
87  
88      /** Scheduler to delay saving. */
89      private final ScheduledExecutorService saveScheduler;
90  
91      /** Counter to skip intermediate saving requests. */
92      private final AtomicLong saveRequest = new AtomicLong(0);
93  
94      /**
95       * Constructor. To populate the default {@link Properties} use the various static {@code valueToString} methods.
96       * @param properties properties pre-loaded with defaults
97       * @param context context of the properties, e.g. {@code "appearance"} or {@code "editor"}
98       * @param description description of the properties, which is saved as a comment in the file that stores the properties
99       */
100     public PropertiesStore(final Properties properties, final String context, final String description)
101     {
102         Throw.whenNull(context, "context");
103         Path path = Paths.get(System.getProperty("user.home"), CONFIG, OTS, safeContext(context));
104         Properties props = properties == null ? new Properties() : properties;
105         try
106         {
107             props.load(new FileReader(path.toFile()));
108         }
109         catch (IOException exception)
110         {
111             // ignore
112         }
113         this.properties = props;
114         this.context = context;
115         this.description = description; // can be null
116         this.limitContextScheduler = Executors
117                 .newSingleThreadScheduledExecutor((runnable) -> new Thread(runnable, this.context + "-context-limiter"));
118         this.saveScheduler =
119                 Executors.newSingleThreadScheduledExecutor((runnable) -> new Thread(runnable, this.context + "-saver"));
120         save(); // saves defaults on missing values or completely missing file
121     }
122 
123     /**
124      * Sets the maximum number of sub-contexts. The default value is 50.
125      * @param maxSubContexts maximum number of sub-contexts
126      */
127     public void setMaxSubContexts(final int maxSubContexts)
128     {
129         Throw.when(maxSubContexts <= 0, IllegalArgumentException.class, "Number of maximum sub-contexts should be at least 1.");
130         this.maxSubContexts = maxSubContexts;
131         limitContexts();
132     }
133 
134     /**
135      * Saves properties.
136      */
137     public void save()
138     {
139         long request = this.saveRequest.incrementAndGet();
140         this.saveScheduler.schedule(() ->
141         {
142             // If a newer request arrived, skip
143             if (request != this.saveRequest.get())
144             {
145                 return;
146             }
147             File f = Paths.get(System.getProperty("user.home"), CONFIG, OTS, safeContext(this.context)).toFile();
148             f.getParentFile().mkdirs();
149             try
150             {
151                 FileWriter writer = new FileWriter(f);
152                 this.properties.store(writer, this.description);
153             }
154             catch (IOException exception)
155             {
156                 // ignore
157             }
158         }, 500L, TimeUnit.MILLISECONDS);
159     }
160 
161     /**
162      * Returns a lower case context that will append .ini if the context does not already end with .ini.
163      * @param context context
164      * @return save context
165      */
166     private static String safeContext(final String context)
167     {
168         return context.toLowerCase().endsWith(".ini") ? context.toLowerCase() : context.toLowerCase() + ".ini";
169     }
170 
171     /**
172      * Returns the property value. If the program has not saved any value, a default value should have been given via the input
173      * properties.
174      * @param key key
175      * @return property value
176      */
177     public String getProperty(final String key)
178     {
179         Throw.whenNull(key, "key");
180         return this.properties.getProperty(key);
181     }
182 
183     /**
184      * Returns property that might not be given.
185      * @param key key
186      * @return property that might not be given
187      */
188     public Optional<String> getOptionalProperty(final String key)
189     {
190         return Optional.ofNullable(getProperty(key));
191     }
192 
193     /**
194      * Returns the property value, or the provided default if there is no value mapped to the key. In the latter case, the
195      * default value will be stored as the property value.
196      * @param key key
197      * @param defaultValue default value
198      * @return property value, or the provided default if there is no value mapped to the key
199      */
200     public String getPropertyOrDefault(final String key, final String defaultValue)
201     {
202         if (!this.properties.containsKey(key))
203         {
204             setProperty(key, defaultValue, true);
205         }
206         return getProperty(key);
207     }
208 
209     /**
210      * Sets a property value.
211      * @param key key
212      * @param value value
213      * @param save whether to save the properties (typically yes, but only on last if multiple properties are set)
214      */
215     public void setProperty(final String key, final String value, final boolean save)
216     {
217         Throw.whenNull(key, "key");
218         Throw.whenNull(value, "value");
219         this.properties.setProperty(key, value);
220         if (save)
221         {
222             save();
223         }
224     }
225 
226     /**
227      * Sets a property value.
228      * @param key key
229      * @param value value
230      */
231     public void setProperty(final String key, final String value)
232     {
233         setProperty(key, value, true);
234     }
235 
236     /**
237      * Removes key from the store.
238      * @param key key
239      */
240     public void clearProperty(final String key)
241     {
242         Throw.whenNull(key, "key");
243         this.properties.remove(key);
244         save();
245     }
246 
247     /**
248      * Returns a key that complies to upper/lower case convention.
249      * @param key key
250      * @return key that complies to upper/lower case convention
251      */
252     public static String key(final String key)
253     {
254         String s = key;
255         if (s.matches("^[A-Z]+$"))
256         {
257             return s.toLowerCase(Locale.ROOT);
258         }
259         Matcher m1 = Pattern.compile("^[A-Z]+(?=[A-Z][a-z])").matcher(s);
260         if (m1.find())
261         {
262             s = m1.replaceFirst(m1.group().toLowerCase(Locale.ROOT));
263             return s;
264         }
265         Matcher m2 = Pattern.compile("^[A-Z](?=[a-z])").matcher(s);
266         if (m2.find())
267         {
268             s = m2.replaceFirst(m2.group().toLowerCase(Locale.ROOT));
269         }
270         return s;
271     }
272 
273     /**
274      * Returns a key specific for the sub-context. The resulting key is <code>context.{hashCode}.{key}</code>, using the hash
275      * code of the sub-context. This method also deals with limiting the number of saved sub-contexts.
276      * @param key key
277      * @param subContext sub-context
278      * @return contextual key
279      */
280     public String contextKey(final String key, final Object subContext)
281     {
282         Throw.whenNull(key, "key");
283         Throw.whenNull(subContext, "context");
284         String keyPart = "context." + Integer.toString(subContext.hashCode());
285         setProperty(key + "_date", DATE_FORMAT.format(new Date())); // context.123456789_date
286         limitContexts();
287         return keyPart + "." + key;
288     }
289 
290     /**
291      * Limit the number of contexts stored. This method is delayed. Intermediate invocations will cancel previous invocations.
292      */
293     private void limitContexts()
294     {
295         long request = this.limitContextRequest.incrementAndGet();
296         this.limitContextScheduler.schedule(() ->
297         {
298             // If a newer request arrived, skip
299             if (request != this.limitContextRequest.get())
300             {
301                 return;
302             }
303             limitContexts0();
304         }, 500L, TimeUnit.MILLISECONDS);
305 
306     }
307 
308     /**
309      * Performs the actual limiting of the number of contexts.
310      */
311     private void limitContexts0()
312     {
313         // Gather sorted contexts
314         SortedMap<Date, String> contexts = new TreeMap<>();
315         Map<String, Set<String>> contextKeys = new LinkedHashMap<>();
316         Pattern pattern = Pattern.compile("context\\.(%d+)(\\.|_date).*=(.*)");
317         for (Object keyObj : PropertiesStore.this.properties.keySet())
318         {
319             String key = keyObj.toString();
320             Matcher matcher = pattern.matcher(key);
321             if (matcher.matches())
322             {
323                 String subContext = matcher.group(0);
324                 if ("_".equals(matcher.group(1)))
325                 {
326                     // date value
327                     Date date;
328                     try
329                     {
330                         date = DATE_FORMAT.parse(matcher.group(2));
331                         contexts.put(date, subContext);
332                     }
333                     catch (ParseException exception)
334                     {
335                         // throw it away by assuming old time
336                         contexts.put(new Date(0L), subContext);
337                     }
338                 }
339                 else
340                 {
341                     contextKeys.computeIfAbsent(subContext, (s) -> new LinkedHashSet<>()).add(key);
342                 }
343             }
344         }
345 
346         // Clear old contexts
347         boolean removed = contexts.size() > PropertiesStore.this.maxSubContexts;
348         while (contexts.size() > PropertiesStore.this.maxSubContexts)
349         {
350             Date first = contexts.firstKey();
351             String oldContext = contexts.remove(first);
352             contextKeys.computeIfAbsent(oldContext, (d) -> new LinkedHashSet<>()).forEach((k) -> clearProperty(k));
353         }
354 
355         // Save if any removed
356         if (removed)
357         {
358             save();
359         }
360     }
361 
362     // ====== List ======
363 
364     /**
365      * Returns list property.
366      * @param key key under which list is stored
367      * @return list (recent to old)
368      */
369     public List<String> getList(final String key)
370     {
371         Throw.whenNull(key, "key");
372         List<String> out = new ArrayList<>();
373         if (this.properties.containsKey(key))
374         {
375             String[] values = ((String) this.properties.get(key)).split("\\|");
376             Arrays.stream(values).forEach(out::add);
377         }
378         return out;
379     }
380 
381     /**
382      * 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
383      * created. The resulting list is saved.
384      * @param key key under which list is stored
385      * @param value value to add to the list
386      * @param maxNumber maximum number of elements in the list
387      * @throws IllegalArgumentException when the value contains a '|'
388      */
389     public void addToList(final String key, final String value, final int maxNumber)
390     {
391         Throw.whenNull(key, "key");
392         Throw.whenNull(value, "value");
393         Throw.when(value.contains("|"), IllegalArgumentException.class, "Value in a list may not contain '|'.");
394         List<String> files = getList(key);
395         if (files.contains(value))
396         {
397             if (files.get(0).equals(value))
398             {
399                 return;
400             }
401             files.remove(value);
402         }
403         files.add(0, value);
404         setList(key, files, maxNumber);
405     }
406 
407     /**
408      * Remove value from list. The resulting list is saved.
409      * @param key key
410      * @param value value
411      */
412     public void removeFromList(final String key, final String value)
413     {
414         List<String> list = getList(key);
415         list.remove(value);
416         setList(key, list, list.size()); // size is ok, we shrink the list so we can't run in to the limit
417     }
418 
419     /**
420      * Sets the list.
421      * @param key key
422      * @param list list
423      * @param maxNumber maximum number of elements in the list
424      */
425     private void setList(final String key, final List<String> list, final int maxNumber)
426     {
427         StringBuilder str = new StringBuilder();
428         int n = Math.min(list.size(), maxNumber);
429         if (n > 0)
430         {
431             list.stream().limit(n - 1).forEach((f) -> str.append(f).append("|"));
432             str.append(list.get(n - 1));
433             setProperty(key, str.toString());
434         }
435         else
436         {
437             clearProperty(key);
438         }
439     }
440 
441     // ====== Color ======
442 
443     /**
444      * Returns color of given key.
445      * @param key key
446      * @return color
447      */
448     public Color getColor(final String key)
449     {
450         Throw.whenNull(key, "key");
451         return this.colorCache.computeIfAbsent(key, (k) ->
452         {
453             String value = getProperty(k);
454             return value == null ? null : stringToColor(value);
455         });
456     }
457 
458     /**
459      * Returns color that might not be given for given key.
460      * @param key key
461      * @return color that might not be given
462      */
463     public Optional<Color> getOptionalColor(final String key)
464     {
465         return Optional.ofNullable(getColor(key));
466     }
467 
468     /**
469      * Returns the property value, or the provided default if there is no value mapped to the key. In the latter case, the
470      * default value will be stored as the property value.
471      * @param key key
472      * @param defaultValue default value
473      * @return property value, or the provided default if there is no value mapped to the key
474      */
475     public Color getColorOrDefault(final String key, final Color defaultValue)
476     {
477         if (!this.properties.containsKey(key))
478         {
479             setProperty(key, valueToString(defaultValue), true);
480         }
481         return getColor(key);
482     }
483 
484     /**
485      * Returns color from string.
486      * @param colorString color as string
487      * @return color
488      */
489     public static Color stringToColor(final String colorString)
490     {
491         Throw.whenNull(colorString, "colorString");
492         String value = colorString.replace(" ", "");
493         String[] channels = value.substring(1, value.length() - 1).split(",");
494         return new Color(Integer.valueOf(channels[0]), Integer.valueOf(channels[1]), Integer.valueOf(channels[2]));
495     }
496 
497     /**
498      * Set color.
499      * @param key key
500      * @param color color
501      */
502     public void setColor(final String key, final Color color)
503     {
504         Throw.whenNull(key, "key");
505         this.colorCache.put(key, color);
506         setProperty(key, valueToString(color));
507     }
508 
509     /**
510      * Returns string from color.
511      * @param color color
512      * @return string from color
513      */
514     public static String valueToString(final Color color)
515     {
516         Throw.whenNull(color, "color");
517         return String.format("[%d, %d, %d]", color.getRed(), color.getGreen(), color.getBlue());
518     }
519 
520     // ====== int ======
521 
522     /**
523      * Returns int for given key.
524      * @param key key
525      * @return int
526      */
527     public Integer getInteger(final String key)
528     {
529         Throw.whenNull(key, "key");
530         return this.intCache.computeIfAbsent(key, (k) ->
531         {
532             String value = getProperty(k);
533             return value == null ? null : Integer.valueOf(value);
534         });
535     }
536 
537     /**
538      * Returns int that might not be given for given key.
539      * @param key key
540      * @return int that might not be given
541      */
542     public Optional<Integer> getOptionalInteger(final String key)
543     {
544         return Optional.ofNullable(getInteger(key));
545     }
546 
547     /**
548      * Returns the property value, or the provided default if there is no value mapped to the key. In the latter case, the
549      * default value will be stored as the property value.
550      * @param key key
551      * @param defaultValue default value
552      * @return property value, or the provided default if there is no value mapped to the key
553      */
554     public Integer getIntegerOrDefault(final String key, final int defaultValue)
555     {
556         if (!this.properties.containsKey(key))
557         {
558             setProperty(key, valueToString(defaultValue), true);
559         }
560         return getInteger(key);
561     }
562 
563     /**
564      * Set int value.
565      * @param key key
566      * @param value value
567      */
568     public void setInt(final String key, final int value)
569     {
570         Throw.whenNull(key, "key");
571         this.intCache.put(key, value);
572         setProperty(key, valueToString(value));
573     }
574 
575     /**
576      * Converts int to String.
577      * @param value value
578      * @return string
579      */
580     public static String valueToString(final int value)
581     {
582         return Integer.toString(value);
583     }
584 
585     // ====== boolean ======
586 
587     /**
588      * Returns boolean for given key.
589      * @param key key
590      * @return boolean
591      */
592     public Boolean getBoolean(final String key)
593     {
594         Throw.whenNull(key, "key");
595         return this.booleanCache.computeIfAbsent(key, (k) ->
596         {
597             String value = getProperty(k);
598             return value == null ? null : Boolean.valueOf(value);
599         });
600     }
601 
602     /**
603      * Returns boolean that might not be given for given key.
604      * @param key key
605      * @return boolean that might not be given
606      */
607     public Optional<Boolean> getOptionalBoolean(final String key)
608     {
609         return Optional.ofNullable(getBoolean(key));
610     }
611 
612     /**
613      * Returns the property value, or the provided default if there is no value mapped to the key. In the latter case, the
614      * default value will be stored as the property value.
615      * @param key key
616      * @param defaultValue default value
617      * @return property value, or the provided default if there is no value mapped to the key
618      */
619     public Boolean getIntegerOrDefault(final String key, final boolean defaultValue)
620     {
621         if (!this.properties.containsKey(key))
622         {
623             setProperty(key, valueToString(defaultValue), true);
624         }
625         return getBoolean(key);
626     }
627 
628     /**
629      * Set boolean value.
630      * @param key key
631      * @param value value
632      */
633     public void setBoolean(final String key, final boolean value)
634     {
635         Throw.whenNull(key, "key");
636         this.booleanCache.put(key, value);
637         setProperty(key, valueToString(value));
638     }
639 
640     /**
641      * Converts boolean to String.
642      * @param value value
643      * @return string
644      */
645     public static String valueToString(final boolean value)
646     {
647         return Boolean.toString(value);
648     }
649 
650 }