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}