JAL-3141 Backup files being created with default suffix up to 10 backups. Configurabl...
[jalview.git] / src / jalview / io / JalviewFileChooser.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 //////////////////////////////////////////////////////////////////
22 package jalview.io;
23
24 import jalview.bin.Cache;
25 import jalview.gui.JvOptionPane;
26 import jalview.util.MessageManager;
27 import jalview.util.Platform;
28
29 import java.awt.Component;
30 import java.awt.Dimension;
31 import java.awt.EventQueue;
32 import java.awt.HeadlessException;
33 import java.awt.event.MouseAdapter;
34 import java.awt.event.MouseEvent;
35 import java.io.File;
36 import java.io.IOException;
37 import java.util.ArrayList;
38 import java.util.List;
39 import java.util.StringTokenizer;
40 import java.util.Vector;
41
42 import javax.swing.DefaultListCellRenderer;
43 import javax.swing.JFileChooser;
44 import javax.swing.JList;
45 import javax.swing.JPanel;
46 import javax.swing.JScrollPane;
47 import javax.swing.SpringLayout;
48 import javax.swing.plaf.basic.BasicFileChooserUI;
49
50 /**
51  * Enhanced file chooser dialog box.
52  *
53  * NOTE: bug on Windows systems when filechooser opened on directory to view
54  * files with colons in title.
55  *
56  * @author AMW
57  *
58  */
59 public class JalviewFileChooser extends JFileChooser
60 {
61   /**
62    * Factory method to return a file chooser that offers readable alignment file
63    * formats
64    * 
65    * @param directory
66    * @param selected
67    * @return
68    */
69   public static JalviewFileChooser forRead(String directory,
70           String selected)
71   {
72     List<String> extensions = new ArrayList<>();
73     List<String> descs = new ArrayList<>();
74     for (FileFormatI format : FileFormats.getInstance().getFormats())
75     {
76       if (format.isReadable())
77       {
78         extensions.add(format.getExtensions());
79         descs.add(format.getName());
80       }
81     }
82     return new JalviewFileChooser(directory,
83             extensions.toArray(new String[extensions.size()]),
84             descs.toArray(new String[descs.size()]), selected, true);
85   }
86
87   /**
88    * Factory method to return a file chooser that offers writable alignment file
89    * formats
90    * 
91    * @param directory
92    * @param selected
93    * @return
94    */
95   public static JalviewFileChooser forWrite(String directory,
96           String selected)
97   {
98     // TODO in Java 8, forRead and forWrite can be a single method
99     // with a lambda expression parameter for isReadable/isWritable
100     List<String> extensions = new ArrayList<>();
101     List<String> descs = new ArrayList<>();
102     for (FileFormatI format : FileFormats.getInstance().getFormats())
103     {
104       if (format.isWritable())
105       {
106         extensions.add(format.getExtensions());
107         descs.add(format.getName());
108       }
109     }
110     return new JalviewFileChooser(directory,
111             extensions.toArray(new String[extensions.size()]),
112             descs.toArray(new String[descs.size()]), selected, false);
113   }
114
115   public JalviewFileChooser(String dir)
116   {
117     super(safePath(dir));
118     setAccessory(new RecentlyOpened());
119   }
120
121   public JalviewFileChooser(String dir, String[] suffix, String[] desc,
122           String selected)
123   {
124     this(dir, suffix, desc, selected, true);
125   }
126
127   /**
128    * Constructor for a single choice of file extension and description
129    * 
130    * @param extension
131    * @param desc
132    */
133   public JalviewFileChooser(String extension, String desc)
134   {
135     this(Cache.getProperty("LAST_DIRECTORY"), new String[] { extension },
136             new String[]
137             { desc }, desc, true);
138   }
139
140   JalviewFileChooser(String dir, String[] extensions, String[] descs,
141           String selected, boolean allFiles)
142   {
143     super(safePath(dir));
144     if (extensions.length == descs.length)
145     {
146       List<String[]> formats = new ArrayList<>();
147       for (int i = 0; i < extensions.length; i++)
148       {
149         formats.add(new String[] { extensions[i], descs[i] });
150       }
151       init(formats, selected, allFiles);
152     }
153     else
154     {
155       System.err.println("JalviewFileChooser arguments mismatch: "
156               + extensions + ", " + descs);
157     }
158   }
159
160   private static File safePath(String dir)
161   {
162     if (dir == null)
163     {
164       return null;
165     }
166
167     File f = new File(dir);
168     if (f.getName().indexOf(':') > -1)
169     {
170       return null;
171     }
172     return f;
173   }
174
175   /**
176    * 
177    * @param formats
178    *          a list of {extensions, description} for each file format
179    * @param selected
180    * @param allFiles
181    *          if true, 'any format' option is included
182    */
183   void init(List<String[]> formats, String selected, boolean allFiles)
184   {
185
186     JalviewFileFilter chosen = null;
187
188     // SelectAllFilter needs to be set first before adding further
189     // file filters to fix bug on Mac OSX
190     setAcceptAllFileFilterUsed(allFiles);
191
192     for (String[] format : formats)
193     {
194       JalviewFileFilter jvf = new JalviewFileFilter(format[0], format[1]);
195       addChoosableFileFilter(jvf);
196       if ((selected != null) && selected.equalsIgnoreCase(format[1]))
197       {
198         chosen = jvf;
199       }
200     }
201
202     if (chosen != null)
203     {
204       setFileFilter(chosen);
205     }
206
207     setAccessory(new RecentlyOpened());
208   }
209
210   @Override
211   public void setFileFilter(javax.swing.filechooser.FileFilter filter)
212   {
213     super.setFileFilter(filter);
214
215     try
216     {
217       if (getUI() instanceof BasicFileChooserUI)
218       {
219         final BasicFileChooserUI fcui = (BasicFileChooserUI) getUI();
220         final String name = fcui.getFileName().trim();
221
222         if ((name == null) || (name.length() == 0))
223         {
224           return;
225         }
226
227         EventQueue.invokeLater(new Thread()
228         {
229           @Override
230           public void run()
231           {
232             String currentName = fcui.getFileName();
233             if ((currentName == null) || (currentName.length() == 0))
234             {
235               fcui.setFileName(name);
236             }
237           }
238         });
239       }
240     } catch (Exception ex)
241     {
242       ex.printStackTrace();
243       // Some platforms do not have BasicFileChooserUI
244     }
245   }
246
247   /**
248    * Returns the selected file format, or null if none selected
249    * 
250    * @return
251    */
252   public FileFormatI getSelectedFormat()
253   {
254     if (getFileFilter() == null)
255     {
256       return null;
257     }
258
259     /*
260      * logic here depends on option description being formatted as 
261      * formatName (extension, extension...)
262      * or the 'no option selected' value
263      * All Files
264      * @see JalviewFileFilter.getDescription
265      */
266     String format = getFileFilter().getDescription();
267     int parenPos = format.indexOf("(");
268     if (parenPos > 0)
269     {
270       format = format.substring(0, parenPos).trim();
271       try
272       {
273         return FileFormats.getInstance().forName(format);
274       } catch (IllegalArgumentException e)
275       {
276         System.err.println("Unexpected format: " + format);
277       }
278     }
279     return null;
280   }
281
282   File ourselectedFile = null;
283
284   @Override
285   public File getSelectedFile()
286   {
287     File selfile = super.getSelectedFile();
288     if (selfile == null && ourselectedFile != null)
289     {
290       return ourselectedFile;
291     }
292     return selfile;
293   }
294
295   @Override
296   public int showSaveDialog(Component parent) throws HeadlessException
297   {
298     this.setAccessory(null);
299
300     setDialogType(SAVE_DIALOG);
301
302     this.setSelectedFile(null);
303     int ret = showDialog(parent, MessageManager.getString("action.save"));
304     ourselectedFile = getSelectedFile();
305
306     if (getSelectedFile() == null)
307     {
308       // Workaround for Java 9,10 on OSX - no selected file, but there is a
309       // filename typed in
310       try
311       {
312         String filename = ((BasicFileChooserUI) getUI()).getFileName();
313         if (filename != null && filename.length() > 0)
314         {
315           ourselectedFile = new File(getCurrentDirectory(), filename);
316         }
317       } catch (Throwable x)
318       {
319         System.err.println(
320                 "Unexpected exception when trying to get filename.");
321         x.printStackTrace();
322       }
323     }
324     if (ourselectedFile == null)
325     {
326       return JalviewFileChooser.CANCEL_OPTION;
327     }
328     if (getFileFilter() instanceof JalviewFileFilter)
329     {
330       JalviewFileFilter jvf = (JalviewFileFilter) getFileFilter();
331
332       if (!jvf.accept(ourselectedFile))
333       {
334         String withExtension = getSelectedFile().getName() + "."
335                 + jvf.getAcceptableExtension();
336         ourselectedFile = (new File(getCurrentDirectory(), withExtension));
337         setSelectedFile(ourselectedFile);
338       }
339     }
340     // TODO: ENSURE THAT FILES SAVED WITH A ':' IN THE NAME ARE REFUSED AND THE
341     // USER PROMPTED FOR A NEW FILENAME
342     if ((ret == JalviewFileChooser.APPROVE_OPTION)
343             && ourselectedFile.exists())
344     {
345       int confirm = JvOptionPane.showConfirmDialog(parent,
346               MessageManager.getString("label.overwrite_existing_file"),
347               MessageManager.getString("label.file_already_exists"),
348               JvOptionPane.YES_NO_OPTION);
349
350       if (confirm != JvOptionPane.YES_OPTION)
351       {
352         ret = JalviewFileChooser.CANCEL_OPTION;
353       }
354
355       rollBackupFiles(ourselectedFile);
356
357     }
358
359     return ret;
360   }
361
362   // attempts to roll backup files for this file (before overwriting). Returns
363   // true if it rolled all the files, false otherwise.
364   private static boolean rollBackupFiles(File file)
365   {
366
367     if (!file.exists())
368     {
369       // nothing to do
370       return true;
371     }
372
373     // split filename up to insert suffix template in the right place. template
374     // and backupMax can be set in .jalview_properties
375     String backupSuffixTemplate = jalview.bin.Cache
376             .getDefault("BACKUP_SUFFIX", "-bak-%n");
377     int backupMax = jalview.bin.Cache.getDefault("BACKUP_MAX", 10);
378     String dir = "";
379     try
380     {
381       File dirFile = file.getParentFile();
382       dir = dirFile.getCanonicalPath();
383     } catch (Exception e)
384     {
385       System.out.println(
386               "Could not get canonical path for file '" + file + "'");
387       return false;
388     }
389     String filename = file.getName();
390     String basename = filename;
391     String extension = "";
392     int dotchar = filename.lastIndexOf('.');
393     // don't split of filenames with the last '.' at the very beginning or
394     // very end of the filename
395     if ((dotchar > 0) && (dotchar < filename.length() - 1))
396     {
397       basename = filename.substring(0, dotchar);
398       extension = filename.substring(dotchar); // NOTE this includes the '.'
399     }
400
401     boolean ret = true;
402     if (backupMax >= 1)
403     {
404       // Create/move backups up one
405       String numString = null;
406       File lastfile = null;
407       for (int n = backupMax; n > 0; n--)
408       {
409         numString = String.format("%02d", n);
410         String backupSuffix = backupSuffixTemplate.replaceAll("%n",
411                 numString);
412         String backupfilename = dir + File.separatorChar + basename
413                 + backupSuffix + extension;
414         File backupfile_n = new File(backupfilename);
415
416         if (! backupfile_n.exists()) {
417           lastfile = backupfile_n;
418           continue;
419         }
420         
421         if (n == backupMax-1)
422         { // Move the max backup to /tmp instead of deleting (Just In
423           // Case)
424           String tmpfile = "tmp-" + backupfilename;
425           try
426           {
427             File tmpFile = File.createTempFile(tmpfile, ".tmp");
428             ret = ret && backupfile_n.renameTo(tmpFile);
429           } catch (IOException e)
430           {
431             System.out.println(
432                     "Could not create temp file '" + tmpfile + ".tmp'");
433           }
434         }
435         else
436         {
437           ret = ret && backupfile_n.renameTo(lastfile);
438         }
439
440         lastfile = backupfile_n;
441       }
442       
443       // now actually backup the important file!
444       ret = ret && file.renameTo(lastfile);
445     }
446
447     return ret;
448   }
449
450   void recentListSelectionChanged(Object selection)
451   {
452     setSelectedFile(null);
453     if (selection != null)
454     {
455       File file = new File((String) selection);
456       if (getFileFilter() instanceof JalviewFileFilter)
457       {
458         JalviewFileFilter jvf = (JalviewFileFilter) this.getFileFilter();
459
460         if (!jvf.accept(file))
461         {
462           setFileFilter(getChoosableFileFilters()[0]);
463         }
464       }
465
466       setSelectedFile(file);
467     }
468   }
469
470   class RecentlyOpened extends JPanel
471   {
472     JList list;
473
474     public RecentlyOpened()
475     {
476
477       String historyItems = jalview.bin.Cache.getProperty("RECENT_FILE");
478       StringTokenizer st;
479       Vector recent = new Vector();
480
481       if (historyItems != null)
482       {
483         st = new StringTokenizer(historyItems, "\t");
484
485         while (st.hasMoreTokens())
486         {
487           recent.addElement(st.nextElement());
488         }
489       }
490
491       list = new JList(recent);
492
493       DefaultListCellRenderer dlcr = new DefaultListCellRenderer();
494       dlcr.setHorizontalAlignment(DefaultListCellRenderer.RIGHT);
495       list.setCellRenderer(dlcr);
496
497       list.addMouseListener(new MouseAdapter()
498       {
499         @Override
500         public void mousePressed(MouseEvent evt)
501         {
502           recentListSelectionChanged(list.getSelectedValue());
503         }
504       });
505
506       this.setBorder(new javax.swing.border.TitledBorder(
507               MessageManager.getString("label.recently_opened")));
508
509       final JScrollPane scroller = new JScrollPane(list);
510
511       SpringLayout layout = new SpringLayout();
512       layout.putConstraint(SpringLayout.WEST, scroller, 5,
513               SpringLayout.WEST, this);
514       layout.putConstraint(SpringLayout.NORTH, scroller, 5,
515               SpringLayout.NORTH, this);
516
517       if (new Platform().isAMac())
518       {
519         scroller.setPreferredSize(new Dimension(500, 100));
520       }
521       else
522       {
523         scroller.setPreferredSize(new Dimension(130, 200));
524       }
525
526       this.add(scroller);
527
528       javax.swing.SwingUtilities.invokeLater(new Runnable()
529       {
530         @Override
531         public void run()
532         {
533           scroller.getHorizontalScrollBar()
534                   .setValue(scroller.getHorizontalScrollBar().getMaximum());
535         }
536       });
537
538     }
539
540   }
541 }