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