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