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