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