001/*
002 * This library is part of OpenCms -
003 * the Open Source Content Management System
004 *
005 * Copyright (c) Alkacon Software GmbH & Co. KG (http://www.alkacon.com)
006 *
007 * This library is free software; you can redistribute it and/or
008 * modify it under the terms of the GNU Lesser General Public
009 * License as published by the Free Software Foundation; either
010 * version 2.1 of the License, or (at your option) any later version.
011 *
012 * This library is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
015 * Lesser General Public License for more details.
016 *
017 * For further information about Alkacon Software GmbH & Co. KG, please see the
018 * company website: http://www.alkacon.com
019 *
020 * For further information about OpenCms, please see the
021 * project website: http://www.opencms.org
022 *
023 * You should have received a copy of the GNU Lesser General Public
024 * License along with this library; if not, write to the Free Software
025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
026 */
027
028package org.opencms.setup.xml;
029
030import org.opencms.i18n.CmsEncoder;
031import org.opencms.i18n.CmsMessageContainer;
032import org.opencms.main.CmsLog;
033import org.opencms.util.CmsCollectionsGenericWrapper;
034import org.opencms.util.CmsStringUtil;
035import org.opencms.xml.CmsXmlEntityResolver;
036import org.opencms.xml.CmsXmlException;
037import org.opencms.xml.CmsXmlUtils;
038
039import java.io.File;
040import java.io.FileNotFoundException;
041import java.io.FileOutputStream;
042import java.io.FileReader;
043import java.io.IOException;
044import java.io.OutputStream;
045import java.io.StringReader;
046import java.util.ArrayList;
047import java.util.HashMap;
048import java.util.Iterator;
049import java.util.List;
050import java.util.Map;
051
052import org.apache.commons.logging.Log;
053
054import org.dom4j.Attribute;
055import org.dom4j.Document;
056import org.dom4j.Element;
057import org.dom4j.Node;
058import org.xml.sax.EntityResolver;
059import org.xml.sax.InputSource;
060
061/**
062 * Helper class to modify xml files.<p>
063 *
064 * For more info about xpath see: <br>
065 * <ul>
066 * <li>http://www.w3.org/TR/xpath.html</li>
067 * <li>http://www.zvon.org/xxl/XPathTutorial/General/examples.html</li>
068 * </ul><p>
069 *
070 * @since 6.1.8
071 */
072public class CmsSetupXmlHelper {
073
074    /** The log object for this class. */
075    private static final Log LOG = CmsLog.getLog(CmsSetupXmlHelper.class);
076
077    /** Entity resolver to skip dtd validation. */
078    private static final EntityResolver NO_ENTITY_RESOLVER = new EntityResolver() {
079
080        /**
081         * @see org.xml.sax.EntityResolver#resolveEntity(java.lang.String, java.lang.String)
082         */
083        public InputSource resolveEntity(String publicId, String systemId) {
084
085            return new InputSource(new StringReader(""));
086        }
087    };
088
089    /** Optional base path. */
090    private String m_basePath;
091
092    /** Document cache. */
093    private Map<String, Document> m_cache = new HashMap<String, Document>();
094
095    /**
096     * Default constructor.<p>
097     *
098     * Uses no base path.<p>
099     */
100    public CmsSetupXmlHelper() {
101
102        // ignore
103    }
104
105    /**
106     * Uses an optional base file path.<p>
107     *
108     * @param basePath the base file path to use;
109     */
110    public CmsSetupXmlHelper(String basePath) {
111
112        m_basePath = basePath;
113    }
114
115    /**
116     * Unmarshals (reads) an XML string into a new document.<p>
117     *
118     * @param xml the XML code to unmarshal
119     *
120     * @return the generated document
121     *
122     * @throws CmsXmlException if something goes wrong
123     */
124    public static String format(String xml) throws CmsXmlException {
125
126        return CmsXmlUtils.marshal((Node)CmsXmlUtils.unmarshalHelper(xml, null), CmsEncoder.ENCODING_UTF_8);
127    }
128
129    /**
130     * Returns the value in the given xpath of the given xml file.<p>
131     *
132     * @param document the xml document
133     * @param xPath the xpath to read (should select a single node or attribute)
134     *
135     * @return the value in the given xpath of the given xml file, or <code>null</code> if no matching node
136     */
137    public static String getValue(Document document, String xPath) {
138
139        Node node = document.selectSingleNode(xPath);
140        if (node != null) {
141            // return the value
142            return node.getText();
143        } else {
144            return null;
145        }
146    }
147
148    /**
149     * Replaces a attibute's value in the given node addressed by the xPath.<p>
150     *
151     * @param document the document to replace the node attribute
152     * @param xPath the xPath to the node
153     * @param attribute the attribute to replace the value of
154     * @param value the new value to set
155     *
156     * @return <code>true</code> if successful <code>false</code> otherwise
157     */
158    public static boolean setAttribute(Document document, String xPath, String attribute, String value) {
159
160        Node node = document.selectSingleNode(xPath);
161        Element e = (Element)node;
162        @SuppressWarnings("unchecked")
163        List<Attribute> attributes = e.attributes();
164        for (Attribute a : attributes) {
165            if (a.getName().equals(attribute)) {
166                a.setValue(value);
167                return true;
168            }
169        }
170        return false;
171    }
172
173    /**
174     * Sets the given value in all nodes identified by the given xpath of the given xml file.<p>
175     *
176     * If value is <code>null</code>, all nodes identified by the given xpath will be deleted.<p>
177     *
178     * If the node identified by the given xpath does not exists, the missing nodes will be created
179     * (if <code>value</code> not <code>null</code>).<p>
180     *
181     * @param document the xml document
182     * @param xPath the xpath to set
183     * @param value the value to set (can be <code>null</code> for deletion)
184     *
185     * @return the number of successful changed or deleted nodes
186     */
187    public static int setValue(Document document, String xPath, String value) {
188
189        return setValue(document, xPath, value, null);
190    }
191
192    /**
193     * Sets the given value in all nodes identified by the given xpath of the given xml file.<p>
194     *
195     * If value is <code>null</code>, all nodes identified by the given xpath will be deleted.<p>
196     *
197     * If the node identified by the given xpath does not exists, the missing nodes will be created
198     * (if <code>value</code> not <code>null</code>).<p>
199     *
200     * @param document the xml document
201     * @param xPath the xpath to set
202     * @param value the value to set (can be <code>null</code> for deletion)
203     * @param nodeToInsert optional, if given it will be inserted after xPath with the given value
204     *
205     * @return the number of successful changed or deleted nodes
206     */
207    public static int setValue(Document document, String xPath, String value, String nodeToInsert) {
208
209        int changes = 0;
210        // be naive and try to find the node
211        Iterator<Node> itNodes = CmsCollectionsGenericWrapper.<Node> list(document.selectNodes(xPath)).iterator();
212
213        // if not found
214        if (!itNodes.hasNext()) {
215            if (value == null) {
216                // if no node found for deletion
217                return 0;
218            }
219            // find the node creating missing nodes in the way
220            Iterator<String> it = CmsStringUtil.splitAsList(xPath, "/", false).iterator();
221            Node currentNode = document;
222            while (it.hasNext()) {
223                String nodeName = it.next();
224                // if a string condition contains '/'
225                while ((nodeName.indexOf("='") > 0) && (nodeName.indexOf("']") < 0)) {
226                    nodeName += "/" + it.next();
227                }
228                Node node = currentNode.selectSingleNode(nodeName);
229                if (node != null) {
230                    // node found
231                    currentNode = node;
232                    if (!it.hasNext()) {
233                        currentNode.setText(value);
234                    }
235                } else if (currentNode.getNodeType() == Node.ELEMENT_NODE) {
236                    Element elem = (Element)currentNode;
237                    if (!nodeName.startsWith("@")) {
238                        elem = handleNode(elem, nodeName);
239                        if (!it.hasNext() && CmsStringUtil.isNotEmptyOrWhitespaceOnly(value)) {
240                            elem.setText(value);
241                        }
242                    } else {
243                        // if node is attribute create it with given value
244                        elem.addAttribute(nodeName.substring(1), value);
245                    }
246                    currentNode = elem;
247                } else {
248                    // should never happen
249                    if (LOG.isDebugEnabled()) {
250                        LOG.debug(Messages.get().getBundle().key(Messages.ERR_XML_SET_VALUE_2, xPath, value));
251                    }
252                    break;
253                }
254            }
255            if (nodeToInsert == null) {
256                // if not inserting we are done
257                return 1;
258            }
259            // if inserting, we just created the insertion point, so continue
260            itNodes = CmsCollectionsGenericWrapper.<Node> list(document.selectNodes(xPath)).iterator();
261        }
262
263        // if found
264        while (itNodes.hasNext()) {
265            Node node = itNodes.next();
266            if (nodeToInsert == null) {
267                // if not inserting
268                if (value != null) {
269                    // if found, change the value
270                    node.setText(value);
271                } else {
272                    // if node for deletion is found
273                    node.getParent().remove(node);
274                }
275            } else {
276                // first create the node to insert
277                Element parent = node.getParent();
278                Element elem = handleNode(parent, nodeToInsert);
279                if (value != null) {
280                    elem.setText(value);
281                }
282                // get the parent element list
283                List<Node> list = CmsCollectionsGenericWrapper.<Node> list(parent.content());
284                // remove the just created element
285                list.remove(list.size() - 1);
286                // insert it back to the right position
287                int pos = list.indexOf(node);
288                list.add(pos + 1, elem); // insert after
289            }
290            changes++;
291        }
292        return changes;
293    }
294
295    /**
296     * Handles the xpath name, by creating the given node and its children.<p>
297     *
298     * @param parent the parent node to use
299     * @param xpathName the xpathName, ie <code>a[@b='c'][d='e'][text()='f']</code>
300     *
301     * @return the new created element
302     */
303    private static Element handleNode(Element parent, String xpathName) {
304
305        // if node is no attribute, create a new node
306        String childrenPart = null;
307        String nodeName;
308        int pos = xpathName.indexOf("[");
309        if (pos > 0) {
310            childrenPart = xpathName.substring(pos + 1, xpathName.length() - 1);
311            nodeName = xpathName.substring(0, pos);
312        } else {
313            nodeName = xpathName;
314        }
315        // create node
316        Element elem = parent.addElement(nodeName);
317        if (childrenPart != null) {
318            pos = childrenPart.indexOf("[");
319            if ((pos > 0) && (childrenPart.indexOf("]") > pos)) {
320                handleNode(elem, childrenPart);
321                return elem;
322            }
323            if (childrenPart.contains("=")) {
324                Map<String, String> children = CmsStringUtil.splitAsMap(childrenPart, "][", "=");
325                // handle child nodes
326                for (Map.Entry<String, String> child : children.entrySet()) {
327                    String childName = child.getKey();
328                    String childValue = child.getValue();
329                    if (childValue.startsWith("'")) {
330                        childValue = childValue.substring(1);
331                    }
332                    if (childValue.endsWith("'")) {
333                        childValue = childValue.substring(0, childValue.length() - 1);
334                    }
335                    if (childName.startsWith("@")) {
336                        elem.addAttribute(childName.substring(1), childValue);
337                    } else if (childName.equals("text()")) {
338                        elem.setText(childValue);
339                    } else if (!childName.contains("(")) {
340                        Element childElem = elem.addElement(childName);
341                        if (CmsStringUtil.isNotEmptyOrWhitespaceOnly(childValue)) {
342                            childElem.addText(childValue);
343                        }
344                    }
345                }
346            }
347        }
348        return elem;
349    }
350
351    /**
352     * Discards the changes in the given file.<p>
353     *
354     * @param xmlFilename the xml config file (could be relative to the base path)
355     */
356    public void flush(String xmlFilename) {
357
358        m_cache.remove(xmlFilename);
359    }
360
361    /**
362     * Discards the changes in all files.<p>
363     */
364    public void flushAll() {
365
366        m_cache.clear();
367    }
368
369    /**
370     * Returns the base file Path.<p>
371     *
372     * @return the base file Path
373     */
374    public String getBasePath() {
375
376        return m_basePath;
377    }
378
379    /**
380     * Returns the document for the given filename.<p>
381     * It can be new read or come from the document cache.<p>
382     *
383     * @param xmlFilename the filename to read
384     *
385     * @return the document for the given filename
386     *
387     * @throws CmsXmlException if something goes wrong while reading
388     */
389    public Document getDocument(String xmlFilename) throws CmsXmlException {
390
391        // try to get it from the cache
392        Document document = m_cache.get(xmlFilename);
393
394        if (document == null) {
395            try {
396                document = CmsXmlUtils.unmarshalHelper(
397                    new InputSource(new FileReader(getFile(xmlFilename))),
398                    NO_ENTITY_RESOLVER);
399            } catch (FileNotFoundException e) {
400                LOG.error("Could not read file " + xmlFilename, e);
401                throw new CmsXmlException(new CmsMessageContainer(null, e.toString()));
402
403            } catch (Exception e) {
404                LOG.error("Could not parse file " + xmlFilename, e);
405                throw new CmsXmlException(
406                    Messages.get().container(Messages.ERR_XML_COULD_NOT_PARSE_FILE_1, xmlFilename),
407                    e);
408            }
409            // cache the doc
410            m_cache.put(xmlFilename, document);
411        }
412        return document;
413    }
414
415    /**
416     * Returns the value in the given xpath of the given xml file.<p>
417     *
418     * @param xmlFilename the xml config file (could be relative to the base path)
419     * @param xPath the xpath to read (should select a single node or attribute)
420     *
421     * @return the value in the given xpath of the given xml file, or <code>null</code> if no matching node
422     *
423     * @throws CmsXmlException if something goes wrong while reading
424     */
425    public String getValue(String xmlFilename, String xPath) throws CmsXmlException {
426
427        return getValue(getDocument(xmlFilename), xPath);
428    }
429
430    /**
431     * Replaces a attibute's value in the given node addressed by the xPath.<p>
432     *
433     * @param xmlFilename the xml file name to get the document from
434     * @param xPath the xPath to the node
435     * @param attribute the attribute to replace the value of
436     * @param value the new value to set
437     *
438     * @return <code>true</code> if successful <code>false</code> otherwise
439     *
440     * @throws CmsXmlException if the xml document coudn't be read
441     */
442    public boolean setAttribute(String xmlFilename, String xPath, String attribute, String value)
443    throws CmsXmlException {
444
445        return setAttribute(getDocument(xmlFilename), xPath, attribute, value);
446    }
447
448    /**
449     * Sets the given value in all nodes identified by the given xpath of the given xml file.<p>
450     *
451     * If value is <code>null</code>, all nodes identified by the given xpath will be deleted.<p>
452     *
453     * If the node identified by the given xpath does not exists, the missing nodes will be created
454     * (if <code>value</code> not <code>null</code>).<p>
455     *
456     * @param xmlFilename the xml config file (could be relative to the base path)
457     * @param xPath the xpath to set
458     * @param value the value to set (can be <code>null</code> for deletion)
459     *
460     * @return the number of successful changed or deleted nodes
461     *
462     * @throws CmsXmlException if something goes wrong
463     */
464    public int setValue(String xmlFilename, String xPath, String value) throws CmsXmlException {
465
466        return setValue(getDocument(xmlFilename), xPath, value, null);
467    }
468
469    /**
470     * Sets the given value in all nodes identified by the given xpath of the given xml file.<p>
471     *
472     * If value is <code>null</code>, all nodes identified by the given xpath will be deleted.<p>
473     *
474     * If the node identified by the given xpath does not exists, the missing nodes will be created
475     * (if <code>value</code> not <code>null</code>).<p>
476     *
477     * @param xmlFilename the xml config file (could be relative to the base path)
478     * @param xPath the xpath to set
479     * @param value the value to set (can be <code>null</code> for deletion)
480     * @param nodeToInsert optional, if given it will be inserted after xPath with the given value
481     *
482     * @return the number of successful changed or deleted nodes
483     *
484     * @throws CmsXmlException if something goes wrong
485     */
486    public int setValue(String xmlFilename, String xPath, String value, String nodeToInsert) throws CmsXmlException {
487
488        return setValue(getDocument(xmlFilename), xPath, value, nodeToInsert);
489    }
490
491    /**
492     * Writes the given file back to disk.<p>
493     *
494     * @param xmlFilename the xml config file (could be relative to the base path)
495     *
496     * @throws CmsXmlException if something wrong while writing
497     */
498    public void write(String xmlFilename) throws CmsXmlException {
499
500        // try to get it from the cache
501        Document document = m_cache.get(xmlFilename);
502
503        if (document != null) {
504            try {
505                CmsXmlUtils.validateXmlStructure(document, CmsEncoder.ENCODING_UTF_8, new CmsXmlEntityResolver(null));
506                OutputStream out = null;
507                out = new FileOutputStream(getFile(xmlFilename));
508                CmsXmlUtils.marshal(document, out, CmsEncoder.ENCODING_UTF_8);
509            } catch (FileNotFoundException e) {
510                throw new CmsXmlException(new CmsMessageContainer(null, e.toString()));
511            } catch (CmsXmlException e) {
512                // write invalid config files to the file system with a prefix of "invalid-" so they can be inspected for errors
513                try {
514                    OutputStream invalidOut = new FileOutputStream(getFile("invalid-" + xmlFilename));
515                    CmsXmlUtils.marshal(document, invalidOut, CmsEncoder.ENCODING_UTF_8);
516                } catch (IOException e2) {
517                    // ignore
518
519                }
520                throw e;
521            }
522        }
523    }
524
525    /**
526     * Flushes all cached documents.<p>
527     *
528     * @throws CmsXmlException if something wrong while writing
529     */
530    public void writeAll() throws CmsXmlException {
531
532        Iterator<String> it = new ArrayList<String>(m_cache.keySet()).iterator();
533        while (it.hasNext()) {
534            String filename = it.next();
535            write(filename);
536        }
537    }
538
539    /**
540     * Returns a file from a given filename.<p>
541     *
542     * @param xmlFilename the file name
543     *
544     * @return the file
545     */
546    private File getFile(String xmlFilename) {
547
548        File file = new File(m_basePath + xmlFilename);
549        if (!file.exists() || !file.canRead()) {
550            file = new File(xmlFilename);
551        }
552        return file;
553    }
554}