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