001package jmri.jmrit;
002
003import java.io.BufferedInputStream;
004import java.io.File;
005import java.io.FileInputStream;
006import java.io.FileNotFoundException;
007import java.io.FileOutputStream;
008import java.io.IOException;
009import java.io.InputStream;
010import java.net.URISyntaxException;
011import java.net.URL;
012import java.util.Calendar;
013import java.util.Date;
014import javax.annotation.Nonnull;
015import javax.swing.JFileChooser;
016import jmri.util.FileUtil;
017import jmri.util.JmriLocalEntityResolver;
018import jmri.util.NoArchiveFileFilter;
019import org.jdom2.Comment;
020import org.jdom2.Content;
021import org.jdom2.DocType;
022import org.jdom2.Document;
023import org.jdom2.Element;
024import org.jdom2.JDOMException;
025import org.jdom2.ProcessingInstruction;
026import org.jdom2.input.SAXBuilder;
027import org.jdom2.output.Format;
028import org.jdom2.output.XMLOutputter;
029import org.slf4j.Logger;
030import org.slf4j.LoggerFactory;
031
032/**
033 * Handle common aspects of XML files.
034 * <p>
035 * JMRI needs to be able to operate offline, so it needs to store resources
036 * locally. At the same time, we want XML files to be transportable, and to have
037 * their schema and stylesheets accessible via the web (for browser rendering).
038 * Further, our code assumes that default values for attributes will be
039 * provided, and it's necessary to read the schema for that to work.
040 * <p>
041 * We implement this using our own EntityResolver, the
042 * {@link jmri.util.JmriLocalEntityResolver} class.
043 * <p>
044 * When reading a file, validation is controlled heirarchically:
045 * <ul>
046 *   <li>There's a global default
047 *   <li>Which can be overridden on a particular XmlFile object
048 *   <li>Finally, the static call to create a builder can be invoked with a
049 * validation specification.
050 * </ul>
051 *
052 * @author Bob Jacobsen Copyright (C) 2001, 2002, 2007, 2012, 2014
053 */
054public class XmlFile {
055
056    /**
057     * Define root part of URL for XSLT style page processing instructions.
058     * <p>
059     * See the <A
060     * HREF="http://jmri.org/help/en/html/doc/Technical/XmlUsage.shtml#xslt">XSLT
061     * versioning discussion</a>.
062     * <p>
063     * Things that have been tried here: <dl>
064     * <dt>/xml/XSLT/ <dd>(Note leading slash) Works if there's a copy of the
065     * xml directory at the root of whatever served the XML file, e.g. the JMRI
066     * web site or a local computer running a server. Doesn't work for e.g.
067     * yahoo groups files. <dt>http://jmri.org/xml/XSLT/ <dd>Works well for
068     * files on the JMRI.org web server, but only that. </dl>
069     */
070    public static final String xsltLocation = "/xml/XSLT/";
071
072    /**
073     * Specify validation operations on input. The available choices are
074     * restricted to what the underlying SAX Xerces and JDOM implementations
075     * allow.
076     */
077    public enum Validate {
078        /**
079         * Don't validate input
080         */
081        None,
082        /**
083         * Require that the input specifies a Schema which validates
084         */
085        RequireSchema,
086        /**
087         * Validate against DTD if present (no DTD passes too)
088         */
089        CheckDtd,
090        /**
091         * Validate against DTD if present, else Schema must be present and
092         * valid
093         */
094        CheckDtdThenSchema
095    }
096
097    private String processingInstructionHRef;
098    private String processingInstructionType;
099
100    /**
101     * Get the value of the attribute 'href' of the process instruction of
102     * the last loaded document.
103     * @return the value of the attribute 'href' or null
104     */
105    public String getProcessingInstructionHRef() {
106        return processingInstructionHRef;
107    }
108
109    /**
110     * Get the value of the attribute 'type' of the process instruction of
111     * the last loaded document.
112     * @return the value of the attribute 'type' or null
113     */
114    public String getProcessingInstructionType() {
115        return processingInstructionType;
116    }
117
118    /**
119     * Read the contents of an XML file from its filename. The name is expanded
120     * by the {@link #findFile} routine. If the file is not found, attempts to
121     * read the XML file from a JAR resource.
122     *
123     * @param name Filename, as needed by {@link #findFile}
124     * @throws org.jdom2.JDOMException       only when all methods have failed
125     * @throws java.io.FileNotFoundException if file not found
126     * @return null if not found, else root element of located file
127     */
128    public Element rootFromName(String name) throws JDOMException, IOException {
129        File fp = findFile(name);
130        if (fp != null && fp.exists() && fp.canRead()) {
131            if (log.isDebugEnabled()) {
132                log.debug("readFile: {} from {}", name, fp.getAbsolutePath());
133            }
134            return rootFromFile(fp);
135        }
136        URL resource = FileUtil.findURL(name);
137        if (resource != null) {
138            return this.rootFromURL(resource);
139        } else {
140            if (!name.startsWith("xml")) {
141                return this.rootFromName("xml" + File.separator + name);
142            }
143            log.warn("Did not find file or resource {}", name);
144            throw new FileNotFoundException("Did not find file or resource " + name);
145        }
146    }
147
148    /**
149     * Read a File as XML, and return the root object.
150     * <p>
151     * Exceptions are only thrown when local recovery is impossible.
152     *
153     * @param file File to be parsed. A FileNotFoundException is thrown if it
154     *             doesn't exist.
155     * @throws org.jdom2.JDOMException       only when all methods have failed
156     * @throws java.io.FileNotFoundException if file not found
157     * @return root element from the file. This should never be null, as an
158     *         exception should be thrown if anything goes wrong.
159     */
160    public Element rootFromFile(File file) throws JDOMException, IOException {
161        if (log.isDebugEnabled()) {
162            log.debug("reading xml from file: {}", file.getPath());
163        }
164
165        try (FileInputStream fs = new FileInputStream(file)) {
166            return getRoot(fs);
167        }
168    }
169
170    /**
171     * Read an {@link java.io.InputStream} as XML, and return the root object.
172     * <p>
173     * Exceptions are only thrown when local recovery is impossible.
174     *
175     * @param stream InputStream to be parsed.
176     * @throws org.jdom2.JDOMException       only when all methods have failed
177     * @throws java.io.FileNotFoundException if file not found
178     * @return root element from the file. This should never be null, as an
179     *         exception should be thrown if anything goes wrong.
180     */
181    public Element rootFromInputStream(InputStream stream) throws JDOMException, IOException {
182        return getRoot(stream);
183    }
184
185    /**
186     * Read a URL as XML, and return the root object.
187     * <p>
188     * Exceptions are only thrown when local recovery is impossible.
189     *
190     * @param url URL locating the data file
191     * @throws org.jdom2.JDOMException only when all methods have failed
192     * @throws FileNotFoundException   if file not found
193     * @return root element from the file. This should never be null, as an
194     *         exception should be thrown if anything goes wrong.
195     */
196    public Element rootFromURL(URL url) throws JDOMException, IOException {
197        if (log.isDebugEnabled()) {
198            log.debug("reading xml from URL: {}", url.toString());
199        }
200        return getRoot(url.openConnection().getInputStream());
201    }
202
203    /**
204     * Get the root element from an XML document in a stream.
205     *
206     * @param stream input containing the XML document
207     * @return the root element of the XML document
208     * @throws org.jdom2.JDOMException if the XML document is invalid
209     * @throws java.io.IOException     if the input cannot be read
210     */
211    protected Element getRoot(InputStream stream) throws JDOMException, IOException {
212        log.trace("getRoot from stream");
213
214        processingInstructionHRef = null;
215        processingInstructionType = null;
216
217        SAXBuilder builder = getBuilder(getValidate());
218        Document doc = builder.build(new BufferedInputStream(stream));
219        doc = processInstructions(doc);  // handle any process instructions
220        // find root
221        return doc.getRootElement();
222    }
223
224    /**
225     * Write a File as XML.
226     *
227     * @param file File to be created.
228     * @param doc  Document to be written out. This should never be null.
229     * @throws IOException when an IO error occurs
230     * @throws FileNotFoundException if file not found
231     */
232    public void writeXML(File file, Document doc) throws IOException, FileNotFoundException {
233        // ensure parent directory exists
234        if (file.getParent() != null) {
235            FileUtil.createDirectory(file.getParent());
236        }
237        // write the result to selected file
238        try (FileOutputStream o = new FileOutputStream(file)) {
239            XMLOutputter fmt = new XMLOutputter();
240            fmt.setFormat(Format.getPrettyFormat()
241                    .setLineSeparator(System.getProperty("line.separator"))
242                    .setTextMode(Format.TextMode.TRIM_FULL_WHITE));
243            fmt.output(doc, o);
244            o.flush();
245        }
246    }
247
248    /**
249     * Check if a file of the given name exists. This uses the same search order
250     * as {@link #findFile}
251     *
252     * @param name file name, either absolute or relative
253     * @return true if the file exists in a searched place
254     */
255    protected boolean checkFile(String name) {
256        File fp = new File(name);
257        if (fp.exists()) {
258            return true;
259        }
260        fp = new File(FileUtil.getUserFilesPath() + name);
261        if (fp.exists()) {
262            return true;
263        } else {
264            File fx = new File(xmlDir() + name);
265            return fx.exists();
266        }
267    }
268
269    /**
270     * Get a File object for a name. This is here to implement the search
271     * rule:
272     * <ol>
273     *   <li>Look in user preferences directory, located by {@link jmri.util.FileUtil#getUserFilesPath()}
274     *   <li>Look in current working directory (usually the JMRI distribution directory)
275     *   <li>Look in program directory, located by {@link jmri.util.FileUtil#getProgramPath()}
276     *   <li>Look in XML directory, located by {@link #xmlDir}
277     *   <li>Check for absolute name.
278     * </ol>
279     *
280     * @param name Filename perhaps containing subdirectory information (e.g.
281     *             "decoders/Mine.xml")
282     * @return null if file found, otherwise the located File
283     */
284    protected File findFile(String name) {
285        URL url = FileUtil.findURL(name,
286                FileUtil.getUserFilesPath(),
287                ".",
288                FileUtil.getProgramPath(),
289                xmlDir());
290        if (url != null) {
291            try {
292                return new File(url.toURI());
293            } catch (URISyntaxException ex) {
294                return null;
295            }
296        }
297        return null;
298    }
299
300    /**
301     * Diagnostic printout of as much as we can find
302     *
303     * @param name Element to print, should not be null
304     */
305    static public void dumpElement(@Nonnull Element name) {
306        name.getChildren().forEach((element) -> {
307            log.info(" Element: {} ns: {}", element.getName(), element.getNamespace());
308        });
309    }
310
311    /**
312     * Move original file to a backup. Use this before writing out a new version
313     * of the file.
314     *
315     * @param name Last part of file pathname i.e. subdir/name, without the
316     *             pathname for either the xml or preferences directory.
317     */
318    public void makeBackupFile(String name) {
319        File file = findFile(name);
320        if (file == null) {
321            log.info("No {} file to backup", name);
322        } else if (file.canWrite()) {
323            String backupName = backupFileName(file.getAbsolutePath());
324            File backupFile = findFile(backupName);
325            if (backupFile != null) {
326                if (backupFile.delete()) {
327                    log.debug("deleted backup file {}", backupName);
328                }
329            }
330            if (file.renameTo(new File(backupName))) {
331                log.debug("created new backup file {}", backupName);
332            } else {
333                log.error("could not create backup file {}", backupName);
334            }
335        }
336    }
337
338    /**
339     * Move original file to backup directory.
340     *
341     * @param directory the backup directory to use.
342     * @param file      the file to be backed up. The file name will have the
343     *                  current date embedded in the backup name.
344     * @return true if successful.
345     */
346    public boolean makeBackupFile(String directory, File file) {
347        if (file == null) {
348            log.info("No file to backup");
349        } else if (file.canWrite()) {
350            String backupFullName = directory + File.separator + createFileNameWithDate(file.getName());
351            if (log.isDebugEnabled()) {
352                log.debug("new backup file: {}", backupFullName);
353            }
354
355            File backupFile = findFile(backupFullName);
356            if (backupFile != null) {
357                if (backupFile.delete()) {
358                    if (log.isDebugEnabled()) {
359                        log.debug("deleted backup file {}", backupFullName);
360                    }
361                }
362            } else {
363                backupFile = new File(backupFullName);
364            }
365            // create directory if needed
366            File parentDir = backupFile.getParentFile();
367            if (!parentDir.exists()) {
368                if (log.isDebugEnabled()) {
369                    log.debug("creating backup directory: {}", parentDir.getName());
370                }
371                if (!parentDir.mkdirs()) {
372                    log.error("backup directory not created");
373                    return false;
374                }
375            }
376            if (file.renameTo(new File(backupFullName))) {
377                if (log.isDebugEnabled()) {
378                    log.debug("created new backup file {}", backupFullName);
379                }
380            } else {
381                if (log.isDebugEnabled()) {
382                    log.debug("could not create backup file {}", backupFullName);
383                }
384                return false;
385            }
386        }
387        return true;
388    }
389
390    /**
391     * Revert to original file from backup. Use this for testing backup files.
392     *
393     * @param name Last part of file pathname i.e. subdir/name, without the
394     *             pathname for either the xml or preferences directory.
395     */
396    public void revertBackupFile(String name) {
397        File file = findFile(name);
398        if (file == null) {
399            log.info("No {} file to revert", name);
400        } else {
401            String backupName = backupFileName(file.getAbsolutePath());
402            File backupFile = findFile(backupName);
403            if (backupFile != null) {
404                log.info("No {} backup file to revert", backupName);
405                if (file.delete()) {
406                    log.debug("deleted original file {}", name);
407                }
408
409                if (backupFile.renameTo(new File(name))) {
410                    log.debug("created original file {}", name);
411                } else {
412                    log.error("could not create original file {}", name);
413                }
414            }
415        }
416    }
417
418    /**
419     * Return the name of a new, unique backup file. This is here so it can be
420     * overridden during tests. File to be backed-up must be within the
421     * preferences directory tree.
422     *
423     * @param name Filename without preference path information, e.g.
424     *             "decoders/Mine.xml".
425     * @return Complete filename, including path information into preferences
426     *         directory
427     */
428    public String backupFileName(String name) {
429        String f = name + ".bak";
430        if (log.isDebugEnabled()) {
431            log.debug("backup file name is: {}", f);
432        }
433        return f;
434    }
435
436    public String createFileNameWithDate(String name) {
437        // remove .xml extension
438        String[] fileName = name.split(".xml");
439        String f = fileName[0] + "_" + getDate() + ".xml";
440        if (log.isDebugEnabled()) {
441            log.debug("backup file name is: {}", f);
442        }
443        return f;
444    }
445
446    /**
447     * @return String based on the current date in the format of year month day
448     *         hour minute second. The date is fixed length and always returns a
449     *         date represented by 14 characters.
450     */
451    private String getDate() {
452        Calendar now = Calendar.getInstance();
453        return String.format("%d%02d%02d%02d%02d%02d",
454                now.get(Calendar.YEAR),
455                now.get(Calendar.MONTH) + 1,
456                now.get(Calendar.DATE),
457                now.get(Calendar.HOUR_OF_DAY),
458                now.get(Calendar.MINUTE),
459                now.get(Calendar.SECOND)
460        );
461    }
462
463    /**
464     * Execute the Processing Instructions in the file.
465     * <p>
466     * JMRI only knows about certain ones; the others will be ignored.
467     *
468     * @param doc the document containing processing instructions
469     * @return the processed document
470     */
471    Document processInstructions(Document doc) {
472        // this iterates over top level
473        for (Content c : doc.cloneContent()) {
474            if (c instanceof ProcessingInstruction) {
475                ProcessingInstruction pi = (ProcessingInstruction) c;
476                for (String attrName : pi.getPseudoAttributeNames()) {
477                    if ("href".equals(attrName)) {
478                        processingInstructionHRef = pi.getPseudoAttributeValue(attrName);
479                    }
480                    if ("type".equals(attrName)) {
481                        processingInstructionType = pi.getPseudoAttributeValue(attrName);
482                    }
483                }
484                try {
485                    doc = processOneInstruction((ProcessingInstruction) c, doc);
486                } catch (org.jdom2.transform.XSLTransformException ex) {
487                    log.error("XSLT error while transforming with {}, ignoring transform", c, ex);
488                } catch (org.jdom2.JDOMException ex) {
489                    log.error("JDOM error while transforming with {}, ignoring transform", c, ex);
490                } catch (java.io.IOException ex) {
491                    log.error("IO error while transforming with {}, ignoring transform", c, ex);
492                }
493            }
494        }
495
496        return doc;
497    }
498
499    Document processOneInstruction(ProcessingInstruction p, Document doc) throws org.jdom2.transform.XSLTransformException, org.jdom2.JDOMException, java.io.IOException {
500        log.trace("handling {}", p);
501
502        // check target
503        String target = p.getTarget();
504        if (!target.equals("transform-xslt")) {
505            return doc;
506        }
507
508        String href = p.getPseudoAttributeValue("href");
509        // we expect this to start with http://jmri.org/ and refer to the JMRI file tree
510        if (!href.startsWith("http://jmri.org/")) {
511            return doc;
512        }
513        href = href.substring(16);
514
515        // if starts with 'xml/' we remove that; findFile will put it back
516        if (href.startsWith("xml/")) {
517            href = href.substring(4);
518        }
519
520        // read the XSLT transform into a Document to get XInclude done
521        SAXBuilder builder = getBuilder(Validate.None);
522        Document xdoc = builder.build(new BufferedInputStream(new FileInputStream(findFile(href))));
523        org.jdom2.transform.XSLTransformer transformer = new org.jdom2.transform.XSLTransformer(xdoc);
524        return transformer.transform(doc);
525    }
526
527    /**
528     * Create the Document object to store a particular root Element.
529     *
530     * @param root Root element of the final document
531     * @param dtd  name of an external DTD
532     * @return new Document, with root installed
533     */
534    static public Document newDocument(Element root, String dtd) {
535        Document doc = new Document(root);
536        doc.setDocType(new DocType(root.getName(), dtd));
537        addDefaultInfo(root);
538        return doc;
539    }
540
541    /**
542     * Create the Document object to store a particular root Element, without a
543     * DocType DTD (e.g. for using a schema)
544     *
545     * @param root Root element of the final document
546     * @return new Document, with root installed
547     */
548    static public Document newDocument(Element root) {
549        Document doc = new Document(root);
550        addDefaultInfo(root);
551        return doc;
552    }
553
554    /**
555     * Add default information to the XML before writing it out.
556     * <p>
557     * Currently, this is identification information as an XML comment. This
558     * includes: <ul>
559     * <li>The JMRI version used <li>Date of writing <li>A CVS id string, in
560     * case the file gets checked in or out </ul>
561     * <p>
562     * It may be necessary to extend this to check whether the info is already
563     * present, e.g. if re-writing a file.
564     *
565     * @param root The root element of the document that will be written.
566     */
567    static public void addDefaultInfo(Element root) {
568        String content = "Written by JMRI version " + jmri.Version.name()
569                + " on " + (new Date()).toString();
570        Comment comment = new Comment(content);
571        root.addContent(comment);
572    }
573
574    /**
575     * Define the location of XML files within the distribution directory.
576     * <p>
577     * Use {@link FileUtil#getProgramPath()} since the current working directory
578     * is not guaranteed to be the JMRI distribution directory if jmri.jar is
579     * referenced by an external Java application.
580     *
581     * @return the XML directory that ships with JMRI.
582     */
583    static public String xmlDir() {
584        return FileUtil.getProgramPath() + "xml" + File.separator;
585    }
586
587    /**
588     * Whether to, by global default, validate the file being read. Public so it
589     * can be set by scripting and for debugging.
590     *
591     * @return the default level of validation to apply to a file
592     */
593    static public Validate getDefaultValidate() {
594        return defaultValidate;
595    }
596
597    static public void setDefaultValidate(Validate v) {
598        defaultValidate = v;
599    }
600
601    static private Validate defaultValidate = Validate.None;
602
603    /**
604     * Whether to verify the DTD of this XML file when read.
605     *
606     * @return the level of validation to apply to a file
607     */
608    public Validate getValidate() {
609        return validate;
610    }
611
612    public void setValidate(Validate v) {
613        validate = v;
614    }
615
616    private Validate validate = defaultValidate;
617
618    /**
619     * Get the default standard location for DTDs in new XML documents. Public
620     * so it can be set by scripting and for debug.
621     *
622     * @return the default DTD location
623     */
624    static public String getDefaultDtdLocation() {
625        return defaultDtdLocation;
626    }
627
628    static public void setDefaultDtdLocation(String v) {
629        defaultDtdLocation = v;
630    }
631
632    static String defaultDtdLocation = "/xml/DTD/";
633
634    /**
635     * Get the location for DTDs in this XML document.
636     *
637     * @return the DTD location
638     */
639    public String getDtdLocation() {
640        return dtdLocation;
641    }
642
643    public void setDtdLocation(String v) {
644        dtdLocation = v;
645    }
646
647    public String dtdLocation = defaultDtdLocation;
648
649    /**
650     * Provide a JFileChooser initialized to the default user location, and with
651     * a default filter. This filter excludes {@code .zip} and {@code .jar}
652     * archives.
653     *
654     * @param filter Title for the filter, may not be null
655     * @param suffix Allowed file extensions, if empty all extensions are
656     *               allowed except {@code .zip} and {@code .jar}; include an
657     *               empty String to allow files without an extension if
658     *               specifying other extensions.
659     * @return a file chooser
660     */
661    public static JFileChooser userFileChooser(String filter, String... suffix) {
662        JFileChooser fc = new jmri.util.swing.JmriJFileChooser(FileUtil.getUserFilesPath());
663        fc.setFileFilter(new NoArchiveFileFilter(filter, suffix));
664        return fc;
665    }
666
667    /**
668     * Provide a JFileChooser initialized to the default user location, and with
669     * a default filter. This filter excludes {@code .zip} and {@code .jar}
670     * archives.
671     *
672     * @return a file chooser
673     */
674    public static JFileChooser userFileChooser() {
675        JFileChooser fc = new jmri.util.swing.JmriJFileChooser(FileUtil.getUserFilesPath());
676        fc.setFileFilter(new NoArchiveFileFilter());
677        return fc;
678    }
679
680    @SuppressWarnings("deprecation") // org.jdom2.input.SAXBuilder(java.lang.String saxDriverClass, boolean validate)
681    //{@see http://www.jdom.org/docs/apidocs/org/jdom2/input/SAXBuilder.html}
682    //{@see http://www.jdom.org/docs/apidocs/org/jdom2/input/sax/XMLReaders.html#NONVALIDATING}
683    // Validate.CheckDtdThenSchema may not be available readily
684    public static SAXBuilder getBuilder(Validate validate) {  // should really be a Verify enum
685        SAXBuilder builder;
686
687        boolean verifyDTD = (validate == Validate.CheckDtd) || (validate == Validate.CheckDtdThenSchema);
688        boolean verifySchema = (validate == Validate.RequireSchema) || (validate == Validate.CheckDtdThenSchema);
689
690        // old style
691        builder = new SAXBuilder("org.apache.xerces.parsers.SAXParser", verifyDTD);  // argument controls DTD validation
692
693        // insert local resolver for includes, schema, DTDs
694        builder.setEntityResolver(new JmriLocalEntityResolver());
695
696        // configure XInclude handling
697        builder.setFeature("http://apache.org/xml/features/xinclude", true);
698        builder.setFeature("http://apache.org/xml/features/xinclude/fixup-base-uris", false);
699
700        // only validate if grammar is available, making ABSENT OK
701        builder.setFeature("http://apache.org/xml/features/validation/dynamic", verifyDTD && !verifySchema);
702
703        // control Schema validation
704        builder.setFeature("http://apache.org/xml/features/validation/schema", verifySchema);
705        builder.setFeature("http://apache.org/xml/features/validation/schema-full-checking", verifySchema);
706
707        // if not validating DTD, just validate Schema
708        builder.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", verifyDTD);
709        if (!verifyDTD) {
710            builder.setProperty("http://java.sun.com/xml/jaxp/properties/schemaLanguage", "http://www.w3.org/2001/XMLSchema");
711        }
712
713        // allow Java character encodings
714        builder.setFeature("http://apache.org/xml/features/allow-java-encodings", true);
715
716        return builder;
717    }
718
719    // initialize logging
720    private static final Logger log = LoggerFactory.getLogger(XmlFile.class);
721
722}