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 java.util.stream.Collectors;
028import org.jgrapes.core.Channel;
029import org.jgrapes.core.EventPipeline;
030import org.jgrapes.core.annotation.Handler;
031import org.jgrapes.core.events.Start;
032import org.jgrapes.util.events.ConfigurationUpdate;
033import org.jgrapes.util.events.InitialPreferences;
034
035/**
036 * This component provides a store for an application's configuration
037 * backed by the Java {@link Preferences}. Preferences
038 * are maps of key value pairs that are associated with a path. A common
039 * base path is passed to the component on creation. The application's
040 * configuration information is stored using paths relative to that 
041 * base path.
042 * 
043 * The component reads the initial values from the Java {@link Preferences}
044 * tree denoted by the base path. During application bootstrap, it 
045 * intercepts the {@link Start} event using a handler with  priority 
046 * 999999. When receiving this event, it fires all known preferences 
047 * values on the channels of the start event as a 
048 * {@link InitialPreferences} event, using a new {@link EventPipeline}
049 * and waiting for its completion. Then, allows the intercepted 
050 * {@link Start} event to continue. 
051 * 
052 * Components that depend on configuration values define handlers
053 * for {@link ConfigurationUpdate} events and adapt themselves to the values 
054 * received. Note that due to the intercepted {@link Start} event, the initial
055 * preferences values are received before the {@link Start} event, so
056 * components' configurations can be rearranged before they actually
057 * start doing something.
058 *
059 * Besides initially publishing the stored preferences values,
060 * the component also listens for {@link ConfigurationUpdate} events
061 * on its channel and updates the preferences store (may be suppressed).
062 */
063@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
064public class PreferencesStore extends ConfigurationStore {
065
066    private Preferences preferences;
067
068    /**
069     * Creates a new component with its channel set to the given 
070     * channel and a base path derived from the given class.
071     * 
072     * @param componentChannel the channel 
073     * @param appClass the application class; the base path
074     * is formed by replacing each dot in the class's package's full 
075     * name with a slash, prepending a slash, and appending 
076     * "`/PreferencesStore`".
077     */
078    public PreferencesStore(Channel componentChannel, Class<?> appClass) {
079        this(componentChannel, appClass, true);
080    }
081
082    /**
083     * Allows the creation of a "read-only" store.
084     * 
085     * @param componentChannel the channel 
086     * @param appClass the application class; the base path
087     * is formed by replacing each dot in the class's package's full 
088     * name with a slash, prepending a slash, and appending 
089     * "`/PreferencesStore`".
090     * @param update whether to update the store when 
091     * {@link ConfigurationUpdate} events are received
092     * 
093     * @see #PreferencesStore(Channel, Class)
094     */
095    public PreferencesStore(
096            Channel componentChannel, Class<?> appClass, boolean update) {
097        super(componentChannel);
098        if (update) {
099            Handler.Evaluator.add(this, "onConfigurationUpdate",
100                channel().defaultCriterion());
101        }
102        preferences = Preferences.userNodeForPackage(appClass)
103            .node("PreferencesStore");
104    }
105
106    /**
107     * Intercepts the {@link Start} event and fires a
108     * {@link ConfigurationUpdate} event.
109     *
110     * @param event the event
111     * @throws BackingStoreException the backing store exception
112     * @throws InterruptedException the interrupted exception
113     */
114    @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
115    @Handler(priority = 999_999, channels = Channel.class)
116    public void onStart(Start event)
117            throws BackingStoreException, InterruptedException {
118        InitialPreferences updEvt
119            = new InitialPreferences(preferences.parent().absolutePath());
120        addPrefs(updEvt, preferences.absolutePath(), preferences);
121        newEventPipeline().fire(updEvt, event.channels()).get();
122    }
123
124    @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
125    private void addPrefs(
126            InitialPreferences updEvt, String rootPath, Preferences node)
127            throws BackingStoreException {
128        String nodePath = node.absolutePath();
129        String relPath = "/" + nodePath.substring(Math.min(
130            rootPath.length() + 1, nodePath.length()));
131        var props = new HashMap<String, String>();
132        for (String key : node.keys()) {
133            props.put(key, node.get(key, null));
134        }
135        updEvt.set(relPath, ConfigurationStore.structure(props));
136        for (String child : node.childrenNames()) {
137            addPrefs(updEvt, rootPath, node.node(child));
138        }
139    }
140
141    /**
142     * Merges and saves configuration updates.
143     *
144     * @param event the event
145     * @throws IOException Signals that an I/O exception has occurred.
146     */
147    @Handler(dynamic = true)
148    @SuppressWarnings("PMD.AvoidReassigningLoopVariables")
149    public void onConfigurationUpdate(ConfigurationUpdate event)
150            throws BackingStoreException {
151        if (event instanceof InitialPreferences) {
152            return;
153        }
154        for (String path : event.paths()) {
155            Optional<Map<String, String>> prefs = event.values(path);
156            path = path.substring(1); // Remove leading slash
157            if (!prefs.isPresent()) {
158                preferences.node(path).removeNode();
159                continue;
160            }
161            for (Map.Entry<String, String> e : prefs.get().entrySet()) {
162                preferences.node(path).put(e.getKey(), e.getValue());
163            }
164        }
165        preferences.flush();
166    }
167
168    @Override
169    public Optional<Map<String, String>> values(String path) {
170        return nodeValues(path).map(m -> m.entrySet().stream()).map(
171            s -> s.collect(Collectors.toMap(e -> e.getKey().replace("\"", ""),
172                Map.Entry::getValue)));
173    }
174
175    @Override
176    public Optional<Map<String, Object>> structured(String path) {
177        return nodeValues(path).map(ConfigurationStore::structure);
178    }
179
180    private Optional<Map<String, String>> nodeValues(String path) {
181        if (!path.startsWith("/")) {
182            throw new IllegalArgumentException("Path must start with \"/\".");
183        }
184        try {
185            var relPath = path.substring(1);
186            if (!preferences.nodeExists(relPath)) {
187                return Optional.empty();
188            }
189            var node = preferences.node(relPath);
190            var result = new HashMap<String, String>();
191            for (String key : node.keys()) {
192                result.put(key, node.get(key, null));
193            }
194            return Optional.of(result);
195        } catch (BackingStoreException e) {
196            throw new IllegalStateException(e);
197        }
198    }
199
200}