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