001package jmri.server.web.app;
002
003import static java.nio.file.StandardWatchEventKinds.OVERFLOW;
004
005import com.fasterxml.jackson.core.JsonProcessingException;
006import com.fasterxml.jackson.databind.ObjectMapper;
007import com.fasterxml.jackson.databind.node.ArrayNode;
008import com.fasterxml.jackson.databind.node.ObjectNode;
009import java.beans.PropertyChangeEvent;
010import java.io.File;
011import java.io.IOException;
012import java.net.URI;
013import java.net.URL;
014import java.nio.file.FileSystems;
015import java.nio.file.Path;
016import java.nio.file.StandardWatchEventKinds;
017import java.nio.file.WatchKey;
018import java.nio.file.WatchService;
019import java.util.ArrayList;
020import java.util.HashMap;
021import java.util.HashSet;
022import java.util.List;
023import java.util.Locale;
024import java.util.Map;
025import java.util.ServiceLoader;
026import java.util.Set;
027import java.util.StringJoiner;
028import jmri.InstanceManager;
029import jmri.profile.Profile;
030import jmri.profile.ProfileUtils;
031import jmri.server.web.spi.AngularRoute;
032import jmri.server.web.spi.WebManifest;
033import jmri.server.web.spi.WebMenuItem;
034import jmri.spi.PreferencesManager;
035import jmri.util.FileUtil;
036import jmri.util.prefs.AbstractPreferencesManager;
037import jmri.util.prefs.InitializationException;
038import jmri.web.server.WebServer;
039import jmri.web.server.WebServerPreferences;
040import org.eclipse.jetty.util.component.LifeCycle;
041import org.openide.util.lookup.ServiceProvider;
042import org.slf4j.Logger;
043import org.slf4j.LoggerFactory;
044
045/**
046 * Manager for the Angular JMRI Web Application.
047 *
048 * @author Randall Wood (C) 2016
049 */
050@ServiceProvider(service = PreferencesManager.class)
051public class WebAppManager extends AbstractPreferencesManager {
052
053    private final HashMap<Profile, WatchService> watcher = new HashMap<>();
054    private final Map<WatchKey, Path> watchPaths = new HashMap<>();
055    private final HashMap<Profile, List<WebManifest>> manifests = new HashMap<>();
056    private Thread lifeCycleListener = null;
057    private final static Logger log = LoggerFactory.getLogger(WebAppManager.class);
058
059    public WebAppManager() {
060    }
061
062    @Override
063    public void initialize(Profile profile) throws InitializationException {
064        WebServerPreferences preferences = InstanceManager.getDefault(WebServerPreferences.class);
065        preferences.addPropertyChangeListener(WebServerPreferences.ALLOW_REMOTE_CONFIG, (PropertyChangeEvent evt) -> {
066            this.savePreferences(profile);
067        });
068        preferences.addPropertyChangeListener(WebServerPreferences.RAILROAD_NAME, (PropertyChangeEvent evt) -> {
069            this.savePreferences(profile);
070        });
071        preferences.addPropertyChangeListener(WebServerPreferences.READONLY_POWER, (PropertyChangeEvent evt) -> {
072            this.savePreferences(profile);
073        });
074        WebServer.getDefault().addLifeCycleListener(new LifeCycle.Listener() {
075            @Override
076            public void lifeCycleStarting(LifeCycle lc) {
077                WebAppManager.this.lifeCycleStarting(lc, profile);
078            }
079
080            @Override
081            public void lifeCycleStarted(LifeCycle lc) {
082                WebAppManager.this.lifeCycleStarted(lc, profile);
083            }
084
085            @Override
086            public void lifeCycleFailure(LifeCycle lc, Throwable thrwbl) {
087                WebAppManager.this.lifeCycleFailure(lc, thrwbl, profile);
088            }
089
090            @Override
091            public void lifeCycleStopping(LifeCycle lc) {
092                WebAppManager.this.lifeCycleStopping(lc, profile);
093            }
094
095            @Override
096            public void lifeCycleStopped(LifeCycle lc) {
097                WebAppManager.this.lifeCycleStopped(lc, profile);
098            }
099        });
100        if (WebServer.getDefault().isRunning()) {
101            this.lifeCycleStarting(null, profile);
102            this.lifeCycleStarted(null, profile);
103        }
104        this.setInitialized(profile, true);
105    }
106
107    @Override
108    public void savePreferences(Profile profile) {
109        File cache = ProfileUtils.getCacheDirectory(profile, this.getClass());
110        FileUtil.delete(cache);
111        this.manifests.getOrDefault(profile, new ArrayList<>()).clear();
112    }
113
114    private List<WebManifest> getManifests(Profile profile) {
115        if (!this.manifests.containsKey(profile)) {
116            this.manifests.put(profile, new ArrayList<>());
117        }
118        if (this.manifests.get(profile).isEmpty()) {
119            ServiceLoader.load(WebManifest.class).forEach((manifest) -> {
120                this.manifests.get(profile).add(manifest);
121            });
122        }
123        return this.manifests.get(profile);
124    }
125
126    public String getScriptTags(Profile profile) {
127        StringBuilder tags = new StringBuilder();
128        List<String> scripts = new ArrayList<>();
129        this.getManifests(profile).forEach((manifest) -> {
130            manifest.getScripts().stream().filter((script) -> (!scripts.contains(script))).forEachOrdered((script) -> {
131                scripts.add(script);
132            });
133        });
134        scripts.forEach((script) -> {
135            tags.append("<script src=\"").append(script).append("\"></script>\n");
136        });
137        return tags.toString();
138    }
139
140    public String getStyleTags(Profile profile) {
141        StringBuilder tags = new StringBuilder();
142        List<String> styles = new ArrayList<>();
143        this.getManifests(profile).forEach((manifest) -> {
144            manifest.getStyles().stream().filter((style) -> (!styles.contains(style))).forEachOrdered((style) -> {
145                styles.add(style);
146            });
147        });
148        styles.forEach((style) -> {
149            tags.append("<link rel=\"stylesheet\" href=\"").append(style).append("\" type=\"text/css\">\n");
150        });
151        return tags.toString();
152    }
153
154    public String getNavigation(Profile profile, Locale locale) throws JsonProcessingException {
155        ObjectMapper mapper = new ObjectMapper();
156        ArrayNode navigation = mapper.createArrayNode();
157        List<WebMenuItem> items = new ArrayList<>();
158        this.getManifests(profile).forEach((WebManifest manifest) -> {
159            manifest.getNavigationMenuItems().stream().filter((WebMenuItem item)
160                    -> !item.getPath().startsWith("help") // NOI18N
161                    && !item.getPath().startsWith("user") // NOI18N
162                    && !items.contains(item))
163                    .forEachOrdered((item) -> {
164                        items.add(item);
165                    });
166        });
167        items.sort((WebMenuItem o1, WebMenuItem o2) -> o1.getPath().compareToIgnoreCase(o2.getPath()));
168        // TODO: get order correct
169        for (int i = 0; i < items.size(); i++) {
170            WebMenuItem item = items.get(i);
171            ObjectNode navItem = this.getMenuItem(item, mapper, locale);
172            ArrayNode children = mapper.createArrayNode();
173            for (int j = i + 1; j < items.size(); j++) {
174                if (!items.get(j).getPath().startsWith(item.getPath())) {
175                    break;
176                }
177                // TODO: add children to arbitrary depth
178                ObjectNode child = this.getMenuItem(items.get(j), mapper, locale);
179                if (items.get(j).getHref() != null) {
180                    children.add(child);
181                }
182                i++;
183            }
184            navItem.set("children", children);
185            // TODO: add badges
186            if (item.getHref() != null || children.size() != 0) {
187                // TODO: handle separator before
188                navigation.add(navItem);
189                // TODO: handle separator after
190            }
191        }
192        return mapper.writeValueAsString(navigation);
193    }
194
195    public String getHelpMenuItems(Profile profile, Locale locale) {
196        return this.getMenuItems("help", profile, locale); // NOI18N
197    }
198
199    public String getUserMenuItems(Profile profile, Locale locale) {
200        return this.getMenuItems("user", profile, locale); // NOI18N
201    }
202
203    private String getMenuItems(String menu, Profile profile, Locale locale) {
204        StringBuilder navigation = new StringBuilder();
205        List<WebMenuItem> items = new ArrayList<>();
206        this.getManifests(profile).forEach((WebManifest manifest) -> {
207            manifest.getNavigationMenuItems().stream().filter((WebMenuItem item)
208                    -> item.getPath().startsWith(menu)
209                    && !items.contains(item))
210                    .forEachOrdered((item) -> {
211                        items.add(item);
212                    });
213        });
214        items.sort((WebMenuItem o1, WebMenuItem o2) -> o1.getPath().compareToIgnoreCase(o2.getPath()));
215        // TODO: get order correct
216        items.forEach((item) -> {
217            // TODO: add children
218            // TODO: add badges
219            // TODO: handle separator before
220            // TODO: handle separator after
221            String href = item.getHref();
222            String title = item.getTitle(locale);
223            if (title.startsWith("translate:")) {
224                title = String.format("<span data-translate>%s</span>", title.substring(10));
225            }
226            if (href != null && href.startsWith("ng-click:")) { // NOI18N
227                navigation.append(String.format("<li><a ng-click=\"%s\">%s</a></li>", href.substring(href.indexOf(":") + 1, href.length()), title)); // NOI18N
228            } else {
229                navigation.append(String.format("<li><a href=\"%s\">%s</a></li>", href, title)); // NOI18N
230            }
231        });
232        return navigation.toString();
233    }
234
235    private ObjectNode getMenuItem(WebMenuItem item, ObjectMapper mapper, Locale locale) {
236        ObjectNode navItem = mapper.createObjectNode();
237        navItem.put("title", item.getTitle(locale));
238        if (item.getIconClass() != null) {
239            navItem.put("iconClass", item.getIconClass());
240        }
241        if (item.getHref() != null) {
242            navItem.put("href", item.getHref());
243        }
244        return navItem;
245    }
246
247    public String getAngularDependencies(Profile profile, Locale locale) {
248        StringJoiner dependencies = new StringJoiner("',\n  '", "\n  '", "'"); // NOI18N
249        List<String> items = new ArrayList<>();
250        this.getManifests(profile).forEach((WebManifest manifest) -> {
251            manifest.getAngularDependencies().stream().filter((dependency)
252                    -> (!items.contains(dependency))).forEachOrdered((dependency) -> {
253                items.add(dependency);
254            });
255        });
256        items.forEach((String dependency) -> {
257            dependencies.add(dependency);
258        });
259        return dependencies.toString();
260    }
261
262    public String getAngularRoutes(Profile profile, Locale locale) {
263        StringJoiner routes = new StringJoiner("\n", "\n", ""); // NOI18N
264        Set<AngularRoute> items = new HashSet<>();
265        this.getManifests(profile).forEach((WebManifest manifest) -> {
266            items.addAll(manifest.getAngularRoutes());
267        });
268        items.forEach((route) -> {
269            if (route.getRedirection() != null) {
270                routes.add(String.format("      .when('%s', { redirectTo: '%s' })", route.getWhen(), route.getRedirection())); // NOI18N
271            } else if (route.getTemplate() != null && route.getController() != null) {
272                routes.add(String.format("      .when('%s', { templateUrl: '%s', controller: '%s' })", route.getWhen(), route.getTemplate(), route.getController())); // NOI18N
273            }
274        });
275        return routes.toString();
276    }
277
278    public String getAngularSources(Profile profile, Locale locale) {
279        StringJoiner sources = new StringJoiner("\n", "\n\n", "\n"); // NOI18N
280        List<URL> urls = new ArrayList<>();
281        this.getManifests(profile).forEach((WebManifest manifest) -> {
282            urls.addAll(manifest.getAngularSources());
283        });
284        urls.forEach((URL source) -> {
285            try {
286                sources.add(FileUtil.readURL(source));
287            } catch (IOException ex) {
288                log.error("Unable to read {}", source, ex);
289            }
290        });
291        return sources.toString();
292    }
293
294    public Set<URI> getPreloadedTranslations(Profile profile, Locale locale) {
295        Set<URI> urls = new HashSet<>();
296        this.getManifests(profile).forEach((WebManifest manifest) -> {
297            urls.addAll(manifest.getPreloadedTranslations(locale));
298        });
299        return urls;
300    }
301
302    private void lifeCycleStarting(LifeCycle lc, Profile profile) {
303        if (this.watcher.get(profile) == null) {
304            try {
305                this.watcher.put(profile, FileSystems.getDefault().newWatchService());
306            } catch (IOException ex) {
307                log.warn("Unable to watch file system for changes.");
308            }
309        }
310    }
311
312    private void lifeCycleStarted(LifeCycle lc, Profile profile) {
313        // register watcher to watch web/app directories everywhere
314        if (this.watcher.get(profile) != null) {
315            FileUtil.findFiles("web", ".").stream().filter((file) -> (file.isDirectory())).forEachOrdered((file) -> {
316                try {
317                    Path path = file.toPath();
318                    WebAppManager.this.watchPaths.put(path.register(this.watcher.get(profile),
319                            StandardWatchEventKinds.ENTRY_CREATE,
320                            StandardWatchEventKinds.ENTRY_DELETE,
321                            StandardWatchEventKinds.ENTRY_MODIFY),
322                            path);
323                } catch (IOException ex) {
324                    log.error("Unable to watch {} for changes.", file);
325                }
326                this.lifeCycleListener = new Thread(() -> {
327                    while (WebAppManager.this.watcher.get(profile) != null) {
328                        WatchKey key;
329                        try {
330                            key = WebAppManager.this.watcher.get(profile).take();
331                        } catch (InterruptedException ex) {
332                            return;
333                        }
334
335                        key.pollEvents().stream().filter((event) -> (event.kind() != OVERFLOW)).forEachOrdered((event) -> {
336                            WebAppManager.this.savePreferences(profile);
337                        });
338                        if (!key.reset()) {
339                            WebAppManager.this.watcher.remove(profile);
340                        }
341                    }
342                }, "WebAppManager");
343                this.lifeCycleListener.start();
344            });
345        }
346    }
347
348    private void lifeCycleFailure(LifeCycle lc, Throwable thrwbl, Profile profile) {
349        log.debug("Web server life cycle failure", thrwbl);
350        this.lifeCycleStopped(lc, profile);
351    }
352
353    private void lifeCycleStopping(LifeCycle lc, Profile profile) {
354        this.lifeCycleStopped(lc, profile);
355    }
356
357    private void lifeCycleStopped(LifeCycle lc, Profile profile) {
358        if (this.lifeCycleListener != null) {
359            this.lifeCycleListener.interrupt();
360            this.lifeCycleListener = null;
361        }
362        // stop watching web/app directories
363        this.watcher.remove(profile);
364    }
365}