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