All Downloads are FREE. Search and download functionalities are using the official Maven repository.

freemarker.ext.dom.NodeModel Maven / Gradle / Ivy

Go to download

Google App Engine compliant variation of FreeMarker. FreeMarker is a "template engine"; a generic tool to generate text output based on templates.

There is a newer version: 2.3.33
Show newest version
/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 freemarker.ext.dom;


import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.WeakReference;
import java.net.MalformedURLException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.WeakHashMap;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import org.w3c.dom.Attr;
import org.w3c.dom.CDATASection;
import org.w3c.dom.CharacterData;
import org.w3c.dom.Document;
import org.w3c.dom.DocumentType;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.w3c.dom.ProcessingInstruction;
import org.w3c.dom.Text;
import org.xml.sax.ErrorHandler;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;

import freemarker.core._UnexpectedTypeErrorExplainerTemplateModel;
import freemarker.ext.util.WrapperTemplateModel;
import freemarker.log.Logger;
import freemarker.template.AdapterTemplateModel;
import freemarker.template.Configuration;
import freemarker.template.DefaultObjectWrapper;
import freemarker.template.SimpleScalar;
import freemarker.template.TemplateBooleanModel;
import freemarker.template.TemplateDateModel;
import freemarker.template.TemplateHashModel;
import freemarker.template.TemplateModel;
import freemarker.template.TemplateModelException;
import freemarker.template.TemplateNodeModel;
import freemarker.template.TemplateNodeModelEx;
import freemarker.template.TemplateNumberModel;
import freemarker.template.TemplateSequenceModel;

