001package jmri.jmrit.roster;
002
003import java.awt.GraphicsEnvironment;
004import java.awt.HeadlessException;
005import java.beans.PropertyChangeEvent;
006import java.beans.PropertyChangeListener;
007import java.beans.PropertyChangeSupport;
008import java.io.File;
009import java.io.IOException;
010import java.nio.file.AtomicMoveNotSupportedException;
011import java.nio.file.Files;
012import java.nio.file.Path;
013import java.nio.file.StandardCopyOption;
014import java.util.ArrayList;
015import java.util.Collections;
016import java.util.HashMap;
017import java.util.List;
018import java.util.Locale;
019import java.util.Set;
020import java.util.TreeSet;
021
022import javax.annotation.CheckForNull;
023import javax.annotation.Nonnull;
024import javax.swing.JDialog;
025import javax.swing.JOptionPane;
026import javax.swing.JProgressBar;
027
028import jmri.InstanceManager;
029import jmri.UserPreferencesManager;
030import jmri.beans.PropertyChangeProvider;
031import jmri.jmrit.XmlFile;
032import jmri.jmrit.roster.rostergroup.RosterGroup;
033import jmri.jmrit.roster.rostergroup.RosterGroupSelector;
034import jmri.jmrit.symbolicprog.SymbolicProgBundle;
035import jmri.profile.Profile;
036import jmri.profile.ProfileManager;
037import jmri.util.FileUtil;
038import jmri.util.ThreadingUtil;
039import jmri.util.swing.JmriJOptionPane;
040
041import org.jdom2.Document;
042import org.jdom2.Element;
043import org.jdom2.JDOMException;
044import org.jdom2.ProcessingInstruction;
045
046/**
047 * Roster manages and manipulates a roster of locomotives.
048 * <p>
049 * It works with the "roster-config" XML schema to load and store its
050 * information.
051 * <p>
052 * This is an in-memory representation of the roster xml file (see below for
053 * constants defining name and location). As such, this class is also
054 * responsible for the "dirty bit" handling to ensure it gets written. As a
055 * temporary reliability enhancement, all changes to this structure are now
056 * being written to a backup file, and a copy is made when the file is opened.
057 * <p>
058 * Multiple Roster objects don't make sense, so we use an "instance" member to
059 * navigate to a single one.
060 * <p>
061 * The only bound property is the list of RosterEntrys; a PropertyChangedEvent
062 * is fired every time that changes.
063 * <p>
064 * The entries are stored in an ArrayList, sorted alphabetically. That sort is
065 * done manually each time an entry is added.
066 * <p>
067 * The roster is stored in a "Roster Index", which can be read or written. Each
068 * individual entry (once stored) contains a filename which can be used to
069 * retrieve the locomotive information for that roster entry. Note that the
070 * RosterEntry information is duplicated in both the Roster (stored in the
071 * roster.xml file) and in the specific file for the entry.
072 * <p>
073 * Originally, JMRI managed just one global roster, held in a global Roster
074 * object. With the rise of more complicated layouts, code has been added to
075 * address multiple rosters, with the primary one now held in Roster.default().
076 * We're moving references to Roster.default() out to the using code, so that
077 * eventually we can make those explicit references to other Roster objects
078 * as/when needed.
079 *
080 * @author Bob Jacobsen Copyright (C) 2001, 2008, 2010
081 * @author Dennis Miller Copyright 2004
082 * @see jmri.jmrit.roster.RosterEntry
083 */
084public class Roster extends XmlFile implements RosterGroupSelector, PropertyChangeProvider, PropertyChangeListener {
085
086    /**
087     * List of contained {@link RosterEntry} elements.
088     */
089    private final List<RosterEntry> _list = new ArrayList<>();
090    private boolean dirty = false;
091    /*
092     * This should always be a real path, changes in the UserFiles location are
093     * tracked by listening to FileUtilSupport for those changes and updating
094     * this path as needed.
095     */
096    private String rosterLocation = FileUtil.getUserFilesPath();
097    private String rosterIndexFileName = Roster.DEFAULT_ROSTER_INDEX;
098    // since we can't do a "super(this)" in the ctor to inherit from PropertyChangeSupport, we'll
099    // reflect to it.
100    // Note that dispose() doesn't act on these.  It isn't clear whether it should...
101    private final PropertyChangeSupport pcs = new PropertyChangeSupport(this);
102    public static final String schemaVersion = ""; // NOI18N
103    private String defaultRosterGroup = null;
104    private final HashMap<String, RosterGroup> rosterGroups = new HashMap<>();
105
106    /**
107     * Name of the default roster index file. {@value #DEFAULT_ROSTER_INDEX}
108     */
109    public static final String DEFAULT_ROSTER_INDEX = "roster.xml"; // NOI18N
110    /**
111     * Name for the property change fired when adding a roster entry.
112     * {@value #ADD}
113     */
114    public static final String ADD = "add"; // NOI18N
115    /**
116     * Name for the property change fired when removing a roster entry.
117     * {@value #REMOVE}
118     */
119    public static final String REMOVE = "remove"; // NOI18N
120    /**
121     * Name for the property change fired when changing the ID of a roster
122     * entry. {@value #CHANGE}
123     */
124    public static final String CHANGE = "change"; // NOI18N
125    /**
126     * Property change event fired when saving the roster. {@value #SAVED}
127     */
128    public static final String SAVED = "saved"; // NOI18N
129    /**
130     * Property change fired when adding a roster group.
131     * {@value #ROSTER_GROUP_ADDED}
132     */
133    public static final String ROSTER_GROUP_ADDED = "RosterGroupAdded"; // NOI18N
134    /**
135     * Property change fired when removing a roster group.
136     * {@value #ROSTER_GROUP_REMOVED}
137     */
138    public static final String ROSTER_GROUP_REMOVED = "RosterGroupRemoved"; // NOI18N
139    /**
140     * Property change fired when renaming a roster group.
141     * {@value  #ROSTER_GROUP_RENAMED}
142     */
143    public static final String ROSTER_GROUP_RENAMED = "RosterGroupRenamed"; // NOI18N
144    /**
145     * String prefixed to roster group names in the roster entry XML.
146     * {@value #ROSTER_GROUP_PREFIX}
147     */
148    public static final String ROSTER_GROUP_PREFIX = "RosterGroup:"; // NOI18N
149    /**
150     * Title of the "All Entries" roster group. As this varies by locale, do not
151     * rely on being able to store this value.
152     */
153    public static final String ALLENTRIES = Bundle.getMessage("ALLENTRIES"); // NOI18N
154    /**
155     * Title of the "No Group" roster group. As this varies by locale, do not
156     * rely on being able to store this value.
157     */
158    public static final String NOGROUP = Bundle.getMessage("NOGROUP"); // NOI18N
159
160    /**
161     * Create a roster with default contents.
162     */
163    public Roster() {
164        super();
165        FileUtil.getDefault().addPropertyChangeListener(FileUtil.PREFERENCES, (PropertyChangeEvent evt) -> {
166            FileUtil.Property oldValue = (FileUtil.Property) evt.getOldValue();
167            FileUtil.Property newValue = (FileUtil.Property) evt.getNewValue();
168            Profile project = oldValue.getKey();
169            if (this.equals(getRoster(project)) && getRosterLocation().equals(oldValue.getValue())) {
170                setRosterLocation(newValue.getValue());
171                reloadRosterFile();
172            }
173        });
174        InstanceManager.getOptionalDefault(UserPreferencesManager.class).ifPresent((upm) -> {
175            // During JUnit testing, preferences is often null
176            this.setDefaultRosterGroup((String) upm.getProperty(Roster.class.getCanonicalName(), "defaultRosterGroup")); // NOI18N
177        });
178    }
179
180    // should be private except that JUnit testing creates multiple Roster objects
181    public Roster(String rosterFilename) {
182        this();
183        try {
184            // if the rosterFilename passed in is null, create a complete path
185            // to the default roster index before attempting to read
186            if (rosterFilename == null) {
187                rosterFilename = Roster.this.getRosterIndexPath();
188            }
189            Roster.this.readFile(rosterFilename);
190        } catch (IOException | JDOMException e) {
191            log.error("Exception during reading while constructing roster", e);
192            try {
193                JmriJOptionPane.showMessageDialog(null,
194                        Bundle.getMessage("ErrorReadingText") + "\n" + e.getMessage(),
195                        Bundle.getMessage("ErrorReadingTitle"),
196                        JmriJOptionPane.ERROR_MESSAGE);
197            } catch (HeadlessException he) {
198                // ignore inability to display dialog
199            }
200        }
201    }
202
203    /**
204     * Get the roster for the profile returned by
205     * {@link ProfileManager#getActiveProfile()}.
206     *
207     * @return the roster for the active profile
208     */
209    public static synchronized Roster getDefault() {
210        return getRoster(ProfileManager.getDefault().getActiveProfile());
211    }
212
213    /**
214     * Get the roster for the specified profile.
215     *
216     * @param profile the Profile to get the roster for
217     * @return the roster for the profile
218     */
219    public static synchronized @Nonnull
220    Roster getRoster(@CheckForNull Profile profile) {
221        return InstanceManager.getDefault(RosterConfigManager.class).getRoster(profile);
222    }
223
224    /**
225     * Add a RosterEntry object to the in-memory Roster.
226     * <p>
227     * This method notifies the UI of changes so should not be used when
228     * adding or reloading many roster entries at once.
229     *
230     * @param e Entry to add
231     */
232    public void addEntry(RosterEntry e) {
233        // add the entry to the roster list
234        addEntryNoNotify(e);
235        // then notify the UI of the change
236        firePropertyChange(ADD, null, e);
237    }
238
239    /**
240     * Add a RosterEntry object to the in-memory Roster without notifying
241     * the UI of changes.
242     * <p>
243     * This method exists so full roster reloads/reindexing can take place without
244     * completely redrawing the UI table for each entry.
245     *
246     * @param e Entry to add
247     */
248    private void addEntryNoNotify(RosterEntry e) {
249        log.debug("Add entry {}", e);
250        // TODO: is sorting really necessary here?
251        synchronized (_list) {
252            int i = _list.size() - 1; // Last valid index
253            while (i >= 0) {
254                if (e.getId().compareToIgnoreCase(_list.get(i).getId()) > 0) {
255                    break; // get out of the loop since the entry at i sorts
256                    // before the new entry
257                }
258                i--;
259            }
260            _list.add(i + 1, e);
261        }
262        e.addPropertyChangeListener(this);
263        this.addRosterGroups(e.getGroups(this));
264        setDirty(true);
265    }
266
267    /**
268     * Remove a RosterEntry object from the in-memory Roster. This does not
269     * delete the file for the RosterEntry!
270     *
271     * @param e Entry to remove
272     */
273    public void removeEntry(RosterEntry e) {
274        log.debug("Remove entry {}", e);
275        synchronized (_list) {
276            _list.remove(e);
277        }
278        e.removePropertyChangeListener(this);
279        setDirty(true);
280        firePropertyChange(REMOVE, e, null);
281    }
282
283    /**
284     * @return number of entries in the roster
285     */
286    public int numEntries() {
287        synchronized (_list) {
288            return _list.size();
289        }
290    }
291
292    /**
293     * @param group The group being queried or null for all entries in the
294     *              roster.
295     * @return The Number of roster entries in the specified group or 0 if the
296     *         group does not exist.
297     */
298    public int numGroupEntries(String group) {
299        log.trace("numGroupEntries for {}", group);
300        if (group != null && group.equals(Roster.NOGROUP)) {
301            return numNoGroupEntries();
302        } else if (group != null
303                && !group.equals(Roster.ALLENTRIES)
304                && !group.equals(Roster.allEntries(Locale.getDefault()))) {
305            return (this.rosterGroups.get(group) != null) ? this.rosterGroups.get(group).getEntries().size() : 0;
306        } else {
307            return this.numEntries();
308        }
309    }
310
311    int numNoGroupEntries() {
312        int count = 0;
313        for (var entry : _list) {
314            if (entry.getGroups().isEmpty()) {
315                count++;
316            }
317        }
318        log.trace("numNoGroupEntries returns {}", count);
319        return count;
320    }
321    
322    /**
323     * Return RosterEntry from a "title" string, ala selection in
324     * matchingComboBox.
325     *
326     * @param title The title for the RosterEntry.
327     * @return The matching RosterEntry or null
328     */
329    public RosterEntry entryFromTitle(String title) {
330        synchronized (_list) {
331            for (RosterEntry re : _list) {
332                if (re.titleString().equals(title)) {
333                    return re;
334                }
335            }
336        }
337        return null;
338    }
339
340    /**
341     * Return RosterEntry from an "id" string.
342     *
343     * @param id The id for the RosterEntry.
344     * @return The matching RosterEntry or null
345     */
346    @CheckForNull
347    public RosterEntry getEntryForId(String id) {
348        synchronized (_list) {
349            for (RosterEntry re : _list) {
350                if (re.getId().equals(id)) {
351                    return re;
352                }
353            }
354        }
355        return null;
356    }
357
358    /**
359     * Return a list of RosterEntry items which have a particular DCC address.
360     *
361     * @param a The address.
362     * @return a List of matching entries, empty if there are no matches.
363     */
364    @Nonnull
365    public List<RosterEntry> getEntriesByDccAddress(String a) {
366        return findMatchingEntries(
367                (RosterEntry re) -> re.getDccAddress().equals(a)
368        );
369    }
370
371    /**
372     * Return a specific entry by index
373     *
374     * @param i The RosterEntry at position i in the roster.
375     * @return The matching RosterEntry
376     */
377    @Nonnull
378    public RosterEntry getEntry(int i) {
379        synchronized (_list) {
380            return _list.get(i);
381        }
382    }
383
384    /**
385     * Get all roster entries.
386     *
387     * @return a list of roster entries; the list is empty if the roster is
388     *         empty
389     */
390    @Nonnull
391    public List<RosterEntry> getAllEntries() {
392        return this.getEntriesInGroup(null);
393    }
394
395    /**
396     * Get the Nth RosterEntry in the group
397     *
398     * @param group The group being queried.
399     * @param i     The index within the group of the requested entry.
400     * @return The specified entry in the group or null if i is larger than the
401     *         group, or the group does not exist.
402     */
403    public RosterEntry getGroupEntry(String group, int i) {
404        log.trace("getGroupEntry({}, {})", group, i);
405        boolean doGroup = (group != null && !group.equals(Roster.ALLENTRIES) && !group.isEmpty());
406        if (!doGroup) {
407            // if not trying to get a specific group entry, just get the specified
408            // entry from the main list
409            try {
410                return _list.get(i);
411            } catch (IndexOutOfBoundsException e) {
412                return null;
413            }
414        } else if (group != null && group.equals(Roster.NOGROUP)) {
415            return getNoGroupEntry(i);
416        }
417        synchronized (_list) {
418            int num = 0;
419            for (RosterEntry r : _list) {
420                if ((r.getAttribute(getRosterGroupProperty(group)) != null)
421                        && r.getAttribute(getRosterGroupProperty(group)).equals("yes")) { // NOI18N
422                    if (num == i) {
423                        return r;
424                    }
425                    num++;
426                }
427            }
428        }
429        return null;
430    }
431
432    RosterEntry getNoGroupEntry(int i) {
433        log.trace("getNoGroupEntry({})", i);
434        try {
435            return getNoGroupList().get(i);
436        } catch (IndexOutOfBoundsException e) {
437            return null;
438        }
439    }
440    
441    List<RosterEntry> getNoGroupList() {
442        List<RosterEntry> result = new ArrayList<>();
443
444        getAllEntries().forEach((entry) -> {
445            if (entry.getGroups().isEmpty()) {
446                result.add(entry);
447            }
448        });
449        log.trace("getNoGroupList returns {} items", result.size());
450        return result;
451    }
452    
453    public int getGroupIndex(String group, RosterEntry re) {
454        log.trace("getGroupIndex({}, {})", group, re);
455        int num = 0;
456        boolean doGroup = (group != null && !group.equals(Roster.ALLENTRIES) && !group.isEmpty());
457
458        synchronized (_list) {
459
460            if (group != null && group.equals(Roster.NOGROUP)) {
461                var list = getNoGroupList();
462                for (RosterEntry r : list) {
463                    if (re == r) {
464                        log.trace("getGroupIndex of NOGROUP returns {}", num);
465                        return num;
466                    }
467                    num++;
468                }
469                log.trace("getGroupIndex of NOGROUP returns -1");
470                return -1;
471            }
472    
473            for (RosterEntry r : _list) {
474                if (doGroup) {
475                    if ((r.getAttribute(getRosterGroupProperty(group)) != null)
476                            && r.getAttribute(getRosterGroupProperty(group)).equals("yes")) { // NOI18N
477                        if (r == re) {
478                            return num;
479                        }
480                        num++;
481                    }
482                } else {
483                    if (re == r) {
484                        return num;
485                    }
486                    num++;
487                }
488            }
489        }
490        return -1;
491    }
492
493    /**
494     * Return filename from a "title" string, ala selection in matchingComboBox.
495     *
496     * @param title The title for the entry.
497     * @return The filename for the RosterEntry matching title, or null if no
498     *         such RosterEntry exists.
499     */
500    public String fileFromTitle(String title) {
501        RosterEntry r = entryFromTitle(title);
502        if (r != null) {
503            return r.getFileName();
504        }
505        return null;
506    }
507
508    public List<RosterEntry> getEntriesWithAttributeKey(String key) {
509        ArrayList<RosterEntry> result = new ArrayList<>();
510        synchronized (_list) {
511            _list.stream().filter((r) -> (r.getAttribute(key) != null)).forEachOrdered(result::add);
512        }
513        return result;
514    }
515
516    public List<RosterEntry> getEntriesWithAttributeKeyValue(String key, String value) {
517        ArrayList<RosterEntry> result = new ArrayList<>();
518        synchronized (_list) {
519            _list.forEach((r) -> {
520                String v = r.getAttribute(key);
521                if (v != null && v.equals(value)) {
522                    result.add(r);
523                }
524            });
525        }
526        return result;
527    }
528
529    public Set<String> getAllAttributeKeys() {
530        Set<String> result = new TreeSet<>();
531        synchronized (_list) {
532            _list.forEach((r) -> result.addAll(r.getAttributes()));
533        }
534        return result;
535    }
536
537    public List<RosterEntry> getEntriesInGroup(String group) {
538        if (group == null || group.equals(Roster.ALLENTRIES) || group.isEmpty()) {
539            // Return a copy of the list
540            return new ArrayList<>(this._list);
541        }  else if (group.equals(Roster.NOGROUP)) {
542            return getNoGroupList();
543        } else {
544            return this.getEntriesWithAttributeKeyValue(Roster.getRosterGroupProperty(group), "yes"); // NOI18N
545        }
546    }
547
548    /**
549     * Internal interface works with #findMatchingEntries to provide a common
550     * search-match-return capability.
551     */
552    private interface RosterComparator {
553
554        boolean check(RosterEntry r);
555    }
556
557    /**
558     * Internal method works with #RosterComparator to provide a common
559     * search-match-return capability.
560     */
561    private List<RosterEntry> findMatchingEntries(RosterComparator c) {
562        List<RosterEntry> l = new ArrayList<>();
563        synchronized (_list) {
564            _list.stream().filter(c::check).forEachOrdered(l::add);
565        }
566        return l;
567    }
568
569    /**
570     * Get a List of {@link RosterEntry} objects in Roster matching 7
571     * basic selectors. The list will be empty if there are no matches.
572     * <p>
573     * This method calls {@link #getEntriesMatchingCriteria(java.lang.String, java.lang.String, java.lang.String, java.lang.String, java.lang.String, java.lang.String, java.lang.String, java.lang.String)
574     * }
575     * with a null group.
576     *
577     * @param roadName      road name of entry or null for any road name
578     * @param roadNumber    road number of entry of null for any number
579     * @param dccAddress    address of entry or null for any address
580     * @param mfg           manufacturer of entry or null for any manufacturer
581     * @param decoderModel  decoder model of entry or null for any model
582     * @param decoderFamily decoder family of entry or null for any family
583     * @param id            id (unique name) of entry or null for any id
584     * @return List of matching RosterEntries or an empty List
585     * @see #getEntriesMatchingCriteria(java.lang.String, java.lang.String,
586     * java.lang.String, java.lang.String, java.lang.String, java.lang.String,
587     * java.lang.String, java.lang.String)
588     */
589    @Nonnull
590    public List<RosterEntry> matchingList(String roadName, String roadNumber, String dccAddress,
591            String mfg, String decoderModel, String decoderFamily, String id) {
592        return this.getEntriesMatchingCriteria(roadName, roadNumber, dccAddress,
593                mfg, decoderModel, decoderFamily, id, null, null, null, null);
594    }
595
596    /**
597     * Get a List of {@link RosterEntry} objects in Roster matching 11
598     * selectors. The list will be empty if there are no matches.
599     *
600     * @param roadName      road name of entry or null for any road name
601     * @param roadNumber    road number of entry of null for any number
602     * @param dccAddress    address of entry or null for any address
603     * @param mfg           manufacturer of entry or null for any manufacturer
604     * @param decoderModel  decoder model of entry or null for any model
605     * @param decoderFamily decoder family of entry or null for any family
606     * @param id            id of entry or null for any id
607     * @param group         group entry is member of or null for any group
608     * @param developerID   developerID of entry, or null for any developerID
609     * @param manufacturerID   manufacturerID of entry, or null for any manufacturerID
610     * @param productID   productID of entry, or null for any productID
611     * @return List of matching RosterEntries or an empty List
612     */
613    @Nonnull
614    public List<RosterEntry> getEntriesMatchingCriteria(String roadName, String roadNumber, String dccAddress,
615                                                        String mfg, String decoderModel, String decoderFamily, String id, String group,
616                                                        String developerID, String manufacturerID, String productID) {
617        // specifically updated for LocoNet SV2.
618        return findMatchingEntries(
619                (RosterEntry r) -> checkEntry(r, roadName, roadNumber, dccAddress,
620                        mfg, decoderModel, decoderFamily,
621                        id, group, developerID, manufacturerID, productID)
622        );
623    }
624
625    /**
626     * Get a List of {@link RosterEntry} objects in Roster matching 8
627     * selectors. The list will be empty if there are no matches.
628     *
629     * @param roadName      road name of entry or null for any road name
630     * @param roadNumber    road number of entry of null for any number
631     * @param dccAddress    address of entry or null for any address
632     * @param mfg           manufacturer of entry or null for any manufacturer
633     * @param decoderModel  decoder model of entry or null for any model
634     * @param decoderFamily decoder family of entry or null for any family
635     * @param id            id of entry or null for any id
636     * @param group         group entry is member of or null for any group
637     * @return List of matching RosterEntries or an empty List
638     */
639    @Nonnull
640    public List<RosterEntry> getEntriesMatchingCriteria(String roadName, String roadNumber, String dccAddress,
641            String mfg, String decoderModel, String decoderFamily, String id, String group) {
642        return findMatchingEntries(
643                (RosterEntry r) -> checkEntry(r, roadName, roadNumber, dccAddress,
644                        mfg, decoderModel, decoderFamily,
645                        id, group, null, null, null)
646        );
647    }
648
649    /**
650     * Get a List of {@link RosterEntry} objects in Roster matching 5
651     * selectors.
652     * The list will be empty if there are no matches.
653     * <p>
654     * This pattern is used for LocoNet LNCV.
655     *
656     * @param dccAddress    address of entry or null for any address
657     * @param decoderModel  decoder model of entry or null for any model
658     * @param decoderFamily decoder family of entry or null for any family
659     * @param productID     decoder productID or null for any productID
660     * @param progMode      decoder programming mode
661     * @return List of matching RosterEntries or an empty List
662     */
663    @Nonnull
664    public List<RosterEntry> getEntriesMatchingCriteria(String dccAddress, String decoderModel,
665                                                        String decoderFamily, String productID,
666                                                        String progMode) {
667        return findMatchingEntries(
668                (RosterEntry r) -> checkEntry(r, dccAddress, decoderModel, decoderFamily, productID, progMode)
669        );
670    }
671
672    /**
673     * Check if an entry is consistent with up to 9 specific properties.
674     * <p>
675     * A null String argument always matches. Strings are used for convenience
676     * in GUI building.
677     *
678     * @param i             index for the RosterEntry in the Roster
679     * @param roadName      road name of entry or null for any road name
680     * @param roadNumber    road number of entry of null for any number
681     * @param dccAddress    address of entry or null for any address
682     * @param mfg           manufacturer of entry or null for any manufacturer
683     * @param decoderModel  decoder model of entry or null for any model
684     * @param decoderFamily decoder family of entry or null for any family
685     * @param id            id of entry or null for any id
686     * @param group         group entry is member of or null for any group
687     * @return true if the entry matches
688     */
689    public boolean checkEntry(int i, String roadName, String roadNumber, String dccAddress,
690            String mfg, String decoderModel, String decoderFamily,
691            String id, String group) {
692        return this.checkEntry(_list, i, roadName, roadNumber, dccAddress, mfg,
693                decoderModel, decoderFamily, id, group);
694    }
695
696    /**
697     * Check if an item from a list of Roster Entry items is consistent with up
698     * to 10 specific properties.
699     * <p>
700     * A null String argument always matches. Strings are used for convenience
701     * in GUI building.
702     *
703     * @param list          the list of RosterEntry items being searched
704     * @param i             the index of the roster entry in the list
705     * @param roadName      road name of entry or null for any road name
706     * @param roadNumber    road number of entry of null for any number
707     * @param dccAddress    address of entry or null for any address
708     * @param mfg           manufacturer of entry or null for any manufacturer
709     * @param decoderModel  decoder model of entry or null for any model
710     * @param decoderFamily decoder family of entry or null for any family
711     * @param id            id of entry or null for any id
712     * @param group         group entry is member of or null for any group
713     * @return True if the entry matches
714     */
715    public boolean checkEntry(List<RosterEntry> list, int i, String roadName, String roadNumber, String dccAddress,
716            String mfg, String decoderModel, String decoderFamily,
717            String id, String group) {
718        RosterEntry r = list.get(i);
719        return checkEntry(r, roadName, roadNumber, dccAddress,
720                mfg, decoderModel, decoderFamily,
721                id, group, null, null, null);
722    }
723
724    /**
725     * Check if an entry is consistent with up to 12 specific (LNSV2/LNCV) properties.
726     * <p>
727     * A null String argument always matches. Strings are used for convenience
728     * in GUI building.
729     *
730     * @param r             the roster entry being checked
731     * @param roadName      road name of entry or null for any road name
732     * @param roadNumber    road number of entry of null for any number
733     * @param dccAddress    address of entry or null for any address
734     * @param mfg           manufacturer of entry or null for any manufacturer
735     * @param decoderModel  decoder model of entry or null for any model
736     * @param decoderFamily decoder family of entry or null for any family
737     * @param id            id of entry or null for any id
738     * @param group         group entry is member of or null for any group
739     * @param developerID   developerID of entry, or null for any developerID
740     * @param manufacturerID   manufacturerID of entry, or null for any manufacturerID
741     * @param productID     productID of entry, or null for any productID
742     * @return True if the entry matches
743     */
744    public boolean checkEntry(RosterEntry r, String roadName, String roadNumber, String dccAddress,
745            String mfg, String decoderModel, String decoderFamily,
746            String id, String group, String developerID,
747                String manufacturerID, String productID) {
748        // specifically updated for LNSV2!
749
750        if (id != null && !id.equals(r.getId())) {
751            return false;
752        }
753        if (roadName != null && !roadName.equals(r.getRoadName())) {
754            return false;
755        }
756        if (roadNumber != null && !roadNumber.equals(r.getRoadNumber())) {
757            return false;
758        }
759        if (dccAddress != null && !dccAddress.equals(r.getDccAddress())) {
760            return false;
761        }
762        if (mfg != null && !mfg.equals(r.getMfg())) {
763            return false;
764        }
765        if (decoderModel != null && !decoderModel.equals(r.getDecoderModel())) {
766            return false;
767        }
768        if (decoderFamily != null && !decoderFamily.equals(r.getDecoderFamily())) {
769            return false;
770        }
771        if (developerID != null && !developerID.equals(r.getDeveloperID())) {
772            return false;
773        }
774        if (manufacturerID != null && !manufacturerID.equals(r.getManufacturerID())) {
775            return false;
776        }
777        if (productID != null && !productID.equals(r.getProductID())) {
778            return false;
779        }
780        return (group == null
781                || Roster.ALLENTRIES.equals(group)
782                || (r.getAttribute(Roster.getRosterGroupProperty(group)) != null
783                && r.getAttribute(Roster.getRosterGroupProperty(group)).equals("yes")));
784    }
785
786    /**
787     * Check if an entry is consistent with up to 5 specific LNCV properties.
788     * <p>
789     * A null String argument always matches. Strings are used for convenience
790     * in GUI building.
791     *
792     * @param r             the roster entry being checked
793     * @param dccAddress    address of entry or null for any address
794     * @param decoderModel  decoder model of entry or null for any model
795     * @param decoderFamily decoder family of entry or null for any family
796     * @param productID     productId of entry or null for any productID
797     * @param progMode      programming mode
798     * @return True if the entry matches
799     */
800    public boolean checkEntry(RosterEntry r, String dccAddress,
801                              String decoderModel, String decoderFamily,
802                              String productID, String progMode) {
803        // used for LNCV and LNSV1
804        if (productID != null && !productID.equals(r.getProductID())) {
805            return false;
806        }
807        if (dccAddress != null && !dccAddress.equals(r.getDccAddress())) {
808            return false;
809        }
810        if (decoderModel != null && !decoderModel.equals(r.getDecoderModel())) {
811            return false;
812        }
813        if (decoderFamily != null && !decoderFamily.equals(r.getDecoderFamily())) {
814            return false;
815        }
816        if (progMode != null && !r.getProgrammingModes().contains(progMode)) {
817            return false;
818        }
819        return true;
820    }
821
822    /**
823     * Write the entire roster to a file.
824     * <p>
825     * Creates a new file with the given name, and then calls writeFile (File)
826     * to perform the actual work.
827     *
828     * @param name Filename for new file, including path info as needed.
829     * @throws java.io.FileNotFoundException if file does not exist
830     * @throws java.io.IOException           if unable to write file
831     */
832    void writeFile(String name) throws java.io.FileNotFoundException, java.io.IOException {
833        log.debug("writeFile {}", name);
834        File file = findFile(name);
835        if (file == null) {
836            file = new File(name);
837        }
838
839        writeFile(file);
840    }
841
842    /**
843     * Write the entire roster to a file object. This does not do backup; that
844     * has to be done separately. See writeRosterFile() for a public function
845     * that finds the default location, does a backup and then calls this.
846     *
847     * @param file the file to write to
848     * @throws java.io.IOException if unable to write file
849     */
850    void writeFile(File file) throws java.io.IOException {
851        // create root element
852        Element root = new Element("roster-config"); // NOI18N
853        root.setAttribute("noNamespaceSchemaLocation", // NOI18N
854                "http://jmri.org/xml/schema/roster" + schemaVersion + ".xsd", // NOI18N
855                org.jdom2.Namespace.getNamespace("xsi", // NOI18N
856                        "http://www.w3.org/2001/XMLSchema-instance")); // NOI18N
857        Document doc = newDocument(root);
858
859        // add XSLT processing instruction
860        // <?xml-stylesheet type="text/xsl" href="XSLT/roster.xsl"?>
861        java.util.Map<String, String> m = new java.util.HashMap<>();
862        m.put("type", "text/xsl"); // NOI18N
863        m.put("href", xsltLocation + "roster2array.xsl"); // NOI18N
864        ProcessingInstruction p = new ProcessingInstruction("xml-stylesheet", m); // NOI18N
865        doc.addContent(0, p);
866
867        String newLocoString = SymbolicProgBundle.getMessage("LabelNewDecoder");
868
869        //Check the Comment and Decoder Comment fields for line breaks and
870        //convert them to a processor directive for storage in XML
871        //Note: this is also done in the LocoFile.java class to do
872        //the same thing in the indidvidual locomotive roster files
873        //Note: these changes have to be undone after writing the file
874        //since the memory version of the roster is being changed to the
875        //file version for writing
876        synchronized (_list) {
877            _list.forEach((entry) -> {
878                //Extract the RosterEntry at this index and inspect the Comment and
879                //Decoder Comment fields to change any \n characters to <?p?> processor
880                //directives, so they can be stored in the xml file and converted
881                //back when the file is read.
882                if (!entry.getId().equals(newLocoString)) {
883                    String tempComment = entry.getComment();
884                    StringBuilder xmlComment = new StringBuilder();
885
886                    //transfer tempComment to xmlComment one character at a time, except
887                    //when \n is found.  In that case, insert <?p?>
888                    for (int k = 0; k < tempComment.length(); k++) {
889                        if (tempComment.startsWith("\n", k)) { // NOI18N
890                            xmlComment.append("<?p?>"); // NOI18N
891                        } else {
892                            xmlComment.append(tempComment.charAt(k));
893                        }
894                    }
895                    entry.setComment(xmlComment.toString());
896
897                    //Now do the same thing for the decoderComment field
898                    String tempDecoderComment = entry.getDecoderComment();
899                    StringBuilder xmlDecoderComment = new StringBuilder();
900
901                    for (int k = 0; k < tempDecoderComment.length(); k++) {
902                        if (tempDecoderComment.startsWith("\n", k)) { // NOI18N
903                            xmlDecoderComment.append("<?p?>"); // NOI18N
904                        } else {
905                            xmlDecoderComment.append(tempDecoderComment.charAt(k));
906                        }
907                    }
908                    entry.setDecoderComment(xmlDecoderComment.toString());
909                } else {
910                    log.debug("skip unsaved roster entry with default name {}", entry.getId());
911                }
912            }); //All Comments and Decoder Comment line feeds have been changed to processor directives
913        }
914        // add top-level elements
915        Element values = new Element("roster"); // NOI18N
916        root.addContent(values);
917        // add entries
918        synchronized (_list) {
919            _list.forEach((entry) -> {
920                if (!entry.getId().equals(newLocoString)) {
921                    values.addContent(entry.store());
922                } else {
923                    log.debug("skip unsaved roster entry with default name {}", entry.getId());
924                }
925            });
926        }
927        if (!this.rosterGroups.isEmpty()) {
928            Element rosterGroup = new Element("rosterGroup"); // NOI18N
929            rosterGroups.keySet().forEach((name) -> {
930                Element group = new Element("group"); // NOI18N
931                if (!name.equals(Roster.ALLENTRIES) && !name.equals(Roster.NOGROUP)) {
932                    group.addContent(name);
933                    rosterGroup.addContent(group);
934                }
935            });
936            root.addContent(rosterGroup);
937        }
938
939        writeXML(file, doc);
940
941        //Now that the roster has been rewritten in file form we need to
942        //restore the RosterEntry object to its normal \n state for the
943        //Comment and Decoder comment fields, otherwise it can cause problems in
944        //other parts of the program (e.g. in copying a roster)
945        synchronized (_list) {
946            _list.forEach((entry) -> {
947                if (!entry.getId().equals(newLocoString)) {
948                    String xmlComment = entry.getComment();
949                    StringBuilder tempComment = new StringBuilder();
950
951                    for (int k = 0; k < xmlComment.length(); k++) {
952                        if (xmlComment.startsWith("<?p?>", k)) { // NOI18N
953                            tempComment.append("\n"); // NOI18N
954                            k = k + 4;
955                        } else {
956                            tempComment.append(xmlComment.charAt(k));
957                        }
958                    }
959                    entry.setComment(tempComment.toString());
960
961                    String xmlDecoderComment = entry.getDecoderComment();
962                    StringBuilder tempDecoderComment = new StringBuilder(); // NOI18N
963
964                    for (int k = 0; k < xmlDecoderComment.length(); k++) {
965                        if (xmlDecoderComment.startsWith("<?p?>", k)) { // NOI18N
966                            tempDecoderComment.append("\n"); // NOI18N
967                            k = k + 4;
968                        } else {
969                            tempDecoderComment.append(xmlDecoderComment.charAt(k));
970                        }
971                    }
972                    entry.setDecoderComment(tempDecoderComment.toString());
973                } else {
974                    log.debug("skip unsaved roster entry with default name {}", entry.getId());
975                }
976            });
977        }
978        // done - roster now stored, so can't be dirty
979        setDirty(false);
980        firePropertyChange(SAVED, false, true);
981    }
982
983    /**
984     * Name a valid roster entry filename from an entry name.
985     * <ul>
986     * <li>Replaces all problematic characters with "_".
987     * <li>Append .xml suffix
988     * </ul> Does not check for duplicates.
989     *
990     * @return Filename for RosterEntry
991     * @param entry the getId() entry name from the RosterEntry
992     * @throws IllegalArgumentException if called with null or empty entry name
993     * @see RosterEntry#ensureFilenameExists()
994     * @since 2.1.5
995     */
996    public static String makeValidFilename(String entry) {
997        if (entry == null) {
998            throw new IllegalArgumentException("makeValidFilename requires non-null argument");
999        }
1000        if (entry.isEmpty()) {
1001            throw new IllegalArgumentException("makeValidFilename requires non-empty argument");
1002        }
1003
1004        // name sure there are no bogus chars in name
1005        String cleanName = entry.replaceAll("[\\W]", "_");  // remove \W, all non-word (a-zA-Z0-9_) characters // NOI18N
1006
1007        // ensure suffix
1008        return cleanName + ".xml"; // NOI18N
1009    }
1010
1011    /**
1012     * Read the contents of a roster XML file into this object.
1013     * <p>
1014     * Note that this does not clear any existing entries.
1015     *
1016     * @param name filename of roster file
1017     * @throws org.jdom2.JDOMException if file is invalid XML
1018     * @throws java.io.IOException     if unable to read file
1019     */
1020    void readFile(String name) throws org.jdom2.JDOMException, java.io.IOException {
1021        // roster exists?
1022        if (!(new File(name)).exists()) {
1023            log.debug("no roster file found; this is normal if you haven't put decoders in your roster locos yet");
1024            return;
1025        }
1026
1027        // find root
1028        log.info("Reading roster file with rootFromName({})", name);
1029        Element root = rootFromName(name);
1030        if (root == null) {
1031            log.error("Roster file exists, but could not be read; roster not available");
1032            return;
1033        }
1034        //if (log.isDebugEnabled()) XmlFile.dumpElement(root);
1035
1036        // decode type, invoke proper processing routine if a decoder file
1037        if (root.getChild("roster") != null) { // NOI18N
1038            List<Element> l = root.getChild("roster").getChildren("locomotive"); // NOI18N
1039            log.debug("readFile sees {} children", l.size());
1040
1041            RosterEntry firstRosterEntry = null;
1042            for (Element e : l) {  // can't be forEach because we need definitive order and non-final variable
1043                // Create a RosterEntry from this element and add to Roster.
1044                // Do not notify UI on each, notify once when all are done
1045                var thisRosterEntry = new RosterEntry(e);
1046                addEntryNoNotify(thisRosterEntry);
1047                if (firstRosterEntry == null) {
1048                    firstRosterEntry = thisRosterEntry;
1049                }
1050            }
1051            // Fire one notification, the table will redraw all entries anyway
1052            //
1053            // This works well with e.g. the Roster Table, which knows to 
1054            // handle an ADD event by doing a redraw-all.  But the JsonRosterSocketService
1055            // only handles the individual roster entries that are brought to its
1056            // attention via an ADD event.  So there's a mismatch here that 
1057            // will need to be resolved at some point.
1058            if (firstRosterEntry != null) {
1059                firePropertyChange(ADD, null, firstRosterEntry);
1060            }
1061
1062            //Scan the object to check the Comment and Decoder Comment fields for
1063            //any <?p?> processor directives and change them to back \n characters
1064            synchronized (_list) {
1065                _list.stream().peek((entry) -> {
1066                    //Extract the Comment field and create a new string for output
1067                    String tempComment = entry.getComment();
1068                    StringBuilder xmlComment = new StringBuilder();
1069                    //transfer tempComment to xmlComment one character at a time, except
1070                    //when <?p?> is found.  In that case, insert a \n and skip over those
1071                    //characters in tempComment.
1072                    for (int k = 0; k < tempComment.length(); k++) {
1073                        if (tempComment.startsWith("<?p?>", k)) { // NOI18N
1074                            xmlComment.append("\n"); // NOI18N
1075                            k = k + 4;
1076                        } else {
1077                            xmlComment.append(tempComment.charAt(k));
1078                        }
1079                    }
1080                    entry.setComment(xmlComment.toString());
1081                }).forEachOrdered((r) -> {
1082                    //Now do the same thing for the decoderComment field
1083                    String tempDecoderComment = r.getDecoderComment();
1084                    StringBuilder xmlDecoderComment = new StringBuilder();
1085
1086                    for (int k = 0; k < tempDecoderComment.length(); k++) {
1087                        if (tempDecoderComment.startsWith("<?p?>", k)) { // NOI18N
1088                            xmlDecoderComment.append("\n"); // NOI18N
1089                            k = k + 4;
1090                        } else {
1091                            xmlDecoderComment.append(tempDecoderComment.charAt(k));
1092                        }
1093                    }
1094
1095                    r.setDecoderComment(xmlDecoderComment.toString());
1096                });
1097            }
1098        } else {
1099            log.error("Unrecognized roster file contents in file: {}", name);
1100        }
1101        if (root.getChild("rosterGroup") != null) { // NOI18N
1102            List<Element> groups = root.getChild("rosterGroup").getChildren("group"); // NOI18N
1103            groups.forEach((group) -> addRosterGroup(group.getText()));
1104        }
1105    }
1106
1107    void setDirty(boolean b) {
1108        dirty = b;
1109    }
1110
1111    boolean isDirty() {
1112        return dirty;
1113    }
1114
1115    public void dispose() {
1116        log.debug("dispose");
1117        if (dirty) {
1118            log.error("Dispose invoked on dirty Roster");
1119        }
1120    }
1121
1122    /**
1123     * Store the roster in the default place, including making a backup if
1124     * needed.
1125     * <p>
1126     * Writes to a temporary file first, then backs up and replaces the roster
1127     * index only after the temporary write succeeds.
1128     */
1129    public void writeRoster() {
1130        try {
1131            this.writeFileAtomic(this.getRosterIndexPath());
1132        } catch (IOException e) {
1133            log.error("Exception while writing the new roster file, may not be complete", e);
1134            try {
1135                JmriJOptionPane.showMessageDialog(null,
1136                        Bundle.getMessage("ErrorSavingText") + "\n" + e.getMessage(),
1137                        Bundle.getMessage("ErrorSavingTitle"),
1138                        JmriJOptionPane.ERROR_MESSAGE);
1139            } catch (HeadlessException he) {
1140                // silently ignore failure to display dialog
1141            }
1142        }
1143    }
1144
1145    /**
1146     * Rebuild the Roster index and store it.
1147     */
1148    public void reindex() {
1149
1150        String[] filenames = Roster.getAllFileNames();
1151        log.info("Indexing {} roster files", filenames.length);
1152
1153        // rosters with smaller number of locos are pretty quick to
1154        // reindex... no need for a background thread and progress dialog
1155        if (filenames.length < 100 || GraphicsEnvironment.isHeadless()) {
1156            try {
1157                reindexInternal(filenames, null, null);
1158            } catch (Exception e) {
1159                log.error("Caught exception trying to reindex roster: ", e);
1160            }
1161            return;
1162        }
1163
1164        // Create a dialog with a progress bar and a cancel button
1165        String message = Bundle.getMessage("RosterProgressMessage"); // NOI18N
1166        String cancel = Bundle.getMessage("RosterProgressCancel"); // NOI18N
1167        // HACK: add long blank space to message to make dialog wider.
1168        JOptionPane pane = new JOptionPane(message + "                       \t",
1169                JOptionPane.PLAIN_MESSAGE, JOptionPane.OK_CANCEL_OPTION,
1170                null, new String[]{cancel});
1171        JProgressBar pb = new JProgressBar(0, filenames.length);
1172        pb.setValue(0);
1173        pane.add(pb, 1);
1174        JDialog dialog = pane.createDialog(null, message);
1175
1176        ThreadingUtil.newThread(() -> {
1177            try {
1178                reindexInternal(filenames, pb, pane);
1179            // catch all exceptions, so progress dialog will close
1180            } catch (Exception e) {
1181                // TODO: show message in progress dialog?
1182                log.error("Error writing new roster index file: {}", e.getMessage());
1183            }
1184            dialog.setVisible(false);
1185            dialog.dispose();
1186        }, "rosterIndexer").start();
1187
1188        // this will block until the thread completes, either by
1189        // finishing or by being cancelled
1190        dialog.setVisible(true);
1191    }
1192
1193    /**
1194     * Re-index roster, optionally updating a progress dialog.
1195     * <p>
1196     * During reindexing, do not notify the UI of changes until
1197     * all indexing is complete (the single notify event is done in
1198     * readFile(), called from reloadRosterFile()).
1199     *
1200     * @param filenames array of filenames to load to new index
1201     * @param pb optional JProgressBar to update during operations
1202     * @param pane optional JOptionPane to check for cancellation
1203     */
1204    private void reindexInternal(String[] filenames, JProgressBar pb, JOptionPane pane) {
1205        Roster roster = new Roster();
1206        int rosterNum = 0;
1207        for (String fileName : filenames) {
1208            if (pb != null) {
1209                pb.setValue(rosterNum++);
1210            }
1211            if (pane != null && pane.getValue() != JOptionPane.UNINITIALIZED_VALUE) {
1212                log.info("Roster index recreation cancelled");
1213                return;
1214            }
1215            // Read individual loco file
1216            try {
1217                Element loco = (new LocoFile()).rootFromName(getRosterFilesLocation() + fileName).getChild("locomotive");
1218                if (loco != null) {
1219                    RosterEntry re = new RosterEntry(loco);
1220                    re.setFileName(fileName);
1221                    // do not notify UI of changes
1222                    roster.addEntryNoNotify(re);
1223                }
1224            } catch (JDOMException | IOException ex) {
1225                log.error("Exception while loading loco XML file: {}", fileName, ex);
1226            }
1227        }
1228
1229        try {
1230            log.debug("Writing new index file");
1231            roster.writeFileAtomic(this.getRosterIndexPath());
1232        } catch (IOException ex) {
1233            log.error("Exception while writing the new roster file, may not be complete", ex);
1234        }
1235        log.debug("Reloading resulting roster index");
1236        this.reloadRosterFile();
1237        log.info("Roster rebuilt, stored in {}", this.getRosterIndexPath());
1238    }
1239
1240    /**
1241     * Update the in-memory Roster to be consistent with the current roster
1242     * file. This removes any existing roster entries!
1243     */
1244    public void reloadRosterFile() {
1245        // clear existing
1246        synchronized (_list) {
1247
1248            _list.clear();
1249        }
1250        this.rosterGroups.clear();
1251        // and read new
1252        try {
1253            this.readFile(this.getRosterIndexPath());
1254        } catch (IOException | JDOMException e) {
1255            log.error("Exception during reading while reloading roster", e);
1256        }
1257    }
1258
1259    public void setRosterIndexFileName(String fileName) {
1260        this.rosterIndexFileName = fileName;
1261    }
1262
1263    public String getRosterIndexFileName() {
1264        return this.rosterIndexFileName;
1265    }
1266
1267    public String getRosterIndexPath() {
1268        return this.getRosterLocation() + this.getRosterIndexFileName();
1269    }
1270
1271    private void writeFileAtomic(String name) throws IOException {
1272        File file = findFile(name);
1273        if (file == null) {
1274            file = new File(name);
1275        }
1276
1277        Path target = file.toPath();
1278        Path temp = target.resolveSibling(file.getName() + ".new"); // NOI18N
1279
1280        try {
1281            writeFile(temp.toFile());
1282        } catch (IOException ex) {
1283            deleteTempFile(temp, ex);
1284            throw ex;
1285        }
1286
1287        try {
1288            if (Files.exists(target)) {
1289                Files.copy(target, new File(backupFileName(file.getAbsolutePath())).toPath(),
1290                        StandardCopyOption.REPLACE_EXISTING);
1291            }
1292            moveTempFile(temp, target);
1293        } catch (IOException ex) {
1294            setDirty(true);
1295            deleteTempFile(temp, ex);
1296            throw ex;
1297        }
1298    }
1299
1300    private void moveTempFile(Path temp, Path target) throws IOException {
1301        try {
1302            Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
1303        } catch (AtomicMoveNotSupportedException ex) {
1304            Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
1305        }
1306    }
1307
1308    private void deleteTempFile(Path temp, IOException originalException) {
1309        try {
1310            Files.deleteIfExists(temp);
1311        } catch (IOException ex) {
1312            originalException.addSuppressed(ex);
1313        }
1314    }
1315
1316    /*
1317     * get the path to the file containing roster entry files.
1318     */
1319    public String getRosterFilesLocation() {
1320        return getDefault().getRosterLocation() + "roster" + File.separator;
1321    }
1322
1323    /**
1324     * Set the default location for the Roster file, and all individual
1325     * locomotive files.
1326     *
1327     * @param f Absolute pathname to use. A null or "" argument flags a return
1328     *          to the original default in the user's files directory. This
1329     *          parameter must be a potentially valid path on the system.
1330     */
1331    public void setRosterLocation(String f) {
1332        String oldRosterLocation = this.rosterLocation;
1333        String p = f;
1334        if (p != null) {
1335            if (p.isEmpty()) {
1336                p = null;
1337            } else {
1338                p = FileUtil.getAbsoluteFilename(p);
1339                if (!p.endsWith(File.separator)) {
1340                    p = p + File.separator;
1341                }
1342            }
1343        }
1344        if (p == null) {
1345            p = FileUtil.getUserFilesPath();
1346        }
1347        this.rosterLocation = p;
1348        log.debug("Setting roster location from {} to {}", oldRosterLocation, this.rosterLocation);
1349        if (this.rosterLocation.equals(FileUtil.getUserFilesPath())) {
1350            log.debug("Roster location reset to default");
1351        }
1352        if (!this.rosterLocation.equals(oldRosterLocation)) {
1353            this.firePropertyChange(RosterConfigManager.DIRECTORY, oldRosterLocation, this.rosterLocation);
1354        }
1355        this.reloadRosterFile();
1356    }
1357
1358    /**
1359     * Absolute path to roster file location.
1360     * <p>
1361     * Default is in the user's files directory, but can be set to anything.
1362     *
1363     * @return location of the Roster file
1364     * @see jmri.util.FileUtil#getUserFilesPath()
1365     */
1366    @Nonnull
1367    public String getRosterLocation() {
1368        return this.rosterLocation;
1369    }
1370
1371    @Override
1372    public synchronized void addPropertyChangeListener(PropertyChangeListener l) {
1373        pcs.addPropertyChangeListener(l);
1374    }
1375
1376    @Override
1377    public synchronized void addPropertyChangeListener(String propertyName, PropertyChangeListener listener) {
1378        pcs.addPropertyChangeListener(propertyName, listener);
1379    }
1380
1381    protected void firePropertyChange(String p, Object old, Object n) {
1382        pcs.firePropertyChange(p, old, n);
1383    }
1384
1385    @Override
1386    public synchronized void removePropertyChangeListener(PropertyChangeListener l) {
1387        pcs.removePropertyChangeListener(l);
1388    }
1389
1390    @Override
1391    public synchronized void removePropertyChangeListener(String propertyName, PropertyChangeListener listener) {
1392        pcs.removePropertyChangeListener(propertyName, listener);
1393    }
1394
1395    @Override
1396    @Nonnull
1397    public PropertyChangeListener [] getPropertyChangeListeners() {
1398        return pcs.getPropertyChangeListeners();
1399    }
1400
1401    @Override
1402    @Nonnull
1403    public PropertyChangeListener [] getPropertyChangeListeners(String propertyName) {
1404        return pcs.getPropertyChangeListeners(propertyName);
1405    }
1406
1407    /**
1408     * Notify that the ID of an entry has changed. This doesn't actually change
1409     * the roster contents, but triggers a reordering of the roster contents.
1410     *
1411     * @param r the entry with a changed Id
1412     */
1413    public void entryIdChanged(RosterEntry r) {
1414        log.debug("EntryIdChanged");
1415        synchronized (_list) {
1416            _list.sort((RosterEntry o1, RosterEntry o2) -> o1.getId().compareToIgnoreCase(o2.getId()));
1417        }
1418        firePropertyChange(CHANGE, null, r);
1419    }
1420
1421    public static String getRosterGroupName(String rosterGroup) {
1422        if (rosterGroup == null) {
1423            return ALLENTRIES;
1424        }
1425        return rosterGroup;
1426    }
1427
1428    /**
1429     * Get the string for a RosterGroup property in a RosterEntry
1430     *
1431     * @param name The name of the rosterGroup
1432     * @return The full property string
1433     */
1434    public static String getRosterGroupProperty(String name) {
1435        return ROSTER_GROUP_PREFIX + name;
1436    }
1437
1438    /**
1439     * Add a roster group, notifying all listeners of the change.
1440     * <p>
1441     * This method fires the property change notification
1442     * {@value #ROSTER_GROUP_ADDED}.
1443     *
1444     * @param rg The group to be added
1445     */
1446    public void addRosterGroup(RosterGroup rg) {
1447        if (this.rosterGroups.containsKey(rg.getName())) {
1448            return;
1449        }
1450        this.rosterGroups.put(rg.getName(), rg);
1451        log.debug("firePropertyChange Roster Groups model: {}", rg.getName()); // test for panel redraw after duplication
1452        firePropertyChange(ROSTER_GROUP_ADDED, null, rg.getName());
1453    }
1454
1455    /**
1456     * Add a roster group, notifying all listeners of the change.
1457     * <p>
1458     * This method creates a {@link jmri.jmrit.roster.rostergroup.RosterGroup}.
1459     * Use {@link #addRosterGroup(jmri.jmrit.roster.rostergroup.RosterGroup) }
1460     * if you need to add a subclass of RosterGroup. This method fires the
1461     * property change notification {@value #ROSTER_GROUP_ADDED}.
1462     *
1463     * @param rg The name of the group to be added
1464     */
1465    public void addRosterGroup(String rg) {
1466        // do a quick return without creating a new RosterGroup object
1467        // if the roster group aleady exists
1468        if (this.rosterGroups.containsKey(rg)) {
1469            return;
1470        }
1471        this.addRosterGroup(new RosterGroup(rg));
1472        firePropertyChange(ROSTER_GROUP_ADDED, null, rg);
1473    }
1474
1475    /**
1476     * Add a list of {@link jmri.jmrit.roster.rostergroup.RosterGroup}.
1477     * RosterGroups that are already known to the Roster are ignored.
1478     *
1479     * @param groups RosterGroups to add to the roster. RosterGroups already in
1480     *               the roster will not be added again.
1481     */
1482    public void addRosterGroups(List<RosterGroup> groups) {
1483        groups.forEach(this::addRosterGroup);
1484    }
1485
1486    public void removeRosterGroup(RosterGroup rg) {
1487        this.delRosterGroupList(rg.getName());
1488    }
1489
1490    /**
1491     * Delete a roster group, notifying all listeners of the change.
1492     * <p>
1493     * This method fires the property change notification
1494     * "{@value #ROSTER_GROUP_REMOVED}".
1495     *
1496     * @param rg The group to be deleted
1497     */
1498    public void delRosterGroupList(String rg) {
1499        RosterGroup group = this.rosterGroups.remove(rg);
1500        String str = Roster.getRosterGroupProperty(rg);
1501        group.getEntries().forEach((re) -> {
1502            re.deleteAttribute(str);
1503            re.updateFile();
1504        });
1505        firePropertyChange(ROSTER_GROUP_REMOVED, rg, null);
1506    }
1507
1508    /**
1509     * Copy a roster group, adding every entry in the roster group to the new
1510     * group.
1511     * <p>
1512     * If a roster group with the target name already exists, this method
1513     * silently fails to rename the roster group. The GUI method
1514     * CopyRosterGroupAction.performAction() catches this error and informs the
1515     * user. This method fires the property change
1516     * "{@value #ROSTER_GROUP_ADDED}".
1517     *
1518     * @param oldName Name of the roster group to be copied
1519     * @param newName Name of the new roster group
1520     * @see jmri.jmrit.roster.swing.RenameRosterGroupAction
1521     */
1522    public void copyRosterGroupList(String oldName, String newName) {
1523        if (this.rosterGroups.containsKey(newName)) {
1524            return;
1525        }
1526        this.rosterGroups.put(newName, new RosterGroup(newName));
1527        String newGroup = Roster.getRosterGroupProperty(newName);
1528        this.rosterGroups.get(oldName).getEntries().forEach((re) -> {
1529            re.putAttribute(newGroup, "yes"); // NOI18N
1530        });
1531        this.addRosterGroup(new RosterGroup(newName));
1532
1533        firePropertyChange(ROSTER_GROUP_ADDED, oldName, newName);
1534    }
1535
1536    public void rosterGroupRenamed(String oldName, String newName) {
1537        this.firePropertyChange(Roster.ROSTER_GROUP_RENAMED, oldName, newName);
1538    }
1539
1540    /**
1541     * Rename a roster group, while keeping every entry in the roster group.
1542     * <p>
1543     * If a roster group with the target name already exists, this method
1544     * silently fails to rename the roster group. The GUI method
1545     * RenameRosterGroupAction.performAction() catches this error and informs
1546     * the user. This method fires the property change
1547     * "{@value #ROSTER_GROUP_RENAMED}".
1548     *
1549     * @param oldName Name of the roster group to be renamed
1550     * @param newName New name for the roster group
1551     * @see jmri.jmrit.roster.swing.RenameRosterGroupAction
1552     */
1553    public void renameRosterGroupList(String oldName, String newName) {
1554        if (this.rosterGroups.containsKey(newName)) {
1555            return;
1556        }
1557        this.rosterGroups.get(oldName).setName(newName);
1558    }
1559
1560    /**
1561     * Get a list of the user defined roster group names.
1562     * <p>
1563     * Strings are immutable, so deleting an item from the copy should not
1564     * affect the system-wide list of roster groups.
1565     *
1566     * @return A list of the roster group names not including All Entries and No Group.
1567     */
1568    public ArrayList<String> getRosterGroupList() {
1569        ArrayList<String> list = new ArrayList<>(this.rosterGroups.keySet());
1570        Collections.sort(list);
1571        return list;
1572    }
1573
1574    /**
1575     * Get a list of the roster group names.
1576     * <p>
1577     * Strings are immutable, so deleting an item from the copy should not
1578     * affect the system-wide list of roster groups.
1579     *
1580     * @return A list of the roster group names including No Group, not including All Entries
1581     */
1582    public ArrayList<String> getRosterGroupListWithNoGroup() {
1583        ArrayList<String> list = new ArrayList<>(this.rosterGroups.keySet());
1584        Collections.sort(list);
1585        list.add(NOGROUP);
1586        return list;
1587    }
1588
1589    /**
1590     * Get the identifier for all entries in the roster.
1591     *
1592     * @param locale The desired locale
1593     * @return "All Entries" in the specified locale
1594     */
1595    public static String allEntries(Locale locale) {
1596        return Bundle.getMessage(locale, "ALLENTRIES"); // NOI18N
1597    }
1598
1599    /**
1600     * Get the default roster group.
1601     * <p>
1602     * This method ensures adherence to the RosterGroupSelector protocol
1603     *
1604     * @return The entire roster
1605     */
1606    @Override
1607    public String getSelectedRosterGroup() {
1608        return getDefaultRosterGroup();
1609    }
1610
1611    /**
1612     * @return the defaultRosterGroup
1613     */
1614    public String getDefaultRosterGroup() {
1615        return defaultRosterGroup;
1616    }
1617
1618    /**
1619     * @param defaultRosterGroup the defaultRosterGroup to set
1620     */
1621    public void setDefaultRosterGroup(String defaultRosterGroup) {
1622        this.defaultRosterGroup = defaultRosterGroup;
1623        InstanceManager.getOptionalDefault(UserPreferencesManager.class).ifPresent((upm) -> {
1624            upm.setProperty(Roster.class.getCanonicalName(), "defaultRosterGroup", defaultRosterGroup); // NOI18N
1625        });
1626    }
1627
1628    /**
1629     * Get an array of all the RosterEntry-containing files in the target
1630     * directory.
1631     *
1632     * @return a string array of file names for entries in this roster
1633     */
1634    static String[] getAllFileNames() {
1635        // ensure preferences will be found for read
1636        FileUtil.createDirectory(getDefault().getRosterFilesLocation());
1637
1638        // create an array of file names from roster dir in preferences, count entries
1639        int i;
1640        int np = 0;
1641        String[] sp = null;
1642        if (log.isDebugEnabled()) {
1643            log.debug("search directory {}", getDefault().getRosterFilesLocation());
1644        }
1645        File fp = new File(getDefault().getRosterFilesLocation());
1646        if (fp.exists()) {
1647            sp = fp.list();
1648            if (sp != null) {
1649                for (i = 0; i < sp.length; i++) {
1650                    if (sp[i].endsWith(".xml") || sp[i].endsWith(".XML")) {
1651                        np++;
1652                    }
1653                }
1654            } else {
1655                log.warn("expected directory, but {} was a file", getDefault().getRosterFilesLocation());
1656            }
1657        } else {
1658            log.warn("{}roster directory was missing, though tried to create it", FileUtil.getUserFilesPath());
1659        }
1660
1661        // Copy the entries to the final array
1662        String[] sbox = new String[np];
1663        int n = 0;
1664        if (sp != null && np > 0) {
1665            for (i = 0; i < sp.length; i++) {
1666                if (sp[i].endsWith(".xml") || sp[i].endsWith(".XML")) {
1667                    sbox[n++] = sp[i];
1668                }
1669            }
1670        }
1671        // The resulting array is now sorted on file-name to make it easier
1672        // for humans to read
1673        java.util.Arrays.sort(sbox);
1674
1675        if (log.isDebugEnabled()) {
1676            log.debug("filename list:");
1677            for (i = 0; i < sbox.length; i++) {
1678                log.debug("     name: {}", sbox[i]);
1679            }
1680        }
1681        return sbox;
1682    }
1683
1684    /**
1685     * Get the groups known to the roster itself. Note that changes to the
1686     * returned Map will not be reflected in the Roster.
1687     *
1688     * @return the rosterGroups
1689     */
1690    @Nonnull
1691    public HashMap<String, RosterGroup> getRosterGroups() {
1692        return new HashMap<>(rosterGroups);
1693    }
1694
1695    /**
1696     * Changes the key used to look up a RosterGroup by name. This is a helper
1697     * method that does not fire a notification to any propertyChangeListeners.
1698     * <p>
1699     * To rename a RosterGroup, use
1700     * {@link jmri.jmrit.roster.rostergroup.RosterGroup#setName(java.lang.String)}.
1701     *
1702     * @param group  The group being associated with newKey and will be
1703     *               disassociated with the key matching
1704     *               {@link RosterGroup#getName()}.
1705     * @param newKey The new key by which group can be found in the map of
1706     *               RosterGroups. This should match the intended new name of
1707     *               group.
1708     */
1709    public void remapRosterGroup(RosterGroup group, String newKey) {
1710        this.rosterGroups.remove(group.getName());
1711        this.rosterGroups.put(newKey, group);
1712    }
1713
1714    @Override
1715    public void propertyChange(PropertyChangeEvent evt) {
1716        if (evt.getSource() instanceof RosterEntry) {
1717            if (evt.getPropertyName().equals(RosterEntry.ID)) {
1718                this.entryIdChanged((RosterEntry) evt.getSource());
1719            }
1720        }
1721    }
1722
1723    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(Roster.class);
1724}