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