001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2017-2018 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.Map;
023import java.util.Optional;
024import java.util.prefs.BackingStoreException;
025import java.util.prefs.Preferences;
026
027import org.jgrapes.core.Channel;
028import org.jgrapes.core.Component;
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 */
063public class PreferencesStore extends Component {
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 = 999999, 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    public void onConfigurationUpdate(ConfigurationUpdate event)
146            throws BackingStoreException {
147        if (event instanceof InitialPreferences) {
148            return;
149        }
150        for (String path : event.paths()) {
151            Optional<Map<String, String>> prefs = event.values(path);
152            path = path.substring(1); // Remove leading slash
153            if (!prefs.isPresent()) {
154                preferences.node(path).removeNode();
155                continue;
156            }
157            for (Map.Entry<String, String> e : prefs.get().entrySet()) {
158                preferences.node(path).put(e.getKey(), e.getValue());
159            }
160        }
161        preferences.flush();
162    }
163}