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