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