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}