001/*
002 * #%L
003 * Netarchivesuite - common
004 * %%
005 * Copyright (C) 2005 - 2014 The Royal Danish Library, the Danish State and University Library,
006 *             the National Library of France and the Austrian National Library.
007 * %%
008 * This program is free software: you can redistribute it and/or modify
009 * it under the terms of the GNU Lesser General Public License as
010 * published by the Free Software Foundation, either version 2.1 of the
011 * License, or (at your option) any later version.
012 * 
013 * This program is distributed in the hope that it will be useful,
014 * but WITHOUT ANY WARRANTY; without even the implied warranty of
015 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
016 * GNU General Lesser Public License for more details.
017 * 
018 * You should have received a copy of the GNU General Lesser Public
019 * License along with this program.  If not, see
020 * <http://www.gnu.org/licenses/lgpl-2.1.html>.
021 * #L%
022 */
023package dk.netarkivet.common.utils;
024
025import java.io.File;
026import java.io.InputStream;
027import java.util.ArrayList;
028import java.util.Arrays;
029import java.util.Collections;
030import java.util.HashMap;
031import java.util.List;
032import java.util.Map;
033
034import org.dom4j.Document;
035import org.dom4j.Element;
036import org.dom4j.Namespace;
037import org.dom4j.Node;
038import org.dom4j.XPath;
039import org.dom4j.dom.DOMDocument;
040
041import dk.netarkivet.common.exceptions.ArgumentNotValid;
042import dk.netarkivet.common.exceptions.IOFailure;
043import dk.netarkivet.common.exceptions.UnknownID;
044
045/**
046 * Utility class to load and save data from/to XML files using a very simple XML format.
047 */
048@SuppressWarnings({"unused", "unchecked"})
049public class SimpleXml {
050    /** The underlying XML Document object that we give access to. */
051    private Document xmlDoc;
052
053    /** The file that this XML was read from, or a fixed string if it was created from scratch. */
054    private String source;
055
056    /**
057     * Create a new SimpleXml object by loading a file.
058     *
059     * @param f XML file to load
060     */
061    public SimpleXml(File f) {
062        ArgumentNotValid.checkNotNull(f, "File f");
063        load(f);
064    }
065
066    /**
067     * Create a new SimpleXml just containing the root element.
068     *
069     * @param rootElement Name of the root element
070     */
071    public SimpleXml(String rootElement) {
072        ArgumentNotValid.checkNotNullOrEmpty(rootElement, "String rootElement");
073        xmlDoc = new DOMDocument();
074        xmlDoc.addElement(rootElement);
075        source = "Newly creating XML file with root '" + rootElement + "'";
076    }
077
078    /**
079     * Create a new SimpleXml object by loading a file.
080     *
081     * @param resourceAsStream XML file to load
082     */
083    public SimpleXml(InputStream resourceAsStream) {
084        ArgumentNotValid.checkNotNull(resourceAsStream, "InputStream resourceAsStream");
085        load(resourceAsStream);
086    }
087
088    /**
089     * Loads an xml stream.
090     *
091     * @param resourceAsStream a XML stream to load.
092     */
093    private void load(InputStream resourceAsStream) {
094        xmlDoc = XmlUtils.getXmlDoc(resourceAsStream);
095        source = "XML file from input stream '" + resourceAsStream + "'";
096    }
097
098    /**
099     * Loads an xml file.
100     *
101     * @param f a XML file
102     */
103    private void load(File f) {
104        source = f.toString();
105
106        if (!f.exists()) {
107            throw new IOFailure("XML file '" + f.getAbsolutePath() + "' does not exist");
108        }
109
110        xmlDoc = XmlUtils.getXmlDoc(f);
111    }
112
113    /**
114     * Add entries to the current set of settings. If a node with this key already exists in the XML, the new nodes are
115     * added after that, otherwise the new nodes are added at the end.
116     *
117     * @param key the key to add
118     * @param values the values to add
119     * @throws ArgumentNotValid if the key is null or empty, or the value is null
120     */
121    public void add(String key, String... values) {
122        ArgumentNotValid.checkNotNullOrEmpty(key, "key");
123        ArgumentNotValid.checkNotNull(values, "values");
124
125        // find values to set
126        List<String> allValues = new ArrayList<String>(getList(key));
127        allValues.addAll(Arrays.asList(values));
128
129        // ensure the key exists
130        Element newNode = addParents(key.split("\\."));
131
132        // set values
133        update(key, allValues.toArray(new String[] {}));
134    }
135
136    /**
137     * Add all the necessary parents to have the given elements available, and add a new Element node at the lowest
138     * level.
139     *
140     * @param elementNames A list of tags, must start with the document root.
141     * @return The last element added.
142     */
143    private Element addParents(String... elementNames) {
144        ArgumentNotValid.checkTrue(elementNames.length >= 2, "Must have at least root element and final element in "
145                + "element names, not just " + Arrays.asList(elementNames));
146        Element currentNode = xmlDoc.getRootElement();
147        if (!currentNode.getName().equals(elementNames[0])) {
148            throw new ArgumentNotValid("Document has root element '" + currentNode.getName() + "', not '"
149                    + elementNames[0] + "'");
150        }
151        for (int i = 1; i < elementNames.length - 1; i++) {
152            String elementName = elementNames[i];
153            List<Element> nodes = currentNode.elements(elementName);
154            if (nodes == null || nodes.size() == 0) {
155                // Element not found, add at end
156                currentNode = currentNode.addElement(elementName);
157            } else {
158                currentNode = nodes.get(nodes.size() - 1);
159            }
160        }
161        return addAfterSameElement(currentNode, elementNames[elementNames.length - 1]);
162    }
163
164    /**
165     * Add another element either right after the last of its kind in currentNode or at the end of currentNode.
166     *
167     * @param currentNode A node that the new element will be a sub-node of
168     * @param elementName The name of the new element
169     * @return The new element, which is now placed under currentNode
170     */
171    private Element addAfterSameElement(Element currentNode, String elementName) {
172        Element newElement = currentNode.addElement(elementName);
173        newElement.detach();
174        // If there are already nodes of this type, add straight after them.
175        List<Element> existingNodes = currentNode.elements();
176        for (int i = existingNodes.size() - 1; i >= 0; i--) {
177            if (existingNodes.get(i).getName().equals(elementName)) {
178                existingNodes.add(i + 1, newElement);
179                return newElement;
180            }
181        }
182        // Otherwise add at the end.
183        existingNodes.add(newElement);
184        return newElement;
185    }
186
187    /**
188     * Removes current settings for a key and adds new values for the same key. Calling update() is equivalent to
189     * calling delete() and add(), except the old value does not get destroyed on errors and order of the elements are
190     * kept. If no values are given, the key is removed.
191     *
192     * @param key The key for which the value should be updated.
193     * @param values The new values that should be set for the key.
194     * @throws UnknownID if the key does not exist
195     * @throws ArgumentNotValid if the key is null or empty, or any of the values are null
196     */
197    public void update(String key, String... values) {
198        ArgumentNotValid.checkNotNullOrEmpty(key, "String key");
199        ArgumentNotValid.checkNotNull(values, "String... values");
200        for (int i = 0; i < values.length; i++) {
201            ArgumentNotValid.checkNotNull(values[i], "String values[" + i + "]");
202        }
203        if (!hasKey(key)) {
204            throw new UnknownID("No key registered with the name: '" + key + "' in '" + source + "'");
205        }
206
207        List<Node> nodes = getXPath(key).selectNodes(xmlDoc);
208        int i = 0;
209        for (; i < nodes.size() && i < values.length; i++) {
210            nodes.get(i).setText(values[i]);
211        }
212        if (i < nodes.size()) {
213            // Delete nodes if there were more nodes than values
214            for (; i < nodes.size(); i++) {
215                nodes.get(i).detach();
216            }
217        } else {
218            // Add nodes if there were fewer nodes than values
219            for (; i < values.length; i++) {
220                Element newNode = addParents(key.split("\\."));
221                newNode.setText(values[i]);
222            }
223        }
224    }
225
226    /**
227     * Get the first entry that matches the key. Keys are constructed as a dot separated path of xml tag names. Example:
228     * The following XML definition of a user name &lt;dk&gt;&lt;netarkivet&gt;&lt;user&gt;ssc&lt;/user&gt;
229     * &lt;/netarkivet&gt;&lt;/dk&gt; is accessed using the path: "dk.netarkivet.user"
230     *
231     * @param key the key of the entry.
232     * @return the first entry that matches the key.
233     * @throws UnknownID if no element matches the key
234     * @throws ArgumentNotValid if the key is null or empty
235     */
236    public String getString(String key) {
237        ArgumentNotValid.checkNotNullOrEmpty(key, "key");
238
239        XPath xpath = getXPath(key);
240        List<Node> nodes = xpath.selectNodes(xmlDoc);
241        if (nodes == null || nodes.size() == 0) {
242            throw new UnknownID("No elements exists for the path '" + key + "' in '" + source + "'");
243        }
244        Node first = nodes.get(0);
245        return first.getStringValue().trim();
246    }
247
248    /**
249     * Checks if a setting with the specified key exists.
250     *
251     * @param key a key for a setting
252     * @return true if the key exists
253     * @throws ArgumentNotValid if key is null or empty
254     */
255    public boolean hasKey(String key) {
256        ArgumentNotValid.checkNotNullOrEmpty(key, "key");
257
258        final List<Node> nodes = (List<Node>) getXPath(key).selectNodes(xmlDoc);
259        return nodes != null && nodes.size() > 0;
260    }
261
262    /**
263     * Get list of all items matching the key. If no items exist matching the key, an empty list is returned.
264     *
265     * @param key the path down to elements to get
266     * @return a list of items that match the supplied key
267     */
268    public List<String> getList(String key) {
269        ArgumentNotValid.checkNotNullOrEmpty(key, "key");
270
271        List<Node> nodes = (List<Node>) getXPath(key).selectNodes(xmlDoc);
272        if (nodes == null || nodes.size() == 0) {
273            return Collections.emptyList();
274        }
275        List<String> results = new ArrayList<String>(nodes.size());
276        for (Node node : nodes) {
277            results.add(node.getText());
278        }
279        return results;
280    }
281
282    /**
283     * Save the current settings as an XML file.
284     *
285     * @param f the file to write the XML to.
286     */
287    public void save(File f) {
288        ArgumentNotValid.checkNotNull(f, "f");
289        XmlUtils.writeXmlToFile(xmlDoc, f);
290    }
291
292    /**
293     * Return a tree structure reflecting the XML and trimmed values.
294     *
295     * @param path Dotted path into the xml.
296     * @return A tree reflecting the xml at the given path.
297     * @throws UnknownID If the path does not exist in the tree or is ambiguous
298     */
299    public StringTree<String> getTree(String path) {
300        ArgumentNotValid.checkNotNullOrEmpty(path, "String path");
301        XPath xpath = getXPath(path);
302        List<Node> nodes = xpath.selectNodes(xmlDoc);
303        if (nodes == null || nodes.size() == 0) {
304            throw new UnknownID("No path '" + path + "' in XML document '" + source + "'");
305        } else if (nodes.size() > 1) {
306            throw new UnknownID("More than one candidate for path '" + path + "' in XML document '" + source + "'");
307        }
308        return XmlTree.getStringTree(nodes.get(0));
309    }
310
311    /**
312     * Get an XPath version of the given dotted path. A dotted path foo.bar.baz corresponds to the XML node
313     * &lt;foo&gt;&lt;bar&gt;&lt;baz&gt; &lt;/baz&gt;&lt;/bar&gt;&lt;/foo&gt;
314     * <p>
315     * Implementation note: If needed, this could be optimized by keeping a HashMap cache of the XPaths, since they
316     * don't change.
317     *
318     * @param path A dotted path
319     * @return An XPath that matches the dotted path equivalent, using "dk:" as namespace prefix for all but the first
320     * element.
321     */
322    private XPath getXPath(String path) {
323        String[] pathParts = path.split("\\.");
324        StringBuilder result = new StringBuilder();
325        result.append("/");
326        result.append(pathParts[0]);
327        for (int i = 1; i < pathParts.length; i++) {
328            result.append("/dk:");
329            result.append(pathParts[i]);
330        }
331        XPath xpath = xmlDoc.createXPath(result.toString());
332        Namespace nameSpace = xmlDoc.getRootElement().getNamespace();
333        Map<String, String> namespaceURIs = new HashMap<String, String>(1);
334        namespaceURIs.put("dk", nameSpace.getURI());
335        xpath.setNamespaceURIs(namespaceURIs);
336        return xpath;
337    }
338
339}