001package jmri.jmrit.operations.setup.backup;
002
003import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
004
005import java.io.*;
006import java.time.LocalDateTime;
007import java.time.format.DateTimeFormatter;
008import java.util.Arrays;
009
010import jmri.InstanceManagerAutoDefault;
011import jmri.beans.PropertyChangeSupport;
012import jmri.jmrit.XmlFile;
013import jmri.jmrit.operations.OperationsXml;
014
015import org.slf4j.Logger;
016import org.slf4j.LoggerFactory;
017
018/**
019 * Base class for backing up and restoring Operations working files. Derived
020 * classes implement specifics for working with different backup set stores,
021 * such as Automatic and Default backups.
022 *
023 * @author Gregory Madsen Copyright (C) 2012
024 */
025public abstract class BackupBase extends PropertyChangeSupport implements InstanceManagerAutoDefault {
026
027    private static final Logger log = LoggerFactory.getLogger(BackupBase.class);
028
029    // Property Changes
030    public static final String COPY_FILES_CHANGED_PROPERTY = "Backup Copy Files";
031
032    // Just for testing......
033    // If this is not null, it will be thrown to simulate various IO exceptions
034    // that are hard to reproduce when running tests..
035    public RuntimeException testException = null;
036
037    // The root directory for all Operations files, usually
038    // "user / name / JMRI / operations"
039    protected File _operationsRoot = null;
040
041    public File getOperationsRoot() {
042        return _operationsRoot;
043    }
044
045    // This will be set to the appropriate backup root directory from the
046    // derived
047    // classes, as their constructor will fill in the correct directory.
048    protected File _backupRoot;
049
050    public File getBackupRoot() {
051        return _backupRoot;
052    }
053
054    // These constitute the set of files for a complete backup set.
055    private final String[] _backupSetFileNames = new String[]{"Operations.xml", // NOI18N
056            "OperationsCarRoster.xml", "OperationsEngineRoster.xml", // NOI18N
057            "OperationsLocationRoster.xml", "OperationsRouteRoster.xml", // NOI18N
058            "OperationsTrainRoster.xml"}; // NOI18N
059
060    private final String _demoPanelFileName = "Operations Demo Panel.xml"; // NOI18N
061
062    public String[] getBackupSetFileNames() {
063        return _backupSetFileNames.clone();
064    }
065
066    /**
067     * Creates a BackupBase instance and initializes the Operations root
068     * directory to its normal value.
069     * @param rootName Directory name to use.
070     */
071    protected BackupBase(String rootName) {
072        // A root directory name for the backups must be supplied, which will be
073        // from the derived class constructors.
074        if (rootName == null) {
075            throw new IllegalArgumentException("Backup root name can't be null"); // NOI18N
076        }
077        _operationsRoot = new File(OperationsXml.getFileLocation(), OperationsXml.getOperationsDirectoryName());
078
079        _backupRoot = new File(getOperationsRoot(), rootName);
080
081        // Make sure it exists
082        if (!getBackupRoot().exists()) {
083            Boolean ok = getBackupRoot().mkdirs();
084            if (!ok) {
085                throw new RuntimeException("Unable to make directory: " // NOI18N
086                        + getBackupRoot().getAbsolutePath());
087            }
088        }
089
090        // We maybe want to check if it failed and throw an exception.
091    }
092
093    /**
094     * Backs up Operations files to the named backup set under the backup root
095     * directory.
096     *
097     * @param setName The name of the new backup set
098     * @throws java.io.IOException Due to trouble writing files
099     * @throws IllegalArgumentException  if string null or empty
100     */
101    public void backupFilesToSetName(String setName) throws IOException, IllegalArgumentException {
102        validateNotNullOrEmpty(setName);
103
104        copyBackupSet(getOperationsRoot(), new File(getBackupRoot(), setName));
105    }
106
107    private void validateNotNullOrEmpty(String s) throws IllegalArgumentException {
108        if (s == null || s.trim().length() == 0) {
109            throw new IllegalArgumentException(
110                    "string cannot be null or empty."); // NOI18N
111        }
112
113    }
114
115    /**
116     * Creates backup files for the directory specified. Assumes that
117     * backupDirectory is a fully qualified path where the individual files will
118     * be created. This will backup files to any directory which does not have
119     * to be part of the JMRI hierarchy.
120     *
121     * @param backupDirectory The directory to use for the backup.
122     * @throws java.io.IOException Due to trouble writing files
123     */
124    public void backupFilesToDirectory(File backupDirectory) throws IOException {
125        copyBackupSet(getOperationsRoot(), backupDirectory);
126    }
127
128    /**
129     * Returns a sorted list of the Backup Sets under the backup root.
130     * @return A sorted backup list.
131     *
132     */
133    @SuppressFBWarnings(value = "NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE",
134            justification = "not possible")  // NOI18N
135    public String[] getBackupSetList() {
136        String[] setList = getBackupRoot().list();
137        // no guarantee of order, so we need to sort
138        Arrays.sort(setList);
139        return setList;
140    }
141
142    public File[] getBackupSetDirs() {
143        // Returns a list of File objects for the backup sets in the
144        // backup store.
145        // Not used at the moment, and can probably be removed in favor of
146        // getBackupSets()
147        File[] dirs = getBackupRoot().listFiles();
148
149        return dirs;
150    }
151
152    @SuppressFBWarnings(value = "NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE",
153            justification = "not possible")  // NOI18N
154    public BackupSet[] getBackupSets() {
155        // This is a bit of a kludge for now, until I learn more about dynamic
156        // sets
157        File[] dirs = getBackupRoot().listFiles();
158        Arrays.sort(dirs);
159        BackupSet[] sets = new BackupSet[dirs.length];
160
161        for (int i = 0; i < dirs.length; i++) {
162            sets[i] = new BackupSet(dirs[i]);
163        }
164
165        return sets;
166    }
167
168    /**
169     * Check to see if the given backup set already exists in the backup store.
170     * @param setName The directory name to check.
171     *
172     * @return true if it exists
173     */
174    public boolean checkIfBackupSetExists(String setName) {
175        // This probably needs to be simplified, but leave for now.
176
177        try {
178            validateNotNullOrEmpty(setName);
179            File file = new File(getBackupRoot(), setName);
180
181            if (file.exists()) {
182                return true;
183            }
184        } catch (Exception e) {
185            log.error("Exception during backup set directory exists check");
186        }
187        return false;
188    }
189
190    /**
191     * Restores a Backup Set with the given name from the backup store.
192     * @param setName The directory name.
193     *
194     * @throws java.io.IOException Due to trouble loading files
195     */
196    public void restoreFilesFromSetName(String setName) throws IOException {
197        copyBackupSet(new File(getBackupRoot(), setName), getOperationsRoot());
198    }
199
200    /**
201     * Restores a Backup Set from the given directory.
202     * @param directory The File directory.
203     *
204     * @throws java.io.IOException Due to trouble loading files
205     */
206    @edu.umd.cs.findbugs.annotations.SuppressFBWarnings(value = "SLF4J_FORMAT_SHOULD_BE_CONST",
207            justification = "I18N of Info Message")
208    public void restoreFilesFromDirectory(File directory) throws IOException {
209        log.info(Bundle.getMessage("InfoRestoringDirectory", directory.getAbsolutePath()));
210
211        copyBackupSet(directory, getOperationsRoot());
212    }
213
214    /**
215     * Copies a complete set of Operations files from one directory to another
216     * directory. Usually used to copy to or from a backup location. Creates the
217     * destination directory if it does not exist.
218     *
219     * Only copies files that are included in the list of Operations files.
220     * @param sourceDir From Directory
221     * @param destDir To Directory
222     *
223     * @throws java.io.IOException Due to trouble reading or writing
224     */
225    @edu.umd.cs.findbugs.annotations.SuppressFBWarnings( value="SLF4J_FORMAT_SHOULD_BE_CONST",
226            justification="I18N of Info Message")
227    public void copyBackupSet(File sourceDir, File destDir) throws IOException {
228        log.debug("copying backup set from: {} to: {}", sourceDir, destDir);
229        log.info(Bundle.getMessage("InfoSavingCopy", destDir));
230
231        if (!sourceDir.exists()) // This throws an exception, as the dir should
232        // exist.
233        {
234            throw new IOException("Backup Set source directory: " // NOI18N
235                    + sourceDir.getAbsolutePath() + " does not exist"); // NOI18N
236        }
237        // See how many Operations files we have. If they are all there, carry
238        // on, if there are none, just return, any other number MAY be an error,
239        // so just log it.
240        // We can't throw an exception, as this CAN be a valid state.
241        // There is no way to tell if a missing file is an error or not the way
242        // the files are created.
243
244        int sourceCount = getSourceFileCount(sourceDir);
245
246        if (sourceCount == 0) {
247            log.debug("No source files found in {} so skipping copy.", sourceDir.getAbsolutePath()); // NOI18N
248            return;
249        }
250
251        if (sourceCount != _backupSetFileNames.length) {
252            log.warn("Only {} file(s) found in directory {}", sourceCount, sourceDir.getAbsolutePath());
253            // throw new IOException("Only " + sourceCount
254            // + " file(s) found in directory "
255            // + sourceDir.getAbsolutePath());
256        }
257
258        // Ensure destination directory exists
259        if (!destDir.exists()) {
260            // Note that mkdirs does NOT throw an exception on error.
261            // It will return false if the directory already exists.
262            boolean result = destDir.mkdirs();
263
264            if (!result) {
265                // This needs to use a better Exception class.....
266                throw new IOException(
267                        destDir.getAbsolutePath() + " (Could not create all or part of the Backup Set path)"); // NOI18N
268            }
269        }
270
271        // Just copy the specific Operations files, now that we know they are
272        // all there.
273        for (String name : _backupSetFileNames) {
274            log.debug("copying file: {}", name);
275
276            File src = new File(sourceDir, name);
277
278            if (src.exists()) {
279                File dst = new File(destDir, name);
280
281                FileHelper.copy(src.getAbsolutePath(), dst.getAbsolutePath(), true);
282            } else {
283                log.debug("Source file: {} does not exist, and is not copied.", src.getAbsolutePath());
284            }
285
286        }
287
288        // Throw a test exception, if we have one.
289        if (testException != null) {
290            testException.fillInStackTrace();
291            throw testException;
292        }
293        firePropertyChange(COPY_FILES_CHANGED_PROPERTY, sourceDir, destDir);
294    }
295
296    /**
297     * Checks to see how many of the Operations files are present in the source
298     * directory.
299     * @param sourceDir The Directory to check.
300     *
301     * @return number of files
302     */
303    public int getSourceFileCount(File sourceDir) {
304        int count = 0;
305        Boolean exists;
306
307        for (String name : _backupSetFileNames) {
308            exists = new File(sourceDir, name).exists();
309            if (exists) {
310                count++;
311            }
312        }
313
314        return count;
315    }
316
317    /**
318     * Reloads the demo Operations files that are distributed with JMRI.
319     *
320     * @throws java.io.IOException Due to trouble loading files
321     */
322    public void loadDemoFiles() throws IOException {
323        File fromDir = new File(XmlFile.xmlDir(), "demoOperations"); // NOI18N
324        copyBackupSet(fromDir, getOperationsRoot());
325
326        // and the demo panel file
327        log.debug("copying file: {}", _demoPanelFileName);
328
329        File src = new File(fromDir, _demoPanelFileName);
330        File dst = new File(getOperationsRoot(), _demoPanelFileName);
331
332        FileHelper.copy(src.getAbsolutePath(), dst.getAbsolutePath(), true);
333    }
334
335    /**
336     * Searches for an unused directory name, based on the default base name,
337     * under the given directory. A name suffix as appended to the base name and
338     * can range from 00 to 99.
339     *
340     * @return A backup set name that is not already in use.
341     */
342    public String suggestBackupSetName() {
343        // Start with a base name that is derived from today's date
344        // This checks to see if the default name already exists under the given
345        // backup root directory.
346        // If it exists, the name is incremented by 1 up to 99 and checked
347        // again.
348        String baseName = getDate();
349        String fullName = null;
350        String[] dirNames = getBackupRoot().list();
351
352        // Check for up to 100 backup file names to see if they already exist
353        for (int i = 0; i < 99; i++) {
354            // Create the trial name, then see if it already exists.
355            fullName = String.format("%s_%02d", baseName, i); // NOI18N
356
357            boolean foundFileNameMatch = false;
358            for (String name : dirNames) {
359                if (name.equals(fullName)) {
360                    foundFileNameMatch = true;
361                    break;
362                }
363            }
364            if (!foundFileNameMatch) {
365                return fullName;
366            }
367
368            //   This should also work, commented out by D. Boudreau
369            //   The Linux problem turned out to be related to the order
370            //   files names are returned by list().
371            //   File testPath = new File(_backupRoot, fullName);
372            //
373            //   if (!testPath.exists()) {
374            //    return fullName; // Found an unused name
375            // Otherwise complain and keep trying...
376            log.debug("Operations backup directory: {} already exists", fullName); // NOI18N
377        }
378
379        // If we get here, we have tried all 100 variants without success. This
380        // should probably throw an exception, but for now it just returns the
381        // last file name tried.
382        return fullName;
383    }
384
385    /**
386     * Reset Operations by deleting XML files, leaves directories and backup
387     * files in place.
388     */
389    @SuppressFBWarnings(value = "NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE",
390            justification = "not possible")  // NOI18N
391    public void deleteOperationsFiles() {
392        // TODO Maybe this should also only delete specific files used by Operations,
393        // and not just all XML files.
394        File files = getOperationsRoot();
395
396        if (!files.exists()) {
397            return;
398        }
399
400        String[] operationFileNames = files.list();
401        for (String fileName : operationFileNames) {
402            // skip non-xml files
403            if (!fileName.toUpperCase().endsWith(".XML")) // NOI18N
404            {
405                continue;
406            }
407            //
408            log.debug("deleting file: {}", fileName);
409            File file = new File(getOperationsRoot() + File.separator + fileName);
410            if (!file.delete()) {
411                log.debug("file not deleted");
412            }
413            // TODO This should probably throw an exception if a delete fails.
414        }
415    }
416
417    /**
418     * Returns the current date formatted for use as part of a Backup Set name.
419     */
420    private String getDate() {
421        LocalDateTime date = LocalDateTime.now();
422        DateTimeFormatter simpleDateFormat = DateTimeFormatter.ofPattern("yyyy_MM_dd"); // NOI18N
423        return date.format(simpleDateFormat);
424    }
425
426    /**
427     * Helper class for working with Files and Paths. Should probably be moved
428     * into its own public class.
429     *
430     * Probably won't be needed now that I discovered the File class and it can
431     * glue together paths. Need to explore it a bit more.
432     *
433     * @author Gregory Madsen Copyright (C) 2012
434     *
435     */
436    private static class FileHelper {
437
438        /**
439         * Copies an existing file to a new file. Overwriting a file of the same
440         * name is allowed. The destination directory must exist.
441         * @param sourceFileName From directory name
442         * @param destFileName To directory name
443         * @param overwrite When true overwrite any existing files
444         * @throws IOException Thrown when overwrite false and destination directory exists.
445         *
446         */
447        @SuppressFBWarnings(value = "OBL_UNSATISFIED_OBLIGATION")
448        public static void copy(String sourceFileName, String destFileName,
449                Boolean overwrite) throws IOException {
450
451            // If we can't overwrite the destination, check if the destination
452            // already exists
453            if (!overwrite) {
454                if (new File(destFileName).exists()) {
455                    throw new IOException(
456                            "Destination file exists and overwrite is false."); // NOI18N
457                }
458            }
459
460            try (InputStream source = new FileInputStream(sourceFileName);
461                    OutputStream dest = new FileOutputStream(destFileName)) {
462
463                byte[] buffer = new byte[1024];
464
465                int len;
466
467                while ((len = source.read(buffer)) > 0) {
468                    dest.write(buffer, 0, len);
469                }
470            } catch (IOException ex) {
471                String msg = String.format("Error copying file: %s to: %s", // NOI18N
472                        sourceFileName, destFileName);
473                throw new IOException(msg, ex);
474            }
475
476            // Now update the last modified time to equal the source file.
477            File src = new File(sourceFileName);
478            File dst = new File(destFileName);
479
480            Boolean ok = dst.setLastModified(src.lastModified());
481            if (!ok) {
482                throw new RuntimeException(
483                        "Failed to set modified time on file: " // NOI18N
484                                + dst.getAbsolutePath());
485            }
486        }
487    }
488
489}