XmlUtil.java

/*
 * This file is part of Waarp Project (named also Waarp or GG).
 *
 *  Copyright (c) 2019, Waarp SAS, and individual contributors by the @author
 *  tags. See the COPYRIGHT.txt in the distribution for a full listing of
 * individual contributors.
 *
 *  All Waarp Project is free software: you can redistribute it and/or
 * modify it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or (at your
 * option) any later version.
 *
 * Waarp 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 General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License along with
 * Waarp . If not, see <http://www.gnu.org/licenses/>.
 */
package org.waarp.common.xml;

import org.dom4j.Document;
import org.dom4j.DocumentException;
import org.dom4j.DocumentHelper;
import org.dom4j.Element;
import org.dom4j.Node;
import org.dom4j.io.OutputFormat;
import org.dom4j.io.SAXReader;
import org.dom4j.io.XMLWriter;
import org.waarp.common.exception.InvalidArgumentException;
import org.waarp.common.utility.WaarpStringUtils;
import org.xml.sax.SAXException;

import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InvalidObjectException;
import java.io.Writer;
import java.util.List;

/**
 * XML utility that handles simple cases as:<br>
 * <ul>
 * <li>XPath as /x/y/z for a node and referring to root of Document</li>
 * <li>XPath as x/y/z for a node and referring to current referenced node</li>
 * <li>Any XPath can be a singleton (unique node referenced by the XPath) or
 * multiple nodes (same XPath)</li>
 * <li>Only Element as Node: no Attribute or any other type within XML</li>
 * <li>Any other path is not supported: //x /x@y ./ ../</li>
 * <li>Supports special SubXml tree as element (singleton or multiple)</li>
 * </ul>
 */
public final class XmlUtil {

  private static final String NODE_NOT_FOUND = "Node not found: ";

  private XmlUtil() {
  }

  /**
   * @return the newly created SAXReader
   */
  public static SAXReader getNewSaxReader() {
    final SAXReader saxReader = new SAXReader();
    try {
      saxReader.setFeature(
          "http://apache.org/xml/features/disallow-doctype-decl", true);
      saxReader.setFeature(
          "http://apache.org/xml/features/nonvalidating/load-dtd-grammar",
          false);
      saxReader.setFeature(
          "http://apache.org/xml/features/nonvalidating/load-external-dtd",
          false);
      saxReader.setFeature("http://xml.org/sax/features/resolve-dtd-uris",
                           false);
      saxReader.setFeature(
          "http://xml.org/sax/features/external-general-entities", false);
      saxReader.setFeature(
          "http://xml.org/sax/features/external-parameter-entities", false);
      saxReader.setFeature(
          "http://apache.org/xml/features/validation/id-idref-checking", false);
    } catch (final SAXException e) {
      //Parse with external resources downloading allowed.
    }
    return saxReader;
  }

  /**
   * @param filename
   *
   * @return Existing Document from filename
   *
   * @throws IOException
   * @throws DocumentException
   */
  public static Document getDocument(final String filename)
      throws IOException, DocumentException {
    final File file = new File(filename);
    if (!file.canRead()) {
      throw new IOException("File is not readable: " + filename);
    }
    return getDocument(file);
  }

  /**
   * @param file
   *
   * @return Existing Document from file
   *
   * @throws IOException
   * @throws DocumentException
   */
  public static Document getDocument(final File file)
      throws IOException, DocumentException {
    if (!file.canRead()) {
      throw new IOException("File is not readable: " + file.getPath());
    }
    final SAXReader reader = getNewSaxReader();
    return reader.read(file);
  }

  /**
   * Read the document from the string
   *
   * @param document as String
   *
   * @return the Document
   *
   * @throws DocumentException
   */
  public static Document readDocument(final String document)
      throws DocumentException {
    return DocumentHelper.parseText(document);
  }

  /**
   * @param document
   *
   * @return the document as an XML string
   */
  public static String writeToString(final Document document) {
    return document.asXML();
  }

  /**
   * @param element
   *
   * @return the element as an XML string
   */
  public static String writeToString(final Element element) {
    return element.asXML();
  }

  /**
   * @return an empty new Document
   */
  public static Document createEmptyDocument() {
    return DocumentHelper.createDocument();
  }

