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