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