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}