001package jmri.configurexml; 002 003import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; 004 005import java.awt.GraphicsEnvironment; 006import java.awt.event.ActionEvent; 007import java.io.BufferedReader; 008import java.io.File; 009import java.io.FileInputStream; 010import java.io.InputStreamReader; 011import java.util.UUID; 012import java.util.concurrent.atomic.AtomicBoolean; 013 014import javax.swing.AbstractAction; 015import javax.swing.JFileChooser; 016 017import jmri.*; 018 019/** 020 * Determine if there have been changes made to the PanelPro data. If so, then a prompt will 021 * be displayed to store the data before the JMRI shutdown process proceeds. 022 * <p> 023 * If the JMRI application is DecoderPro, the checking does not occur. If the PanelPro tables 024 * contain only 3 time related beans and no panels, the checking does not occur. 025 * <p> 026 * The main check process uses the checkFile process which is used by the load and store tests. 027 * The current configuration is stored to a temporary file. This temp file is compared to the file 028 * that was loaded manually or via a start up action. If there are differences and the 029 * shutdown store check preference is enabled, a store request prompt is displayed. The 030 * prompt does not occur when running in headless mode. 031 * 032 * @author Dave Sand Copyright (c) 2022 033 */ 034public class StoreAndCompare extends AbstractAction { 035 036 public StoreAndCompare() { 037 this("Store and Compare"); // NOI18N 038 } 039 040 public StoreAndCompare(String s) { 041 super(s); 042 } 043 044 private static ShutdownPreferences _preferences = jmri.InstanceManager.getDefault(ShutdownPreferences.class); 045 046 @Override 047 public void actionPerformed(ActionEvent e) { 048 requestStoreIfNeeded(); 049 } 050 051 /** 052 * Check if data has changed and if so, if the user has permission to store. 053 * @return true if user wants to abort shutdown, false otherwise 054 */ 055 public static boolean checkPermissionToStoreIfNeeded() { 056 if (InstanceManager.getDefault(PermissionManager.class) 057 .hasAtLeastPermission(LoadAndStorePermissionOwner.STORE_XML_FILE_PERMISSION, 058 BooleanPermission.BooleanValue.TRUE)) { 059 // User has permission to store. No need to abort. 060 return false; 061 } 062 063 if (Application.getApplicationName().equals("PanelPro")) { 064 if (_preferences.isStoreCheckEnabled()) { 065 if (dataHasChanged() && !GraphicsEnvironment.isHeadless()) { 066 return jmri.configurexml.swing.StoreAndCompareDialog.showAbortShutdownDialogPermissionDenied(); 067 } 068 } 069 } 070 071 // If here, no need to abort. 072 return false; 073 } 074 075 public static boolean requestStoreIfNeeded() { 076 if (!InstanceManager.getDefault(PermissionManager.class) 077 .hasAtLeastPermission(LoadAndStorePermissionOwner.STORE_XML_FILE_PERMISSION, 078 BooleanPermission.BooleanValue.TRUE)) { 079 // User has not permission to store. 080 return false; 081 } 082 083 AtomicBoolean cancelShutdown = new AtomicBoolean(false); 084 if (Application.getApplicationName().equals("PanelPro") &&_preferences.isStoreCheckEnabled()) { 085 jmri.util.ThreadingUtil.runOnGUI(() -> { 086 if (dataHasChanged() && !GraphicsEnvironment.isHeadless()) { 087 cancelShutdown.set(jmri.configurexml.swing.StoreAndCompareDialog.showDialog()); 088 log.debug("The store dialog returned {}", cancelShutdown.get()); 089 } 090 }); 091 } 092 return cancelShutdown.get(); 093 } 094 095 public static boolean dataHasChanged() { 096 var result = false; 097 098 // Get file 1 :: This will be the file used to load the layout data. 099 JFileChooser chooser = LoadStoreBaseAction.getUserFileChooser(); 100 File file1 = chooser.getSelectedFile(); 101 if (file1 == null) { 102 // No file loaded, check for possible additions. 103 return noFileChecks(); 104 } 105 106 // Get file 2 :: This is the default tmp directory with a random xml file name. 107 var tempDir = System.getProperty("java.io.tmpdir") + File.separator; 108 var fileName = UUID.randomUUID().toString(); 109 File file2 = new File(tempDir + fileName + ".xml"); 110 111 // Store the current data using the temp file. 112 jmri.ConfigureManager cm = jmri.InstanceManager.getNullableDefault(jmri.ConfigureManager.class); 113 if (cm != null) { 114 boolean stored = cm.storeUser(file2); 115 log.debug("temp file '{}' stored :: {}", file2, stored); 116 117 try { 118 result = checkFile(file1, file2); 119 } catch (Exception ex) { 120 log.debug("checkFile exception: ", ex); 121 } 122 123 if (!file2.delete()) { 124 log.warn("An error occurred while deleting temporary file {}", file2.getPath()); 125 } 126 } 127 128 return result; 129 } 130 131 /** 132 * When a file has not been loaded, there might be items that should be stored. This check 133 * is not exhaustive. 134 * <p> 135 * If ISCLOCKRUNNING is the only sensor, that is not considered a change. This also applies 136 * to the IMCURRENTTIME and IMRATEFACTOR memories. 137 * @return true if notification should occur. 138 */ 139 @SuppressFBWarnings(value = {"RCN_REDUNDANT_NULLCHECK_OF_NONNULL_VALUE"}, 140 justification = 141 "spotbugs did not like the protection provided by the result boolean, but the second test was declared redundant") 142 private static boolean noFileChecks() { 143 var result = false; 144 145 var tMgr = InstanceManager.getDefault(TurnoutManager.class); 146 var sMgr = InstanceManager.getDefault(SensorManager.class); 147 var mMgr = InstanceManager.getDefault(MemoryManager.class); 148 149 // Get the system prefix for internal beans using the memory manager to avoid the default prefix. 150 var systemPrefix = "I"; 151 if (mMgr != null) { 152 systemPrefix = mMgr.getSystemPrefix(); 153 } 154 155 if (tMgr == null || sMgr == null || mMgr == null) { 156 log.debug("triple manager test sets true"); 157 result = true; 158 } 159 160 if (!result && tMgr != null && tMgr.getNamedBeanSet().size() > 0) { 161 log.debug("turnout manager test sets true"); 162 result = true; 163 } 164 165 if (!result && sMgr != null) { 166 var sensorSize = sMgr.getNamedBeanSet().size(); 167 if (sensorSize > 1) { 168 log.debug("sensor > 1 sets true"); 169 result = true; 170 } else if (sensorSize == 1) { 171 if (sMgr.getBySystemName(systemPrefix + "SCLOCKRUNNING") == null) { 172 log.debug("sensor == 1 sets true"); 173 result = true; // One sensor but it is not ISCLOCKRUNNING 174 } 175 } 176 } 177 178 if (!result && mMgr != null) { 179 var memSize = mMgr.getNamedBeanSet().size(); 180 if (memSize > 2) { 181 log.debug("memory > 2 sets true"); 182 result = true; 183 } else if (memSize != 0) { 184 if (mMgr.getBySystemName(systemPrefix + "MCURRENTTIME") == null) { 185 log.debug("memory no MCURRENTTIME sets true"); 186 result = true; // Two memories but one is not IMCURRENTTIME 187 } 188 if (mMgr.getBySystemName(systemPrefix + "MRATEFACTOR") == null) { 189 log.debug("memory no MRATEFACTOR sets true"); 190 result = true; // Two memories but one is not IMRATEFACTOR 191 } 192 } 193 } 194 195 if (!result) { 196 if (InstanceManager.getDefault(jmri.jmrit.display.EditorManager.class).getList().size() > 0) { 197 log.debug("panel check sets true"); 198 result = true; // One or more panels have been added. 199 } 200 } 201 202 return result; 203 } 204 205 @SuppressFBWarnings(value = {"OS_OPEN_STREAM_EXCEPTION_PATH", "RV_DONT_JUST_NULL_CHECK_READLINE"}, 206 justification = 207 "Open streams are not a problem during JMRI shutdown." 208 + "The line represents the end of a XML comment and is not relevant") 209 public static boolean checkFile(File inFile1, File inFile2) throws Exception { 210 boolean result = false; 211 // compare files, except for certain special lines 212 BufferedReader fileStream1 = new BufferedReader( 213 new InputStreamReader(new FileInputStream(inFile1))); 214 BufferedReader fileStream2 = new BufferedReader( 215 new InputStreamReader(new FileInputStream(inFile2))); 216 217 String line1 = fileStream1.readLine(); 218 String line2 = fileStream2.readLine(); 219 220 int lineNumber1 = 0, lineNumber2 = 0; 221 String next1, next2; 222 while ((next1 = fileStream1.readLine()) != null && (next2 = fileStream2.readLine()) != null) { 223 lineNumber1++; 224 lineNumber2++; 225 226 // Do we have a multi line comment? Comments in the xml file is used by LogixNG. 227 // This only happens in the first file since store() will not store comments 228 if (next1.startsWith("<!--")) { 229 while ((next1 = fileStream1.readLine()) != null && !next1.endsWith("-->")) { 230 lineNumber1++; 231 } 232 233 // If here, we either have a line that ends with --> or we have reached endf of file 234 if (fileStream1.readLine() == null) break; 235 236 // If here, we have a line that ends with --> or we have reached end of file 237 continue; 238 } 239 240 // where the (empty) entryexitpairs line ends up seems to be non-deterministic 241 // so if we see it in either file we just skip it 242 String entryexitpairs = "<entryexitpairs class=\"jmri.jmrit.signalling.configurexml.EntryExitPairsXml\" />"; 243 if (line1.contains(entryexitpairs)) { 244 line1 = next1; 245 if ((next1 = fileStream1.readLine()) == null) { 246 break; 247 } 248 lineNumber1++; 249 } 250 if (line2.contains(entryexitpairs)) { 251 line2 = next2; 252 if ((next2 = fileStream2.readLine()) == null) { 253 break; 254 } 255 lineNumber2++; 256 } 257 258 // if we get to the file history... 259 String filehistory = "filehistory"; 260 if (line1.contains(filehistory) && line2.contains(filehistory)) { 261 break; // we're done! 262 } 263 264 boolean match = false; // assume failure (pessimist!) 265 266 String[] startsWithStrings = { 267 " <!--Written by JMRI version", 268 " <test>", // version changes over time 269 " <modifier", // version changes over time 270 " <major", // version changes over time 271 " <minor", // version changes over time 272 "<layout-config", // Linux seems to put attributes in different order 273 "<?xml-stylesheet", // Linux seems to put attributes in different order 274 " <memory systemName=\"IMCURRENTTIME\"", // time varies - old format 275 " <modifier>This line ignored</modifier>" 276 }; 277 for (String startsWithString : startsWithStrings) { 278 if (line1.startsWith(startsWithString) && line2.startsWith(startsWithString)) { 279 match = true; 280 break; 281 } 282 } 283 284 // Memory variables have a value attribute for non-null values or no attribute. 285 if (!match) { 286 var mem1 = line1.startsWith(" <memory value") || line1.startsWith(" <memory>"); 287 var mem2 = line2.startsWith(" <memory value") || line2.startsWith(" <memory>"); 288 if (mem1 && mem2) { 289 match = true; 290 } 291 } 292 293 // Screen size will vary when written out 294 if (!match) { 295 if (line1.contains(" <LayoutEditor")) { 296 // if either line contains a windowheight attribute 297 String windowheight_regexe = "( windowheight=\"[^\"]*\")"; 298 line1 = filterLineUsingRegEx(line1, windowheight_regexe); 299 line2 = filterLineUsingRegEx(line2, windowheight_regexe); 300 // if either line contains a windowheight attribute 301 String windowwidth_regexe = "( windowwidth=\"[^\"]*\")"; 302 line1 = filterLineUsingRegEx(line1, windowwidth_regexe); 303 line2 = filterLineUsingRegEx(line2, windowwidth_regexe); 304 } 305 } 306 307 // window positions will sometimes differ based on window decorations. 308 if (!match) { 309 if (line1.contains(" <LayoutEditor") || 310 line1.contains(" <switchboardeditor")) { 311 // if either line contains a y position attribute 312 String yposition_regexe = "( y=\"[^\"]*\")"; 313 line1 = filterLineUsingRegEx(line1, yposition_regexe); 314 line2 = filterLineUsingRegEx(line2, yposition_regexe); 315 // if either line contains an x position attribute 316 String xposition_regexe = "( x=\"[^\"]*\")"; 317 line1 = filterLineUsingRegEx(line1, xposition_regexe); 318 line2 = filterLineUsingRegEx(line2, xposition_regexe); 319 } 320 } 321 322 // Dates can vary when written out 323 String date_string = "<date>"; 324 if (!match && line1.contains(date_string) && line2.contains(date_string)) { 325 match = true; 326 } 327 328 if (!match) { 329 // remove fontname and fontFamily attributes from comparison 330 String fontname_regexe = "( fontname=\"[^\"]*\")"; 331 line1 = filterLineUsingRegEx(line1, fontname_regexe); 332 line2 = filterLineUsingRegEx(line2, fontname_regexe); 333 String fontFamily_regexe = "( fontFamily=\"[^\"]*\")"; 334 line1 = filterLineUsingRegEx(line1, fontFamily_regexe); 335 line2 = filterLineUsingRegEx(line2, fontFamily_regexe); 336 } 337 338 // Check if timebase is ignored 339 if (!match && line1.startsWith(" <timebase") && line2.startsWith(" <timebase")) { 340 if (_preferences.isIgnoreTimebaseEnabled()) { 341 match = true; 342 } 343 } 344 345 // Check if sensor icon label colors are ignored 346 if (!match 347 && line1.startsWith(" <sensoricon") && line2.startsWith(" <sensoricon") 348 && line1.contains("icon=\"no\"") && line2.contains("icon=\"no\"") 349 && _preferences.isIgnoreSensorColorsEnabled()) { 350 line1 = removeSensorColors(line1); 351 line2 = removeSensorColors(line2); 352 } 353 354 if (!match && !line1.equals(line2)) { 355 log.warn("Match failed in StoreAndCompare:"); 356 log.warn(" file1:line {}: \"{}\"", lineNumber1, line1); 357 log.warn(" file2:line {}: \"{}\"", lineNumber2, line2); 358 log.warn(" comparing file1:\"{}\"", inFile1.getPath()); 359 log.warn(" to file2:\"{}\"", inFile2.getPath()); 360 result = true; 361 break; 362 } 363 line1 = next1; 364 line2 = next2; 365 } // while readLine() != null 366 367 fileStream1.close(); 368 fileStream2.close(); 369 370 return result; 371 } 372 373 private static String filterLineUsingRegEx(String line, String regexe) { 374 String[] splits = line.split(regexe); 375 if (splits.length == 2) { // (yes) remove it 376 line = splits[0] + splits[1]; 377 } 378 return line; 379 } 380 381 private static String removeSensorColors(String line) { 382 var leftSide = line.substring(0, line.indexOf(" red=")); 383 384 // Find the next non color attribute. "justification" is always present." 385 var index = 0; 386 if (line.indexOf("margin=") != -1) { 387 index = line.indexOf("margin="); 388 } else if (line.indexOf("borderSize=") != -1) { 389 index = line.indexOf("borderSize="); 390 } else if (line.indexOf("redBorder=") != -1) { 391 index = line.indexOf("redBorder="); 392 } else if (line.indexOf("greenBorder=") != -1) { 393 index = line.indexOf("greenBorder="); 394 } else if (line.indexOf("blueBorder=") != -1) { 395 index = line.indexOf("blueBorder="); 396 } else if (line.indexOf("fixedWidth=") != -1) { 397 index = line.indexOf("fixedWidth="); 398 } else if (line.indexOf("fixedHeight=") != -1) { 399 index = line.indexOf("fixedHeight="); 400 } else { 401 index = line.indexOf("justification="); 402 } 403 404 var rightSide = line.substring(index - 1, line.length()); 405 return leftSide + rightSide; 406 } 407 408 private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(StoreAndCompare.class); 409}