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