package jj.engine; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.inject.Inject; import javax.inject.Singleton; import jj.document.CurrentDocumentRequestProcessor; import jj.document.DocumentScriptEnvironment; import jj.http.server.websocket.CurrentWebSocketConnection; import jj.jjmessage.JJMessage; import jj.script.CurrentScriptEnvironment; import org.jsoup.nodes.Element; import org.mozilla.javascript.BaseFunction; import org.mozilla.javascript.Context; import org.mozilla.javascript.Function; import org.mozilla.javascript.Scriptable; /** * Implements the $ function host object, which is the primary script API to the document/client * @author jason * */ @Singleton final class DollarFunction extends BaseFunction implements HostObject { // this is to match JQuery's element creation syntax and semantics private static final Pattern SIMPLE_ELEMENT_CREATION = Pattern.compile("^<(\\w+)\\s*\\/?>(?:<\\/\\1>|)$"); private static final Pattern COMPLEX_ELEMENT_CREATION = Pattern.compile("^[^<]*(<[\\w\\W]+>)[^>]*$"); private static final long serialVersionUID = 1L; private final CurrentWebSocketConnection connection; private final CurrentDocumentRequestProcessor document; private final CurrentScriptEnvironment env; @Inject public DollarFunction( final CurrentWebSocketConnection connection, final CurrentDocumentRequestProcessor document, final CurrentScriptEnvironment env ) { this.connection = connection; this.document = document; this.env = env; } @Override public String name() { return "$"; } @Override public boolean constant() { return true; } @Override public boolean readonly() { return true; } @Override public boolean permanent() { return true; } @Override public boolean dontenum() { return true; } @Override public String getFunctionName() { return name(); } @Override public Scriptable construct(Context cx, Scriptable scope, Object[] args) { throw new UnsupportedOperationException("$ does not support construction"); } @Override public Object call(Context cx, Scriptable scope, Scriptable thisObj, Object[] args) { DocumentScriptEnvironment dse = env.currentAs(DocumentScriptEnvironment.class); if ( args.length == 1 && (args[0] instanceof Function) && dse.initializing() && dse.getFunction(DocumentScriptEnvironment.READY_FUNCTION_KEY) == null ) { // this works in three modes - initial execution, it registers a function // document in scope, it's a selection API // connection in scope, it's a remote control dse.addFunction(DocumentScriptEnvironment.READY_FUNCTION_KEY, (Function)args[0]); return this; } // Rhino uses something called a "ConsString" to delay string concatenations until they // are needed, so instead of a string directly, we just look for CharSequence and toString // it. this lets us accept any String-like objects for selection // this bug was fun to track down :D if (args.length == 1 && (args[0] instanceof CharSequence)) { return select(((CharSequence)args[0]).toString()); } if (args.length == 2 && (args[0] instanceof CharSequence) && (args[1] instanceof Map)) { return create(((CharSequence)args[0]).toString(), (Map<?, ?>)args[1]); } // if nothing matches, we just return ourselves. this allows silly constructs like // $()()()()()()()()()("body") // heh return this; } private Selection select(String selector) { // first try create Selection result = createInternal(selector, null); // then just select if (result == null) { if (document.current() != null) { result = new DocumentSelection(selector, document.currentDocument().select(selector), document, env); } else { result = new EventSelection(selector, connection, env); } } return result; } /** * Two argument form of creation, with a "map of properties" to set * @param selector * @param args * @return */ private Selection create(String selector, Map<?,?> args) { Selection result = createInternal(selector, args); if (result == null) { // in this case we throw an exception because the selector // format was wrong for creation but these are creation args throw new SelectorFormatException(selector); } return result; } private static final String ATTR_ID = "id"; private Selection createInternal(String html, Map<?,?> args) { String el = checkSimpleCreation(html); if (el != null) { if (document.current() != null) { Element element = document.currentDocument().createElement(el); String id = null; if (args != null) { for (Object key : args.keySet()) { if (key != null && args.get(key) != null) { element.attr(String.valueOf(key), String.valueOf(args.get(key))); if ("id".equals(key)) { id = String.valueOf(args.get(key)); } } } } String newSelection; if (id == null) { newSelection = html; } else { newSelection = "#" + id; } return new DocumentSelection(newSelection, element, document, env); } if (args != null && args.containsKey(ATTR_ID)) { // we can just return immediately, since we can make a unique selector here // so fire-and-forget style connection.current().send(JJMessage.makeInlineCreate(html, args)); return new EventSelection(String.format("#%s", args.get(ATTR_ID)), connection, env); } else { throw env.preparedContinuation(JJMessage.makeCreate(html, args)); } } checkComplexCreation(html); return null; } /** * For now, this is unsupported because JSoup doesn't support the * same nodes in a collection as JQuery does. TODO? * @param selector */ private void checkComplexCreation(String selector) { Matcher matcher = COMPLEX_ELEMENT_CREATION.matcher(selector); if (matcher.matches()) { // we don't support this, jquery does some stuff with inner nodes we can't handle throw new SelectorFormatException(selector); } } private String checkSimpleCreation(String selector) { Matcher matcher = SIMPLE_ELEMENT_CREATION.matcher(selector); if (matcher.matches()) { return matcher.group(1); } return null; } }