JAL-3628 Changed all file1.renameTo(file2) calls to Files.move(path1,path2,REPLACE_EX...
[jalview.git] / src / jalview / io / BackupFiles.java
1 /*
2  * Jalview - A Sequence Alignment Editor and Viewer ($$Version-Rel$$)
3  * Copyright (C) $$Year-Rel$$ The Jalview Authors
4  * 
5  * This file is part of Jalview.
6  * 
7  * Jalview is free software: you can redistribute it and/or
8  * modify it under the terms of the GNU General Public License 
9  * as published by the Free Software Foundation, either version 3
10  * of the License, or (at your option) any later version.
11  *  
12  * Jalview is distributed in the hope that it will be useful, but 
13  * WITHOUT ANY WARRANTY; without even the implied warranty 
14  * of MERCHANTABILITY or FITNESS FOR A PARTICULAR 
15  * PURPOSE.  See the GNU General Public License for more details.
16  * 
17  * You should have received a copy of the GNU General Public License
18  * along with Jalview.  If not, see <http://www.gnu.org/licenses/>.
19  * The Jalview Authors are detailed in the 'AUTHORS' file.
20  */
21 package jalview.io;
22
23 import java.io.File;
24 import java.io.IOException;
25 import java.nio.file.Files;
26 import java.nio.file.Path;
27 import java.nio.file.Paths;
28 import java.nio.file.StandardCopyOption;
29 import java.text.SimpleDateFormat;
30 import java.util.ArrayList;
31 import java.util.HashMap;
32 import java.util.Map;
33 import java.util.TreeMap;
34
35 import jalview.bin.Cache;
36 import jalview.gui.Desktop;
37 import jalview.gui.JvOptionPane;
38 import jalview.util.MessageManager;
39
40 /*
41  * BackupFiles used for manipulating (naming rolling/deleting) backup/version files when an alignment or project file is saved.
42  * User configurable options are:
43  * BACKUPFILES_ENABLED - boolean flag as to whether to use this mechanism or act as before, including overwriting files as saved.
44  * The rest of the options are now saved as BACKUPFILES_PRESET, BACKUPFILES_SAVED and BACKUPFILES_CUSTOM
45  * (see BackupFilesPresetEntry)
46  */
47
48 public class BackupFiles
49 {
50
51   // labels for saved params in Cache and .jalview_properties
52   public static final String NS = "BACKUPFILES";
53
54   public static final String ENABLED = NS + "_ENABLED";
55
56   public static final String NUM_PLACEHOLDER = "%n";
57
58   private static final String DEFAULT_TEMP_FILE = "jalview_temp_file_" + NS;
59
60   private static final String TEMP_FILE_EXT = ".tmp";
61
62   // file - File object to be backed up and then updated (written over)
63   private File file;
64
65   // enabled - default flag as to whether to do the backup file roll (if not
66   // defined in preferences)
67   private static boolean enabled;
68
69   // confirmDelete - default flag as to whether to confirm with the user before
70   // deleting old backup/version files
71   private static boolean confirmDelete;
72
73   // defaultSuffix - default template to use to append to basename of file
74   private String suffix;
75
76   // noMax - flag to turn off a maximum number of files
77   private boolean noMax;
78
79   // defaultMax - default max number of backup files
80   private int max;
81
82   // defaultDigits - number of zero-led digits to use in the filename
83   private int digits;
84
85   // reverseOrder - set to true to make newest (latest) files lowest number
86   // (like rolled log files)
87   private boolean reverseOrder;
88
89   // temp saved file to become new saved file
90   private File tempFile;
91
92   // flag set to see if file save to temp file was successful
93   private boolean tempFileWriteSuccess;
94
95   // array of files to be deleted, with extra information
96   private ArrayList<File> deleteFiles = new ArrayList<>();
97
98   // date formatting for modification times
99   private static final SimpleDateFormat sdf = new SimpleDateFormat(
100           "yyyy-MM-dd HH:mm:ss");
101
102   private static final String newTempFileSuffix = "_newfile";
103
104   private static final String oldTempFileSuffix = "_oldfile_tobedeleted";
105
106   public BackupFiles(String filename)
107   {
108     this(new File(filename));
109   }
110
111   // first time defaults for SUFFIX, NO_MAX, ROLL_MAX, SUFFIX_DIGITS and
112   // REVERSE_ORDER
113   public BackupFiles(File file)
114   {
115     classInit();
116     this.file = file;
117     BackupFilesPresetEntry bfpe = BackupFilesPresetEntry
118             .getSavedBackupEntry();
119     this.suffix = bfpe.suffix;
120     this.noMax = bfpe.keepAll;
121     this.max = bfpe.rollMax;
122     this.digits = bfpe.digits;
123     this.reverseOrder = bfpe.reverse;
124
125     // create a temp file to save new data in
126     File temp = null;
127     try
128     {
129       if (file != null)
130       {
131         String tempfilename = file.getName();
132         File tempdir = file.getParentFile();
133         temp = File.createTempFile(tempfilename,
134                 TEMP_FILE_EXT + newTempFileSuffix, tempdir);
135       }
136       else
137       {
138         temp = File.createTempFile(DEFAULT_TEMP_FILE, TEMP_FILE_EXT);
139       }
140     } catch (IOException e)
141     {
142       System.out.println(
143               "Could not create temp file to save into (IOException)");
144     } catch (Exception e)
145     {
146       System.out.println("Exception ctreating temp file for saving");
147     }
148     this.setTempFile(temp);
149   }
150
151   public static void classInit()
152   {
153     setEnabled(Cache.getDefault(ENABLED, true));
154     BackupFilesPresetEntry bfpe = BackupFilesPresetEntry
155             .getSavedBackupEntry();
156     setConfirmDelete(bfpe.confirmDelete);
157   }
158
159   public static void setEnabled(boolean flag)
160   {
161     enabled = flag;
162   }
163
164   public static boolean getEnabled()
165   {
166     classInit();
167     return enabled;
168   }
169
170   public static void setConfirmDelete(boolean flag)
171   {
172     confirmDelete = flag;
173   }
174
175   public static boolean getConfirmDelete()
176   {
177     classInit();
178     return confirmDelete;
179   }
180
181   // set, get and rename temp file into place
182   public void setTempFile(File temp)
183   {
184     this.tempFile = temp;
185   }
186
187   public File getTempFile()
188   {
189     return tempFile;
190   }
191
192   public String getTempFilePath()
193   {
194     String path = null;
195     try
196     {
197       path = this.getTempFile().getCanonicalPath();
198     } catch (IOException e)
199     {
200       System.out.println(
201               "IOException when getting Canonical Path of temp file '"
202                       + this.getTempFile().getName() + "'");
203     }
204     return path;
205   }
206
207   public boolean setWriteSuccess(boolean flag)
208   {
209     boolean old = this.tempFileWriteSuccess;
210     this.tempFileWriteSuccess = flag;
211     return old;
212   }
213
214   public boolean getWriteSuccess()
215   {
216     return this.tempFileWriteSuccess;
217   }
218
219   public boolean renameTempFile()
220   {
221     return moveFileToFile(tempFile, file);
222   }
223
224   // roll the backupfiles
225   public boolean rollBackupFiles()
226   {
227     return this.rollBackupFiles(true);
228   }
229
230   public boolean rollBackupFiles(boolean tidyUp)
231   {
232     // file doesn't yet exist or backups are not enabled or template is null or
233     // empty
234     if ((!file.exists()) || (!enabled) || max < 0 || suffix == null
235             || suffix.length() == 0)
236     {
237       // nothing to do
238       return true;
239     }
240
241     String dir = "";
242     File dirFile;
243     try
244     {
245       dirFile = file.getParentFile();
246       dir = dirFile.getCanonicalPath();
247     } catch (Exception e)
248     {
249       System.out.println(
250               "Could not get canonical path for file '" + file + "'");
251       return false;
252     }
253     String filename = file.getName();
254     String basename = filename;
255
256     boolean ret = true;
257     // Create/move backups up one
258
259     deleteFiles.clear();
260
261     // find existing backup files
262     BackupFilenameFilter bff = new BackupFilenameFilter(basename, suffix,
263             digits);
264     File[] backupFiles = dirFile.listFiles(bff);
265     int nextIndexNum = 0;
266
267     if (backupFiles.length == 0)
268     {
269       // No other backup files. Just need to move existing file to backupfile_1
270       nextIndexNum = 1;
271     }
272     else
273     {
274       TreeMap<Integer, File> bfTreeMap = sortBackupFilesAsTreeMap(
275               backupFiles, basename);
276       // bfTreeMap now a sorted list of <Integer index>,<File backupfile>
277       // mappings
278
279       if (reverseOrder)
280       {
281         // backup style numbering
282
283         int tempMax = noMax ? -1 : max;
284         // noMax == true means no limits
285         // look for first "gap" in backupFiles
286         // if tempMax is -1 at this stage just keep going until there's a gap,
287         // then hopefully tempMax gets set to the right index (a positive
288         // integer so the loop breaks)...
289         // why do I feel a little uneasy about this loop?..
290         for (int i = 1; tempMax < 0 || i <= max; i++)
291         {
292           if (!bfTreeMap.containsKey(i)) // first index without existent
293                                          // backupfile
294           {
295             tempMax = i;
296           }
297         }
298
299         File previousFile = null;
300         File fileToBeDeleted = null;
301         for (int n = tempMax; n > 0; n--)
302         {
303           String backupfilename = dir + File.separatorChar
304                   + BackupFilenameParts.getBackupFilename(n, basename,
305                           suffix, digits);
306           File backupfile_n = new File(backupfilename);
307
308           if (!backupfile_n.exists())
309           {
310             // no "oldest" file to delete
311             previousFile = backupfile_n;
312             fileToBeDeleted = null;
313             continue;
314           }
315
316           // check the modification time of this (backupfile_n) and the previous
317           // file (fileToBeDeleted) if the previous file is going to be deleted
318           if (fileToBeDeleted != null)
319           {
320             File replacementFile = backupfile_n;
321             long fileToBeDeletedLMT = fileToBeDeleted.lastModified();
322             long replacementFileLMT = replacementFile.lastModified();
323
324             try
325             {
326               File oldestTempFile = nextTempFile(fileToBeDeleted.getName(),
327                       dirFile);
328
329               if (fileToBeDeletedLMT > replacementFileLMT)
330               {
331                 String fileToBeDeletedLMTString = sdf
332                         .format(fileToBeDeletedLMT);
333                 String replacementFileLMTString = sdf
334                         .format(replacementFileLMT);
335                 System.out.println("WARNING! I am set to delete backupfile "
336                         + fileToBeDeleted.getName()
337                         + " has modification time "
338                         + fileToBeDeletedLMTString
339                         + " which is newer than its replacement "
340                         + replacementFile.getName()
341                         + " with modification time "
342                         + replacementFileLMTString);
343
344                 boolean delete = confirmNewerDeleteFile(fileToBeDeleted,
345                         replacementFile, true);
346
347                 if (delete)
348                 {
349                   // User has confirmed delete -- no need to add it to the list
350                   fileToBeDeleted.delete();
351                 }
352                 else
353                 {
354                   moveFileToFile(fileToBeDeleted, oldestTempFile);
355                 }
356               }
357               else
358               {
359                 moveFileToFile(fileToBeDeleted, oldestTempFile);
360                 addDeleteFile(oldestTempFile);
361               }
362
363             } catch (Exception e)
364             {
365               System.out.println(
366                       "Error occurred, probably making new temp file for '"
367                               + fileToBeDeleted.getName() + "'");
368               e.printStackTrace();
369             }
370
371             // reset
372             fileToBeDeleted = null;
373           }
374
375           if (!noMax && n == tempMax && backupfile_n.exists())
376           {
377             fileToBeDeleted = backupfile_n;
378           }
379           else
380           {
381             if (previousFile != null)
382             {
383               ret = ret && moveFileToFile(backupfile_n, previousFile);
384             }
385           }
386
387           previousFile = backupfile_n;
388         }
389
390         // index to use for the latest backup
391         nextIndexNum = 1;
392       }
393       else
394       {
395         // version style numbering (with earliest file deletion if max files
396         // reached)
397
398         bfTreeMap.values().toArray(backupFiles);
399
400         // noMax == true means keep all backup files
401         if ((!noMax) && bfTreeMap.size() >= max)
402         {
403           // need to delete some files to keep number of backups to designated
404           // max
405           int numToDelete = bfTreeMap.size() - max + 1;
406           // the "replacement" file is the latest backup file being kept (it's
407           // not replacing though)
408           File replacementFile = numToDelete < backupFiles.length
409                   ? backupFiles[numToDelete]
410                   : null;
411           for (int i = 0; i < numToDelete; i++)
412           {
413             // check the deletion files for modification time of the last
414             // backupfile being saved
415             File fileToBeDeleted = backupFiles[i];
416             boolean delete = true;
417
418             boolean newer = false;
419             if (replacementFile != null)
420             {
421               long fileToBeDeletedLMT = fileToBeDeleted.lastModified();
422               long replacementFileLMT = replacementFile != null
423                       ? replacementFile.lastModified()
424                       : Long.MAX_VALUE;
425               if (fileToBeDeletedLMT > replacementFileLMT)
426               {
427                 String fileToBeDeletedLMTString = sdf
428                         .format(fileToBeDeletedLMT);
429                 String replacementFileLMTString = sdf
430                         .format(replacementFileLMT);
431
432                 System.out
433                         .println("WARNING! I am set to delete backupfile '"
434                                 + fileToBeDeleted.getName()
435                                 + "' has modification time "
436                                 + fileToBeDeletedLMTString
437                                 + " which is newer than the oldest backupfile being kept '"
438                                 + replacementFile.getName()
439                                 + "' with modification time "
440                                 + replacementFileLMTString);
441
442                 delete = confirmNewerDeleteFile(fileToBeDeleted,
443                         replacementFile, false);
444                 if (delete)
445                 {
446                   // User has confirmed delete -- no need to add it to the list
447                   fileToBeDeleted.delete();
448                   delete = false;
449                 }
450                 else
451                 {
452                   // keeping file, nothing to do!
453                 }
454               }
455             }
456             if (delete)
457             {
458               addDeleteFile(fileToBeDeleted);
459             }
460
461           }
462
463         }
464
465         nextIndexNum = bfTreeMap.lastKey() + 1;
466       }
467     }
468
469     // Let's make the new backup file!! yay, got there at last!
470     String latestBackupFilename = dir + File.separatorChar
471             + BackupFilenameParts.getBackupFilename(nextIndexNum, basename,
472                     suffix, digits);
473     ret |= moveFileToFile(file, new File(latestBackupFilename));
474
475     if (tidyUp)
476     {
477       tidyUpFiles();
478     }
479
480     return ret;
481   }
482
483   private static File nextTempFile(String filename, File dirFile)
484           throws IOException
485   {
486     File temp = null;
487     COUNT: for (int i = 1; i < 1000; i++)
488     {
489       File trythis = new File(dirFile,
490               filename + '~' + Integer.toString(i));
491       if (!trythis.exists())
492       {
493         temp = trythis;
494         break COUNT;
495       }
496
497     }
498     if (temp == null)
499     {
500       temp = File.createTempFile(filename, TEMP_FILE_EXT, dirFile);
501     }
502     return temp;
503   }
504
505   private void tidyUpFiles()
506   {
507     deleteOldFiles();
508   }
509
510   private static boolean confirmNewerDeleteFile(File fileToBeDeleted,
511           File replacementFile, boolean replace)
512   {
513     StringBuilder messageSB = new StringBuilder();
514
515     File ftbd = fileToBeDeleted;
516     String ftbdLMT = sdf.format(ftbd.lastModified());
517     String ftbdSize = Long.toString(ftbd.length());
518
519     File rf = replacementFile;
520     String rfLMT = sdf.format(rf.lastModified());
521     String rfSize = Long.toString(rf.length());
522
523     int confirmButton = JvOptionPane.NO_OPTION;
524     if (replace)
525     {
526       File saveFile = null;
527       try
528       {
529         saveFile = nextTempFile(ftbd.getName(), ftbd.getParentFile());
530       } catch (Exception e)
531       {
532         System.out.println(
533                 "Error when confirming to keep backup file newer than other backup files.");
534         e.printStackTrace();
535       }
536       messageSB.append(MessageManager.formatMessage(
537               "label.newerdelete_replacement_line", new String[]
538               { ftbd.getName(), rf.getName(), ftbdLMT, rfLMT, ftbdSize,
539                   rfSize }));
540       messageSB.append("\n\n");
541       messageSB.append(MessageManager.formatMessage(
542               "label.confirm_deletion_or_rename", new String[]
543               { ftbd.getName(), saveFile.getName() }));
544       String[] options = new String[] {
545           MessageManager.getString("label.delete"),
546           MessageManager.getString("label.rename") };
547
548       confirmButton = JvOptionPane.showOptionDialog(Desktop.desktop,
549               messageSB.toString(),
550               MessageManager.getString("label.backupfiles_confirm_delete"),
551               JvOptionPane.YES_NO_OPTION, JvOptionPane.WARNING_MESSAGE,
552               null, options, options[0]);
553     }
554     else
555     {
556       messageSB.append(MessageManager
557               .formatMessage("label.newerdelete_line", new String[]
558               { ftbd.getName(), rf.getName(), ftbdLMT, rfLMT, ftbdSize,
559                   rfSize }));
560       messageSB.append("\n\n");
561       messageSB.append(MessageManager
562               .formatMessage("label.confirm_deletion", new String[]
563               { ftbd.getName() }));
564       String[] options = new String[] {
565           MessageManager.getString("label.delete"),
566           MessageManager.getString("label.keep") };
567
568       confirmButton = JvOptionPane.showOptionDialog(Desktop.desktop,
569               messageSB.toString(),
570               MessageManager.getString("label.backupfiles_confirm_delete"),
571               JvOptionPane.YES_NO_OPTION, JvOptionPane.WARNING_MESSAGE,
572               null, options, options[0]);
573     }
574
575     // return should be TRUE if file is to be deleted
576     return (confirmButton == JvOptionPane.YES_OPTION);
577   }
578
579   private void deleteOldFiles()
580   {
581     if (deleteFiles != null && !deleteFiles.isEmpty())
582     {
583       boolean doDelete = false;
584       StringBuilder messageSB = null;
585       if (confirmDelete && deleteFiles.size() > 0)
586       {
587         messageSB = new StringBuilder();
588         messageSB.append(MessageManager
589                 .getString("label.backupfiles_confirm_delete_old_files"));
590         for (int i = 0; i < deleteFiles.size(); i++)
591         {
592           File df = deleteFiles.get(i);
593           messageSB.append("\n");
594           messageSB.append(df.getName());
595           messageSB.append(" ");
596           messageSB.append(MessageManager.formatMessage("label.file_info",
597                   new String[]
598                   { sdf.format(df.lastModified()),
599                       Long.toString(df.length()) }));
600         }
601
602         int confirmButton = JvOptionPane.showConfirmDialog(Desktop.desktop,
603                 messageSB.toString(),
604                 MessageManager
605                         .getString("label.backupfiles_confirm_delete"),
606                 JvOptionPane.YES_NO_OPTION, JvOptionPane.WARNING_MESSAGE);
607
608         doDelete = (confirmButton == JvOptionPane.YES_OPTION);
609       }
610       else
611       {
612         doDelete = true;
613       }
614
615       if (doDelete)
616       {
617         for (int i = 0; i < deleteFiles.size(); i++)
618         {
619           File fileToDelete = deleteFiles.get(i);
620           fileToDelete.delete();
621           System.out.println("DELETING '" + fileToDelete.getName() + "'");
622         }
623       }
624
625     }
626
627     deleteFiles.clear();
628   }
629
630   private TreeMap<Integer, File> sortBackupFilesAsTreeMap(
631           File[] backupFiles, String basename)
632   {
633     // sort the backup files (based on integer found in the suffix) using a
634     // precomputed Hashmap for speed
635     Map<Integer, File> bfHashMap = new HashMap<>();
636     for (int i = 0; i < backupFiles.length; i++)
637     {
638       File f = backupFiles[i];
639       BackupFilenameParts bfp = new BackupFilenameParts(f, basename, suffix,
640               digits);
641       bfHashMap.put(bfp.indexNum(), f);
642     }
643     TreeMap<Integer, File> bfTreeMap = new TreeMap<>();
644     bfTreeMap.putAll(bfHashMap);
645     return bfTreeMap;
646   }
647
648   public boolean rollBackupsAndRenameTempFile()
649   {
650     boolean write = this.getWriteSuccess();
651
652     boolean roll = false;
653     boolean rename = false;
654     if (write)
655     {
656       roll = this.rollBackupFiles(false);
657       rename = this.renameTempFile();
658     }
659
660     /*
661      * Not sure that this confirmation is desirable.  By this stage the new file is
662      * already written successfully, but something (e.g. disk full) has happened while 
663      * trying to roll the backup files, and most likely the filename needed will already
664      * be vacant so renaming the temp file is nearly always correct!
665      */
666     boolean okay = roll && rename;
667     if (!okay)
668     {
669       StringBuilder messageSB = new StringBuilder();
670       messageSB.append(MessageManager.getString(
671               "label.backupfiles_confirm_save_file_backupfiles_roll_wrong"));
672       if (rename)
673       {
674         if (messageSB.length() > 0)
675         {
676           messageSB.append("\n");
677         }
678         messageSB.append(MessageManager.getString(
679                 "label.backupfiles_confirm_save_new_saved_file_ok"));
680       }
681       else
682       {
683         if (messageSB.length() > 0)
684         {
685           messageSB.append("\n");
686         }
687         messageSB.append(MessageManager.getString(
688                 "label.backupfiles_confirm_save_new_saved_file_not_ok"));
689       }
690
691       int confirmButton = JvOptionPane.showConfirmDialog(Desktop.desktop,
692               messageSB.toString(),
693               MessageManager
694                       .getString("label.backupfiles_confirm_save_file"),
695               JvOptionPane.OK_OPTION, JvOptionPane.WARNING_MESSAGE);
696       okay = confirmButton == JvOptionPane.OK_OPTION;
697     }
698     if (okay)
699     {
700       tidyUpFiles();
701     }
702
703     return rename;
704   }
705
706   public static TreeMap<Integer, File> getBackupFilesAsTreeMap(
707           String fileName, String suffix, int digits)
708   {
709     File[] backupFiles = null;
710
711     File file = new File(fileName);
712
713     File dirFile;
714     try
715     {
716       dirFile = file.getParentFile();
717     } catch (Exception e)
718     {
719       System.out.println(
720               "Could not get canonical path for file '" + file + "'");
721       return new TreeMap<>();
722     }
723
724     String filename = file.getName();
725     String basename = filename;
726
727     // find existing backup files
728     BackupFilenameFilter bff = new BackupFilenameFilter(basename, suffix,
729             digits);
730     backupFiles = dirFile.listFiles(bff); // is clone needed?
731
732     // sort the backup files (based on integer found in the suffix) using a
733     // precomputed Hashmap for speed
734     Map<Integer, File> bfHashMap = new HashMap<>();
735     for (int i = 0; i < backupFiles.length; i++)
736     {
737       File f = backupFiles[i];
738       BackupFilenameParts bfp = new BackupFilenameParts(f, basename, suffix,
739               digits);
740       bfHashMap.put(bfp.indexNum(), f);
741     }
742     TreeMap<Integer, File> bfTreeMap = new TreeMap<>();
743     bfTreeMap.putAll(bfHashMap);
744
745     return bfTreeMap;
746   }
747
748   /*
749   private boolean addDeleteFile(File fileToBeDeleted, File originalFile,
750           boolean delete, boolean newer)
751   {
752     return addDeleteFile(fileToBeDeleted, originalFile, null, delete, newer);
753   }
754   */
755   private boolean addDeleteFile(File fileToBeDeleted)
756   {
757     boolean ret = false;
758     int pos = deleteFiles.indexOf(fileToBeDeleted);
759     if (pos > -1)
760     {
761       return true;
762     }
763     else
764     {
765       deleteFiles.add(fileToBeDeleted);
766     }
767     return ret;
768   }
769
770   private boolean moveFileToFile(File oldFile, File newFile)
771   {
772     boolean ret = false;
773     Path oldPath = Paths.get(oldFile.getAbsolutePath());
774     Path newPath = Paths.get(newFile.getAbsolutePath());
775     try
776     {
777       Files.move(oldPath, newPath, StandardCopyOption.REPLACE_EXISTING);
778       ret = true;
779     } catch (IOException e)
780     {
781       Cache.log.warn("Could not move file '" + oldPath.toString() + "' to '"
782               + newPath.toString() + "'");
783       ret = false;
784     }
785     return ret;
786   }
787 }