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