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}