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