  /**
   * Save the document into the file
   *
   * @param filename
   * @param document
   *
   * @throws IOException
   */
  public static void saveDocument(final String filename,
                                  final Document document) throws IOException {
    final File file = new File(filename);
    saveDocument(file, document);
  }

  /**
   * Save the document into the file
   *
   * @param file
   * @param document
   *
   * @throws IOException
   */
  public static void saveDocument(final File file, final Document document)
      throws IOException {
    if (file.exists() && !file.canWrite()) {
      throw new IOException("File is not writable: " + file.getPath());
    }

    saveDocument(new FileWriter(file), document);
  }

  /**
   * Save the document into the Writer outWriter
   *
   * @param outWriter
   * @param document
   *
   * @throws IOException
   */
  public static void saveDocument(final Writer outWriter,
                                  final Document document) throws IOException {
    final OutputFormat format = OutputFormat.createPrettyPrint();
    format.setEncoding(WaarpStringUtils.UTF8.name());
    final XMLWriter writer = new XMLWriter(outWriter, format);
    writer.write(document);
    writer.flush();
    writer.close();
  }

  /**
   * Save the branch from element into the file
   *
   * @param filename
   * @param element
   *
   * @throws IOException
   */
  public static void saveElement(final String filename, final Element element)
      throws IOException {
    final File file = new File(filename);
    saveElement(file, element);
  }

  /**
   * Save the branch from element into the file
   *
   * @param file
   * @param element
   *
   * @throws IOException
   */
  public static void saveElement(final File file, final Element element)
      throws IOException {
    if (file.exists() && !file.canWrite()) {
      throw new IOException("File is not writable: " + file.getPath());
    }
    final OutputFormat format = OutputFormat.createPrettyPrint();
    format.setEncoding(WaarpStringUtils.UTF8.name());
    final XMLWriter writer = new XMLWriter(new FileWriter(file), format);
    writer.write(element);
    writer.flush();
    writer.close();
  }

  /**
   * Add or Get (if already existing) an element given by the path relative to
   * the referent element and set the
   * value
   *
   * @param ref
   * @param path
   * @param value
   *
   * @return the new added or already existing element with new value
   */
  public static Element addOrSetElement(final Element ref, final String path,
                                        final String value) {
    final Element current = addOrGetElement(ref, path);
    current.setText(value);
    return current;
  }

  /**
   * Add or Get (if already existing) an element given by the path relative to
   * the referent element
   *
   * @param ref
   * @param path
   *
   * @return the new added or already existing element
   */
  public static Element addOrGetElement(final Element ref, final String path) {
    final String[] pathes = path.split("/");
    Element current = ref;
    for (final String nodename : pathes) {
      if (!nodename.isEmpty()) {
        final Element exist = current.element(nodename);
        if (exist == null) {
          current = current.addElement(nodename);
        } else {
          current = exist;
        }
      }
    }
    return current;
  }

  /**
   * Add an element given by the path relative to the referent element and set
   * the value
   *
   * @param ref
   * @param path
   * @param value
   *
   * @return the new added element with value
   */
  public static Element addAndSetElementMultiple(final Element ref,
                                                 final String path,
                                                 final String value) {
    final Element current = addAndGetElementMultiple(ref, path);
    current.setText(value);
    return current;
  }

  /**
   * Add an element given by the path relative to the referent element
   *
   * @param ref
   * @param path
   *
   * @return the new added element
   */
  public static Element addAndGetElementMultiple(final Element ref,
                                                 final String path) {
    final String[] pathes = path.split("/");
    Element current = ref;
    for (int i = 0; i < pathes.length - 1; i++) {
      final String nodename = pathes[i];
      if (!nodename.isEmpty()) {
        final Element exist = current.element(nodename);
        if (exist == null) {
          current = current.addElement(nodename);
        } else {
          current = exist;
        }
      }
    }
    final String nodename = pathes[pathes.length - 1];
    if (!nodename.isEmpty()) {
      current = current.addElement(nodename);
    }
    return current;
  }

  /**
   * @param ref
   * @param path
   *
   * @return the parent element associated with the path relatively to the
   *     referent element
   *
   * @throws DocumentException
   */
  public static Element getParentElement(final Element ref, final String path)
      throws DocumentException {
    String npath = path;
    while (npath.charAt(0) == '/') {
      npath = npath.substring(1);
    }
    final Element current = (Element) ref.selectSingleNode(npath);
    if (current == null) {
      throw new DocumentException(NODE_NOT_FOUND + path);
    }
    return current.getParent();
  }