/**
 * A base class for wrapping a single W3C DOM Node as a FreeMarker template model.
 * 
 * 

* Note that {@link DefaultObjectWrapper} automatically wraps W3C DOM {@link Node}-s into this, so you may need do that * with this class manually. However, before dropping the {@link Node}-s into the data-model, you certainly want to * apply {@link NodeModel#simplify(Node)} on them. * *

* This class is not guaranteed to be thread safe, so instances of this shouldn't be used as shared variable ( * {@link Configuration#setSharedVariable(String, Object)}). * *

* To represent a node sequence (such as a query result) of exactly 1 nodes, this class should be used instead of * {@link NodeListModel}, as it adds extra capabilities by utilizing that we have exactly 1 node. If you need to wrap a * node sequence of 0 or multiple nodes, you must use {@link NodeListModel}. */ abstract public class NodeModel implements TemplateNodeModelEx, TemplateHashModel, TemplateSequenceModel, AdapterTemplateModel, WrapperTemplateModel, _UnexpectedTypeErrorExplainerTemplateModel { static private final Logger LOG = Logger.getLogger("freemarker.dom"); private static final Object STATIC_LOCK = new Object(); static private DocumentBuilderFactory docBuilderFactory; static private final Map xpathSupportMap = Collections.synchronizedMap(new WeakHashMap()); static private XPathSupport jaxenXPathSupport; static private ErrorHandler errorHandler; static Class xpathSupportClass; static { try { useDefaultXPathSupport(); } catch (Exception e) { // do nothing } if (xpathSupportClass == null && LOG.isWarnEnabled()) { LOG.warn("No XPath support is available. If you need it, add Apache Xalan or Jaxen as dependency."); } } /** * The W3C DOM Node being wrapped. */ final Node node; private TemplateSequenceModel children; private NodeModel parent; /** * Sets the DOM parser implementation to be used when building {@link NodeModel} objects from XML files or from * {@link InputStream} with the static convenience methods of {@link NodeModel}. Otherwise FreeMarker itself doesn't * use this. * * @see #getDocumentBuilderFactory() * * @deprecated It's a bad practice to change static fields, as if multiple independent components do that in the * same JVM, they unintentionally affect each other. Therefore it's recommended to leave this static * value at its default. */ @Deprecated static public void setDocumentBuilderFactory(DocumentBuilderFactory docBuilderFactory) { synchronized (STATIC_LOCK) { NodeModel.docBuilderFactory = docBuilderFactory; } } /** * Returns the DOM parser implementation that is used when building {@link NodeModel} objects from XML files or from * {@link InputStream} with the static convenience methods of {@link NodeModel}. Otherwise FreeMarker itself doesn't * use this. * * @see #setDocumentBuilderFactory(DocumentBuilderFactory) */ static public DocumentBuilderFactory getDocumentBuilderFactory() { synchronized (STATIC_LOCK) { if (docBuilderFactory == null) { DocumentBuilderFactory newFactory = DocumentBuilderFactory.newInstance(); newFactory.setNamespaceAware(true); newFactory.setIgnoringElementContentWhitespace(true); docBuilderFactory = newFactory; // We only write it out when the initialization was full } return docBuilderFactory; } } /** * Sets the error handler to use when parsing the document with the static convenience methods of {@link NodeModel}. * * @deprecated It's a bad practice to change static fields, as if multiple independent components do that in the * same JVM, they unintentionally affect each other. Therefore it's recommended to leave this static * value at its default. * * @see #getErrorHandler() */ @Deprecated static public void setErrorHandler(ErrorHandler errorHandler) { synchronized (STATIC_LOCK) { NodeModel.errorHandler = errorHandler; } } /** * @since 2.3.20 * * @see #setErrorHandler(ErrorHandler) */ static public ErrorHandler getErrorHandler() { synchronized (STATIC_LOCK) { return NodeModel.errorHandler; } } /** * Convenience method to create a {@link NodeModel} from a SAX {@link InputSource}; please see the security warning * further down. Adjacent text nodes will be merged (and CDATA sections are considered as text nodes) as with * {@link #mergeAdjacentText(Node)}. Further simplifications are applied depending on the parameters. If all * simplifications are turned on, then it applies {@link #simplify(Node)} on the loaded DOM. * *

* Note that {@code parse(...)} is only a convenience method, and FreeMarker itself doesn't use it (except when you * call the other similar static convenience methods, as they may build on each other). In particular, if you want * full control over the {@link DocumentBuilderFactory} used, create the {@link Node} with your own * {@link DocumentBuilderFactory}, apply {@link #simplify(Node)} (or such) on it, then call * {@link NodeModel#wrap(Node)}. * *

* Security warning: If the XML to load is coming from a source that you can't fully trust, you shouldn't use * this method, as the {@link DocumentBuilderFactory} it uses by default supports external entities, and so it * doesn't prevent XML External Entity (XXE) attacks. Note that XXE attacks are not specific to FreeMarker, they * affect all XML parsers in general. If that's a problem in your application, OWASP has a cheat sheet to set up a * {@link DocumentBuilderFactory} that has limited functionality but is immune to XXE attacks. Because it's just a * convenience method, you can just use your own {@link DocumentBuilderFactory} and do a few extra steps instead * (see earlier). * * @param removeComments * Whether to remove all comment nodes (recursively); this is like calling {@link #removeComments(Node)} * @param removePIs * Whether to remove all processing instruction nodes (recursively); this is like calling * {@link #removePIs(Node)} */ static public NodeModel parse(InputSource is, boolean removeComments, boolean removePIs) throws SAXException, IOException, ParserConfigurationException { DocumentBuilder builder = getDocumentBuilderFactory().newDocumentBuilder(); ErrorHandler errorHandler = getErrorHandler(); if (errorHandler != null) builder.setErrorHandler(errorHandler); final Document doc; try { doc = builder.parse(is); } catch (MalformedURLException e) { // This typical error has an error message that is hard to understand, so let's translate it: if (is.getSystemId() == null && is.getCharacterStream() == null && is.getByteStream() == null) { throw new MalformedURLException( "The SAX InputSource has systemId == null && characterStream == null && byteStream == null. " + "This is often because it was created with a null InputStream or Reader, which is often because " + "the XML file it should point to was not found. " + "(The original exception was: " + e + ")"); } else { throw e; } } if (removeComments && removePIs) { simplify(doc); } else { if (removeComments) { removeComments(doc); } if (removePIs) { removePIs(doc); } mergeAdjacentText(doc); } return wrap(doc); } /** * Same as {@link #parse(InputSource, boolean, boolean) parse(is, true, true)}; don't miss the security warnings * documented there. */ static public NodeModel parse(InputSource is) throws SAXException, IOException, ParserConfigurationException { return parse(is, true, true); } /** * Same as {@link #parse(InputSource, boolean, boolean)}, but loads from a {@link File}; don't miss the security * warnings documented there. */ static public NodeModel parse(File f, boolean removeComments, boolean removePIs) throws SAXException, IOException, ParserConfigurationException { DocumentBuilder builder = getDocumentBuilderFactory().newDocumentBuilder(); ErrorHandler errorHandler = getErrorHandler(); if (errorHandler != null) builder.setErrorHandler(errorHandler); Document doc = builder.parse(f); if (removeComments && removePIs) { simplify(doc); } else { if (removeComments) { removeComments(doc); } if (removePIs) { removePIs(doc); } mergeAdjacentText(doc); } return wrap(doc); } /** * Same as {@link #parse(InputSource, boolean, boolean) parse(source, true, true)}, but loads from a {@link File}; * don't miss the security warnings documented there. */ static public NodeModel parse(File f) throws SAXException, IOException, ParserConfigurationException { return parse(f, true, true); } protected NodeModel(Node node) { this.node = node; } /** * @return the underling W3C DOM Node object that this TemplateNodeModel * is wrapping. */ public Node getNode() { return node; } @Override public TemplateModel get(String key) throws TemplateModelException { if (key.startsWith("@@")) { if (key.equals(AtAtKey.TEXT.getKey())) { return new SimpleScalar(getText(node)); } else if (key.equals(AtAtKey.NAMESPACE.getKey())) { String nsURI = node.getNamespaceURI(); return nsURI == null ? null : new SimpleScalar(nsURI); } else if (key.equals(AtAtKey.LOCAL_NAME.getKey())) { String localName = node.getLocalName(); if (localName == null) { localName = getNodeName(); } return new SimpleScalar(localName); } else if (key.equals(AtAtKey.MARKUP.getKey())) { StringBuilder buf = new StringBuilder(); NodeOutputter nu = new NodeOutputter(node); nu.outputContent(node, buf); return new SimpleScalar(buf.toString()); } else if (key.equals(AtAtKey.NESTED_MARKUP.getKey())) { StringBuilder buf = new StringBuilder(); NodeOutputter nu = new NodeOutputter(node); nu.outputContent(node.getChildNodes(), buf); return new SimpleScalar(buf.toString()); } else if (key.equals(AtAtKey.QNAME.getKey())) { String qname = getQualifiedName(); return qname != null ? new SimpleScalar(qname) : null; } else { // As @@... would cause exception in the XPath engine, we throw a nicer exception now. if (AtAtKey.containsKey(key)) { throw new TemplateModelException( "\"" + key + "\" is not supported for an XML node of type \"" + getNodeType() + "\"."); } else { throw new TemplateModelException("Unsupported @@ key: " + key); } } } else { XPathSupport xps = getXPathSupport(); if (xps == null) { throw new TemplateModelException( "No XPath support is available (add Apache Xalan or Jaxen as dependency). " + "This is either malformed, or an XPath expression: " + key); } return xps.executeQuery(node, key); } } @Override public TemplateNodeModel getParentNode() { if (parent == null) { Node parentNode = node.getParentNode(); if (parentNode == null) { if (node instanceof Attr) { parentNode = ((Attr) node).getOwnerElement(); } } parent = wrap(parentNode); } return parent; } @Override public TemplateNodeModelEx getPreviousSibling() throws TemplateModelException { return wrap(node.getPreviousSibling()); } @Override public TemplateNodeModelEx getNextSibling() throws TemplateModelException { return wrap(node.getNextSibling()); } @Override public TemplateSequenceModel getChildNodes() { if (children == null) { children = new NodeListModel(node.getChildNodes(), this); } return children; } @Override public final String getNodeType() throws TemplateModelException { short nodeType = node.getNodeType(); switch (nodeType) { case Node.ATTRIBUTE_NODE : return "attribute"; case Node.CDATA_SECTION_NODE : return "text"; case Node.COMMENT_NODE : return "comment"; case Node.DOCUMENT_FRAGMENT_NODE : return "document_fragment"; case Node.DOCUMENT_NODE : return "document"; case Node.DOCUMENT_TYPE_NODE : return "document_type"; case Node.ELEMENT_NODE : return "element"; case Node.ENTITY_NODE : return "entity"; case Node.ENTITY_REFERENCE_NODE : return "entity_reference"; case Node.NOTATION_NODE : return "notation"; case Node.PROCESSING_INSTRUCTION_NODE : return "pi"; case Node.TEXT_NODE : return "text"; } throw new TemplateModelException("Unknown node type: " + nodeType + ". This should be impossible!"); } public TemplateModel exec(List args) throws TemplateModelException { if (args.size() != 1) { throw new TemplateModelException("Expecting exactly one arguments"); } String query = (String) args.get(0); // Now, we try to behave as if this is an XPath expression XPathSupport xps = getXPathSupport(); if (xps == null) { throw new TemplateModelException("No XPath support available"); } return xps.executeQuery(node, query); } /** * Always returns 1. */ @Override public final int size() { return 1; } @Override public final TemplateModel get(int i) { return i == 0 ? this : null; } @Override public String getNodeNamespace() { int nodeType = node.getNodeType(); if (nodeType != Node.ATTRIBUTE_NODE && nodeType != Node.ELEMENT_NODE) { return null; } String result = node.getNamespaceURI(); if (result == null && nodeType == Node.ELEMENT_NODE) { result = ""; } else if ("".equals(result) && nodeType == Node.ATTRIBUTE_NODE) { result = null; } return result; } @Override public final int hashCode() { return node.hashCode(); } @Override public boolean equals(Object other) { if (other == null) return false; return other.getClass() == this.getClass() && ((NodeModel) other).node.equals(this.node); } static public NodeModel wrap(Node node) { if (node == null) { return null; } NodeModel result = null; switch (node.getNodeType()) { case Node.DOCUMENT_NODE : result = new DocumentModel((Document) node); break; case Node.ELEMENT_NODE : result = new ElementModel((Element) node); break; case Node.ATTRIBUTE_NODE : result = new AttributeNodeModel((Attr) node); break; case Node.CDATA_SECTION_NODE : case Node.COMMENT_NODE : case Node.TEXT_NODE : result = new CharacterDataNodeModel((org.w3c.dom.CharacterData) node); break; case Node.PROCESSING_INSTRUCTION_NODE : result = new PINodeModel((ProcessingInstruction) node); break; case Node.DOCUMENT_TYPE_NODE : result = new DocumentTypeModel((DocumentType) node); break; } return result; } /** * Recursively removes all comment nodes from the subtree. * * @see #simplify */ static public void removeComments(Node parent) { Node child = parent.getFirstChild(); while (child != null) { Node nextSibling = child.getNextSibling(); if (child.getNodeType() == Node.COMMENT_NODE) { parent.removeChild(child); } else if (child.hasChildNodes()) { removeComments(child); } child = nextSibling; } } /** * Recursively removes all processing instruction nodes from the subtree. * * @see #simplify */ static public void removePIs(Node parent) { Node child = parent.getFirstChild(); while (child != null) { Node nextSibling = child.getNextSibling(); if (child.getNodeType() == Node.PROCESSING_INSTRUCTION_NODE) { parent.removeChild(child); } else if (child.hasChildNodes()) { removePIs(child); } child = nextSibling; } } /** * Merges adjacent text nodes (where CDATA counts as text node too). Operates recursively on the entire subtree. * The merged node will have the type of the first node of the adjacent merged nodes. * *

Because XPath assumes that there are no adjacent text nodes in the tree, not doing this can have * undesirable side effects. Xalan queries like {@code text()} will only return the first of a list of matching * adjacent text nodes instead of all of them, while Jaxen will return all of them as intuitively expected. * * @see #simplify */ static public void mergeAdjacentText(Node parent) { mergeAdjacentText(parent, new StringBuilder(0)); } static private void mergeAdjacentText(Node parent, StringBuilder collectorBuf) { Node child = parent.getFirstChild(); while (child != null) { Node next = child.getNextSibling(); if (child instanceof Text) { boolean atFirstText = true; while (next instanceof Text) { // if (atFirstText) { collectorBuf.setLength(0); collectorBuf.ensureCapacity(child.getNodeValue().length() + next.getNodeValue().length()); collectorBuf.append(child.getNodeValue()); atFirstText = false; } collectorBuf.append(next.getNodeValue()); parent.removeChild(next); next = child.getNextSibling(); } if (!atFirstText && collectorBuf.length() != 0) { ((CharacterData) child).setData(collectorBuf.toString()); } } else { mergeAdjacentText(child, collectorBuf); } child = next; } } /** * Removes all comments and processing instruction, and unites adjacent text nodes (here CDATA counts as text as * well). This is similar to applying {@link #removeComments(Node)}, {@link #removePIs(Node)}, and finally * {@link #mergeAdjacentText(Node)}, but it does all that somewhat faster. */ static public void simplify(Node parent) { simplify(parent, new StringBuilder(0)); } static private void simplify(Node parent, StringBuilder collectorTextChildBuff) { Node collectorTextChild = null; Node child = parent.getFirstChild(); while (child != null) { Node next = child.getNextSibling(); if (child.hasChildNodes()) { if (collectorTextChild != null) { // Commit pending text node merge: if (collectorTextChildBuff.length() != 0) { ((CharacterData) collectorTextChild).setData(collectorTextChildBuff.toString()); collectorTextChildBuff.setLength(0); } collectorTextChild = null; } simplify(child, collectorTextChildBuff); } else { int type = child.getNodeType(); if (type == Node.TEXT_NODE || type == Node.CDATA_SECTION_NODE ) { if (collectorTextChild != null) { if (collectorTextChildBuff.length() == 0) { collectorTextChildBuff.ensureCapacity( collectorTextChild.getNodeValue().length() + child.getNodeValue().length()); collectorTextChildBuff.append(collectorTextChild.getNodeValue()); } collectorTextChildBuff.append(child.getNodeValue()); parent.removeChild(child); } else { collectorTextChild = child; collectorTextChildBuff.setLength(0); } } else if (type == Node.COMMENT_NODE) { parent.removeChild(child); } else if (type == Node.PROCESSING_INSTRUCTION_NODE) { parent.removeChild(child); } else if (collectorTextChild != null) { // Commit pending text node merge: if (collectorTextChildBuff.length() != 0) { ((CharacterData) collectorTextChild).setData(collectorTextChildBuff.toString()); collectorTextChildBuff.setLength(0); } collectorTextChild = null; } } child = next; } if (collectorTextChild != null) { // Commit pending text node merge: if (collectorTextChildBuff.length() != 0) { ((CharacterData) collectorTextChild).setData(collectorTextChildBuff.toString()); collectorTextChildBuff.setLength(0); } } } NodeModel getDocumentNodeModel() { if (node instanceof Document) { return this; } else { return wrap(node.getOwnerDocument()); } } /** * Tells the system to use (restore) the default (initial) XPath system used by * this FreeMarker version on this system. */ static public void useDefaultXPathSupport() { synchronized (STATIC_LOCK) { xpathSupportClass = null; jaxenXPathSupport = null; try { useXalanXPathSupport(); } catch (ClassNotFoundException e) { // Expected } catch (Exception e) { LOG.debug("Failed to use Xalan XPath support.", e); } catch (IllegalAccessError e) { LOG.debug("Failed to use Xalan internal XPath support.", e); } if (xpathSupportClass == null) try { useSunInternalXPathSupport(); } catch (Exception e) { LOG.debug("Failed to use Sun internal XPath support.", e); } catch (IllegalAccessError e) { // Happens on OpenJDK 9 (but not on Oracle Java 9) LOG.debug("Failed to use Sun internal XPath support. " + "Tip: On Java 9+, you may need Xalan or Jaxen+Saxpath.", e); } if (xpathSupportClass == null) try { useJaxenXPathSupport(); } catch (ClassNotFoundException e) { // Expected } catch (Exception | IllegalAccessError e) { LOG.debug("Failed to use Jaxen XPath support.", e); } } } /** * Convenience method. Tells the system to use Jaxen for XPath queries. * @throws Exception if the Jaxen classes are not present. */ static public void useJaxenXPathSupport() throws Exception { Class.forName("org.jaxen.dom.DOMXPath"); Class c = Class.forName("freemarker.ext.dom.JaxenXPathSupport"); jaxenXPathSupport = (XPathSupport) c.newInstance(); synchronized (STATIC_LOCK) { xpathSupportClass = c; } LOG.debug("Using Jaxen classes for XPath support"); } /** * Convenience method. Tells the system to use Xalan for XPath queries. * @throws Exception if the Xalan XPath classes are not present. */ static public void useXalanXPathSupport() throws Exception { Class.forName("org.apache.xpath.XPath"); Class c = Class.forName("freemarker.ext.dom.XalanXPathSupport"); synchronized (STATIC_LOCK) { xpathSupportClass = c; } LOG.debug("Using Xalan classes for XPath support"); } static public void useSunInternalXPathSupport() throws Exception { Class.forName("com.sun.org.apache.xpath.internal.XPath"); Class c = Class.forName("freemarker.ext.dom.SunInternalXalanXPathSupport"); synchronized (STATIC_LOCK) { xpathSupportClass = c; } LOG.debug("Using Sun's internal Xalan classes for XPath support"); } /** * Set an alternative implementation of freemarker.ext.dom.XPathSupport to use * as the XPath engine. * @param cl the class, or null to disable XPath support. */ static public void setXPathSupportClass(Class cl) { if (cl != null && !XPathSupport.class.isAssignableFrom(cl)) { throw new RuntimeException("Class " + cl.getName() + " does not implement freemarker.ext.dom.XPathSupport"); } synchronized (STATIC_LOCK) { xpathSupportClass = cl; } } /** * Get the currently used freemarker.ext.dom.XPathSupport used as the XPath engine. * Returns null if XPath support is disabled. */ static public Class getXPathSupportClass() { synchronized (STATIC_LOCK) { return xpathSupportClass; } } static private String getText(Node node) { String result = ""; if (node instanceof Text || node instanceof CDATASection) { result = ((org.w3c.dom.CharacterData) node).getData(); } else if (node instanceof Element) { NodeList children = node.getChildNodes(); for (int i = 0; i < children.getLength(); i++) { result += getText(children.item(i)); } } else if (node instanceof Document) { result = getText(((Document) node).getDocumentElement()); } return result; } XPathSupport getXPathSupport() { if (jaxenXPathSupport != null) { return jaxenXPathSupport; } XPathSupport xps = null; Document doc = node.getOwnerDocument(); if (doc == null) { doc = (Document) node; } synchronized (doc) { WeakReference ref = (WeakReference) xpathSupportMap.get(doc); if (ref != null) { xps = (XPathSupport) ref.get(); } if (xps == null && xpathSupportClass != null) { try { xps = (XPathSupport) xpathSupportClass.newInstance(); xpathSupportMap.put(doc, new WeakReference(xps)); } catch (Exception e) { LOG.error("Error instantiating xpathSupport class", e); } } } return xps; } String getQualifiedName() throws TemplateModelException { return getNodeName(); } @Override public Object getAdaptedObject(Class hint) { return node; } @Override public Object getWrappedObject() { return node; } @Override public Object[] explainTypeError(Class[] expectedClasses) { for (int i = 0; i < expectedClasses.length; i++) { Class expectedClass = expectedClasses[i]; if (TemplateDateModel.class.isAssignableFrom(expectedClass) || TemplateNumberModel.class.isAssignableFrom(expectedClass) || TemplateBooleanModel.class.isAssignableFrom(expectedClass)) { return new Object[] { "XML node values are always strings (text), that is, they can't be used as number, " + "date/time/datetime or boolean without explicit conversion (such as " + "someNode?number, someNode?datetime.xs, someNode?date.xs, someNode?time.xs, " + "someNode?boolean).", }; } } return null; } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy