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