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