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