/** * Copyright 2014 55 Minutes (http://www.55minutes.com) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package fiftyfive.wicket.js.locator; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; import fiftyfive.wicket.js.JavaScriptDependencySettings; import org.apache.wicket.Application; import org.apache.wicket.WicketRuntimeException; import org.apache.wicket.request.resource.PackageResourceReference; import org.apache.wicket.request.resource.ResourceReference; import org.apache.wicket.util.lang.Args; import org.apache.wicket.util.lang.Classes; import org.apache.wicket.util.lang.Packages; import org.apache.wicket.util.resource.IResourceStream; import org.apache.wicket.util.resource.locator.IResourceStreamLocator; import org.apache.wicket.util.time.Duration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Default implementation of JavaScriptDependencyLocator. Uses the Wicket * application's {@link IResourceStreamLocator} to load JavaScript files, * and our {@link SprocketsDependencyCollector} to parse them for dependencies. * A {@link ConcurrentHashMap} is used as a simple in-memory cache for the * dependency trees that are discovered. * * @since 2.0 */ public class DefaultJavaScriptDependencyLocator implements JavaScriptDependencyLocator { static final Pattern JQUERYUI_PATT = Pattern.compile("jquery(\\.|-|_)?ui"); private static final Logger LOGGER = LoggerFactory.getLogger( DefaultJavaScriptDependencyLocator.class ); private Map<ResourceReference,CacheEntry> cache; public DefaultJavaScriptDependencyLocator() { super(); this.cache = new ConcurrentHashMap<ResourceReference,CacheEntry>(); } public void findLibraryScripts(String libraryName, DependencyCollection scripts) { List<ResourceReference> refs = new ArrayList<ResourceReference>(); if("jquery".equalsIgnoreCase(libraryName)) { scripts.add(settings().getJQueryResource()); } else if(JQUERYUI_PATT.matcher(libraryName.toLowerCase()).matches()) { scripts.add(settings().getJQueryResource()); scripts.add(settings().getJQueryUIResource()); scripts.setCss(getJQueryUITheme()); } else { collectResourceAndDependencies( searchForRequiredLibrary(libraryName), scripts ); } } public void findResourceScripts(Class<?> cls, String fileName, DependencyCollection scripts) { collectResourceAndDependencies( newResourceReference(cls, fileName), scripts ); } public void findAssociatedScripts(final Class<?> cls, final DependencyCollection scripts) { Args.notNull(cls, "cls"); Args.notNull(scripts, "scripts"); // Traverse up the class hierarchy until we find // a valid JavaScript resource or we run out of super classes. Class<?> scope = cls; while(scope != null) { ResourceReference reference = newResourceReference( scope, Classes.simpleName(scope) ); LOGGER.debug("Searching for: {}", reference); if(load(reference) != null) { LOGGER.debug("Found: {}", reference); collectResourceAndDependencies(reference, scripts); break; } scope = scope.getSuperclass(); } } /** * Returns a reference to the CSS file that should be used to style * jQuery UI widgets. The default implementation simply delegates to * {@link JavaScriptDependencySettings#getJQueryUICSSResource JavaScriptDependencySettings.getJQueryUICSSResource()}. * This means that one style is used for the entire application. * If you want something more advanced, for example to choose a theme * based on user preferences or a session value, override this method for * your custom logic. */ protected ResourceReference getJQueryUITheme() { return settings().getJQueryUICSSResource(); } /** * Adds the resource to the DependencyCollection and recursively traverses * all of the sprocket dependencies of that resource (and its dependencies * and so forth), until the entire dependency tree has been added to * the collection. The cache will first be consulted to avoid the * recursion, if possible. Otherwise the result of the recursion is cached * for future use. */ private void collectResourceAndDependencies(ResourceReference ref, DependencyCollection scripts) { if(scripts.isEmpty() && populateFromCache(ref, scripts)) return; if(!scripts.add(ref)) return; SprocketsParser parser = settings().getSprocketsParser(); if(parser != null) { SprocketsDependencyCollector coll = new SprocketsDependencyCollector(this, parser); scripts.descend(); IResourceStream stream = load(ref); if(null == stream) { throw new WicketRuntimeException( "JavaScript file does not exist: " + ref ); } coll.collectDependencies(ref, stream, scripts); scripts.ascend(); } putIntoCache(ref, scripts); } /** * Modifies the given DependencyCollection so that it is identical to the * cached copy based on a previous call to putIntoCache() and returns * {@code true}. * If the cache is disabled or there is no existing cache for the given * resource, returns {@code false}. */ private boolean populateFromCache(ResourceReference ref, DependencyCollection scripts) { if(null == ref) return false; CacheEntry ce = this.cache.get(ref); if(ce != null && ce.isActive()) { ce.populate(scripts); return true; } return false; } /** * Stores the dependencies of the given resource in the cache. If the * cache is disabled (i.e. duration of zero), this has no effect. */ private void putIntoCache(ResourceReference ref, DependencyCollection scripts) { if(null == ref) return; Duration duration = settings().getTraversalCacheDuration(); if(duration.getMilliseconds() > 0) { this.cache.put(ref, new CacheEntry(scripts, duration)); } } /** * Loops through all the library search paths as configured in * JavaScriptDependencySettings and looks for the JavaScript library * with the specified name, returning a ResourceReference for the first * match. If none could be found, throws a WicketRuntimeException. */ private ResourceReference searchForRequiredLibrary(final String name) { ResourceReference ref = null; for(SearchLocation loc : settings().getLibraryPaths()) { String path = loc.getPath(); String absolutePath = String.format( "%s%s", path.isEmpty() ? "" : path + "/", name ); ResourceReference testRef = newResourceReference( loc.getScope(), absolutePath ); if(load(testRef) != null) { ref = testRef; break; } } if(null == ref) { throw new WicketRuntimeException( "Could not find JavaScript library named: " + name ); } return ref; } /** * Loads the given ResourceReference as an IResourceStream or returns * {@code null} if the resource could not be found. */ private IResourceStream load(ResourceReference ref) { IResourceStreamLocator locator = Application.get().getResourceSettings().getResourceStreamLocator(); Class<?> scope = ref.getScope(); String path = Packages.absolutePath(scope, ref.getName()); return locator.locate(scope, path); } /** * Constructs a new ResourceReference, automatically adding the ".js" * extension if needed. */ private ResourceReference newResourceReference(Class<?> scope, String name) { // TODO: test whether file exists first, just in case it has no // extension or a non-js extension. if(!name.toLowerCase().endsWith(".js")) { name = name + ".js"; } return new PackageResourceReference(scope, name); } /** * Returns the JavaScriptDependencySettings associated with the current * Application. */ private JavaScriptDependencySettings settings() { return JavaScriptDependencySettings.get(); } /** * A cache entry holds an immutable DependencyCollection and expires * after a certain duration. */ private static class CacheEntry { private long start; private long timeToLive; private DependencyCollection scripts; private CacheEntry(DependencyCollection orig, Duration duration) { super(); // Make a private copy so that the cached copy is never mutated this.scripts = new DependencyCollection(); orig.copyTo(this.scripts); this.scripts.freeze(); this.start = System.currentTimeMillis(); this.timeToLive = duration.getMilliseconds(); } private void populate(DependencyCollection other) { this.scripts.copyTo(other); } private boolean isActive() { return System.currentTimeMillis() - this.start < this.timeToLive; } } }