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