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}