Merge branch 'features/r2_11_2_alphafold/JAL-629' into features/JAL-3858_PAEsInProjects
[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.List;
33 import java.util.Map;
34 import java.util.TreeMap;
35 import java.util.concurrent.CompletableFuture;
36 import java.util.concurrent.ExecutionException;
37 import java.util.concurrent.ExecutorService;
38 import java.util.concurrent.Executors;
39 import java.util.concurrent.Future;
40
41 import jalview.bin.Cache;
42 import jalview.bin.Console;
43 import jalview.gui.Desktop;
44 import jalview.gui.JvOptionPane;
45 import jalview.util.MessageManager;
46 import jalview.util.Platform;
47
48 /*
49  * BackupFiles used for manipulating (naming rolling/deleting) backup/version files when an alignment or project file is saved.
50  * User configurable options are:
51  * BACKUPFILES_ENABLED - boolean flag as to whether to use this mechanism or act as before, including overwriting files as saved.
52  * The rest of the options are now saved as BACKUPFILES_PRESET, BACKUPFILES_SAVED and BACKUPFILES_CUSTOM
53  * (see BackupFilesPresetEntry)
54  */
55
56 public class BackupFiles
57 {
58
59   // labels for saved params in Cache and .jalview_properties
60   public static final String NS = "BACKUPFILES";
61
62   public static final String ENABLED = NS + "_ENABLED";
63
64   public static final String NUM_PLACEHOLDER = "%n";
65
66   private static final String DEFAULT_TEMP_FILE = "jalview_temp_file_" + NS;
67
68   private static final String TEMP_FILE_EXT = ".tmp";
69
70   // file - File object to be backed up and then updated (written over)
71   private File file;
72
73   // enabled - default flag as to whether to do the backup file roll (if not
74   // defined in preferences)
75   private static boolean enabled;
76
77   // confirmDelete - default flag as to whether to confirm with the user before
78   // deleting old backup/version files
79   private static boolean confirmDelete;
80
81   // defaultSuffix - default template to use to append to basename of file
82   private String suffix;
83
84   // noMax - flag to turn off a maximum number of files
85   private boolean noMax;
86
87   // defaultMax - default max number of backup files
88   private int max;
89
90   // defaultDigits - number of zero-led digits to use in the filename
91   private int digits;
92
93   // reverseOrder - set to true to make newest (latest) files lowest number
94   // (like rolled log files)
95   private boolean reverseOrder;
96
97   // temp saved file to become new saved file
98   private File tempFile;
99
100   // flag set to see if file save to temp file was successful
101   private boolean tempFileWriteSuccess;
102
103   // array of files to be deleted, with extra information
104   private ArrayList<File> deleteFiles = new ArrayList<>();
105
106   // date formatting for modification times
107   private static final SimpleDateFormat sdf = new SimpleDateFormat(
108           "yyyy-MM-dd HH:mm:ss");
109
110   private static final String newTempFileSuffix = "_newfile";
111
112   private static final String oldTempFileSuffix = "_oldfile_tobedeleted";
113
114   // thread pool used for completablefutures
115   private static final ExecutorService executorService = Executors
116           .newFixedThreadPool(3);
117
118   private static List<BackupFiles> savesInProgress = new ArrayList<>();
119
120   private CompletableFuture<Boolean> myFuture = null;
121
122   private boolean addSaveInProgress()
123   {
124     if (savesInProgress.contains(this))
125     {
126       return false;
127     }
128     else
129     {
130       this.setMyFuture();
131       savesInProgress.add(this);
132       return true;
133     }
134   }
135
136   private boolean removeSaveInProgress(boolean ret)
137   {
138     if (savesInProgress.contains(this))
139     {
140       this.getMyFuture().complete(ret);
141       // remove all occurrences
142       while (savesInProgress.remove(this))
143       {
144       }
145       return true;
146     }
147     return false;
148   }
149
150   private static CompletableFuture<Boolean> getNewFuture()
151   {
152     return new CompletableFuture<Boolean>()
153     {
154     };
155   }
156
157   private CompletableFuture<Boolean> getMyFuture()
158   {
159     return this.myFuture;
160   }
161
162   private void setMyFuture()
163   {
164     this.myFuture = getNewFuture();
165   }
166
167   public static boolean hasSavesInProgress()
168   {
169     boolean has = false;
170     for (CompletableFuture cf : savesInProgressCompletableFutures(true))
171     {
172       has |= !cf.isDone();
173     }
174     return has;
175   }
176
177   public static List<File> savesInProgressFiles(boolean all)
178   {
179     List<File> files = new ArrayList<>();
180     for (BackupFiles bfile : savesInProgress)
181     {
182       if (all || !bfile.getMyFuture().isDone())
183         files.add(bfile.getFile());
184     }
185     return files;
186   }
187
188   public static List<CompletableFuture<Boolean>> savesInProgressCompletableFutures(
189           boolean all)
190   {
191     List<CompletableFuture<Boolean>> cfs = new ArrayList<>();
192     for (BackupFiles bfile : savesInProgress)
193     {
194       if (all || !bfile.getMyFuture().isDone())
195         cfs.add(bfile.getMyFuture());
196     }
197     return cfs;
198   }
199
200   public static Future<Boolean> allSaved()
201   {
202     CompletableFuture<Boolean> f = new CompletableFuture<>();
203
204     executorService.submit(() -> {
205       for (BackupFiles buf : savesInProgress)
206       {
207         boolean allSaved = true;
208         try
209         {
210           allSaved &= buf.getMyFuture().get();
211         } catch (InterruptedException e)
212         {
213           Console.debug("InterruptedException waiting for files to save",
214                   e);
215         } catch (ExecutionException e)
216         {
217           Console.debug("ExecutionException waiting for files to save", e);
218         }
219         f.complete(allSaved);
220       }
221     });
222     return f;
223   }
224
225   public BackupFiles(String filename)
226   {
227     this(new File(filename));
228   }
229
230   // first time defaults for SUFFIX, NO_MAX, ROLL_MAX, SUFFIX_DIGITS and
231   // REVERSE_ORDER
232   public BackupFiles(File file)
233   {
234     classInit();
235     this.file = file;
236
237     // add this file from the save in progress stack
238     addSaveInProgress();
239
240     BackupFilesPresetEntry bfpe = BackupFilesPresetEntry
241             .getSavedBackupEntry();
242     this.suffix = bfpe.suffix;
243     this.noMax = bfpe.keepAll;
244     this.max = bfpe.rollMax;
245     this.digits = bfpe.digits;
246     this.reverseOrder = bfpe.reverse;
247
248     // create a temp file to save new data in
249     File temp = null;
250     try
251     {
252       if (file != null)
253       {
254         String tempfilename = file.getName();
255         File tempdir = file.getParentFile();
256         tempdir.mkdirs();
257         Console.trace(
258                 "BACKUPFILES [file!=null] attempting to create temp file for "
259                         + tempfilename + " in dir " + tempdir);
260         temp = File.createTempFile(tempfilename,
261                 TEMP_FILE_EXT + newTempFileSuffix, tempdir);
262         Console.debug(
263                 "BACKUPFILES using temp file " + temp.getAbsolutePath());
264       }
265       else
266       {
267         Console.trace(
268                 "BACKUPFILES [file==null] attempting to create default temp file "
269                         + DEFAULT_TEMP_FILE + " with extension "
270                         + TEMP_FILE_EXT);
271         temp = File.createTempFile(DEFAULT_TEMP_FILE, TEMP_FILE_EXT);
272       }
273     } catch (IOException e)
274     {
275       Console.error("Could not create temp file to save to (IOException)");
276       Console.error(e.getMessage());
277       Console.debug(Cache.getStackTraceString(e));
278     } catch (Exception e)
279     {
280       Console.error("Exception creating temp file for saving");
281       Console.debug(Cache.getStackTraceString(e));
282     }
283     this.setTempFile(temp);
284   }
285
286   public static void classInit()
287   {
288     Console.initLogger();
289     Console.trace("BACKUPFILES classInit");
290     boolean e = Cache.getDefault(ENABLED, !Platform.isJS());
291     setEnabled(e);
292     Console.trace("BACKUPFILES " + (e ? "enabled" : "disabled"));
293     BackupFilesPresetEntry bfpe = BackupFilesPresetEntry
294             .getSavedBackupEntry();
295     Console.trace("BACKUPFILES preset scheme " + bfpe.toString());
296     setConfirmDelete(bfpe.confirmDelete);
297     Console.trace("BACKUPFILES confirm delete " + bfpe.confirmDelete);
298   }
299
300   public static void setEnabled(boolean flag)
301   {
302     enabled = flag;
303   }
304
305   public static boolean getEnabled()
306   {
307     classInit();
308     return enabled;
309   }
310
311   public static void setConfirmDelete(boolean flag)
312   {
313     confirmDelete = flag;
314   }
315
316   public static boolean getConfirmDelete()
317   {
318     classInit();
319     return confirmDelete;
320   }
321
322   // set, get and rename temp file into place
323   public void setTempFile(File temp)
324   {
325     this.tempFile = temp;
326   }
327
328   public File getTempFile()
329   {
330     return tempFile;
331   }
332
333   public String getTempFilePath()
334   {
335     String path = null;
336     try
337     {
338       path = this.getTempFile().getCanonicalPath();
339     } catch (IOException e)
340     {
341       Console.error("IOException when getting Canonical Path of temp file '"
342               + this.getTempFile().getName() + "'");
343       Console.debug(Cache.getStackTraceString(e));
344     }
345     return path;
346   }
347
348   public boolean setWriteSuccess(boolean flag)
349   {
350     boolean old = this.tempFileWriteSuccess;
351     this.tempFileWriteSuccess = flag;
352     return old;
353   }
354
355   public boolean getWriteSuccess()
356   {
357     return this.tempFileWriteSuccess;
358   }
359
360   public boolean renameTempFile()
361   {
362     return moveFileToFile(tempFile, file);
363   }
364
365   // roll the backupfiles
366   public boolean rollBackupFiles()
367   {
368     return this.rollBackupFiles(true);
369   }
370
371   public boolean rollBackupFiles(boolean tidyUp)
372   {
373     // file doesn't yet exist or backups are not enabled or template is null or
374     // empty
375     if ((!file.exists()) || (!enabled) || max < 0 || suffix == null
376             || suffix.length() == 0)
377     {
378       // nothing to do
379       Console.debug("BACKUPFILES rollBackupFiles nothing to do." + ", "
380               + "filename: " + (file != null ? file.getName() : "null")
381               + ", " + "file exists: " + file.exists() + ", " + "enabled: "
382               + enabled + ", " + "max: " + max + ", " + "suffix: '" + suffix
383               + "'");
384       return true;
385     }
386
387     Console.trace("BACKUPFILES rollBackupFiles starting");
388
389     String dir = "";
390     File dirFile;
391     try
392     {
393       dirFile = file.getParentFile();
394       dir = dirFile.getCanonicalPath();
395       Console.trace("BACKUPFILES dir: " + dir);
396     } catch (Exception e)
397     {
398       Console.error("Could not get canonical path for file '" + file + "'");
399       Console.error(e.getMessage());
400       Console.debug(Cache.getStackTraceString(e));
401       return false;
402     }
403     String filename = file.getName();
404     String basename = filename;
405
406     Console.trace("BACKUPFILES filename is " + filename);
407     boolean ret = true;
408     // Create/move backups up one
409
410     deleteFiles.clear();
411
412     // find existing backup files
413     BackupFilenameFilter bff = new BackupFilenameFilter(basename, suffix,
414             digits);
415     File[] backupFiles = dirFile.listFiles(bff);
416     int nextIndexNum = 0;
417
418     Console.trace("BACKUPFILES backupFiles.length: " + backupFiles.length);
419     if (backupFiles.length == 0)
420     {
421       // No other backup files. Just need to move existing file to backupfile_1
422       Console.trace(
423               "BACKUPFILES no existing backup files, setting index to 1");
424       nextIndexNum = 1;
425     }
426     else
427     {
428       TreeMap<Integer, File> bfTreeMap = sortBackupFilesAsTreeMap(
429               backupFiles, basename);
430       // bfTreeMap now a sorted list of <Integer index>,<File backupfile>
431       // mappings
432
433       if (reverseOrder)
434       {
435         // backup style numbering
436         Console.trace("BACKUPFILES rolling files in reverse order");
437
438         int tempMax = noMax ? -1 : max;
439         // noMax == true means no limits
440         // look for first "gap" in backupFiles
441         // if tempMax is -1 at this stage just keep going until there's a gap,
442         // then hopefully tempMax gets set to the right index (a positive
443         // integer so the loop breaks)...
444         // why do I feel a little uneasy about this loop?..
445         for (int i = 1; tempMax < 0 || i <= max; i++)
446         {
447           if (!bfTreeMap.containsKey(i)) // first index without existent
448                                          // backupfile
449           {
450             tempMax = i;
451           }
452         }
453
454         File previousFile = null;
455         File fileToBeDeleted = null;
456         for (int n = tempMax; n > 0; n--)
457         {
458           String backupfilename = dir + File.separatorChar
459                   + BackupFilenameParts.getBackupFilename(n, basename,
460                           suffix, digits);
461           File backupfile_n = new File(backupfilename);
462
463           if (!backupfile_n.exists())
464           {
465             // no "oldest" file to delete
466             previousFile = backupfile_n;
467             fileToBeDeleted = null;
468             Console.trace("BACKUPFILES No oldest file to delete");
469             continue;
470           }
471
472           // check the modification time of this (backupfile_n) and the previous
473           // file (fileToBeDeleted) if the previous file is going to be deleted
474           if (fileToBeDeleted != null)
475           {
476             File replacementFile = backupfile_n;
477             long fileToBeDeletedLMT = fileToBeDeleted.lastModified();
478             long replacementFileLMT = replacementFile.lastModified();
479             Console.trace("BACKUPFILES fileToBeDeleted is "
480                     + fileToBeDeleted.getAbsolutePath());
481             Console.trace("BACKUPFILES replacementFile is "
482                     + backupfile_n.getAbsolutePath());
483
484             try
485             {
486               File oldestTempFile = nextTempFile(fileToBeDeleted.getName(),
487                       dirFile);
488
489               if (fileToBeDeletedLMT > replacementFileLMT)
490               {
491                 String fileToBeDeletedLMTString = sdf
492                         .format(fileToBeDeletedLMT);
493                 String replacementFileLMTString = sdf
494                         .format(replacementFileLMT);
495                 Console.warn("WARNING! I am set to delete backupfile "
496                         + fileToBeDeleted.getName()
497                         + " has modification time "
498                         + fileToBeDeletedLMTString
499                         + " which is newer than its replacement "
500                         + replacementFile.getName()
501                         + " with modification time "
502                         + replacementFileLMTString);
503
504                 boolean delete = confirmNewerDeleteFile(fileToBeDeleted,
505                         replacementFile, true);
506                 Console.trace("BACKUPFILES "
507                         + (delete ? "confirmed" : "not") + " deleting file "
508                         + fileToBeDeleted.getAbsolutePath()
509                         + " which is newer than "
510                         + replacementFile.getAbsolutePath());
511
512                 if (delete)
513                 {
514                   // User has confirmed delete -- no need to add it to the list
515                   fileToBeDeleted.delete();
516                 }
517                 else
518                 {
519                   Console.debug("BACKUPFILES moving "
520                           + fileToBeDeleted.getAbsolutePath() + " to "
521                           + oldestTempFile.getAbsolutePath());
522                   moveFileToFile(fileToBeDeleted, oldestTempFile);
523                 }
524               }
525               else
526               {
527                 Console.debug("BACKUPFILES going to move "
528                         + fileToBeDeleted.getAbsolutePath() + " to "
529                         + oldestTempFile.getAbsolutePath());
530                 moveFileToFile(fileToBeDeleted, oldestTempFile);
531                 addDeleteFile(oldestTempFile);
532               }
533
534             } catch (Exception e)
535             {
536               Console.error(
537                       "Error occurred, probably making new temp file for '"
538                               + fileToBeDeleted.getName() + "'");
539               Console.error(Cache.getStackTraceString(e));
540             }
541
542             // reset
543             fileToBeDeleted = null;
544           }
545
546           if (!noMax && n == tempMax && backupfile_n.exists())
547           {
548             fileToBeDeleted = backupfile_n;
549           }
550           else
551           {
552             if (previousFile != null)
553             {
554               // using boolean '&' instead of '&&' as don't want moveFileToFile
555               // attempt to be conditional (short-circuit)
556               ret = ret & moveFileToFile(backupfile_n, previousFile);
557             }
558           }
559
560           previousFile = backupfile_n;
561         }
562
563         // index to use for the latest backup
564         nextIndexNum = 1;
565       }
566       else // not reverse numbering
567       {
568         // version style numbering (with earliest file deletion if max files
569         // reached)
570
571         bfTreeMap.values().toArray(backupFiles);
572         StringBuilder bfsb = new StringBuilder();
573         for (int i = 0; i < backupFiles.length; i++)
574         {
575           if (bfsb.length() > 0)
576           {
577             bfsb.append(", ");
578           }
579           bfsb.append(backupFiles[i].getName());
580         }
581         Console.trace("BACKUPFILES backupFiles: " + bfsb.toString());
582
583         // noMax == true means keep all backup files
584         if ((!noMax) && bfTreeMap.size() >= max)
585         {
586           Console.trace("BACKUPFILES noMax: " + noMax + ", " + "max: " + max
587                   + ", " + "bfTreeMap.size(): " + bfTreeMap.size());
588           // need to delete some files to keep number of backups to designated
589           // max.
590           // Note that if the suffix is not numbered then do not delete any
591           // backup files later or we'll delete the new backup file (there can
592           // be only one).
593           int numToDelete = suffix.indexOf(NUM_PLACEHOLDER) > -1
594                   ? bfTreeMap.size() - max + 1
595                   : 0;
596           Console.trace("BACKUPFILES numToDelete: " + numToDelete);
597           // the "replacement" file is the latest backup file being kept (it's
598           // not replacing though)
599           File replacementFile = numToDelete < backupFiles.length
600                   ? backupFiles[numToDelete]
601                   : null;
602           for (int i = 0; i < numToDelete; i++)
603           {
604             // check the deletion files for modification time of the last
605             // backupfile being saved
606             File fileToBeDeleted = backupFiles[i];
607             boolean delete = true;
608
609             Console.trace(
610                     "BACKUPFILES fileToBeDeleted: " + fileToBeDeleted);
611
612             boolean newer = false;
613             if (replacementFile != null)
614             {
615               long fileToBeDeletedLMT = fileToBeDeleted.lastModified();
616               long replacementFileLMT = replacementFile != null
617                       ? replacementFile.lastModified()
618                       : Long.MAX_VALUE;
619               if (fileToBeDeletedLMT > replacementFileLMT)
620               {
621                 String fileToBeDeletedLMTString = sdf
622                         .format(fileToBeDeletedLMT);
623                 String replacementFileLMTString = sdf
624                         .format(replacementFileLMT);
625
626                 Console.warn("WARNING! I am set to delete backupfile '"
627                         + fileToBeDeleted.getName()
628                         + "' has modification time "
629                         + fileToBeDeletedLMTString
630                         + " which is newer than the oldest backupfile being kept '"
631                         + replacementFile.getName()
632                         + "' with modification time "
633                         + replacementFileLMTString);
634
635                 delete = confirmNewerDeleteFile(fileToBeDeleted,
636                         replacementFile, false);
637                 if (delete)
638                 {
639                   // User has confirmed delete -- no need to add it to the list
640                   fileToBeDeleted.delete();
641                   Console.debug("BACKUPFILES deleting fileToBeDeleted: "
642                           + fileToBeDeleted);
643                   delete = false;
644                 }
645                 else
646                 {
647                   // keeping file, nothing to do!
648                   Console.debug("BACKUPFILES keeping fileToBeDeleted: "
649                           + fileToBeDeleted);
650                 }
651               }
652             }
653             if (delete)
654             {
655               addDeleteFile(fileToBeDeleted);
656               Console.debug("BACKUPFILES addDeleteFile(fileToBeDeleted): "
657                       + fileToBeDeleted);
658             }
659
660           }
661
662         }
663
664         nextIndexNum = bfTreeMap.lastKey() + 1;
665       }
666     }
667
668     // Let's make the new backup file!! yay, got there at last!
669     String latestBackupFilename = dir + File.separatorChar
670             + BackupFilenameParts.getBackupFilename(nextIndexNum, basename,
671                     suffix, digits);
672     Console.trace("BACKUPFILES Moving old file [" + file
673             + "] to latestBackupFilename [" + latestBackupFilename + "]");
674     // using boolean '&' instead of '&&' as don't want moveFileToFile attempt to
675     // be conditional (short-circuit)
676     ret = ret & moveFileToFile(file, new File(latestBackupFilename));
677     Console.debug(
678             "BACKUPFILES moving " + file + " to " + latestBackupFilename
679                     + " was " + (ret ? "" : "NOT ") + "successful");
680     if (tidyUp)
681     {
682       Console.debug("BACKUPFILES tidying up files");
683       tidyUpFiles();
684     }
685
686     return ret;
687   }
688
689   private static File nextTempFile(String filename, File dirFile)
690           throws IOException
691   {
692     File temp = null;
693     COUNT: for (int i = 1; i < 1000; i++)
694     {
695       File trythis = new File(dirFile,
696               filename + '~' + Integer.toString(i));
697       if (!trythis.exists())
698       {
699         temp = trythis;
700         break COUNT;
701       }
702
703     }
704     if (temp == null)
705     {
706       temp = File.createTempFile(filename, TEMP_FILE_EXT, dirFile);
707     }
708     return temp;
709   }
710
711   private void tidyUpFiles()
712   {
713     deleteOldFiles();
714   }
715
716   private static boolean confirmNewerDeleteFile(File fileToBeDeleted,
717           File replacementFile, boolean replace)
718   {
719     StringBuilder messageSB = new StringBuilder();
720
721     File ftbd = fileToBeDeleted;
722     String ftbdLMT = sdf.format(ftbd.lastModified());
723     String ftbdSize = Long.toString(ftbd.length());
724
725     File rf = replacementFile;
726     String rfLMT = sdf.format(rf.lastModified());
727     String rfSize = Long.toString(rf.length());
728
729     int confirmButton = JvOptionPane.NO_OPTION;
730     if (replace)
731     {
732       File saveFile = null;
733       try
734       {
735         saveFile = nextTempFile(ftbd.getName(), ftbd.getParentFile());
736       } catch (Exception e)
737       {
738         Console.error(
739                 "Error when confirming to keep backup file newer than other backup files.");
740         e.printStackTrace();
741       }
742       messageSB.append(MessageManager.formatMessage(
743               "label.newerdelete_replacement_line", new String[]
744               { ftbd.getName(), rf.getName(), ftbdLMT, rfLMT, ftbdSize,
745                   rfSize }));
746       // "Backup file\n''{0}''\t(modified {2}, size {4})\nis to be deleted and
747       // replaced by apparently older file \n''{1}''\t(modified {3}, size
748       // {5}).""
749       messageSB.append("\n\n");
750       messageSB.append(MessageManager.formatMessage(
751               "label.confirm_deletion_or_rename", new String[]
752               { ftbd.getName(), saveFile.getName() }));
753       // "Confirm deletion of ''{0}'' or rename to ''{1}''?"
754       String[] options = new String[] {
755           MessageManager.getString("label.delete"),
756           MessageManager.getString("label.rename") };
757
758       confirmButton = Platform.isHeadless() ? JvOptionPane.YES_OPTION
759               : JvOptionPane.showOptionDialog(Desktop.desktop,
760                       messageSB.toString(),
761                       MessageManager.getString(
762                               "label.backupfiles_confirm_delete"),
763                       // "Confirm delete"
764                       JvOptionPane.YES_NO_OPTION,
765                       JvOptionPane.WARNING_MESSAGE, null, options,
766                       options[0]);
767     }
768     else
769     {
770       messageSB.append(MessageManager
771               .formatMessage("label.newerdelete_line", new String[]
772               { ftbd.getName(), rf.getName(), ftbdLMT, rfLMT, ftbdSize,
773                   rfSize }));
774       // "Backup file\n''{0}''\t(modified {2}, size {4})\nis to be deleted but
775       // is newer than the oldest remaining backup file \n''{1}''\t(modified
776       // {3}, size {5})."
777       messageSB.append("\n\n");
778       messageSB.append(MessageManager
779               .formatMessage("label.confirm_deletion", new String[]
780               { ftbd.getName() }));
781       // "Confirm deletion of ''{0}''?"
782       String[] options = new String[] {
783           MessageManager.getString("label.delete"),
784           MessageManager.getString("label.keep") };
785
786       confirmButton = Platform.isHeadless() ? JvOptionPane.YES_OPTION
787               : JvOptionPane.showOptionDialog(Desktop.desktop,
788                       messageSB.toString(),
789                       MessageManager.getString(
790                               "label.backupfiles_confirm_delete"),
791                       // "Confirm delete"
792                       JvOptionPane.YES_NO_OPTION,
793                       JvOptionPane.WARNING_MESSAGE, null, options,
794                       options[0]);
795     }
796
797     // return should be TRUE if file is to be deleted
798     return (confirmButton == JvOptionPane.YES_OPTION);
799   }
800
801   private void deleteOldFiles()
802   {
803     if (deleteFiles != null && !deleteFiles.isEmpty())
804     {
805       boolean doDelete = false;
806       StringBuilder messageSB = null;
807       if (confirmDelete && deleteFiles.size() > 0)
808       {
809         messageSB = new StringBuilder();
810         messageSB.append(MessageManager
811                 .getString("label.backupfiles_confirm_delete_old_files"));
812         // "Delete the following older backup files? (see the Backups tab in
813         // Preferences for more options)"
814         for (int i = 0; i < deleteFiles.size(); i++)
815         {
816           File df = deleteFiles.get(i);
817           messageSB.append("\n");
818           messageSB.append(df.getName());
819           messageSB.append(" ");
820           messageSB.append(MessageManager.formatMessage("label.file_info",
821                   new String[]
822                   { sdf.format(df.lastModified()),
823                       Long.toString(df.length()) }));
824           // "(modified {0}, size {1})"
825         }
826
827         int confirmButton = Platform.isHeadless() ? JvOptionPane.YES_OPTION
828                 : JvOptionPane.showConfirmDialog(Desktop.desktop,
829                         messageSB.toString(),
830                         MessageManager.getString(
831                                 "label.backupfiles_confirm_delete"),
832                         // "Confirm delete"
833                         JvOptionPane.YES_NO_OPTION,
834                         JvOptionPane.WARNING_MESSAGE);
835
836         doDelete = (confirmButton == JvOptionPane.YES_OPTION);
837       }
838       else
839       {
840         doDelete = true;
841       }
842
843       if (doDelete)
844       {
845         for (int i = 0; i < deleteFiles.size(); i++)
846         {
847           File fileToDelete = deleteFiles.get(i);
848           Console.trace("BACKUPFILES about to delete fileToDelete:"
849                   + fileToDelete);
850           fileToDelete.delete();
851           Console.warn("deleted '" + fileToDelete.getName() + "'");
852         }
853       }
854
855     }
856
857     deleteFiles.clear();
858   }
859
860   private TreeMap<Integer, File> sortBackupFilesAsTreeMap(
861           File[] backupFiles, String basename)
862   {
863     // sort the backup files (based on integer found in the suffix) using a
864     // precomputed Hashmap for speed
865     Map<Integer, File> bfHashMap = new HashMap<>();
866     for (int i = 0; i < backupFiles.length; i++)
867     {
868       File f = backupFiles[i];
869       BackupFilenameParts bfp = new BackupFilenameParts(f, basename, suffix,
870               digits);
871       bfHashMap.put(bfp.indexNum(), f);
872     }
873     TreeMap<Integer, File> bfTreeMap = new TreeMap<>();
874     bfTreeMap.putAll(bfHashMap);
875     return bfTreeMap;
876   }
877
878   public boolean rollBackupsAndRenameTempFile()
879   {
880     boolean write = this.getWriteSuccess();
881
882     boolean roll = false;
883     boolean rename = false;
884     if (write)
885     {
886       roll = this.rollBackupFiles(false); // tidyUpFiles at the end
887       rename = this.renameTempFile();
888     }
889
890     /*
891      * Not sure that this confirmation is desirable.  By this stage the new file is
892      * already written successfully, but something (e.g. disk full) has happened while 
893      * trying to roll the backup files, and most likely the filename needed will already
894      * be vacant so renaming the temp file is nearly always correct!
895      */
896     boolean okay = roll && rename;
897     if (!okay)
898     {
899       StringBuilder messageSB = new StringBuilder();
900       messageSB.append(MessageManager.getString(
901               "label.backupfiles_confirm_save_file_backupfiles_roll_wrong"));
902       // "Something possibly went wrong with the backups of this file."
903       if (rename)
904       {
905         if (messageSB.length() > 0)
906         {
907           messageSB.append("\n");
908         }
909         messageSB.append(MessageManager.getString(
910                 "label.backupfiles_confirm_save_new_saved_file_ok"));
911         // "The new saved file seems okay."
912       }
913       else
914       {
915         if (messageSB.length() > 0)
916         {
917           messageSB.append("\n");
918         }
919         messageSB.append(MessageManager.getString(
920                 "label.backupfiles_confirm_save_new_saved_file_not_ok"));
921         // "The new saved file might not be okay."
922       }
923       if (messageSB.length() > 0)
924       {
925         messageSB.append("\n");
926       }
927       messageSB
928               .append(MessageManager.getString("label.continue_operation"));
929
930       int confirmButton = Platform.isHeadless() ? JvOptionPane.OK_OPTION
931               : JvOptionPane.showConfirmDialog(Desktop.desktop,
932                       messageSB.toString(),
933                       MessageManager.getString(
934                               "label.backupfiles_confirm_save_file"),
935                       // "Confirm save file"
936                       JvOptionPane.OK_OPTION, JvOptionPane.WARNING_MESSAGE);
937       okay = confirmButton == JvOptionPane.OK_OPTION;
938     }
939     if (okay)
940     {
941       tidyUpFiles();
942     }
943
944     // remove this file from the save in progress stack
945     removeSaveInProgress(rename);
946
947     return rename;
948   }
949
950   public static TreeMap<Integer, File> getBackupFilesAsTreeMap(
951           String fileName, String suffix, int digits)
952   {
953     File[] backupFiles = null;
954
955     File file = new File(fileName);
956
957     File dirFile;
958     try
959     {
960       dirFile = file.getParentFile();
961     } catch (Exception e)
962     {
963       Console.error("Could not get canonical path for file '" + file + "'");
964       return new TreeMap<>();
965     }
966
967     String filename = file.getName();
968     String basename = filename;
969
970     // find existing backup files
971     BackupFilenameFilter bff = new BackupFilenameFilter(basename, suffix,
972             digits);
973     backupFiles = dirFile.listFiles(bff); // is clone needed?
974
975     // sort the backup files (based on integer found in the suffix) using a
976     // precomputed Hashmap for speed
977     Map<Integer, File> bfHashMap = new HashMap<>();
978     for (int i = 0; i < backupFiles.length; i++)
979     {
980       File f = backupFiles[i];
981       BackupFilenameParts bfp = new BackupFilenameParts(f, basename, suffix,
982               digits);
983       bfHashMap.put(bfp.indexNum(), f);
984     }
985     TreeMap<Integer, File> bfTreeMap = new TreeMap<>();
986     bfTreeMap.putAll(bfHashMap);
987
988     return bfTreeMap;
989   }
990
991   /*
992   private boolean addDeleteFile(File fileToBeDeleted, File originalFile,
993           boolean delete, boolean newer)
994   {
995     return addDeleteFile(fileToBeDeleted, originalFile, null, delete, newer);
996   }
997   */
998   private boolean addDeleteFile(File fileToBeDeleted)
999   {
1000     boolean ret = false;
1001     int pos = deleteFiles.indexOf(fileToBeDeleted);
1002     if (pos > -1)
1003     {
1004       Console.debug("BACKUPFILES not adding file "
1005               + fileToBeDeleted.getAbsolutePath()
1006               + " to the delete list (already at index" + pos + ")");
1007       return true;
1008     }
1009     else
1010     {
1011       Console.debug("BACKUPFILES adding file "
1012               + fileToBeDeleted.getAbsolutePath() + " to the delete list");
1013       deleteFiles.add(fileToBeDeleted);
1014     }
1015     return ret;
1016   }
1017
1018   public File getFile()
1019   {
1020     return file;
1021   }
1022
1023   public static boolean moveFileToFile(File oldFile, File newFile)
1024   {
1025     Console.initLogger();
1026     boolean ret = false;
1027     Path oldPath = Paths.get(oldFile.getAbsolutePath());
1028     Path newPath = Paths.get(newFile.getAbsolutePath());
1029     try
1030     {
1031       // delete destination file - not usually necessary but Just In Case...
1032       Console.trace("BACKUPFILES deleting " + newFile.getAbsolutePath());
1033       newFile.delete();
1034       Console.trace("BACKUPFILES moving " + oldFile.getAbsolutePath()
1035               + " to " + newFile.getAbsolutePath());
1036       Files.move(oldPath, newPath, StandardCopyOption.REPLACE_EXISTING);
1037       ret = true;
1038       Console.trace("BACKUPFILES move seems to have succeeded");
1039     } catch (IOException e)
1040     {
1041       Console.warn("Could not move file '" + oldPath.toString() + "' to '"
1042               + newPath.toString() + "'");
1043       Console.error(e.getMessage());
1044       Console.debug(Cache.getStackTraceString(e));
1045       ret = false;
1046     } catch (Exception e)
1047     {
1048       Console.error(e.getMessage());
1049       Console.debug(Cache.getStackTraceString(e));
1050       ret = false;
1051     }
1052     return ret;
1053   }
1054 }