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