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