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