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