  /**
   * @param ref
   * @param path
   *
   * @return the element associated with the path relatively to the referent
   *     element
   *
   * @throws DocumentException
   */
  public static Element getElement(final Element ref, final String path)
      throws DocumentException {
    String npath = path;
    while (npath.charAt(0) == '/') {
      npath = npath.substring(1);
    }
    final Element current = (Element) ref.selectSingleNode(npath);
    if (current == null) {
      throw new DocumentException(NODE_NOT_FOUND + path);
    }
    return current;
  }

  /**
   * @param ref
   * @param path
   *
   * @return the element associated with the path relatively to the referent
   *     element
   *
   * @throws DocumentException
   */
  public static List<Node> getElementMultiple(final Element ref,
                                              final String path)
      throws DocumentException {
    String npath = path;
    while (npath.charAt(0) == '/') {
      npath = npath.substring(1);
    }
    final List<Node> list = ref.selectNodes(npath);
    if (list == null || list.isEmpty()) {
      throw new DocumentException("Nodes not found: " + path);
    }
    return list;
  }

  /**
   * Add or Get (if already existing) an element given by the path relative to
   * the document and set the value
   *
   * @param doc
   * @param path
   * @param value
   *
   * @return the new added or already existing element with new value
   */
  public static Element addOrSetElement(final Document doc, final String path,
                                        final String value) {
    final Element current = addOrGetElement(doc, path);
    if (current != null) {
      current.setText(value);
    }
    return current;
  }

  /**
   * Add or Get (if already existing) an element given by the path relative to
   * the document
   *
   * @param doc
   * @param path
   *
   * @return the new added or already existing element
   */
  public static Element addOrGetElement(final Document doc, final String path) {
    final String[] pathes = path.split("/");
    int rank;
    for (rank = 0; rank < pathes.length; rank++) {
      if (!pathes[rank].isEmpty()) {
        break; // found
      }
    }
    if (rank >= pathes.length) {
      return null; // Should not be !
    }
    Element current = (Element) doc.selectSingleNode(pathes[rank]);
    if (current == null) {
      current = doc.addElement(pathes[rank]);
    }
    for (int i = rank + 1; i < pathes.length; i++) {
      final String nodename = pathes[i];
      if (!nodename.isEmpty()) {
        final Element exist = current.element(nodename);
        if (exist == null) {
          current = current.addElement(nodename);
        } else {
          current = exist;
        }
      }
    }
    return current;
  }

  /**
   * Add an element given by the path relative to the document and set the
   * value
   *
   * @param doc
   * @param path
   * @param value
   *
   * @return the new added element with value
   */
  public static Element addAndSetElementMultiple(final Document doc,
                                                 final String path,
                                                 final String value) {
    final Element current = addAndGetElementMultiple(doc, path);
    if (current != null) {
      current.setText(value);
    }
    return current;
  }

  /**
   * Add an element given by the path relative to the document
   *
   * @param doc
   * @param path
   *
   * @return the new added element
   */
  public static Element addAndGetElementMultiple(final Document doc,
                                                 final String path) {
    final String[] pathes = path.split("/");
    int rank;
    for (rank = 0; rank < pathes.length; rank++) {
      if (!pathes[rank].isEmpty()) {
        break; // found
      }
    }
    if (rank >= pathes.length) {
      return null; // Should not be !
    }
    Element current = (Element) doc.selectSingleNode(pathes[rank]);
    if (current == null) {
      current = doc.addElement(pathes[rank]);
    }
    if (rank == pathes.length - 1) {
      // Last level is the root !!! No multiple root is allowed !!!
      // So just give back the root if it exists
      return current;
    }
    for (int i = rank + 1; i < pathes.length - 1; i++) {
      final String nodename = pathes[i];
      if (!nodename.isEmpty()) {
        final Element exist = current.element(nodename);
        if (exist == null) {
          current = current.addElement(nodename);
        } else {
          current = exist;
        }
      }
    }
    final String nodename = pathes[pathes.length - 1];
    if (!nodename.isEmpty()) {
      current = current.addElement(nodename);
    }
    return current;
  }

