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