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