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