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