/** * Copyright (C) 2010 Orbeon, Inc. * * This program is free software; you can redistribute it and/or modify it under the terms of the * GNU Lesser General Public License as published by the Free Software Foundation; either version * 2.1 of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. * See the GNU Lesser General Public License for more details. * * The full text of the license is available at http://www.gnu.org/copyleft/lesser.html */ package org.orbeon.oxf.xforms.action.actions; import org.orbeon.dom.*; import org.orbeon.oxf.common.OXFException; import org.orbeon.oxf.util.IndentedLogger; import org.orbeon.oxf.xforms.*; import org.orbeon.oxf.xforms.action.XFormsAction; import org.orbeon.oxf.xforms.action.XFormsActionInterpreter; import org.orbeon.oxf.xforms.event.Dispatch; import org.orbeon.oxf.xforms.event.events.XFormsInsertEvent; import org.orbeon.oxf.xforms.event.events.XXFormsReplaceEvent; import org.orbeon.oxf.xforms.model.DataModel; import org.orbeon.oxf.xforms.model.FlaggedDefaultsStrategy$; import org.orbeon.oxf.xforms.model.InstanceDataOps; import org.orbeon.oxf.xforms.model.XFormsInstance; import org.orbeon.oxf.xforms.xbl.Scope; import org.orbeon.oxf.xml.dom4j.Dom4jUtils; import org.orbeon.dom.saxon.DocumentWrapper; import org.orbeon.saxon.om.*; import org.orbeon.saxon.value.AtomicValue; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * 9.3.5 The insert Element */ public class XFormsInsertAction extends XFormsAction { private static final String CANNOT_INSERT_READONLY_MESSAGE = "Cannot perform insertion into read-only instance."; public void execute(XFormsActionInterpreter actionInterpreter, Element actionElement, Scope actionScope, boolean hasOverriddenContext, Item overriddenContext) { final IndentedLogger indentedLogger = actionInterpreter.indentedLogger(); final XFormsContainingDocument containingDocument = actionInterpreter.containingDocument(); final XFormsContextStack contextStack = actionInterpreter.actionXPathContext(); final String atAttribute = actionElement.attributeValue("at"); final String originAttribute = actionElement.attributeValue("origin"); final String contextAttribute = actionElement.attributeValue(XFormsConstants.CONTEXT_QNAME); // Extension: allow position to be an AVT final String resolvedPositionAttribute = actionInterpreter.resolveAVT(actionElement, "position"); // Extension: xxf:default="true" AVT requires that recalculate apply default values on the inserted nodes. final boolean setRequireDefaultValues = "true".equals(actionInterpreter.resolveAVT(actionElement, XFormsConstants.XXFORMS_DEFAULTS_QNAME)); // "2. The Node Set Binding node-set is determined." final List<Item> collectionToBeUpdated; { final BindingContext currentBindingContext = contextStack.getCurrentBindingContext(); collectionToBeUpdated = currentBindingContext.newBind() ? currentBindingContext.nodeset() : XFormsConstants.EMPTY_ITEM_LIST; } final boolean isEmptyNodesetBinding = collectionToBeUpdated == null || collectionToBeUpdated.size() == 0; // "1. The insert context is determined." // "The insert action is terminated with no effect if [...] a. The context attribute is not given and the Node // Set Binding node-set is the empty node-set." if (contextAttribute == null && isEmptyNodesetBinding) { if (indentedLogger.isDebugEnabled()) indentedLogger.logDebug("xf:insert", "context is empty, terminating"); return; } // Handle insert context (with @context attribute) final Item insertContextItem; if (hasOverriddenContext) { // "If the result is an empty nodeset or not a nodeset, then the insert action is terminated with no effect. " if (overriddenContext == null || !(overriddenContext instanceof NodeInfo)) { if (indentedLogger.isDebugEnabled()) indentedLogger.logDebug("xf:insert", "overridden context is an empty nodeset or not a nodeset, terminating"); return; } else { insertContextItem = overriddenContext; } } else { insertContextItem = contextStack.getCurrentBindingContext().getSingleItem(); } // "The insert action is terminated with no effect if [...] b. The context attribute is given, the insert // context does not evaluate to an element node and the Node Set Binding node-set is the empty node-set." // NOTE: In addition we support inserting into a context which is a document node if (contextAttribute != null && isEmptyNodesetBinding && !DataModel.isElement(insertContextItem) && !DataModel.isDocument(insertContextItem)) { if (indentedLogger.isDebugEnabled()) indentedLogger.logDebug("xf:insert", "insert context is not an element node and binding node-set is empty, terminating"); return; } // "3. The origin node-set is determined." final List<Item> originObjects; { if (originAttribute == null) { originObjects = null; } else { // There is an @origin attribute // "If the origin attribute is given, the origin node-set is the result of the evaluation of the // origin attribute in the insert context." originObjects = actionInterpreter.evaluateKeepItems(actionElement, Collections.singletonList(insertContextItem), 1, originAttribute); // "The insert action is terminated with no effect if the origin node-set is the empty node-set." if (originObjects.size() == 0) { if (indentedLogger.isDebugEnabled()) indentedLogger.logDebug("xf:insert", "origin node-set is empty, terminating"); return; } } } // "4. The insert location node is determined." int insertionIndex; { if (isEmptyNodesetBinding) { // "If the Node Set Binding node-set empty, then this attribute is ignored" insertionIndex = 0; } else if (atAttribute == null) { // "If the attribute is not given, then the default is the size of the Node Set Binding node-set" insertionIndex = collectionToBeUpdated.size(); } else { // "a. The evaluation context node is the first node in document order from the Node Set Binding // node-set, the context size is the size of the Node Set Binding node-set, and the context // position is 1." // "b. The return value is processed according to the rules of the XPath function round()" final String insertionIndexString = actionInterpreter.evaluateAsString( actionElement, collectionToBeUpdated, 1, "round(" + atAttribute + ")"); // "c. If the result is in the range 1 to the Node Set Binding node-set size, then the insert // location is equal to the result. If the result is non-positive, then the insert location is // 1. Otherwise, the result is NaN or exceeds the Node Set Binding node-set size, so the insert // location is the Node Set Binding node-set size." // Don't think we will get NaN with XPath 2.0... insertionIndex = "NaN".equals(insertionIndexString) ? collectionToBeUpdated.size() : Integer.parseInt(insertionIndexString) ; // Adjust index to be in range if (insertionIndex > collectionToBeUpdated.size()) insertionIndex = collectionToBeUpdated.size(); if (insertionIndex < 1) insertionIndex = 1; } } final String normalizedPosition; { if (resolvedPositionAttribute == null) { // Default value normalizedPosition = "after"; } else if ("after".equals(resolvedPositionAttribute) || "before".equals(resolvedPositionAttribute)) { // Specified value normalizedPosition = resolvedPositionAttribute; } else { // Invalid value if (indentedLogger.isInfoEnabled()) indentedLogger.logWarning("xf:insert", "invalid position attribute, defaulting to \"after\"", "value", resolvedPositionAttribute); normalizedPosition = "after"; } } doInsert( containingDocument, indentedLogger, normalizedPosition, collectionToBeUpdated, (NodeInfo) insertContextItem, originObjects, insertionIndex, true, true, setRequireDefaultValues ); } public static List<NodeInfo> doInsert( XFormsContainingDocument containingDocument, IndentedLogger indentedLogger, String positionAttribute, List collectionToBeUpdated, NodeInfo insertContextNodeInfo, List<Item> originItems, int insertionIndex, boolean doClone, boolean doDispatch, boolean requireDefaultValues ) { final boolean isEmptyNodesetBinding = collectionToBeUpdated == null || collectionToBeUpdated.size() == 0; final NodeInfo insertLocationNodeInfo; if (isEmptyNodesetBinding) { // Insert INTO a node // "If the Node Set Binding node-set is not specified or empty, the insert location node is the insert // context node." // "a. If the Node Set Binding node-set is not specified or empty, the target location depends on the // node type of the cloned node. If the cloned node is an attribute, then the target location is before // the first attribute of the insert location node. If the cloned node is not an attribute, then the // target location is before the first child of the insert location node." insertLocationNodeInfo = insertContextNodeInfo; } else { // Insert BEFORE or AFTER a node insertLocationNodeInfo = (NodeInfo) collectionToBeUpdated.get(insertionIndex - 1); } // Identify the instance that actually changes final XFormsInstance modifiedInstanceOrNull = (containingDocument != null) ? containingDocument.getInstanceForNode(insertLocationNodeInfo) : null; // NOTE: The check on `hasAnyCalculationBind` is not optimal: we should check whether specifically there are any xxf:default which can touch this // instance, ideally. // NOTE: We do this test here so that we don't unnecessarily annotate nodes. final boolean applyDefaults = requireDefaultValues && modifiedInstanceOrNull != null && modifiedInstanceOrNull.model().staticModel().hasDefaultValueBind() && containingDocument.getXPathDependencies().hasAnyCalculationBind( modifiedInstanceOrNull.model().staticModel(), modifiedInstanceOrNull.getPrefixedId() ); // "3. The origin node-set is determined." // "5. Each node in the origin node-set is cloned in the order it appears in the origin node-set." final List<Node> sourceNodes; final List<Node> clonedNodes; { final List<Node> clonedNodesTemp; if (originItems == null) { // There are no explicitly specified origin objects, use node from Node Set Binding node-set // "If the origin attribute is not given and the Node Set Binding node-set is empty, then the origin // node-set is the empty node-set. [...] The insert action is terminated with no effect if the // origin node-set is the empty node-set." if (isEmptyNodesetBinding) { if (indentedLogger != null && indentedLogger.isDebugEnabled()) indentedLogger.logDebug("xf:insert", "origin node-set from node-set binding is empty, terminating"); return Collections.emptyList(); } // "Otherwise, if the origin attribute is not given, then the origin node-set consists of the last // node of the Node Set Binding node-set." final Node singleSourceNode = XFormsUtils.getNodeFromNodeInfoConvert((NodeInfo) collectionToBeUpdated.get(collectionToBeUpdated.size() - 1)); // TODO: check namespace handling might be incorrect. Should use copyElementCopyParentNamespaces() instead? final Node singleClonedNode = Dom4jUtils.createCopy(singleSourceNode); sourceNodes = Collections.singletonList(singleSourceNode); clonedNodesTemp = Collections.singletonList(singleClonedNode); } else { // There are explicitly specified origin objects // "The insert action is terminated with no effect if the origin node-set is the empty node-set." if (originItems.size() == 0) { if (indentedLogger != null && indentedLogger.isDebugEnabled()) indentedLogger.logDebug("xf:insert", "origin node-set is empty, terminating"); return Collections.emptyList(); } // "Each node in the origin node-set is cloned in the order it appears in the origin node-set." sourceNodes = new ArrayList<Node>(originItems.size()); // set to max possible size clonedNodesTemp = new ArrayList<Node>(originItems.size()); for (final Object currentObject : originItems) { if (currentObject instanceof NodeInfo) { // This is the regular case covered by XForms 1.1 / XPath 1.0 // NOTE: Don't clone nodes if doClone == false final Node sourceNode = XFormsUtils.getNodeFromNodeInfoConvert((NodeInfo) currentObject); final Node clonedNode = doClone ? (sourceNode instanceof Element) ? ((Element) sourceNode).createCopy() : (Node) sourceNode.clone() : sourceNode; sourceNodes.add(sourceNode); clonedNodesTemp.add(clonedNode); } else if (currentObject instanceof AtomicValue){ // This is an extension: support sequences containing atomic values // Convert the result to a text node final String stringValue = ((Item) currentObject).getStringValue(); final Text textNode = DocumentFactory.createText(stringValue); sourceNodes.add(null); // there is no source node for this cloned node, it's a source item clonedNodesTemp.add(textNode); } else throw new IllegalStateException(); } } // Remove instance data from cloned nodes and perform Document node adjustment for (int i = 0; i < clonedNodesTemp.size(); i++) { final Node clonedNodeTemp = clonedNodesTemp.get(i); if (clonedNodeTemp instanceof Element) { // Element node if (applyDefaults) InstanceDataOps.setRequireDefaultValueRecursively(clonedNodeTemp); else InstanceDataOps.removeRecursively(clonedNodeTemp); clonedNodeTemp.detach(); } else if (clonedNodeTemp instanceof Attribute) { // Attribute node if (applyDefaults) InstanceDataOps.setRequireDefaultValueRecursively(clonedNodeTemp); else InstanceDataOps.removeRecursively(clonedNodeTemp); clonedNodeTemp.detach(); } else if (clonedNodeTemp instanceof Document) { // Document node final Element clonedNodeTempRootElement = clonedNodeTemp.getDocument().getRootElement(); if (clonedNodeTempRootElement == null) { // Can be null in rare cases of documents without root element clonedNodesTemp.set(i, null); // we support having a null node further below, so set this to null } else { if (applyDefaults) InstanceDataOps.setRequireDefaultValueRecursively(clonedNodeTempRootElement); else InstanceDataOps.removeRecursively(clonedNodeTempRootElement); // We can never really insert a document into anything at this point, but we assume that this means the root element clonedNodesTemp.set(i, clonedNodeTempRootElement.detach()); } } else { // Other nodes clonedNodeTemp.detach(); } } clonedNodes = clonedNodesTemp; } // "6. The target location of each cloned node or nodes is determined" // "7. The cloned node or nodes are inserted in the order they were cloned at their target location // depending on their node type." // Find actual insertion point and insert final int insertLocationIndexWithinParentBeforeUpdate; final List<Node> insertedNodes; final String beforeAfterInto; if (isEmptyNodesetBinding) { // Insert INTO a node insertLocationIndexWithinParentBeforeUpdate = findNodeIndexRewrapIfNeeded(insertLocationNodeInfo); final Node insertLocationNode = XFormsUtils.getNodeFromNodeInfo(insertContextNodeInfo, CANNOT_INSERT_READONLY_MESSAGE); insertedNodes = doInsert(insertLocationNode, clonedNodes, modifiedInstanceOrNull, doDispatch); beforeAfterInto = "into"; // Normalize text nodes if needed to respect XPath 1.0 constraint { boolean hasTextNode = false; for (Node clonedNode: clonedNodes) { hasTextNode |= clonedNode != null && (clonedNode instanceof Text); } if (hasTextNode) Dom4jUtils.normalizeTextNodes(insertLocationNode); } } else { // Insert BEFORE or AFTER a node insertLocationIndexWithinParentBeforeUpdate = findNodeIndexRewrapIfNeeded(insertLocationNodeInfo); final Node insertLocationNode = XFormsUtils.getNodeFromNodeInfo(insertLocationNodeInfo, CANNOT_INSERT_READONLY_MESSAGE); final Document insertLocationNodeDocument = insertLocationNode.getDocument(); if (insertLocationNodeDocument != null && insertLocationNodeDocument.getRootElement() == insertLocationNode) { // "c. if insert location node is the root element of an instance, then that instance root element // location is the target location. If there is more than one cloned node to insert, only the // first node that does not cause a conflict is considered." insertedNodes = doInsert(insertLocationNode.getDocument(), clonedNodes, modifiedInstanceOrNull, doDispatch); beforeAfterInto = positionAttribute; // TODO: ideally normalize to "into document node"? // NOTE: Don't need to normalize text nodes in this case, as no new text node is inserted } else { // "d. Otherwise, the target location is immediately before or after the insert location // node, based on the position attribute setting or its default." if (insertLocationNode instanceof Attribute) { // Special case for "next to an attribute" // NOTE: In XML, attributes are unordered. dom4j handles them as a list so has order, but // the XForms spec shouldn't rely on attribute order. We could try to keep the order, but it // is harder as we have to deal with removing duplicate attributes and find a reasonable // insertion strategy. // TODO: Don't think we should even do this now in XForms 1.1 insertedNodes = doInsert(insertLocationNode.getParent(), clonedNodes, modifiedInstanceOrNull, doDispatch); } else { // Other node types final Element parentNode = insertLocationNode.getParent(); final List<Node> siblingElements = parentNode.content(); final int actualIndex = siblingElements.indexOf(insertLocationNode); // Prepare insertion of new element final int actualInsertionIndex; if ("before".equals(positionAttribute)) { actualInsertionIndex = actualIndex; } else { // "after" actualInsertionIndex = actualIndex + 1; } // "7. The cloned node or nodes are inserted in the order they were cloned at their target // location depending on their node type." boolean hasTextNode = false; int addIndex = 0; insertedNodes = new ArrayList<Node>(clonedNodes.size()); for (Node clonedNode: clonedNodes) { if (clonedNode != null) {// NOTE: we allow passing some null nodes so we check on null if (!(clonedNode instanceof Attribute || clonedNode instanceof Namespace)) { // Element, text, comment, processing instruction node siblingElements.add(actualInsertionIndex + addIndex, clonedNode); insertedNodes.add(clonedNode); hasTextNode |= clonedNode instanceof Text; addIndex++; } else { // We never insert attributes or namespace nodes as siblings if (indentedLogger != null && indentedLogger.isDebugEnabled()) indentedLogger.logDebug("xf:insert", "skipping insertion of node as sibling in element content", "type", Node$.MODULE$.nodeTypeName(clonedNode), "node", clonedNode instanceof Attribute ? Dom4jUtils.attributeToDebugString((Attribute) clonedNode) : clonedNode.toString() ); } } } // Normalize text nodes if needed to respect XPath 1.0 constraint if (hasTextNode) Dom4jUtils.normalizeTextNodes(parentNode); } beforeAfterInto = positionAttribute; } } // Whether some nodes were inserted final boolean didInsertNodes = insertedNodes != null && insertedNodes.size() > 0; // Log stuff if (indentedLogger != null && indentedLogger.isDebugEnabled()) { if (didInsertNodes) indentedLogger.logDebug("xf:insert", "inserted nodes", "count", Integer.toString(insertedNodes.size()), "instance", (modifiedInstanceOrNull != null) ? modifiedInstanceOrNull.getEffectiveId() : null); else indentedLogger.logDebug("xf:insert", "no node inserted"); } // "XForms Actions that change the tree structure of instance data result in setting all four flags to true" if (didInsertNodes && modifiedInstanceOrNull != null) { // NOTE: Can be null if document into which delete is performed is not in an instance, e.g. in a variable modifiedInstanceOrNull.markModified(); modifiedInstanceOrNull.model().markStructuralChange( scala.Option.<XFormsInstance>apply(modifiedInstanceOrNull), FlaggedDefaultsStrategy$.MODULE$ ); } // Gather list of modified nodes final List<NodeInfo> insertedNodeInfos; if (didInsertNodes && modifiedInstanceOrNull != null) { // Instance can be null if document into which delete is performed is not in an instance, e.g. in a variable final DocumentWrapper documentWrapper = (DocumentWrapper) modifiedInstanceOrNull.documentInfo(); insertedNodeInfos = new ArrayList<NodeInfo>(insertedNodes.size()); for (Node insertedNode : insertedNodes) insertedNodeInfos.add(documentWrapper.wrap(insertedNode)); } else { insertedNodeInfos = Collections.emptyList(); } // "4. If the insert is successful, the event xforms-insert is dispatched." // XFormsInstance handles index and repeat items updates if (doDispatch && didInsertNodes && modifiedInstanceOrNull != null) { // Adjust insert location node and before/after/into in case the root element was replaced final NodeInfo adjustedInsertLocationNodeInfo; final String adjustedBeforeAfterInto; final NodeInfo parent = insertedNodeInfos.get(0).getNodeKind() == org.w3c.dom.Node.ELEMENT_NODE ? insertedNodeInfos.get(0).getParent() : null; if (parent != null && parent.equals(parent.getDocumentRoot())) { // Node was inserted under document node adjustedInsertLocationNodeInfo = parent.getDocumentRoot(); adjustedBeforeAfterInto = "into"; } else { adjustedInsertLocationNodeInfo = rewrapIfNeeded(insertLocationNodeInfo); adjustedBeforeAfterInto = beforeAfterInto; } Dispatch.dispatchEvent( new XFormsInsertEvent( modifiedInstanceOrNull, insertedNodeInfos, originItems, adjustedInsertLocationNodeInfo, adjustedBeforeAfterInto, insertLocationIndexWithinParentBeforeUpdate ) ); } return insertedNodeInfos; } private static int findNodeIndexRewrapIfNeeded(NodeInfo node) { return findNodeIndex(rewrapIfNeeded(node)); } // See https://github.com/orbeon/orbeon-forms/issues/2803 private static NodeInfo rewrapIfNeeded(NodeInfo node) { if (node instanceof VirtualNode) { final DocumentWrapper doc = (DocumentWrapper) node.getDocumentRoot(); final Object underlying = ((VirtualNode) node).getUnderlyingNode(); if (doc != null) return doc.wrap((Node) underlying); else return DocumentWrapper.makeWrapper((Node) underlying); // unclear whether NodeWrappers are created with a null doc, but if so rewrapping this way should be ok } else return node; } private static int findNodeIndex(NodeInfo node) { if (node.getParent() == null) return 0; final AxisIterator it = node.iterateAxis(Axis.PRECEDING_SIBLING); int result = 0; Item i = it.next(); while (i != null) { result++; i = it.next(); } return result; } private static List<Node> doInsert(Node insertionNode, List<Node> clonedNodes, XFormsInstance modifiedInstance, boolean doDispatch) { final List<Node> insertedNodes = new ArrayList<Node>(clonedNodes.size()); if (insertionNode instanceof Element) { // Insert inside an element final Element insertContextElement = (Element) insertionNode; int otherNodeIndex = 0; for (Node clonedNode: clonedNodes) { if (clonedNode != null) {// NOTE: we allow passing some null nodes so we check on null if (clonedNode instanceof Attribute) { // Add attribute to element // NOTE: In XML, attributes are unordered. dom4j handles them as a list so has order, but the // XForms spec shouldn't rely on attribute order. We could try to keep the order, but it is harder // as we have to deal with removing duplicate attributes and find a reasonable insertion strategy. final Attribute clonedAttribute = (Attribute) clonedNode; final Attribute existingAttribute = insertContextElement.attribute(clonedAttribute.getQName()); if (existingAttribute != null) insertContextElement.remove(existingAttribute); insertContextElement.add(clonedAttribute); if (existingAttribute != null) { // Dispatch xxforms-replace event if required and possible // NOTE: For now, still dispatch xforms-insert for backward compatibility. if (doDispatch && modifiedInstance != null) { final DocumentWrapper documentWrapper = (DocumentWrapper) modifiedInstance.documentInfo(); Dispatch.dispatchEvent( new XXFormsReplaceEvent( modifiedInstance, documentWrapper.wrap(existingAttribute), documentWrapper.wrap(clonedAttribute))); } } insertedNodes.add(clonedAttribute); } else if (!(clonedNode instanceof Document)) { // Add other node to element insertContextElement.content().add(otherNodeIndex++, clonedNode); insertedNodes.add(clonedNode); } else { // "If a cloned node cannot be placed at the target location due to a node type conflict, then the // insertion for that particular clone node is ignored." } } } return insertedNodes; } else if (insertionNode instanceof Document) { final Document insertContextDocument = (Document) insertionNode; // "If there is more than one cloned node to insert, only the first node that does not cause a conflict is // considered." for (Node clonedNode: clonedNodes) { // Only an element can be inserted at the root of an instance if (clonedNode instanceof Element) { final Element formerRootElement = insertContextDocument.getRootElement(); insertContextDocument.setRootElement((Element) clonedNode); // Dispatch xxforms-replace event if required and possible // NOTE: For now, still dispatch xforms-insert for backward compatibility. if (doDispatch && modifiedInstance != null) { final DocumentWrapper documentWrapper = (DocumentWrapper) modifiedInstance.documentInfo(); Dispatch.dispatchEvent( new XXFormsReplaceEvent( modifiedInstance, documentWrapper.wrap(formerRootElement), documentWrapper.wrap(insertContextDocument.getRootElement()))); } insertedNodes.add(clonedNode); return insertedNodes; } } // NOTE: The spec does not allow inserting comments and PIs at the root of an instance document at this // point. return insertedNodes; } else { throw new OXFException("Unsupported insertion node type: " + insertionNode.getClass().getName()); } } }