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}