001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2017-2022 Michael N. Lipp
004 * 
005 * This program is free software; you can redistribute it and/or modify it 
006 * under the terms of the GNU Affero General Public License as published by 
007 * the Free Software Foundation; either version 3 of the License, or 
008 * (at your option) any later version.
009 * 
010 * This program is distributed in the hope that it will be useful, but 
011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 
013 * for more details.
014 * 
015 * You should have received a copy of the GNU Affero General Public License along 
016 * with this program; if not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jgrapes.util;
020
021import java.io.IOException;
022import java.util.HashMap;
023import java.util.Map;
024import java.util.Optional;
025import java.util.prefs.BackingStoreException;
026import java.util.prefs.Preferences;
027import org.jgrapes.core.Channel;
028import org.jgrapes.core.EventPipeline;
029import org.jgrapes.core.annotation.Handler;
030import org.jgrapes.core.events.Start;
031import org.jgrapes.util.events.ConfigurationUpdate;
032import org.jgrapes.util.events.InitialPreferences;
033
034/**
035 * This component provides a store for an application's configuration
036 * backed by the Java {@link Preferences}. Preferences
037 * are maps of key value pairs that are associated with a path. A common
038 * base path is passed to the component on creation. The application's
039 * configuration information is stored using paths relative to that 
040 * base path.
041 * 
042 * The component reads the initial values from the Java {@link Preferences}
043 * tree denoted by the base path. During application bootstrap, it 
044 * intercepts the {@link Start} event using a handler with  priority 
045 * 999999. When receiving this event, it fires all known preferences 
046 * values on the channels of the start event as a 
047 * {@link InitialPreferences} event, using a new {@link EventPipeline}
048 * and waiting for its completion. Then, allows the intercepted 
049 * {@link Start} event to continue. 
050 * 
051 * Components that depend on configuration values define handlers
052 * for {@link ConfigurationUpdate} events and adapt themselves to the values 
053 * received. Note that due to the intercepted {@link Start} event, the initial
054 * preferences values are received before the {@link Start} event, so
055 * components' configurations can be rearranged before they actually
056 * start doing something.
057 *
058 * Besides initially publishing the stored preferences values,
059 * the component also listens for {@link ConfigurationUpdate} events
060 * on its channel and updates the preferences store (may be suppressed).
061 */
062@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
063public class PreferencesStore extends ConfigurationStore {
064
065    private Preferences preferences;
066
067    /**
068     * Creates a new component with its channel set to the given 
069     * channel and a base path derived from the given class.
070     * 
071     * @param componentChannel the channel 
072     * @param appClass the application class; the base path
073     * is formed by replacing each dot in the class's package's full 
074     * name with a slash, prepending a slash, and appending 
075     * "`/PreferencesStore`".
076     */
077    public PreferencesStore(Channel componentChannel, Class<?> appClass) {
078        this(componentChannel, appClass, true);
079    }
080
081    /**
082     * Allows the creation of a "read-only" store.
083     * 
084     * @param componentChannel the channel 
085     * @param appClass the application class; the base path
086     * is formed by replacing each dot in the class's package's full 
087     * name with a slash, prepending a slash, and appending 
088     * "`/PreferencesStore`".
089     * @param update whether to update the store when 
090     * {@link ConfigurationUpdate} events are received
091     * 
092     * @see #PreferencesStore(Channel, Class)
093     */
094    public PreferencesStore(
095            Channel componentChannel, Class<?> appClass, boolean update) {
096        super(componentChannel);
097        if (update) {
098            Handler.Evaluator.add(this, "onConfigurationUpdate",
099                channel().defaultCriterion());
100        }
101        preferences = Preferences.userNodeForPackage(appClass)
102            .node("PreferencesStore");
103    }
104
105    /**
106     * Intercepts the {@link Start} event and fires a
107     * {@link ConfigurationUpdate} event.
108     *
109     * @param event the event
110     * @throws BackingStoreException the backing store exception
111     * @throws InterruptedException the interrupted exception
112     */
113    @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
114    @Handler(priority = 999_999, channels = Channel.class)
115    public void onStart(Start event)
116            throws BackingStoreException, InterruptedException {
117        InitialPreferences updEvt
118            = new InitialPreferences(preferences.parent().absolutePath());
119        addPrefs(updEvt, preferences.absolutePath(), preferences);
120        newEventPipeline().fire(updEvt, event.channels()).get();
121    }
122
123    @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
124    private void addPrefs(
125            InitialPreferences updEvt, String rootPath, Preferences node)
126            throws BackingStoreException {
127        String nodePath = node.absolutePath();
128        String relPath = "/" + nodePath.substring(Math.min(
129            rootPath.length() + 1, nodePath.length()));
130        for (String key : node.keys()) {
131            updEvt.add(relPath, key, node.get(key, null));
132        }
133        for (String child : node.childrenNames()) {
134            addPrefs(updEvt, rootPath, node.node(child));
135        }
136    }
137
138    /**
139     * Merges and saves configuration updates.
140     *
141     * @param event the event
142     * @throws IOException Signals that an I/O exception has occurred.
143     */
144    @Handler(dynamic = true)
145    @SuppressWarnings("PMD.AvoidReassigningLoopVariables")
146    public void onConfigurationUpdate(ConfigurationUpdate event)
147            throws BackingStoreException {
148        if (event instanceof InitialPreferences) {
149            return;
150        }
151        for (String path : event.paths()) {
152            Optional<Map<String, String>> prefs = event.values(path);
153            path = path.substring(1); // Remove leading slash
154            if (!prefs.isPresent()) {
155                preferences.node(path).removeNode();
156                continue;
157            }
158            for (Map.Entry<String, String> e : prefs.get().entrySet()) {
159                preferences.node(path).put(e.getKey(), e.getValue());
160            }
161        }
162        preferences.flush();
163    }
164
165    @Override
166    public Optional<Map<String, String>> values(String path) {
167        if (!path.startsWith("/")) {
168            throw new IllegalArgumentException("Path must start with \"/\".");
169        }
170        try {
171            var relPath = path.substring(1);
172            if (!preferences.nodeExists(relPath)) {
173                return Optional.empty();
174            }
175            var node = preferences.node(relPath);
176            var result = new HashMap<String, String>();
177            for (String key : node.keys()) {
178                result.put(key, node.get(key, null));
179            }
180            return Optional.of(result);
181        } catch (BackingStoreException e) {
182            throw new IllegalStateException(e);
183        }
184    }
185}