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