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}