  /**
   * @param doc
   * @param path
   *
   * @return the Parent element associated with the path relatively to the
   *     document
   *
   * @throws DocumentException
   */
  public static Element getParentElement(final Document doc, final String path)
      throws DocumentException {
    final Element current = (Element) doc.selectSingleNode(path);
    if (current == null) {
      throw new DocumentException(NODE_NOT_FOUND + path);
    }
    return current.getParent();
  }

  /**
   * @param doc
   * @param path
   *
   * @return the element associated with the path relatively to the document
   *
   * @throws DocumentException
   */
  public static Element getElement(final Document doc, final String path)
      throws DocumentException {
    final Element current = (Element) doc.selectSingleNode(path);
    if (current == null) {
      throw new DocumentException(NODE_NOT_FOUND + path);
    }
    return current;
  }

  /**
   * @param doc
   * @param path
   *
   * @return the element associated with the path relatively to the document
   *
   * @throws DocumentException
   */
  public static List<Node> getElementMultiple(final Document doc,
                                              final String path)
      throws DocumentException {
    final List<Node> list = doc.selectNodes(path);
    if (list == null || list.isEmpty()) {
      throw new DocumentException("Nodes not found: " + path);
    }
    return list;
  }

  /**
   * Remove extra space and tab, newline from beginning and end of String
   *
   * @param string
   *
   * @return the trimed string
   */
  public static String getExtraTrimed(final String string) {
    return string.replaceAll("[\\s]+", " ").trim();
    // was ("^[\\s]*|[\\s]*$ ", "")
  }

  /**
   * Create the XmlValues from the XmlDevls and the Document
   *
   * @param doc
   * @param decls
   *
   * @return XmlValues
   */
  public static XmlValue[] read(final Document doc, final XmlDecl[] decls) {
    final XmlValue[] values;
    final int len = decls.length;
    values = new XmlValue[len];
    for (int i = 0; i < len; i++) {
      final XmlValue value = new XmlValue(decls[i]);
      values[i] = value;
      if (decls[i].isSubXml()) {
        if (decls[i].isMultiple()) {
          final List<Node> elts;
          try {
            elts = getElementMultiple(doc, decls[i].getXmlPath());
          } catch (final DocumentException e) {
            continue;
          }
          addValueToNodes(value, elts, decls[i]);
        } else {
          final Element element;
          try {
            element = getElement(doc, decls[i].getXmlPath());
          } catch (final DocumentException e) {
            continue;
          }
          setValueToElement(value, read(element, decls[i].getSubXml()));
        }
      } else if (decls[i].isMultiple()) {
        final List<Node> elts;
        try {
          elts = getElementMultiple(doc, decls[i].getXmlPath());
        } catch (final DocumentException e) {
          continue;
        }
        addFromStringToElements(value, elts);
      } else {
        final Element element;
        try {
          element = getElement(doc, decls[i].getXmlPath());
        } catch (final DocumentException e) {
          continue;
        }
        final String svalue = element.getText();
        try {
          value.setFromString(getExtraTrimed(svalue));
        } catch (final InvalidArgumentException e) {
          // nothing
        }
      }
    }
    return values;
  }

  /**
   * Create the XmlValues from the XmlDevls and the reference Element
   *
   * @param ref
   * @param decls
   *
   * @return XmlValues
   */
  public static XmlValue[] read(final Element ref, final XmlDecl[] decls) {
    final XmlValue[] values;
    final int len = decls.length;
    values = new XmlValue[len];
    for (int i = 0; i < len; i++) {
      final XmlValue value = new XmlValue(decls[i]);
      values[i] = value;
      if (decls[i].isSubXml()) {
        if (decls[i].isMultiple()) {
          final List<Node> elts;
          try {
            elts = getElementMultiple(ref, decls[i].getXmlPath());
          } catch (final DocumentException e) {
            continue;
          }
          addValueToNodes(value, elts, decls[i]);
        } else {
          final Element element;
          try {
            element = getElement(ref, decls[i].getXmlPath());
          } catch (final DocumentException e) {
            continue;
          }
          setValueToElement(value, read(element, decls[i].getSubXml()));
        }
      } else if (decls[i].isMultiple()) {
        final List<Node> elts;
        try {
          elts = getElementMultiple(ref, decls[i].getXmlPath());
        } catch (final DocumentException e) {
          continue;
        }
        addFromStringToElements(value, elts);
      } else {
        final Element element;
        try {
          element = getElement(ref, decls[i].getXmlPath());
        } catch (final DocumentException e) {
          continue;
        }
        final String svalue = element.getText();
        try {
          value.setFromString(getExtraTrimed(svalue));
        } catch (final InvalidArgumentException e) {
          // nothing
        }
      }
    }
    return values;
  }

