package com.laytonsmith.PureUtilities;
import com.laytonsmith.PureUtilities.Common.ArrayUtils;
import com.laytonsmith.PureUtilities.Common.StringUtils;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.List;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import javax.xml.xpath.XPathFactoryConfigurationException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
/**
* This class abstracts up and simplifies XML document parsing. You give it an XML
* string, and it gives you the ability to manipulate and query the document. This works
* via a DOM implementation.
*
*/
public class XMLDocument {
private DocumentBuilder docBuilder;
private Document doc;
private XPath xpath;
private boolean uglyDirty = true;
private boolean prettyDirty = true;
private String uglyRender;
private String prettyRender;
/**
* Creates a new, blank XMLDocument.
*/
public XMLDocument(){
try {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(false);
docBuilder = dbf.newDocumentBuilder();
doc = docBuilder.newDocument();
XPathFactory xpf = XPathFactory.newInstance(XPathFactory.DEFAULT_OBJECT_MODEL_URI,
"com.sun.org.apache.xpath.internal.jaxp.XPathFactoryImpl", XMLDocument.class.getClassLoader());
xpath = xpf.newXPath();
} catch (ParserConfigurationException | XPathFactoryConfigurationException ex) {
throw new RuntimeException(ex);
}
}
/**
* Given an XML document in a string, creates a new XMLDocument.
* @param document
* @throws IOException If any IO error occurs
*/
public XMLDocument(String document, String encoding) throws UnsupportedEncodingException, SAXException{
this();
try {
doc = docBuilder.parse(new ByteArrayInputStream(document.getBytes(encoding)));
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
/**
* Creates a new XMLDocument from an XML string, assuming UTF-8 encoding.
* @param document
* @throws SAXException
*/
public XMLDocument(String document) throws SAXException{
this();
try {
doc = docBuilder.parse(new ByteArrayInputStream(document.getBytes("UTF-8")));
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
/**
* Creates a new XMLDocument from an InputStream that represents an XML document.
* @param in
* @throws SAXException
* @throws IOException
*/
public XMLDocument(InputStream in) throws SAXException, IOException{
this();
doc = docBuilder.parse(in);
}
/**
* Returns an xpath expression from a given xpath string
* @param xpath
* @return
* @throws XPathExpressionException
*/
private XPathExpression getXPath(String xpath) throws XPathExpressionException{
return this.xpath.compile(xpath);
}
/**
* Sets the text value of a node, creating nodes as needed. If a node already exists and has
* content, the content is replaced. All XPath expressions
* are considered absolute, even if they don't start with a '/'.
* @param xpath
* @param value
* @throws XPathExpressionException
*/
public void setNode(String xpath, Object value) throws XPathExpressionException{
String sval = "";
if(value != null){
sval = value.toString();
}
getXPath(xpath); //Verifies this is a generally valid xpath, so we can roll with that assumption
while(xpath.startsWith("/")){
xpath = xpath.substring(1);
}
String [] xpathParts = xpath.split("/");
int count = xpathParts.length;
while(count > 0){
String newXPath = "/" + StringUtils.Join(ArrayUtils.slice(xpathParts, 0, count - 1), "/");
if(!nodeExists(newXPath)){
count--;
} else {
break;
}
}
if(count == xpathParts.length){
//We're at the node already, so just set it and bail
getElement(xpath).setTextContent(sval);
setDirty();
return;
}
//Ok, count now points to the topmost actually existing node, so we need to go down each part and
//create nodes as we go
Element parent = null;
Element newNode = null;
do{
String part = xpathParts[count];
String nodeName = getNodeName(part);
if(count > 0){
parent = getElement("/" + StringUtils.Join(ArrayUtils.slice(xpathParts, 0, count - 1), "/"));
}
if(nodeName == null){
//This is an attribute, edit the node above us
parent.setAttribute(getAttributeName(part), sval);
setDirty();
return; //Go ahead and bail
} else {
int position = getNodeIndex(part);
if(count == 0 && position != -1){
throw new XPathExpressionException("The root node cannot have multiple instances.");
}
newNode = doc.createElement(nodeName);
if(position == -1){
if(count == 0){
//Special case, we need to create a new element and put it in the root
doc.appendChild(newNode);
} else {
parent.appendChild(newNode);
}
} else {
//It's an array
if(!(countChildren(parent) + 1 >= position)){
//If /root/node[1] exists, but they try to create /root/node[3], this exception is thrown
throw new XPathExpressionException("Will not tolerate a jump in node numbers, will only create the next node in sequence.");
}
parent.appendChild(newNode);
}
}
count++;
} while(count < xpathParts.length);
newNode.setTextContent(sval);
setDirty();
}
private int countChildren(Element e){
Node child = e.getFirstChild();
if(child == null){
return 0;
}
int counter = 1;
while((child = child.getNextSibling()) != null){
counter++;
}
return counter;
}
/**
* Returns the node name, or null if this is an attribute.
* @param node
* @return
*/
private static String getNodeName(String node){
if(node.startsWith("@")){
return null;
}
int firstBracket = node.indexOf("[");
if(firstBracket != -1){
return node.substring(0, firstBracket).trim();
} else {
return node.trim();
}
}
/**
* Gets the position of the node, for instance, node[1] would return 1.
* If no node position is specified, -1 is returned.
* @param node
* @return
*/
private static int getNodeIndex(String node){
int indexFirst = node.indexOf("[");
int indexLast = node.indexOf("]");
if(indexFirst == -1){
return -1;
} else {
return Integer.parseInt(node.substring(indexFirst + 1, indexLast).trim());
}
}
/**
* Returns the attribute name, or null if this is not an attribute.
* @param node
* @return
*/
private static String getAttributeName(String node){
if(node.trim().startsWith("@")){
return node.trim().substring(1);
} else {
return null;
}
}
/**
* Returns the text value at a particular node. All XPath expressions
* are considered absolute, even if they don't start with a '/'
* @param xpath
* @return
* @throws XPathExpressionException
*/
public String getNode(String xpath) throws XPathExpressionException{
return getXPath(xpath).evaluate(doc);
}
/**
* Shorthand for Boolean.parseBoolean(getNode(xpath))
* @param xpath
* @return
* @throws XPathExpressionException
*/
public boolean getBoolean(String xpath) throws XPathExpressionException{
return Boolean.parseBoolean(getNode(xpath));
}
/**
* Shorthand for Integer.parseInt(getNode(xpath))
* @param xpath
* @return
* @throws XPathExpressionException
*/
public int getInt(String xpath) throws XPathExpressionException{
return Integer.parseInt(getNode(xpath));
}
/**
* Shorthand for Long.parseLong(getNode(xpath))
* @param xpath
* @return
* @throws XPathExpressionException
*/
public long getLong(String xpath) throws XPathExpressionException{
return Long.parseLong(getNode(xpath));
}
/**
* Shorthand for Double.parseDouble(getNode(xpath))
* @param xpath
* @return
* @throws XPathExpressionException
*/
public double getDouble(String xpath) throws XPathExpressionException{
return Double.parseDouble(getNode(xpath));
}
/**
* Checks to see if a node exists or not.
* @param xpath
* @return
* @throws XPathExpressionException
*/
public boolean nodeExists(String xpath) throws XPathExpressionException{
Object o = getXPath(xpath).evaluate(doc, XPathConstants.NODE);
return o != null;
}
private Element getElement(String xpath) throws XPathExpressionException{
return (Element)getXPath(xpath).evaluate(doc, XPathConstants.NODE);
}
/**
* Counts the number of direct descendants of this node.
* @param xpath
* @return
*/
public int countChildren(String xpath) throws XPathExpressionException{
Element e = getElement(xpath);
return e.getChildNodes().getLength();
}
/**
* Counts the number of elements that exist at this level. For instance, if
* the xml were:
* <pre>
* <xmlroot>
* <elem />
* <elem />
* </xmlroot>
* </pre>
* And the xpath were <code>/xmlroot/elem</code>, then this would return 2.
* @param xpath
* @return
* @throws XPathExpressionException
*/
public int countNodes(String xpath) throws XPathExpressionException {
return ((Number)(getXPath("count(" + xpath + ")").evaluate(doc, XPathConstants.NUMBER))).intValue();
}
/**
* Returns a list of all the child element names at the specified location. For instance, in
* <pre>
* <root>
* <elem />
* <elem />
* <elem2 />
* </root>
* </pre>
* The list for "/root" would contain [elem, elem, elem2]. This is useful for examining undefined or variable xml
* elements. If this is a text node or has no children, an empty list is returned. The elements
* will be listed in the order they are defined in the xml.
* @param xpath
* @return
* @throws XPathExpressionException
*/
public List<String> getChildren(String xpath) throws XPathExpressionException {
List<String> list = new ArrayList<String>();
NodeList o = (NodeList)getXPath(xpath + "/child::*").evaluate(doc, XPathConstants.NODESET);
for(int i = 0; i < o.getLength(); i++){
Node n = o.item(i);
list.add(n.getNodeName());
}
return list;
}
/**
* Signals to the getXML function that the cache is no longer valid.
*/
private void setDirty(){
uglyDirty = true;
prettyDirty = true;
}
/**
* Equivalent to getXML(false);
* @return
*/
public String getXML(){
return getXML(false);
}
/**
* Renders the XML as it currently stands. If pretty is true, it is formatted with
* indentation, otherwise, no indentation is used.
* @param pretty
* @return
*/
public String getXML(boolean pretty){
if(uglyDirty || prettyDirty){
try {
Transformer transformer = TransformerFactory.newInstance().newTransformer();
DOMSource source = new DOMSource(doc);
StringWriter writer = new StringWriter();
StreamResult result = new StreamResult(writer);
if(pretty){
transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4");
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
}
transformer.transform(source, result);
if(pretty){
prettyRender = writer.toString();
prettyDirty = false;
} else {
uglyRender = writer.toString();
uglyDirty = false;
}
} catch (Exception ex) {
throw new RuntimeException(ex);
}
}
if(pretty){
return prettyRender;
} else {
return uglyRender;
}
}
@Override
public String toString() {
return getXML(true);
}
}