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