  private static void addFromStringToElements(final XmlValue value,
                                              final List<Node> elts) {
    for (final Node element : elts) {
      final String svalue = element.getText();
      try {
        value.addFromString(getExtraTrimed(svalue));
      } catch (final InvalidObjectException e) {
        // nothing
      } catch (final InvalidArgumentException e) {
        // nothing
      }
    }
  }

  private static void setValueToElement(final XmlValue value,
                                        final XmlValue[] read) {
    if (read == null) {
      return;
    }
    try {
      value.setValue(read);
    } catch (final InvalidObjectException e) {
      // nothing
    }
  }

  private static void addValueToNodes(final XmlValue value,
                                      final List<Node> elts,
                                      final XmlDecl decl) {
    for (final Node element : elts) {
      final XmlValue[] newValue = read((Element) element, decl.getSubXml());
      try {
        value.addValue(newValue);
      } catch (final InvalidObjectException e) {
        // nothing
      }
    }
  }

  /**
   * Add all nodes from XmlValues into Document
   *
   * @param doc
   * @param values
   */
  @SuppressWarnings("unchecked")
  public static void write(final Document doc, final XmlValue[] values) {
    for (final XmlValue value : values) {
      if (value != null) {
        if (value.isSubXml()) {
          if (value.isMultiple()) {
            final List<XmlValue[]> list = (List<XmlValue[]>) value.getList();
            for (final XmlValue[] object : list) {
              final Element ref =
                  addAndGetElementMultiple(doc, value.getXmlPath());
              write(ref, object);
            }
          } else {
            final Element ref = addOrGetElement(doc, value.getXmlPath());
            write(ref, value.getSubXml());
          }
        } else if (value.isMultiple()) {
          final List<?> list = value.getList();
          for (final Object object : list) {
            addAndSetElementMultiple(doc, value.getXmlPath(),
                                     object.toString());
          }
        } else {
          addOrSetElement(doc, value.getXmlPath(), value.getIntoString());
        }
      }
    }
  }

  /**
   * Add all nodes from XmlValues from the referenced Element
   *
   * @param ref
   * @param values
   */
  @SuppressWarnings("unchecked")
  public static void write(final Element ref, final XmlValue[] values) {
    for (final XmlValue value : values) {
      if (value != null) {
        if (value.isSubXml()) {
          if (value.isMultiple()) {
            final List<XmlValue[]> list = (List<XmlValue[]>) value.getList();
            for (final XmlValue[] object : list) {
              final Element newref =
                  addAndGetElementMultiple(ref, value.getXmlPath());
              write(newref, object);
            }
          } else {
            final Element newref = addOrGetElement(ref, value.getXmlPath());
            write(newref, value.getSubXml());
          }
        } else if (value.isMultiple()) {
          final List<?> list = value.getList();
          for (final Object object : list) {
            addAndSetElementMultiple(ref, value.getXmlPath(),
                                     object.toString());
          }
        } else {
          addOrSetElement(ref, value.getXmlPath(), value.getIntoString());
        }
      }
    }
  }

  /**
   * Write the given XML document to filename using the encoding
   *
   * @param filename
   * @param encoding if null, default encoding UTF-8 will be used
   * @param document
   *
   * @throws IOException
   */
  public static void writeXML(final String filename, final String encoding,
                              final Document document) throws IOException {
    final OutputFormat format = OutputFormat.createPrettyPrint();
    if (encoding != null) {
      format.setEncoding(encoding);
    } else {
      format.setEncoding(WaarpStringUtils.UTF8.name());
    }
    final XMLWriter writer;
    writer = new XMLWriter(new FileWriter(filename), format);
    writer.write(document);
    try {
      writer.close();
    } catch (final IOException ignored) {
      // nothing
    }
  }
}