001package jmri.jmrit.roster; 002 003import com.fasterxml.jackson.databind.util.StdDateFormat; 004 005import java.awt.HeadlessException; 006import java.awt.Image; 007import java.io.File; 008import java.io.FileNotFoundException; 009import java.io.IOException; 010import java.io.Writer; 011import java.text.*; 012import java.util.*; 013 014import javax.annotation.CheckForNull; 015import javax.annotation.Nonnull; 016import javax.swing.ImageIcon; 017import javax.swing.JLabel; 018 019import jmri.BasicRosterEntry; 020import jmri.DccLocoAddress; 021import jmri.InstanceManager; 022import jmri.LocoAddress; 023import jmri.beans.ArbitraryBean; 024import jmri.jmrit.roster.rostergroup.RosterGroup; 025import jmri.jmrit.symbolicprog.CvTableModel; 026import jmri.jmrit.symbolicprog.VariableTableModel; 027import jmri.util.FileUtil; 028import jmri.util.StringUtil; 029import jmri.util.davidflanagan.HardcopyWriter; 030import jmri.util.jdom.LocaleSelector; 031import jmri.util.swing.JmriJOptionPane; 032 033import org.jdom2.Attribute; 034import org.jdom2.Element; 035import org.jdom2.JDOMException; 036 037/** 038 * RosterEntry represents a single element in a locomotive roster, including 039 * information on how to locate it from decoder information. 040 * <p> 041 * The RosterEntry is the central place to find information about a locomotive's 042 * configuration, including CV and "programming variable" information. 043 * RosterEntry handles persistence through the LocoFile class. Creating a 044 * RosterEntry does not necessarily read the corresponding file (which might not 045 * even exist), please see readFile(), writeFile() member functions. 046 * <p> 047 * All the data attributes have a content, not null. FileName, however, is 048 * special. A null value for it indicates that no physical file is (yet) 049 * associated with this entry. 050 * <p> 051 * When the filePath attribute is non-null, the user has decided to organize the 052 * roster into directories. 053 * <p> 054 * Each entry can have one or more "Attributes" associated with it. These are 055 * (key, value) pairs. The key has to be unique, and currently both objects have 056 * to be Strings. 057 * <p> 058 * All properties, including the "Attributes", are bound. 059 * 060 * @author Bob Jacobsen Copyright (C) 2001, 2002, 2004, 2005, 2009 061 * @author Dennis Miller Copyright 2004 062 * @author Egbert Broerse Copyright (C) 2018 063 * @author Dave Heap Copyright (C) 2019 064 * @see jmri.jmrit.roster.LocoFile 065 */ 066public class RosterEntry extends ArbitraryBean implements RosterObject, BasicRosterEntry { 067 068 // identifiers for property change events and some XML elements 069 public static final String ID = "id"; // NOI18N 070 public static final String FILENAME = "filename"; // NOI18N 071 public static final String ROADNAME = "roadname"; // NOI18N 072 public static final String MFG = "mfg"; // NOI18N 073 public static final String MODEL = "model"; // NOI18N 074 public static final String OWNER = "owner"; // NOI18N 075 public static final String DCC_ADDRESS = "dccaddress"; // NOI18N 076 public static final String LONG_ADDRESS = "longaddress"; // NOI18N 077 public static final String PROTOCOL = "protocol"; // NOI18N 078 public static final String COMMENT = "comment"; // NOI18N 079 public static final String DECODER_MODEL = "decodermodel"; // NOI18N 080 public static final String DECODER_DEVELOPERID = "developerID"; // NOI18N 081 public static final String DECODER_MANUFACTURERID = "manufacturerID"; // NOI18N 082 public static final String DECODER_PRODUCTID = "productID"; // NOI18N 083 public static final String DECODER_FAMILY = "decoderfamily"; // NOI18N 084 public static final String DECODER_COMMENT = "decodercomment"; // NOI18N 085 public static final String DECODER_MAXFNNUM = "decodermaxFnNum"; // NOI18N 086 public static final String DEFAULT_MAXFNNUM = "28"; // NOI18N 087 public static final String IMAGE_FILE_PATH = "imagefilepath"; // NOI18N 088 public static final String ICON_FILE_PATH = "iconfilepath"; // NOI18N 089 public static final String URL = "url"; // NOI18N 090 public static final String DATE_UPDATED = "dateupdated"; // NOI18N 091 public static final String FUNCTION_IMAGE = "functionImage"; // NOI18N 092 public static final String FUNCTION_LABEL = "functionlabel"; // NOI18N 093 public static final String FUNCTION_LOCKABLE = "functionLockable"; // NOI18N 094 public static final String FUNCTION_SELECTED_IMAGE = "functionSelectedImage"; // NOI18N 095 public static final String ATTRIBUTE_UPDATED = "attributeUpdated:"; // NOI18N 096 public static final String ATTRIBUTE_DELETED = "attributeDeleted"; // NOI18N 097 public static final String MAX_SPEED = "maxSpeed"; // NOI18N 098 public static final String SHUNTING_FUNCTION = "IsShuntingOn"; // NOI18N 099 public static final String SPEED_PROFILE = "speedprofile"; // NOI18N 100 public static final String SOUND_LABEL = "soundlabel"; // NOI18N 101 public static final String ATTRIBUTE_OPERATING_DURATION = "OperatingDuration"; // NOI18N 102 public static final String ATTRIBUTE_LAST_OPERATED = "LastOperated"; // NOI18N 103 104 // members to remember all the info 105 protected String _fileName = null; 106 107 protected String _id = ""; 108 protected String _roadName = ""; 109 protected String _roadNumber = ""; 110 protected String _mfg = ""; 111 protected String _owner = ""; 112 protected String _model = ""; 113 protected String _dccAddress = "3"; 114 protected LocoAddress.Protocol _protocol = LocoAddress.Protocol.DCC_SHORT; 115 protected String _comment = ""; 116 protected String _decoderModel = ""; 117 protected String _decoderFamily = ""; 118 protected String _decoderComment = ""; 119 protected String _maxFnNum = DEFAULT_MAXFNNUM; 120 protected String _dateUpdated = ""; 121 protected Date dateModified = null; 122 protected int _maxSpeedPCT = 100; 123 protected String _developerID = ""; 124 protected String _manufacturerID = ""; 125 protected String _productID = ""; 126 127 /** 128 * Get the highest valid Fn key number for this roster entry. 129 * <dl> 130 * <dt>The default value (28) can be overridden by a "maxFnNum" attribute in 131 * the "model" element of a decoder definition file</dt> 132 * <dd><ul> 133 * <li>A European standard (RCN-212) extends NMRA S9.2.1 up to F68.</li> 134 * <li>ESU LokSound 5 already uses up to F31.</li> 135 * </ul></dd> 136 * </dl> 137 * 138 * @return the highest function number (Fn) supported by this roster entry. 139 * 140 * @see "http://normen.railcommunity.de/RCN-212.pdf" 141 */ 142 public int getMaxFnNumAsInt() { 143 return Integer.parseInt(getMaxFnNum()); 144 } 145 146 protected Map<Integer, String> functionLabels; 147 protected Map<Integer, String> soundLabels; 148 protected Map<Integer, String> functionSelectedImages; 149 protected Map<Integer, String> functionImages; 150 protected Map<Integer, Boolean> functionLockables; 151 protected String _isShuntingOn = ""; 152 153 protected final TreeMap<String, String> attributePairs = new TreeMap<>(); 154 155 protected String _imageFilePath = null; 156 protected String _iconFilePath = null; 157 protected String _URL = ""; 158 159 protected RosterSpeedProfile _sp = null; 160 161 /** 162 * Construct a blank object. 163 */ 164 public RosterEntry() { 165 functionLabels = Collections.synchronizedMap(new HashMap<>()); 166 soundLabels = Collections.synchronizedMap(new HashMap<>()); 167 functionSelectedImages = Collections.synchronizedMap(new HashMap<>()); 168 functionImages = Collections.synchronizedMap(new HashMap<>()); 169 functionLockables = Collections.synchronizedMap(new HashMap<>()); 170 } 171 172 /** 173 * Constructor based on a given file name. 174 * 175 * @param fileName xml file name for the user's Roster entry 176 */ 177 public RosterEntry(String fileName) { 178 this(); 179 _fileName = fileName; 180 } 181 182 /** 183 * Constructor based on a given RosterEntry object and name/ID. 184 * 185 * @param pEntry RosterEntry object 186 * @param pID unique name/ID for the roster entry 187 */ 188 public RosterEntry(RosterEntry pEntry, String pID) { 189 this(); 190 // The ID is different for this element 191 _id = pID; 192 193 // The filename is not set here, rather later 194 _fileName = null; 195 196 // All other items are copied 197 _roadName = pEntry._roadName; 198 _roadNumber = pEntry._roadNumber; 199 _mfg = pEntry._mfg; 200 _model = pEntry._model; 201 _dccAddress = pEntry._dccAddress; 202 _protocol = pEntry._protocol; 203 _comment = pEntry._comment; 204 _decoderModel = pEntry._decoderModel; 205 _decoderFamily = pEntry._decoderFamily; 206 _developerID = pEntry._developerID; 207 _manufacturerID = pEntry._manufacturerID; 208 _productID = pEntry._productID; 209 _decoderComment = pEntry._decoderComment; 210 _owner = pEntry._owner; 211 _imageFilePath = pEntry._imageFilePath; 212 _iconFilePath = pEntry._iconFilePath; 213 _URL = pEntry._URL; 214 _maxSpeedPCT = pEntry._maxSpeedPCT; 215 _isShuntingOn = pEntry._isShuntingOn; 216 217 if (pEntry.functionLabels != null) { 218 pEntry.functionLabels.forEach((key, value) -> { 219 if (value != null) { 220 functionLabels.put(key, value); 221 } 222 }); 223 } 224 if (pEntry.soundLabels != null) { 225 pEntry.soundLabels.forEach((key, value) -> { 226 if (value != null) { 227 soundLabels.put(key, value); 228 } 229 }); 230 } 231 if (pEntry.functionSelectedImages != null) { 232 pEntry.functionSelectedImages.forEach((key, value) -> { 233 if (value != null) { 234 functionSelectedImages.put(key, value); 235 } 236 }); 237 } 238 if (pEntry.functionImages != null) { 239 pEntry.functionImages.forEach((key, value) -> { 240 if (value != null) { 241 functionImages.put(key, value); 242 } 243 }); 244 } 245 if (pEntry.functionLockables != null) { 246 pEntry.functionLockables.forEach((key, value) -> { 247 if (value != null) { 248 functionLockables.put(key, value); 249 } 250 }); 251 } 252 } 253 254 /** 255 * Set the roster ID for this roster entry. 256 * 257 * @param s new ID 258 */ 259 public void setId(String s) { 260 String oldID = _id; 261 _id = s; 262 if (oldID == null || !oldID.equals(s)) { 263 firePropertyChange(RosterEntry.ID, oldID, s); 264 } 265 } 266 267 @Override 268 public String getId() { 269 return _id; 270 } 271 272 /** 273 * Set the file name for this roster entry. 274 * 275 * @param s the new roster entry file name 276 */ 277 public void setFileName(String s) { 278 String oldName = _fileName; 279 _fileName = s; 280 firePropertyChange(RosterEntry.FILENAME, oldName, s); 281 } 282 283 public String getFileName() { 284 return _fileName; 285 } 286 287 public String getPathName() { 288 return Roster.getDefault().getRosterFilesLocation() + _fileName; 289 } 290 291 /** 292 * Ensure the entry has a valid filename. 293 * <p> 294 * If none exists, create one based on the ID string. Does _not_ enforce any 295 * particular naming; you have to check separately for {@literal "<none>"} 296 * or whatever your convention is for indicating an invalid name. Does 297 * replace the space, period, colon, slash and backslash characters so that 298 * the filename will be generally usable. 299 */ 300 public void ensureFilenameExists() { 301 // if there isn't a filename, store using the id 302 if (getFileName() == null || getFileName().isEmpty()) { 303 304 String newFilename = Roster.makeValidFilename(getId()); 305 306 // we don't want to overwrite a file that exists, whether or not 307 // it's in the roster 308 File testFile = new File(Roster.getDefault().getRosterFilesLocation() + newFilename); 309 int count = 0; 310 String oldFilename = newFilename; 311 while (testFile.exists()) { 312 // oops - change filename and try again 313 newFilename = oldFilename.substring(0, oldFilename.length() - 4) + count + ".xml"; 314 count++; 315 log.debug("try to use {} as filename instead of {}", newFilename, oldFilename); 316 testFile = new File(Roster.getDefault().getRosterFilesLocation() + newFilename); 317 } 318 setFileName(newFilename); 319 log.debug("new filename: {}", getFileName()); 320 } 321 } 322 323 public void setRoadName(String s) { 324 String old = _roadName; 325 _roadName = s; 326 firePropertyChange(RosterEntry.ROADNAME, old, s); 327 } 328 329 public String getRoadName() { 330 return _roadName; 331 } 332 333 public void setRoadNumber(String s) { 334 String old = _roadNumber; 335 _roadNumber = s; 336 firePropertyChange(RosterEntry.ROADNAME, old, s); 337 } 338 339 public String getRoadNumber() { 340 return _roadNumber; 341 } 342 343 public void setMfg(String s) { 344 String old = _mfg; 345 _mfg = s; 346 firePropertyChange(RosterEntry.MFG, old, s); 347 } 348 349 public String getMfg() { 350 return _mfg; 351 } 352 353 public void setModel(String s) { 354 String old = _model; 355 _model = s; 356 firePropertyChange(RosterEntry.MODEL, old, s); 357 } 358 359 public String getModel() { 360 return _model; 361 } 362 363 public void setOwner(String s) { 364 String old = _owner; 365 _owner = s; 366 firePropertyChange(RosterEntry.OWNER, old, s); 367 } 368 369 public String getOwner() { 370 if (_owner.isEmpty()) { 371 RosterConfigManager manager = InstanceManager.getNullableDefault(RosterConfigManager.class); 372 if (manager != null) { 373 _owner = manager.getDefaultOwner(); 374 } 375 } 376 return _owner; 377 } 378 379 public void setDccAddress(String s) { 380 String old = _dccAddress; 381 _dccAddress = s; 382 firePropertyChange(RosterEntry.DCC_ADDRESS, old, s); 383 } 384 385 @Override 386 public String getDccAddress() { 387 return _dccAddress; 388 } 389 390 public void setLongAddress(boolean b) { 391 boolean old = false; 392 if (_protocol == LocoAddress.Protocol.DCC_LONG) { 393 old = true; 394 } 395 if (b) { 396 _protocol = LocoAddress.Protocol.DCC_LONG; 397 } else { 398 _protocol = LocoAddress.Protocol.DCC_SHORT; 399 } 400 firePropertyChange(RosterEntry.LONG_ADDRESS, old, b); 401 } 402 403 public RosterSpeedProfile getSpeedProfile() { 404 return _sp; 405 } 406 407 public void setSpeedProfile(RosterSpeedProfile sp) { 408 if (sp.getRosterEntry() != this) { 409 log.error("Attempting to set a speed profile against the wrong roster entry"); 410 return; 411 } 412 RosterSpeedProfile old = this._sp; 413 _sp = sp; 414 this.firePropertyChange(RosterEntry.SPEED_PROFILE, old, this._sp); 415 } 416 417 @Override 418 public boolean isLongAddress() { 419 return _protocol == LocoAddress.Protocol.DCC_LONG; 420 } 421 422 public void setProtocol(LocoAddress.Protocol protocol) { 423 LocoAddress.Protocol old = _protocol; 424 _protocol = protocol; 425 firePropertyChange(RosterEntry.PROTOCOL, old, _protocol); 426 } 427 428 public LocoAddress.Protocol getProtocol() { 429 return _protocol; 430 } 431 432 public String getProtocolAsString() { 433 return _protocol.getPeopleName(); 434 } 435 436 public void setComment(String s) { 437 String old = _comment; 438 _comment = s; 439 firePropertyChange(RosterEntry.COMMENT, old, s); 440 } 441 442 public String getComment() { 443 return _comment; 444 } 445 446 public void setDecoderModel(String s) { 447 String old = _decoderModel; 448 _decoderModel = s; 449 firePropertyChange(RosterEntry.DECODER_MODEL, old, s); 450 } 451 452 public String getDecoderModel() { 453 return _decoderModel; 454 } 455 456 public void setDeveloperID(String s) { 457 String old = _developerID; 458 _developerID = s; 459 firePropertyChange(DECODER_DEVELOPERID, old, s); 460 } 461 462 public String getDeveloperID() { 463 return _developerID; 464 } 465 466 public void setManufacturerID(String s) { 467 String old = _manufacturerID; 468 _manufacturerID = s; 469 firePropertyChange(DECODER_MANUFACTURERID, old, s); 470 } 471 472 public String getManufacturerID() { 473 return _manufacturerID; 474 } 475 476 public void setProductID(String s) { 477 String old = _productID; 478 if (s == null) {s="";} 479 _productID = s; 480 firePropertyChange(DECODER_PRODUCTID, old, s); 481 } 482 483 public String getProductID() { 484 return _productID; 485 } 486 487 public void setDecoderFamily(String s) { 488 String old = _decoderFamily; 489 _decoderFamily = s; 490 firePropertyChange(RosterEntry.DECODER_FAMILY, old, s); 491 } 492 493 public String getDecoderFamily() { 494 return _decoderFamily; 495 } 496 497 public void setDecoderComment(String s) { 498 String old = _decoderComment; 499 _decoderComment = s; 500 firePropertyChange(RosterEntry.DECODER_COMMENT, old, s); 501 } 502 503 public String getDecoderComment() { 504 return _decoderComment; 505 } 506 507 public void setMaxFnNum(String s) { 508 String old = _maxFnNum; 509 _maxFnNum = s; 510 firePropertyChange(RosterEntry.DECODER_MAXFNNUM, old, s); 511 } 512 513 public String getMaxFnNum() { 514 return _maxFnNum; 515 } 516 517 @Override 518 public DccLocoAddress getDccLocoAddress() { 519 int n; 520 try { 521 n = Integer.parseInt(getDccAddress()); 522 } catch (NumberFormatException e) { 523 log.error("Illegal format for DCC address roster entry: \"{}\" value: \"{}\"", getId(), getDccAddress()); 524 n = 0; 525 } 526 return new DccLocoAddress(n, _protocol); 527 } 528 529 public void setImagePath(String s) { 530 String old = _imageFilePath; 531 _imageFilePath = s; 532 firePropertyChange(RosterEntry.IMAGE_FILE_PATH, old, s); 533 } 534 535 public String getImagePath() { 536 return _imageFilePath; 537 } 538 539 public void setIconPath(String s) { 540 String old = _iconFilePath; 541 _iconFilePath = s; 542 firePropertyChange(RosterEntry.ICON_FILE_PATH, old, s); 543 } 544 545 public String getIconPath() { 546 return _iconFilePath; 547 } 548 549 public void setShuntingFunction(String fn) { 550 String old = this._isShuntingOn; 551 _isShuntingOn = fn; 552 this.firePropertyChange(RosterEntry.SHUNTING_FUNCTION, old, this._isShuntingOn); 553 } 554 555 @Override 556 public String getShuntingFunction() { 557 return _isShuntingOn; 558 } 559 560 public void setURL(String s) { 561 String old = _URL; 562 _URL = s; 563 firePropertyChange(RosterEntry.URL, old, s); 564 } 565 566 public String getURL() { 567 return _URL; 568 } 569 570 public void setDateModified(@Nonnull Date date) { 571 Date old = this.dateModified; 572 this.dateModified = date; 573 this.firePropertyChange(RosterEntry.DATE_UPDATED, old, date); 574 } 575 576 /** 577 * Set the date modified given a string representing a date. 578 * <p> 579 * Tries ISO 8601 and the current Java defaults as formats for parsing a 580 * date. 581 * 582 * @param date the string to parse into a date 583 * @throws ParseException if the date cannot be parsed 584 */ 585 public void setDateModified(@Nonnull String date) throws ParseException { 586 try { 587 // parse using ISO 8601 date format(s) 588 setDateModified(new StdDateFormat().parse(date)); 589 } catch (ParseException ex) { 590 log.debug("ParseException in setDateModified ISO attempt: \"{}\"", date); 591 // next, try parse using defaults since thats how it was saved if saved 592 // by earlier versions of JMRI 593 try { 594 setDateModified(DateFormat.getDateTimeInstance().parse(date)); 595 } catch (ParseException ex2) { 596 // then try with a specific format to handle e.g. "Apr 1, 2016 9:13:36 AM" 597 DateFormat customFmt = new SimpleDateFormat("MMM dd, yyyy hh:mm:ss a"); 598 try { 599 setDateModified(customFmt.parse(date)); 600 } catch (ParseException ex3) { 601 // then try with a specific format to handle e.g. "01-Oct-2016 9:13:36" 602 customFmt = new SimpleDateFormat("dd-MMM-yyyy hh:mm:ss"); 603 setDateModified(customFmt.parse(date)); 604 } 605 } 606 } catch (IllegalArgumentException ex2) { 607 // warn that there's perhaps something wrong with the classpath 608 log.error( 609 "IllegalArgumentException in RosterEntry.setDateModified - this may indicate a problem with the classpath, specifically multiple copies of the 'jackson` library. See release notes"); 610 // parse using defaults since thats how it was saved if saved 611 // by earlier versions of JMRI 612 this.setDateModified(DateFormat.getDateTimeInstance().parse(date)); 613 } 614 } 615 616 @CheckForNull 617 public Date getDateModified() { 618 return this.dateModified; 619 } 620 621 /** 622 * Set the date last updated. 623 * 624 * @param s the string to parse into a date 625 */ 626 protected void setDateUpdated(String s) { 627 String old = _dateUpdated; 628 _dateUpdated = s; 629 try { 630 this.setDateModified(s); 631 } catch (ParseException ex) { 632 log.warn("Unable to parse \"{}\" as a date in roster entry \"{}\".", s, getId()); 633 // property change is fired by setDateModified if s parses as a date 634 firePropertyChange(RosterEntry.DATE_UPDATED, old, s); 635 } 636 } 637 638 /** 639 * Get the date this entry was last modified. Returns the value of 640 * {@link #getDateModified()} in ISO 8601 format if that is not null, 641 * otherwise returns the raw value for the last modified date from the XML 642 * file for the roster entry. 643 * <p> 644 * Use getDateModified() if control over formatting is required 645 * 646 * @return the string representation of the date last modified 647 */ 648 public String getDateUpdated() { 649 Date date = this.getDateModified(); 650 if (date == null) { 651 return _dateUpdated; 652 } else { 653 return new StdDateFormat().format(date); 654 } 655 } 656 657 //openCounter is used purely to indicate if the roster entry has been opened in an editing mode. 658 int openCounter = 0; 659 660 @Override 661 public void setOpen(boolean boo) { 662 if (boo) { 663 openCounter++; 664 } else { 665 openCounter--; 666 } 667 if (openCounter < 0) { 668 openCounter = 0; 669 } 670 } 671 672 @Override 673 public boolean isOpen() { 674 return openCounter != 0; 675 } 676 677 /** 678 * Construct this Entry from XML. 679 * <p> 680 * This member has to remain synchronized with the detailed schema in 681 * xml/schema/locomotive-config.xsd. 682 * 683 * @param e Locomotive XML element 684 */ 685 public RosterEntry(Element e) { 686 functionLabels = Collections.synchronizedMap(new HashMap<>()); 687 soundLabels = Collections.synchronizedMap(new HashMap<>()); 688 functionSelectedImages = Collections.synchronizedMap(new HashMap<>()); 689 functionImages = Collections.synchronizedMap(new HashMap<>()); 690 functionLockables = Collections.synchronizedMap(new HashMap<>()); 691 if (log.isDebugEnabled()) { 692 log.debug("ctor from element {}", e); 693 } 694 Attribute a; 695 if ((a = e.getAttribute("id")) != null) { 696 _id = a.getValue(); 697 } else { 698 log.warn("no id attribute in locomotive element when reading roster"); 699 } 700 if ((a = e.getAttribute("fileName")) != null) { 701 _fileName = a.getValue(); 702 } 703 if ((a = e.getAttribute("roadName")) != null) { 704 _roadName = a.getValue(); 705 } 706 if ((a = e.getAttribute("roadNumber")) != null) { 707 _roadNumber = a.getValue(); 708 } 709 if ((a = e.getAttribute("owner")) != null) { 710 _owner = a.getValue(); 711 } 712 if ((a = e.getAttribute("mfg")) != null) { 713 _mfg = a.getValue(); 714 } 715 if ((a = e.getAttribute("model")) != null) { 716 _model = a.getValue(); 717 } 718 if ((a = e.getAttribute("dccAddress")) != null) { 719 _dccAddress = a.getValue(); 720 } 721 722 // file path was saved without default xml config path 723 if ((a = e.getAttribute("imageFilePath")) != null && !a.getValue().isEmpty()) { 724 try { 725 if (FileUtil.getFile(a.getValue()).isFile()) { 726 _imageFilePath = FileUtil.getAbsoluteFilename(a.getValue()); 727 } 728 } catch (FileNotFoundException ex) { 729 try { 730 if (FileUtil.getFile(FileUtil.getUserResourcePath() + a.getValue()).isFile()) { 731 _imageFilePath = FileUtil.getUserResourcePath() + a.getValue(); 732 } 733 } catch (FileNotFoundException ex1) { 734 _imageFilePath = null; 735 } 736 } 737 } 738 if ((a = e.getAttribute("iconFilePath")) != null && !a.getValue().isEmpty()) { 739 try { 740 if (FileUtil.getFile(a.getValue()).isFile()) { 741 _iconFilePath = FileUtil.getAbsoluteFilename(a.getValue()); 742 } 743 } catch (FileNotFoundException ex) { 744 try { 745 if (FileUtil.getFile(FileUtil.getUserResourcePath() + a.getValue()).isFile()) { 746 _iconFilePath = FileUtil.getUserResourcePath() + a.getValue(); 747 } 748 } catch (FileNotFoundException ex1) { 749 _iconFilePath = null; 750 } 751 } 752 } 753 if ((a = e.getAttribute("URL")) != null) { 754 _URL = a.getValue(); 755 } 756 if ((a = e.getAttribute(RosterEntry.SHUNTING_FUNCTION)) != null) { 757 _isShuntingOn = a.getValue(); 758 } 759 if ((a = e.getAttribute(RosterEntry.MAX_SPEED)) != null) { 760 _maxSpeedPCT = Integer.parseInt(a.getValue()); 761 } 762 763 if ((a = e.getAttribute(DECODER_DEVELOPERID)) != null) { 764 _developerID = a.getValue(); 765 } 766 767 if ((a = e.getAttribute(DECODER_MANUFACTURERID)) != null) { 768 _manufacturerID = a.getValue(); 769 } 770 771 if ((a = e.getAttribute(DECODER_PRODUCTID)) != null) { 772 _productID = a.getValue(); 773 } 774 775 Element e3; 776 if ((e3 = e.getChild("dateUpdated")) != null) { 777 this.setDateUpdated(e3.getText()); 778 } 779 if ((e3 = e.getChild("locoaddress")) != null) { 780 DccLocoAddress la = (DccLocoAddress) ((new jmri.configurexml.LocoAddressXml()).getAddress(e3)); 781 if (la != null) { 782 _dccAddress = "" + la.getNumber(); 783 _protocol = la.getProtocol(); 784 } else { 785 _dccAddress = ""; 786 _protocol = LocoAddress.Protocol.DCC_SHORT; 787 } 788 } else {// Did not find "locoaddress" element carrying the short/long, probably 789 // because this is an older-format file, so try to use system default. 790 // This is generally the best we can do without parsing the decoder file now 791 // but may give the wrong answer in some cases (low value long addresses on NCE) 792 793 jmri.ThrottleManager tf = jmri.InstanceManager.getNullableDefault(jmri.ThrottleManager.class); 794 int address; 795 try { 796 address = Integer.parseInt(_dccAddress); 797 } catch (NumberFormatException e2) { 798 address = 3; 799 } // ignore, accepting the default value 800 if (tf != null && tf.canBeLongAddress(address) && !tf.canBeShortAddress(address)) { 801 // if it has to be long, handle that 802 _protocol = LocoAddress.Protocol.DCC_LONG; 803 } else if (tf != null && !tf.canBeLongAddress(address) && tf.canBeShortAddress(address)) { 804 // if it has to be short, handle that 805 _protocol = LocoAddress.Protocol.DCC_SHORT; 806 } else { 807 // else guess short address 808 // These people should resave their roster, so we'll warn them 809 warnShortLong(_id); 810 _protocol = LocoAddress.Protocol.DCC_SHORT; 811 812 } 813 } 814 if ((a = e.getAttribute("comment")) != null) { 815 _comment = a.getValue(); 816 } 817 Element d = e.getChild("decoder"); 818 if (d != null) { 819 if ((a = d.getAttribute("model")) != null) { 820 _decoderModel = a.getValue(); 821 } 822 if ((a = d.getAttribute("family")) != null) { 823 _decoderFamily = a.getValue(); 824 } 825 if ((a = d.getAttribute(DECODER_DEVELOPERID)) != null) { 826 _developerID = a.getValue(); 827 } 828 if ((a = d.getAttribute(DECODER_MANUFACTURERID)) != null) { 829 _manufacturerID = a.getValue(); 830 } 831 if ((a = d.getAttribute(DECODER_PRODUCTID)) != null) { 832 _productID = a.getValue(); 833 } 834 if ((a = d.getAttribute("comment")) != null) { 835 _decoderComment = a.getValue(); 836 } 837 if ((a = d.getAttribute("maxFnNum")) != null) { 838 _maxFnNum = a.getValue(); 839 } 840 } 841 842 loadFunctions(e.getChild("functionlabels"), "RosterEntry"); 843 loadSounds(e.getChild("soundlabels"), "RosterEntry"); 844 loadAttributes(e.getChild("attributepairs")); 845 846 if (e.getChild(RosterEntry.SPEED_PROFILE) != null) { 847 _sp = new RosterSpeedProfile(this); 848 _sp.load(e.getChild(RosterEntry.SPEED_PROFILE)); 849 } 850 851 } 852 853 boolean loadedOnce = false; 854 855 /** 856 * Load function names from a JDOM element. 857 * <p> 858 * Does not change values that are already present! 859 * 860 * @param e3 the XML element containing functions 861 */ 862 public void loadFunctions(Element e3) { 863 this.loadFunctions(e3, "family"); 864 } 865 866 /** 867 * Loads function names from a JDOM element. Does not change values that are 868 * already present! 869 * 870 * @param e3 the XML element containing the functions 871 * @param source "family" if source is the decoder definition, or "model" if 872 * source is the roster entry itself 873 */ 874 public void loadFunctions(Element e3, String source) { 875 /* 876 * Load flag once, means that when the roster entry is edited only the 877 * first set of function labels are displayed ie those saved in the 878 * roster file, rather than those being left blank rather than being 879 * over-written by the defaults linked to the decoder def 880 */ 881 if (loadedOnce) { 882 return; 883 } 884 if (e3 != null) { 885 // load function names 886 List<Element> l = e3.getChildren(RosterEntry.FUNCTION_LABEL); 887 for (Element fn : l) { 888 int num = Integer.parseInt(fn.getAttribute("num").getValue()); 889 String lock = fn.getAttribute("lockable").getValue(); 890 String val = LocaleSelector.getAttribute(fn, "text"); 891 if (val == null) { 892 val = fn.getText(); 893 } 894 if ((this.getFunctionLabel(num) == null) || (source.equalsIgnoreCase("model"))) { 895 this.setFunctionLabel(num, val); 896 this.setFunctionLockable(num, lock.equals("true")); 897 Attribute a; 898 if ((a = fn.getAttribute("functionImage")) != null && !a.getValue().isEmpty()) { 899 try { 900 if (FileUtil.getFile(a.getValue()).isFile()) { 901 this.setFunctionImage(num, FileUtil.getAbsoluteFilename(a.getValue())); 902 } 903 } catch (FileNotFoundException ex) { 904 try { 905 if (FileUtil.getFile(FileUtil.getUserResourcePath() + a.getValue()).isFile()) { 906 this.setFunctionImage(num, FileUtil.getUserResourcePath() + a.getValue()); 907 } 908 } catch (FileNotFoundException ex1) { 909 this.setFunctionImage(num, null); 910 } 911 } 912 } 913 if ((a = fn.getAttribute("functionImageSelected")) != null && !a.getValue().isEmpty()) { 914 try { 915 if (FileUtil.getFile(a.getValue()).isFile()) { 916 this.setFunctionSelectedImage(num, FileUtil.getAbsoluteFilename(a.getValue())); 917 } 918 } catch (FileNotFoundException ex) { 919 try { 920 if (FileUtil.getFile(FileUtil.getUserResourcePath() + a.getValue()).isFile()) { 921 this.setFunctionSelectedImage(num, FileUtil.getUserResourcePath() + a.getValue()); 922 } 923 } catch (FileNotFoundException ex1) { 924 this.setFunctionSelectedImage(num, null); 925 } 926 } 927 } 928 } 929 } 930 } 931 if (source.equalsIgnoreCase("RosterEntry")) { 932 loadedOnce = true; 933 } 934 } 935 936 boolean soundLoadedOnce = false; 937 938 /** 939 * Loads sound names from a JDOM element. Does not change values that are 940 * already present! 941 * 942 * @param e3 the XML element containing sound names 943 * @param source "family" if source is the decoder definition, or "model" if 944 * source is the roster entry itself 945 */ 946 public void loadSounds(Element e3, String source) { 947 /* 948 * Load flag once, means that when the roster entry is edited only the 949 * first set of sound labels are displayed ie those saved in the roster 950 * file, rather than those being left blank rather than being 951 * over-written by the defaults linked to the decoder def 952 */ 953 if (soundLoadedOnce) { 954 return; 955 } 956 if (e3 != null) { 957 // load sound names 958 List<Element> l = e3.getChildren(RosterEntry.SOUND_LABEL); 959 for (Element fn : l) { 960 int num = Integer.parseInt(fn.getAttribute("num").getValue()); 961 String val = LocaleSelector.getAttribute(fn, "text"); 962 if (val == null) { 963 val = fn.getText(); 964 } 965 if ((this.getSoundLabel(num) == null) || (source.equalsIgnoreCase("model"))) { 966 this.setSoundLabel(num, val); 967 } 968 } 969 } 970 if (source.equalsIgnoreCase("RosterEntry")) { 971 soundLoadedOnce = true; 972 } 973 } 974 975 /** 976 * Load attribute key/value pairs from a JDOM element. 977 * 978 * @param e3 XML element containing roster entry attributes 979 */ 980 public void loadAttributes(Element e3) { 981 if (e3 != null) { 982 List<Element> l = e3.getChildren("keyvaluepair"); 983 for (Element fn : l) { 984 String key = fn.getChild("key").getText(); 985 String value = fn.getChild("value").getText(); 986 this.putAttribute(key, value); 987 } 988 } 989 } 990 991 /** 992 * Set the label for a specific function. 993 * 994 * @param fn function number, starting with 0 995 * @param label the label to use 996 */ 997 public void setFunctionLabel(int fn, String label) { 998 if (functionLabels == null) { 999 functionLabels = Collections.synchronizedMap(new HashMap<>()); 1000 } 1001 String old = functionLabels.get(fn); 1002 functionLabels.put(fn, label); 1003 this.firePropertyChange(RosterEntry.FUNCTION_LABEL + fn, old, label); 1004 } 1005 1006 /** 1007 * If a label has been defined for a specific function, return it, otherwise 1008 * return null. 1009 * 1010 * @param fn function number, starting with 0 1011 * @return function label or null if not defined 1012 */ 1013 public String getFunctionLabel(int fn) { 1014 if (functionLabels == null) { 1015 return null; 1016 } 1017 return functionLabels.get(fn); 1018 } 1019 1020 /** 1021 * Define label for a specific sound. 1022 * 1023 * @param fn sound number, starting with 0 1024 * @param label display label for the sound function 1025 */ 1026 public void setSoundLabel(int fn, String label) { 1027 if (soundLabels == null) { 1028 soundLabels = Collections.synchronizedMap(new HashMap<>()); 1029 } 1030 String old = soundLabels.get(fn); 1031 soundLabels.put(fn, label); 1032 this.firePropertyChange(RosterEntry.SOUND_LABEL + fn, old, label); 1033 } 1034 1035 /** 1036 * If a label has been defined for a specific sound, return it, otherwise 1037 * return null. 1038 * 1039 * @param fn sound number, starting with 0 1040 * @return sound label or null 1041 */ 1042 public String getSoundLabel(int fn) { 1043 if (soundLabels == null) { 1044 return null; 1045 } 1046 return soundLabels.get(fn); 1047 } 1048 1049 public void setFunctionImage(int fn, String s) { 1050 if (functionImages == null) { 1051 functionImages = Collections.synchronizedMap(new HashMap<>()); 1052 } 1053 String old = functionImages.get(fn); 1054 functionImages.put(fn, s); 1055 firePropertyChange(RosterEntry.FUNCTION_IMAGE + fn, old, s); 1056 } 1057 1058 public String getFunctionImage(int fn) { 1059 if (functionImages == null) { 1060 return null; 1061 } 1062 return functionImages.get(fn); 1063 } 1064 1065 public void setFunctionSelectedImage(int fn, String s) { 1066 if (functionSelectedImages == null) { 1067 functionSelectedImages = Collections.synchronizedMap(new HashMap<>()); 1068 } 1069 String old = functionSelectedImages.get(fn); 1070 functionSelectedImages.put(fn, s); 1071 firePropertyChange(RosterEntry.FUNCTION_SELECTED_IMAGE + fn, old, s); 1072 } 1073 1074 public String getFunctionSelectedImage(int fn) { 1075 if (functionSelectedImages == null) { 1076 return null; 1077 } 1078 return functionSelectedImages.get(fn); 1079 } 1080 1081 /** 1082 * Define whether a specific function is lockable. 1083 * 1084 * @param fn function number, starting with 0 1085 * @param lockable true if function is continuous; false if momentary 1086 */ 1087 public void setFunctionLockable(int fn, boolean lockable) { 1088 if (functionLockables == null) { 1089 functionLockables = Collections.synchronizedMap(new HashMap<>()); 1090 functionLockables.put(fn, true); 1091 } 1092 boolean old = ((functionLockables.get(fn) != null) ? functionLockables.get(fn) : true); 1093 functionLockables.put(fn, lockable); 1094 this.firePropertyChange(RosterEntry.FUNCTION_LOCKABLE + fn, old, lockable); 1095 } 1096 1097 /** 1098 * Return the lockable state of a specific function. Defaults to true. 1099 * 1100 * @param fn function number, starting with 0 1101 * @return true if function is lockable 1102 */ 1103 public boolean getFunctionLockable(int fn) { 1104 if (functionLockables == null) { 1105 return true; 1106 } 1107 return ((functionLockables.get(fn) != null) ? functionLockables.get(fn) : true); 1108 } 1109 1110 @Override 1111 public void putAttribute(String key, String value) { 1112 String oldValue = getAttribute(key); 1113 attributePairs.put(key, value); 1114 firePropertyChange(RosterEntry.ATTRIBUTE_UPDATED + key, oldValue, value); 1115 } 1116 1117 @Override 1118 public String getAttribute(String key) { 1119 return attributePairs.get(key); 1120 } 1121 1122 @Override 1123 public void deleteAttribute(String key) { 1124 if (attributePairs.containsKey(key)) { 1125 attributePairs.remove(key); 1126 firePropertyChange(RosterEntry.ATTRIBUTE_DELETED, key, null); 1127 } 1128 } 1129 1130 /** 1131 * Provide access to the set of attributes. 1132 * <p> 1133 * This is directly backed access, so e.g. removing an item from this Set 1134 * removes it from the RosterEntry too. 1135 * 1136 * @return a set of attribute keys 1137 */ 1138 public java.util.Set<String> getAttributes() { 1139 return attributePairs.keySet(); 1140 } 1141 1142 @Override 1143 public String[] getAttributeList() { 1144 return attributePairs.keySet().toArray(new String[attributePairs.size()]); 1145 } 1146 1147 /** 1148 * List the roster groups this entry is a member of, returning existing 1149 * {@link jmri.jmrit.roster.rostergroup.RosterGroup}s from the default 1150 * {@link jmri.jmrit.roster.Roster} if they exist. 1151 * 1152 * @return list of roster groups 1153 */ 1154 public List<RosterGroup> getGroups() { 1155 return this.getGroups(Roster.getDefault()); 1156 } 1157 1158 /** 1159 * List the roster groups this entry is a member of, returning existing 1160 * {@link jmri.jmrit.roster.rostergroup.RosterGroup}s from the specified 1161 * {@link jmri.jmrit.roster.Roster} if they exist. 1162 * 1163 * @param roster the roster to get matching groups from 1164 * @return list of roster groups 1165 */ 1166 public List<RosterGroup> getGroups(Roster roster) { 1167 List<RosterGroup> groups = new ArrayList<>(); 1168 if (!this.getAttributes().isEmpty()) { 1169 for (String attribute : this.getAttributes()) { 1170 if (attribute.startsWith(Roster.ROSTER_GROUP_PREFIX)) { 1171 String name = attribute.substring(Roster.ROSTER_GROUP_PREFIX.length()); 1172 if (roster.getRosterGroups().containsKey(name)) { 1173 groups.add(roster.getRosterGroups().get(name)); 1174 } else { 1175 groups.add(new RosterGroup(name)); 1176 } 1177 } 1178 } 1179 } 1180 return groups; 1181 } 1182 1183 @Override 1184 public int getMaxSpeedPCT() { 1185 return _maxSpeedPCT; 1186 } 1187 1188 public void setMaxSpeedPCT(int maxSpeedPCT) { 1189 int old = this._maxSpeedPCT; 1190 _maxSpeedPCT = maxSpeedPCT; 1191 this.firePropertyChange(RosterEntry.MAX_SPEED, old, this._maxSpeedPCT); 1192 } 1193 1194 /** 1195 * Warn user that the roster entry needs to be resaved. 1196 * 1197 * @param id roster ID to warn about 1198 */ 1199 protected void warnShortLong(String id) { 1200 log.warn("Roster entry \"{}\" should be saved again to store the short/long address value", id); 1201 } 1202 1203 /** 1204 * Create an XML element to represent this Entry. 1205 * <p> 1206 * This member has to remain synchronized with the detailed schema in 1207 * xml/schema/locomotive-config.xsd. 1208 * 1209 * @return Contents in a JDOM Element 1210 */ 1211 @Override 1212 public Element store() { 1213 Element e = new Element("locomotive"); 1214 e.setAttribute("id", getId()); 1215 e.setAttribute("fileName", getFileName()); 1216 e.setAttribute("roadNumber", getRoadNumber()); 1217 e.setAttribute("roadName", getRoadName()); 1218 e.setAttribute("mfg", getMfg()); 1219 e.setAttribute("owner", getOwner()); 1220 e.setAttribute("model", getModel()); 1221 e.setAttribute("dccAddress", getDccAddress()); 1222 //e.setAttribute("protocol", "" + getProtocol()); 1223 e.setAttribute("comment", getComment()); 1224 e.setAttribute(DECODER_DEVELOPERID, getDeveloperID()); 1225 e.setAttribute(DECODER_MANUFACTURERID, getManufacturerID()); 1226 e.setAttribute(DECODER_PRODUCTID, getProductID()); 1227 e.setAttribute(RosterEntry.MAX_SPEED, (Integer.toString(getMaxSpeedPCT()))); 1228 // file path are saved without default xml config path 1229 e.setAttribute("imageFilePath", 1230 (this.getImagePath() != null) ? FileUtil.getPortableFilename(this.getImagePath()) : ""); 1231 e.setAttribute("iconFilePath", 1232 (this.getIconPath() != null) ? FileUtil.getPortableFilename(this.getIconPath()) : ""); 1233 e.setAttribute("URL", getURL()); 1234 e.setAttribute(RosterEntry.SHUNTING_FUNCTION, getShuntingFunction()); 1235 if (_dateUpdated.isEmpty()) { 1236 // set date updated to now if never set previously 1237 this.changeDateUpdated(); 1238 } 1239 e.addContent(new Element("dateUpdated").addContent(this.getDateUpdated())); 1240 Element d = new Element("decoder"); 1241 d.setAttribute("model", getDecoderModel()); 1242 d.setAttribute("family", getDecoderFamily()); 1243 d.setAttribute("comment", getDecoderComment()); 1244 d.setAttribute("maxFnNum", getMaxFnNum()); 1245 1246 e.addContent(d); 1247 if (_dccAddress.isEmpty()) { 1248 e.addContent((new jmri.configurexml.LocoAddressXml()).store(null)); // store a null address 1249 } else { 1250 e.addContent((new jmri.configurexml.LocoAddressXml()) 1251 .store(new DccLocoAddress(Integer.parseInt(_dccAddress), _protocol))); 1252 } 1253 1254 if (functionLabels != null) { 1255 Element s = new Element("functionlabels"); 1256 1257 // loop to copy non-null elements 1258 functionLabels.forEach((key, value) -> { 1259 if (value != null && !value.isEmpty()) { 1260 Element fne = new Element(RosterEntry.FUNCTION_LABEL); 1261 fne.setAttribute("num", "" + key); 1262 fne.setAttribute("lockable", getFunctionLockable(key) ? "true" : "false"); 1263 fne.setAttribute("functionImage", 1264 (getFunctionImage(key) != null) ? FileUtil.getPortableFilename(getFunctionImage(key)) : ""); 1265 fne.setAttribute("functionImageSelected", (getFunctionSelectedImage(key) != null) 1266 ? FileUtil.getPortableFilename(getFunctionSelectedImage(key)) : ""); 1267 fne.addContent(value); 1268 s.addContent(fne); 1269 } 1270 }); 1271 e.addContent(s); 1272 } 1273 1274 if (soundLabels != null) { 1275 Element s = new Element("soundlabels"); 1276 1277 // loop to copy non-null elements 1278 soundLabels.forEach((key, value) -> { 1279 if (value != null && !value.isEmpty()) { 1280 Element fne = new Element(RosterEntry.SOUND_LABEL); 1281 fne.setAttribute("num", "" + key); 1282 fne.addContent(value); 1283 s.addContent(fne); 1284 } 1285 }); 1286 e.addContent(s); 1287 } 1288 1289 if (!getAttributes().isEmpty()) { 1290 d = new Element("attributepairs"); 1291 for (String key : getAttributes()) { 1292 d.addContent(new Element("keyvaluepair") 1293 .addContent(new Element("key") 1294 .addContent(key)) 1295 .addContent(new Element("value") 1296 .addContent(getAttribute(key)))); 1297 } 1298 e.addContent(d); 1299 } 1300 if (_sp != null) { 1301 _sp.store(e); 1302 } 1303 return e; 1304 } 1305 1306 @Override 1307 public String titleString() { 1308 return getId(); 1309 } 1310 1311 @Override 1312 public String toString() { 1313 String out = "[RosterEntry: " 1314 + _id 1315 + " " 1316 + (_fileName != null ? _fileName : "<null>") 1317 + " " 1318 + _roadName 1319 + " " 1320 + _roadNumber 1321 + " " 1322 + _mfg 1323 + " " 1324 + _owner 1325 + " " 1326 + _model 1327 + " " 1328 + _dccAddress 1329 + " " 1330 + _comment 1331 + " " 1332 + _decoderModel 1333 + " " 1334 + _decoderFamily 1335 + " " 1336 + _developerID 1337 + " " 1338 + _manufacturerID 1339 + " " 1340 + _productID 1341 + " " 1342 + _decoderComment 1343 + "]"; 1344 return out; 1345 } 1346 1347 /** 1348 * Write the contents of this RosterEntry back to a file, preserving all 1349 * existing decoder CV content. 1350 * <p> 1351 * This writes the file back in place, with the same decoder-specific 1352 * content. 1353 */ 1354 public void updateFile() { 1355 LocoFile df = new LocoFile(); 1356 1357 String fullFilename = Roster.getDefault().getRosterFilesLocation() + getFileName(); 1358 1359 // read in the content 1360 try { 1361 mRootElement = df.rootFromName(fullFilename); 1362 } catch (JDOMException 1363 | IOException e) { 1364 log.error("Exception while loading loco XML file: {} exception", getFileName(), e); 1365 } 1366 1367 try { 1368 File f = new File(fullFilename); 1369 // do backup 1370 df.makeBackupFile(Roster.getDefault().getRosterFilesLocation() + getFileName()); 1371 1372 // and finally write the file 1373 df.writeFile(f, mRootElement, this.store()); 1374 1375 } catch (Exception e) { 1376 log.error("error during locomotive file output", e); 1377 try { 1378 JmriJOptionPane.showMessageDialog(null, 1379 Bundle.getMessage("ErrorSavingText") + "\n" 1380 + e.getMessage(), 1381 Bundle.getMessage("ErrorSavingTitle"), 1382 JmriJOptionPane.ERROR_MESSAGE); 1383 } catch (HeadlessException he) { 1384 // silently ignore inability to display dialog 1385 } 1386 } 1387 } 1388 1389 /** 1390 * Write the contents of this RosterEntry to a file. 1391 * <p> 1392 * Information on the contents is passed through the parameters, as the 1393 * actual XML creation is done in the LocoFile class. 1394 * 1395 * @param cvModel CV contents to include in file 1396 * @param variableModel Variable contents to include in file 1397 * 1398 */ 1399 public void writeFile(CvTableModel cvModel, VariableTableModel variableModel) { 1400 LocoFile df = new LocoFile(); 1401 1402 // do I/O 1403 FileUtil.createDirectory(Roster.getDefault().getRosterFilesLocation()); 1404 1405 try { 1406 String fullFilename = Roster.getDefault().getRosterFilesLocation() + getFileName(); 1407 File f = new File(fullFilename); 1408 // do backup 1409 df.makeBackupFile(Roster.getDefault().getRosterFilesLocation() + getFileName()); 1410 1411 // changed 1412 changeDateUpdated(); 1413 1414 // and finally write the file 1415 df.writeFile(f, cvModel, variableModel, this); 1416 1417 } catch (Exception e) { 1418 log.error("error during locomotive file output", e); 1419 try { 1420 JmriJOptionPane.showMessageDialog(null, 1421 Bundle.getMessage("ErrorSavingText") + "\n" 1422 + e.getMessage(), 1423 Bundle.getMessage("ErrorSavingTitle"), 1424 JmriJOptionPane.ERROR_MESSAGE); 1425 } catch (HeadlessException he) { 1426 // silently ignore inability to display dialog 1427 } 1428 } 1429 } 1430 1431 /** 1432 * Mark the date updated, e.g. from storing this roster entry. 1433 */ 1434 public void changeDateUpdated() { 1435 // used to create formatted string of now using defaults 1436 this.setDateModified(new Date()); 1437 } 1438 1439 /** 1440 * Store the root element of the JDOM tree representing this RosterEntry. 1441 */ 1442 private Element mRootElement = null; 1443 1444 /** 1445 * Load pre-existing Variable and CvTableModel object with the contents of 1446 * this entry. 1447 * 1448 * @param varModel the variable model to load 1449 * @param cvModel CV contents to load 1450 */ 1451 public void loadCvModel(VariableTableModel varModel, CvTableModel cvModel) { 1452 if (cvModel == null) { 1453 log.error("loadCvModel must be given a non-null argument"); 1454 return; 1455 } 1456 if (mRootElement == null) { 1457 log.error("loadCvModel called before readFile() succeeded"); 1458 return; 1459 } 1460 try { 1461 if (varModel != null) { 1462 LocoFile.loadVariableModel(mRootElement.getChild("locomotive"), varModel); 1463 } 1464 1465 LocoFile.loadCvModel(mRootElement.getChild("locomotive"), cvModel, getManufacturerID(), getDecoderFamily()); 1466 } catch (Exception ex) { 1467 log.error("Error reading roster entry", ex); 1468 try { 1469 JmriJOptionPane.showMessageDialog(null, 1470 Bundle.getMessage("ErrorReadingText") + "\n" + _fileName, 1471 Bundle.getMessage("ErrorReadingTitle"), 1472 JmriJOptionPane.ERROR_MESSAGE); 1473 } catch (HeadlessException he) { 1474 // silently ignore inability to display dialog 1475 } 1476 } 1477 } 1478 1479 /** 1480 * Ultra compact list view of roster entries. Shows text from fields as 1481 * initially visible in the Roster frame table. 1482 * <p> 1483 * Header is created in 1484 * {@link PrintListAction#actionPerformed(java.awt.event.ActionEvent)} so 1485 * keep column widths identical with values of colWidth below. 1486 * 1487 * @param w writer providing output 1488 */ 1489 public void printEntryLine(HardcopyWriter w) { 1490 // no image 1491 // @see #printEntryDetails(w); 1492 1493 try { 1494 //int textSpace = w.getCharactersPerLine() - 1; // could be used to truncate line. 1495 // for now, text just flows to next line 1496 String thisText; 1497 String thisLine = ""; 1498 1499 // start each entry on a new line 1500 w.write(newLine, 0, 1); 1501 1502 int colWidth = 15; 1503 // roster entry ID (not the filname) 1504 if (_id != null) { 1505 thisText = String.format("%-" + colWidth + "s", _id.substring(0, Math.min(_id.length(), colWidth))); // %- = left align 1506 log.debug("thisText = |{}|, length = {}", thisText, thisText.length()); 1507 } else { 1508 thisText = String.format("%-" + colWidth + "s", "<null>"); 1509 } 1510 thisLine += thisText; 1511 colWidth = 6; 1512 // _dccAddress 1513 thisLine += StringUtil.padString(_dccAddress, colWidth); 1514 colWidth = 6; 1515 // _roadName 1516 thisLine += StringUtil.padString(_roadName, colWidth); 1517 colWidth = 6; 1518 // _roadNumber 1519 thisLine += StringUtil.padString(_roadNumber, colWidth); 1520 colWidth = 6; 1521 // _mfg 1522 thisLine += StringUtil.padString(_mfg, colWidth); 1523 colWidth = 10; 1524 // _model 1525 thisLine += StringUtil.padString(_model, colWidth); 1526 colWidth = 10; 1527 // _decoderModel 1528 thisLine += StringUtil.padString(_decoderModel, colWidth); 1529 colWidth = 12; 1530 // _protocol (type) 1531 thisLine += StringUtil.padString(_protocol.toString(), colWidth); 1532 colWidth = 6; 1533 // _owner 1534 thisLine += StringUtil.padString(_owner, colWidth); 1535 colWidth = 10; 1536 1537 // dateModified (type) 1538 if (dateModified != null) { 1539 DateFormat.getDateTimeInstance().format(dateModified); 1540 thisText = String.format("%-" + colWidth + "s", 1541 dateModified.toString().substring(0, Math.min(dateModified.toString().length(), colWidth))); 1542 thisLine += thisText; 1543 } 1544 // don't include comment and decoder family 1545 1546 w.write(thisLine); 1547 // extra whitespace line after each entry would miss goal of a compact listing 1548 // w.write(newLine, 0, 1); 1549 } catch (IOException e) { 1550 log.error("Error printing RosterEntry: ", e); 1551 } 1552 } 1553 1554 public void printEntry(HardcopyWriter w) { 1555 if (getIconPath() != null) { 1556 ImageIcon icon = new ImageIcon(getIconPath()); 1557 // We use an ImageIcon because it's guaranteed to have been loaded when ctor is complete. 1558 // We set the imagesize to 150x150 pixels 1559 int imagesize = 150; 1560 1561 Image img = icon.getImage(); 1562 int width = img.getWidth(null); 1563 int height = img.getHeight(null); 1564 double widthratio = (double) width / imagesize; 1565 double heightratio = (double) height / imagesize; 1566 double ratio = Math.max(widthratio, heightratio); 1567 width = (int) (width / ratio); 1568 height = (int) (height / ratio); 1569 Image newImg = img.getScaledInstance(width, height, java.awt.Image.SCALE_SMOOTH); 1570 1571 ImageIcon newIcon = new ImageIcon(newImg); 1572 w.writeNoScale(newIcon.getImage(), new JLabel(newIcon)); 1573 // Work out the number of line approx that the image takes up. 1574 // We might need to pad some areas of the roster out, so that things 1575 // look correct and text doesn't overflow into the image. 1576 blanks = (newImg.getHeight(null) - w.getLineAscent()) / w.getLineHeight(); 1577 textSpaceWithIcon 1578 = w.getCharactersPerLine() - ((newImg.getWidth(null) / w.getCharWidth())) - indentWidth - 1; 1579 1580 } 1581 printEntryDetails(w); 1582 } 1583 1584 private int blanks = 0; 1585 private int textSpaceWithIcon = 0; 1586 String indent = " "; 1587 int indentWidth = indent.length(); 1588 String newLine = "\n"; 1589 1590 /** 1591 * Print the roster information. 1592 * <p> 1593 * Updated to allow for multiline comment and decoder comment fields. 1594 * Separate write statements for text and line feeds to work around the 1595 * HardcopyWriter bug that misplaces borders. 1596 * 1597 * @param w the HardcopyWriter used to print 1598 */ 1599 public void printEntryDetails(Writer w) { 1600 if (!(w instanceof HardcopyWriter)) { 1601 throw new IllegalArgumentException("No HardcopyWriter instance passed"); 1602 } 1603 int linesAdded = -1; 1604 String title; 1605 String leftMargin = " "; // 3 spaces in front of legend labels 1606 int labelColumn = 19; // pad remaining spaces for legend using fixed width font, forms "%-19s" in line 1607 try { 1608 HardcopyWriter ww = (HardcopyWriter) w; 1609 int textSpace = ww.getCharactersPerLine() - indentWidth - 1; 1610 title = String.format("%-" + labelColumn + "s", 1611 (Bundle.getMessage("MakeLabel", Bundle.getMessage("FieldID")))); // I18N ID: 1612 if ((textSpaceWithIcon != 0) && (linesAdded < blanks)) { 1613 linesAdded = writeWrappedComment(w, _id, leftMargin + title, textSpaceWithIcon) + linesAdded; 1614 } else { 1615 linesAdded = writeWrappedComment(w, _id, leftMargin + title, textSpace) + linesAdded; 1616 } 1617 title = String.format("%-" + labelColumn + "s", 1618 (Bundle.getMessage("MakeLabel", Bundle.getMessage("FieldFilename")))); // I18N Filename: 1619 if ((textSpaceWithIcon != 0) && (linesAdded < blanks)) { 1620 linesAdded = writeWrappedComment(w, _fileName != null ? _fileName : "<null>", leftMargin + title, 1621 textSpaceWithIcon) + linesAdded; 1622 } else { 1623 linesAdded = writeWrappedComment(w, _fileName != null ? _fileName : "<null>", leftMargin + title, 1624 textSpace) + linesAdded; 1625 } 1626 1627 if (!(_roadName.isEmpty())) { 1628 title = String.format("%-" + labelColumn + "s", 1629 (Bundle.getMessage("MakeLabel", Bundle.getMessage("FieldRoadName")))); // I18N Road name: 1630 if ((textSpaceWithIcon != 0) && (linesAdded < blanks)) { 1631 linesAdded = writeWrappedComment(w, _roadName, leftMargin + title, textSpaceWithIcon) + linesAdded; 1632 } else { 1633 linesAdded = writeWrappedComment(w, _roadName, leftMargin + title, textSpace) + linesAdded; 1634 } 1635 } 1636 if (!(_roadNumber.isEmpty())) { 1637 title = String.format("%-" + labelColumn + "s", 1638 (Bundle.getMessage("MakeLabel", Bundle.getMessage("FieldRoadNumber")))); // I18N Road number: 1639 1640 if ((textSpaceWithIcon != 0) && (linesAdded < blanks)) { 1641 linesAdded 1642 = writeWrappedComment(w, _roadNumber, leftMargin + title, textSpaceWithIcon) + linesAdded; 1643 } else { 1644 linesAdded = writeWrappedComment(w, _roadNumber, leftMargin + title, textSpace) + linesAdded; 1645 } 1646 } 1647 if (!(_mfg.isEmpty())) { 1648 title = String.format("%-" + labelColumn + "s", 1649 (Bundle.getMessage("MakeLabel", Bundle.getMessage("FieldManufacturer")))); // I18N Manufacturer: 1650 1651 if ((textSpaceWithIcon != 0) && (linesAdded < blanks)) { 1652 linesAdded = writeWrappedComment(w, _mfg, leftMargin + title, textSpaceWithIcon) + linesAdded; 1653 } else { 1654 linesAdded = writeWrappedComment(w, _mfg, leftMargin + title, textSpace) + linesAdded; 1655 } 1656 } 1657 if (!(_owner.isEmpty())) { 1658 title = String.format("%-" + labelColumn + "s", 1659 (Bundle.getMessage("MakeLabel", Bundle.getMessage("FieldOwner")))); // I18N Owner: 1660 1661 if ((textSpaceWithIcon != 0) && (linesAdded < blanks)) { 1662 linesAdded = writeWrappedComment(w, _owner, leftMargin + title, textSpaceWithIcon) + linesAdded; 1663 } else { 1664 linesAdded = writeWrappedComment(w, _owner, leftMargin + title, textSpace) + linesAdded; 1665 } 1666 } 1667 if (!(_model.isEmpty())) { 1668 title = String.format("%-" + labelColumn + "s", 1669 (Bundle.getMessage("MakeLabel", Bundle.getMessage("FieldModel")))); // I18N Model: 1670 if ((textSpaceWithIcon != 0) && (linesAdded < blanks)) { 1671 linesAdded = writeWrappedComment(w, _model, leftMargin + title, textSpaceWithIcon) + linesAdded; 1672 } else { 1673 linesAdded = writeWrappedComment(w, _model, leftMargin + title, textSpace) + linesAdded; 1674 } 1675 } 1676 if (!(_dccAddress.isEmpty())) { 1677 w.write(newLine, 0, 1); 1678 title = String.format("%-" + labelColumn + "s", 1679 (Bundle.getMessage("MakeLabel", Bundle.getMessage("FieldDCCAddress")))); // I18N DCC Address: 1680 String s = leftMargin + title + _dccAddress; 1681 w.write(s, 0, s.length()); 1682 linesAdded++; 1683 } 1684 1685 // If there is a comment field, then wrap it using the new wrapCommment() 1686 // method and print it 1687 if (!(_comment.isEmpty())) { 1688 //Because the text will fill the width if the roster entry has an icon 1689 //then we need to add some blank lines to prevent the comment text going 1690 //through the picture. 1691 for (int i = 0; i < (blanks - linesAdded); i++) { 1692 w.write(newLine, 0, 1); 1693 } 1694 //As we have added the blank lines to pad out the comment we will 1695 //reset the number of blanks to 0. 1696 if (blanks != 0) { 1697 blanks = 0; 1698 } 1699 title = String.format("%-" + labelColumn + "s", 1700 (Bundle.getMessage("MakeLabel", Bundle.getMessage("FieldComment")))); // I18N Comment: 1701 linesAdded = writeWrappedComment(w, _comment, leftMargin + title, textSpace) + linesAdded; 1702 } 1703 if (!(_decoderModel.isEmpty())) { 1704 title = String.format("%-" + labelColumn + "s", 1705 (Bundle.getMessage("MakeLabel", Bundle.getMessage("FieldDecoderModel")))); // I18N Decoder Model: 1706 if ((textSpaceWithIcon != 0) && (linesAdded < blanks)) { 1707 linesAdded 1708 = writeWrappedComment(w, _decoderModel, leftMargin + title, textSpaceWithIcon) + linesAdded; 1709 } else { 1710 linesAdded = writeWrappedComment(w, _decoderModel, leftMargin + title, textSpace) + linesAdded; 1711 } 1712 } 1713 if (!(_decoderFamily.isEmpty())) { 1714 title = String.format("%-" + labelColumn + "s", 1715 (Bundle.getMessage("MakeLabel", Bundle.getMessage("FieldDecoderFamily")))); // I18N Decoder Family: 1716 if ((textSpaceWithIcon != 0) && (linesAdded < blanks)) { 1717 linesAdded 1718 = writeWrappedComment(w, _decoderFamily, leftMargin + title, textSpaceWithIcon) + linesAdded; 1719 } else { 1720 linesAdded = writeWrappedComment(w, _decoderFamily, leftMargin + title, textSpace) + linesAdded; 1721 } 1722 } 1723 1724 //If there is a decoderComment field, need to wrap it 1725 if (!(_decoderComment.isEmpty())) { 1726 //Because the text will fill the width if the roster entry has an icon 1727 //then we need to add some blank lines to prevent the comment text going 1728 //through the picture. 1729 for (int i = 0; i < (blanks - linesAdded); i++) { 1730 w.write(newLine, 0, 1); 1731 } 1732 //As we have added the blank lines to pad out the comment we will 1733 //reset the number of blanks to 0. 1734 if (blanks != 0) { 1735 blanks = 0; 1736 } 1737 title = String.format("%-" + labelColumn + "s", 1738 (Bundle.getMessage("MakeLabel", Bundle.getMessage("FieldDecoderComment")))); // I18N Decoder Comment: 1739 linesAdded = writeWrappedComment(w, _decoderComment, leftMargin + title, textSpace) + linesAdded; 1740 } 1741 w.write(newLine, 0, 1); 1742 for (int i = -1; i < (blanks - linesAdded); i++) { 1743 w.write(newLine, 0, 1); 1744 } 1745 } catch (IOException e) { 1746 log.error("Error printing RosterEntry", e); 1747 } 1748 } 1749 1750 private int writeWrappedComment(Writer w, String text, String title, int textSpace) { 1751 Vector<String> commentVector = wrapComment(text, textSpace); 1752 1753 //Now have a vector of text pieces and line feeds that will all 1754 //fit in the allowed space. Print each piece, prefixing the first one 1755 //with the label and indenting any remaining. 1756 String s; 1757 int k = 0; 1758 try { 1759 w.write(newLine, 0, 1); 1760 s = title + commentVector.elementAt(k); 1761 w.write(s, 0, s.length()); 1762 k++; 1763 while (k < commentVector.size()) { 1764 String token = commentVector.elementAt(k); 1765 if (!token.equals("\n")) { 1766 s = indent + token; 1767 } else { 1768 s = token; 1769 } 1770 w.write(s, 0, s.length()); 1771 k++; 1772 } 1773 } catch (IOException e) { 1774 log.error("Error printing RosterEntry", e); 1775 } 1776 return k; 1777 } 1778 1779 /** 1780 * Line wrap a comment. 1781 * 1782 * @param comment the comment to wrap at word boundaries 1783 * @param textSpace the width of the space to print 1784 * 1785 * @return comment wrapped to fit given width 1786 */ 1787 public Vector<String> wrapComment(String comment, int textSpace) { 1788 //Tokenize the string using \n to separate the text on mulitple lines 1789 //and create a vector to hold the processed text pieces 1790 StringTokenizer commentTokens = new StringTokenizer(comment, "\n", true); 1791 Vector<String> textVector = new Vector<>(commentTokens.countTokens()); 1792 while (commentTokens.hasMoreTokens()) { 1793 String commentToken = commentTokens.nextToken(); 1794 int startIndex = 0; 1795 int endIndex; 1796 //Check each token to see if it needs to have a line wrap. 1797 //Get a piece of the token, either the size of the allowed space or 1798 //a shorter piece if there isn't enough text to fill the space 1799 if (commentToken.length() < startIndex + textSpace) { 1800 //the piece will fit so extract it and put it in the vector 1801 textVector.addElement(commentToken); 1802 } else { 1803 //Piece too long to fit. Extract a piece the size of the textSpace 1804 //and check for farthest right space for word wrapping. 1805 log.debug("token: /{}/", commentToken); 1806 1807 while (startIndex < commentToken.length()) { 1808 String tokenPiece = commentToken.substring(startIndex, startIndex + textSpace); 1809 if (log.isDebugEnabled()) { 1810 log.debug("loop: /{}/ {}", tokenPiece, tokenPiece.lastIndexOf(" ")); 1811 } 1812 if (tokenPiece.lastIndexOf(" ") == -1) { 1813 //If no spaces, put the whole piece in the vector and add a line feed, then 1814 //increment the startIndex to reposition for extracting next piece 1815 textVector.addElement(tokenPiece); 1816 textVector.addElement(newLine); 1817 startIndex += textSpace; 1818 } else { 1819 //If there is at least one space, extract up to and including the 1820 //last space and put in the vector as well as a line feed 1821 endIndex = tokenPiece.lastIndexOf(" ") + 1; 1822 log.debug("tokenPiece /{}/ {} {}", tokenPiece, startIndex, endIndex); 1823 1824 textVector.addElement(tokenPiece.substring(0, endIndex)); 1825 textVector.addElement(newLine); 1826 startIndex += endIndex; 1827 } 1828 //Check the remaining piece to see if it fits - startIndex now points 1829 //to the start of the next piece 1830 if (commentToken.substring(startIndex).length() < textSpace) { 1831 //It will fit so just insert it, otherwise will cycle through the 1832 //while loop and the checks above will take care of the remainder. 1833 //Line feed is not required as this is the last part of the token. 1834 textVector.addElement(commentToken.substring(startIndex)); 1835 startIndex += textSpace; 1836 } 1837 } 1838 } 1839 } 1840 return textVector; 1841 } 1842 1843 /** 1844 * Read a file containing the contents of this RosterEntry. 1845 * <p> 1846 * This has to be done before a call to loadCvModel, for example. 1847 */ 1848 public void readFile() { 1849 if (getFileName() == null) { 1850 log.warn("readFile invoked with null filename"); 1851 return; 1852 } else { 1853 log.debug("readFile invoked with filename {}", getFileName()); 1854 } 1855 1856 LocoFile lf = new LocoFile(); // used as a temporary 1857 String file = Roster.getDefault().getRosterFilesLocation() + getFileName(); 1858 if (!(new File(file).exists())) { 1859 // try without prefix 1860 file = getFileName(); 1861 } 1862 try { 1863 mRootElement = lf.rootFromName(file); 1864 } catch (JDOMException | IOException e) { 1865 log.error("Exception while loading loco XML file: {} from {}", getFileName(), file, e); 1866 } 1867 } 1868 1869 /** 1870 * Create a RosterEntry from a file. 1871 * 1872 * @param file The file containing the RosterEntry 1873 * @return a new RosterEntry 1874 * @throws JDOMException if unable to parse file 1875 * @throws IOException if unable to read file 1876 */ 1877 public static RosterEntry fromFile(@Nonnull File file) throws JDOMException, IOException { 1878 Element loco = (new LocoFile()).rootFromFile(file).getChild("locomotive"); 1879 if (loco == null) { 1880 throw new JDOMException("missing expected element"); 1881 } 1882 RosterEntry re = new RosterEntry(loco); 1883 re.setFileName(file.getName()); 1884 return re; 1885 } 1886 1887 @Override 1888 public String getDisplayName() { 1889 if (this.getRoadName() != null && !this.getRoadName().isEmpty()) { // NOI18N 1890 return Bundle.getMessage("RosterEntryDisplayName", this.getDccAddress(), this.getRoadName(), 1891 this.getRoadNumber()); // NOI18N 1892 } else { 1893 return Bundle.getMessage("RosterEntryDisplayName", this.getDccAddress(), this.getId(), ""); // NOI18N 1894 } 1895 } 1896 1897 private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(RosterEntry.class); 1898 1899}