001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.io.BufferedReader;
007import java.io.File;
008import java.io.FileFilter;
009import java.io.FileInputStream;
010import java.io.IOException;
011import java.io.InputStreamReader;
012import java.io.PrintStream;
013import java.lang.management.ManagementFactory;
014import java.util.ArrayList;
015import java.util.Date;
016import java.util.Deque;
017import java.util.HashSet;
018import java.util.Iterator;
019import java.util.LinkedList;
020import java.util.List;
021import java.util.Set;
022import java.util.Timer;
023import java.util.TimerTask;
024import java.util.regex.Pattern;
025
026import org.openstreetmap.josm.Main;
027import org.openstreetmap.josm.actions.OpenFileAction.OpenFileTask;
028import org.openstreetmap.josm.data.osm.DataSet;
029import org.openstreetmap.josm.data.osm.event.AbstractDatasetChangedEvent;
030import org.openstreetmap.josm.data.osm.event.DataSetListenerAdapter;
031import org.openstreetmap.josm.data.osm.event.DataSetListenerAdapter.Listener;
032import org.openstreetmap.josm.data.preferences.BooleanProperty;
033import org.openstreetmap.josm.data.preferences.IntegerProperty;
034import org.openstreetmap.josm.gui.MapView;
035import org.openstreetmap.josm.gui.MapView.LayerChangeListener;
036import org.openstreetmap.josm.gui.Notification;
037import org.openstreetmap.josm.gui.layer.Layer;
038import org.openstreetmap.josm.gui.layer.OsmDataLayer;
039import org.openstreetmap.josm.gui.util.GuiHelper;
040import org.openstreetmap.josm.io.OsmExporter;
041import org.openstreetmap.josm.io.OsmImporter;
042import org.openstreetmap.josm.tools.Utils;
043
044/**
045 * Saves data layers periodically so they can be recovered in case of a crash.
046 *
047 * There are 2 directories
048 *  - autosave dir: copies of the currently open data layers are saved here every
049 *      PROP_INTERVAL seconds. When a data layer is closed normally, the corresponding
050 *      files are removed. If this dir is non-empty on start, JOSM assumes
051 *      that it crashed last time.
052 *  - deleted layers dir: "secondary archive" - when autosaved layers are restored
053 *      they are copied to this directory. We cannot keep them in the autosave folder,
054 *      but just deleting it would be dangerous: Maybe a feature inside the file
055 *      caused JOSM to crash. If the data is valuable, the user can still try to
056 *      open with another versions of JOSM or fix the problem manually.
057 *
058 *      The deleted layers dir keeps at most PROP_DELETED_LAYERS files.
059 */
060public class AutosaveTask extends TimerTask implements LayerChangeListener, Listener {
061
062    private static final char[] ILLEGAL_CHARACTERS = { '/', '\n', '\r', '\t', '\0', '\f', '`', '?', '*', '\\', '<', '>', '|', '\"', ':' };
063    private static final String AUTOSAVE_DIR = "autosave";
064    private static final String DELETED_LAYERS_DIR = "autosave/deleted_layers";
065
066    public static final BooleanProperty PROP_AUTOSAVE_ENABLED = new BooleanProperty("autosave.enabled", true);
067    public static final IntegerProperty PROP_FILES_PER_LAYER = new IntegerProperty("autosave.filesPerLayer", 1);
068    public static final IntegerProperty PROP_DELETED_LAYERS = new IntegerProperty("autosave.deletedLayersBackupCount", 5);
069    public static final IntegerProperty PROP_INTERVAL = new IntegerProperty("autosave.interval", 5 * 60);
070    public static final IntegerProperty PROP_INDEX_LIMIT = new IntegerProperty("autosave.index-limit", 1000);
071    /** Defines if a notification should be displayed after each autosave */
072    public static final BooleanProperty PROP_NOTIFICATION = new BooleanProperty("autosave.notification", false);
073
074    private static class AutosaveLayerInfo {
075        OsmDataLayer layer;
076        String layerName;
077        String layerFileName;
078        final Deque<File> backupFiles = new LinkedList<File>();
079    }
080
081    private final DataSetListenerAdapter datasetAdapter = new DataSetListenerAdapter(this);
082    private final Set<DataSet> changedDatasets = new HashSet<DataSet>();
083    private final List<AutosaveLayerInfo> layersInfo = new ArrayList<AutosaveLayerInfo>();
084    private Timer timer;
085    private final Object layersLock = new Object();
086    private final Deque<File> deletedLayers = new LinkedList<File>();
087
088    private final File autosaveDir = new File(Main.pref.getPreferencesDir() + AUTOSAVE_DIR);
089    private final File deletedLayersDir = new File(Main.pref.getPreferencesDir() + DELETED_LAYERS_DIR);
090
091    public void schedule() {
092        if (PROP_INTERVAL.get() > 0) {
093
094            if (!autosaveDir.exists() && !autosaveDir.mkdirs()) {
095                Main.warn(tr("Unable to create directory {0}, autosave will be disabled", autosaveDir.getAbsolutePath()));
096                return;
097            }
098            if (!deletedLayersDir.exists() && !deletedLayersDir.mkdirs()) {
099                Main.warn(tr("Unable to create directory {0}, autosave will be disabled", deletedLayersDir.getAbsolutePath()));
100                return;
101            }
102
103            for (File f: deletedLayersDir.listFiles()) {
104                deletedLayers.add(f); // FIXME: sort by mtime
105            }
106
107            timer = new Timer(true);
108            timer.schedule(this, 1000, PROP_INTERVAL.get() * 1000);
109            MapView.addLayerChangeListener(this);
110            if (Main.isDisplayingMapView()) {
111                for (OsmDataLayer l: Main.map.mapView.getLayersOfType(OsmDataLayer.class)) {
112                    registerNewlayer(l);
113                }
114            }
115        }
116    }
117
118    private String getFileName(String layerName, int index) {
119        String result = layerName;
120        for (char illegalCharacter : ILLEGAL_CHARACTERS) {
121            result = result.replaceAll(Pattern.quote(String.valueOf(illegalCharacter)),
122                    '&' + String.valueOf((int) illegalCharacter) + ';');
123        }
124        if (index != 0) {
125            result = result + '_' + index;
126        }
127        return result;
128    }
129
130    private void setLayerFileName(AutosaveLayerInfo layer) {
131        int index = 0;
132        while (true) {
133            String filename = getFileName(layer.layer.getName(), index);
134            boolean foundTheSame = false;
135            for (AutosaveLayerInfo info: layersInfo) {
136                if (info != layer && filename.equals(info.layerFileName)) {
137                    foundTheSame = true;
138                    break;
139                }
140            }
141
142            if (!foundTheSame) {
143                layer.layerFileName = filename;
144                return;
145            }
146
147            index++;
148        }
149    }
150
151    private File getNewLayerFile(AutosaveLayerInfo layer) {
152        int index = 0;
153        Date now = new Date();
154        while (true) {
155            String filename = String.format("%1$s_%2$tY%2$tm%2$td_%2$tH%2$tM%3$s", layer.layerFileName, now, index == 0?"":"_" + index);
156            File result = new File(autosaveDir, filename+".osm");
157            try {
158                if (result.createNewFile()) {
159                    try {
160                        File pidFile = new File(autosaveDir, filename+".pid");
161                        PrintStream ps = new PrintStream(pidFile);
162                        ps.println(ManagementFactory.getRuntimeMXBean().getName());
163                        Utils.close(ps);
164                    } catch (Throwable t) {
165                        Main.error(t);
166                    }
167                    return result;
168                } else {
169                    Main.warn(tr("Unable to create file {0}, other filename will be used", result.getAbsolutePath()));
170                    if (index > PROP_INDEX_LIMIT.get())
171                        throw new IOException("index limit exceeded");
172                }
173            } catch (IOException e) {
174                Main.error(tr("IOError while creating file, autosave will be skipped: {0}", e.getMessage()));
175                return null;
176            }
177            index++;
178        }
179    }
180
181    private void savelayer(AutosaveLayerInfo info) throws IOException {
182        if (!info.layer.getName().equals(info.layerName)) {
183            setLayerFileName(info);
184            info.layerName = info.layer.getName();
185        }
186        if (changedDatasets.remove(info.layer.data)) {
187            File file = getNewLayerFile(info);
188            if (file != null) {
189                info.backupFiles.add(file);
190                new OsmExporter().exportData(file, info.layer, true /* no backup with appended ~ */);
191            }
192        }
193        while (info.backupFiles.size() > PROP_FILES_PER_LAYER.get()) {
194            File oldFile = info.backupFiles.remove();
195            if (!oldFile.delete()) {
196                Main.warn(tr("Unable to delete old backup file {0}", oldFile.getAbsolutePath()));
197            } else {
198                getPidFile(oldFile).delete();
199            }
200        }
201    }
202
203    @Override
204    public void run() {
205        synchronized (layersLock) {
206            try {
207                for (AutosaveLayerInfo info: layersInfo) {
208                    savelayer(info);
209                }
210                changedDatasets.clear();
211                if (PROP_NOTIFICATION.get() && !layersInfo.isEmpty()) {
212                    displayNotification();
213                }
214            } catch (Throwable t) {
215                // Don't let exception stop time thread
216                Main.error("Autosave failed:");
217                Main.error(t);
218                t.printStackTrace();
219            }
220        }
221    }
222
223    protected void displayNotification() {
224        GuiHelper.runInEDT(new Runnable() {
225            @Override
226            public void run() {
227                new Notification(tr("Your work has been saved automatically."))
228                .setDuration(Notification.TIME_SHORT)
229                .show();
230            }
231        });
232    }
233
234    @Override
235    public void activeLayerChange(Layer oldLayer, Layer newLayer) {
236        // Do nothing
237    }
238
239    private void registerNewlayer(OsmDataLayer layer) {
240        synchronized (layersLock) {
241            layer.data.addDataSetListener(datasetAdapter);
242            AutosaveLayerInfo info = new AutosaveLayerInfo();
243            info.layer = layer;
244            layersInfo.add(info);
245        }
246    }
247
248    @Override
249    public void layerAdded(Layer newLayer) {
250        if (newLayer instanceof OsmDataLayer) {
251            registerNewlayer((OsmDataLayer) newLayer);
252        }
253    }
254
255    @Override
256    public void layerRemoved(Layer oldLayer) {
257        if (oldLayer instanceof OsmDataLayer) {
258            synchronized (layersLock) {
259                OsmDataLayer osmLayer = (OsmDataLayer) oldLayer;
260                osmLayer.data.removeDataSetListener(datasetAdapter);
261                Iterator<AutosaveLayerInfo> it = layersInfo.iterator();
262                while (it.hasNext()) {
263                    AutosaveLayerInfo info = it.next();
264                    if (info.layer == osmLayer) {
265
266                        try {
267                            savelayer(info);
268                            File lastFile = info.backupFiles.pollLast();
269                            if (lastFile != null) {
270                                moveToDeletedLayersFolder(lastFile);
271                            }
272                            for (File file: info.backupFiles) {
273                                if (file.delete()) {
274                                    getPidFile(file).delete();
275                                }
276                            }
277                        } catch (IOException e) {
278                            Main.error(tr("Error while creating backup of removed layer: {0}", e.getMessage()));
279                        }
280
281                        it.remove();
282                    }
283                }
284            }
285        }
286    }
287
288    @Override
289    public void processDatasetEvent(AbstractDatasetChangedEvent event) {
290        changedDatasets.add(event.getDataset());
291    }
292
293    private final File getPidFile(File osmFile) {
294        return new File(autosaveDir, osmFile.getName().replaceFirst("[.][^.]+$", ".pid"));
295    }
296
297    /**
298     * Replies the list of .osm files still present in autosave dir, that are not currently managed by another instance of JOSM.
299     * These files are hence unsaved layers from an old instance of JOSM that crashed and may be recovered by this instance.
300     * @return The list of .osm files still present in autosave dir, that are not currently managed by another instance of JOSM
301     */
302    public List<File> getUnsavedLayersFiles() {
303        List<File> result = new ArrayList<File>();
304        File[] files = autosaveDir.listFiles(OsmImporter.FILE_FILTER);
305        if (files == null)
306            return result;
307        for (File file: files) {
308            if (file.isFile()) {
309                boolean skipFile = false;
310                File pidFile = getPidFile(file);
311                if (pidFile.exists()) {
312                    try {
313                        BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(pidFile)));
314                        try {
315                            String jvmId = reader.readLine();
316                            if (jvmId != null) {
317                                String pid = jvmId.split("@")[0];
318                                skipFile = jvmPerfDataFileExists(pid);
319                            }
320                        } catch (Throwable t) {
321                            Main.error(t);
322                        } finally {
323                            Utils.close(reader);
324                        }
325                    } catch (Throwable t) {
326                        Main.error(t);
327                    }
328                }
329                if (!skipFile) {
330                    result.add(file);
331                }
332            }
333        }
334        return result;
335    }
336
337    private boolean jvmPerfDataFileExists(final String jvmId) {
338        File jvmDir = new File(System.getProperty("java.io.tmpdir") + File.separator + "hsperfdata_" + System.getProperty("user.name"));
339        if (jvmDir.exists() && jvmDir.canRead()) {
340            File[] files = jvmDir.listFiles(new FileFilter() {
341                @Override
342                public boolean accept(File file) {
343                    return file.getName().equals(jvmId) && file.isFile();
344                }
345            });
346            return files != null && files.length == 1;
347        }
348        return false;
349    }
350
351    public void recoverUnsavedLayers() {
352        List<File> files = getUnsavedLayersFiles();
353        final OpenFileTask openFileTsk = new OpenFileTask(files, null, tr("Restoring files"));
354        Main.worker.submit(openFileTsk);
355        Main.worker.submit(new Runnable() {
356            @Override
357            public void run() {
358                for (File f: openFileTsk.getSuccessfullyOpenedFiles()) {
359                    moveToDeletedLayersFolder(f);
360                }
361            }
362        });
363    }
364
365    /**
366     * Move file to the deleted layers directory.
367     * If moving does not work, it will try to delete the file directly.
368     * Afterwards, if the number of deleted layers gets larger than PROP_DELETED_LAYERS,
369     * some files in the deleted layers directory will be removed.
370     *
371     * @param f the file, usually from the autosave dir
372     */
373    private void moveToDeletedLayersFolder(File f) {
374        File backupFile = new File(deletedLayersDir, f.getName());
375        File pidFile = getPidFile(f);
376
377        if (backupFile.exists()) {
378            deletedLayers.remove(backupFile);
379            if (!backupFile.delete()) {
380                Main.warn(String.format("Could not delete old backup file %s", backupFile));
381            }
382        }
383        if (f.renameTo(backupFile)) {
384            deletedLayers.add(backupFile);
385            pidFile.delete();
386        } else {
387            Main.warn(String.format("Could not move autosaved file %s to %s folder", f.getName(), deletedLayersDir.getName()));
388            // we cannot move to deleted folder, so just try to delete it directly
389            if (!f.delete()) {
390                Main.warn(String.format("Could not delete backup file %s", f));
391            } else {
392                pidFile.delete();
393            }
394        }
395        while (deletedLayers.size() > PROP_DELETED_LAYERS.get()) {
396            File next = deletedLayers.remove();
397            if (next == null) {
398                break;
399            }
400            if (!next.delete()) {
401                Main.warn(String.format("Could not delete archived backup file %s", next));
402            }
403        }
404    }
405
406    public void dicardUnsavedLayers() {
407        for (File f: getUnsavedLayersFiles()) {
408            moveToDeletedLayersFolder(f);
409        }
410    }
411}