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