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