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