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}