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        private OsmDataLayer layer;
075        private String layerName;
076        private String layerFileName;
077        private 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            File[] files = deletedLayersDir.listFiles();
103            if (files != null) {
104                for (File f: files) {
105                    deletedLayers.add(f); // FIXME: sort by mtime
106                }
107            }
108
109            timer = new Timer(true);
110            timer.schedule(this, 1000L, PROP_INTERVAL.get() * 1000L);
111            MapView.addLayerChangeListener(this);
112            if (Main.isDisplayingMapView()) {
113                for (OsmDataLayer l: Main.map.mapView.getLayersOfType(OsmDataLayer.class)) {
114                    registerNewlayer(l);
115                }
116            }
117        }
118    }
119
120    private static String getFileName(String layerName, int index) {
121        String result = layerName;
122        for (char illegalCharacter : ILLEGAL_CHARACTERS) {
123            result = result.replaceAll(Pattern.quote(String.valueOf(illegalCharacter)),
124                    '&' + String.valueOf((int) illegalCharacter) + ';');
125        }
126        if (index != 0) {
127            result = result + '_' + index;
128        }
129        return result;
130    }
131
132    private void setLayerFileName(AutosaveLayerInfo layer) {
133        int index = 0;
134        while (true) {
135            String filename = getFileName(layer.layer.getName(), index);
136            boolean foundTheSame = false;
137            for (AutosaveLayerInfo info: layersInfo) {
138                if (info != layer && filename.equals(info.layerFileName)) {
139                    foundTheSame = true;
140                    break;
141                }
142            }
143
144            if (!foundTheSame) {
145                layer.layerFileName = filename;
146                return;
147            }
148
149            index++;
150        }
151    }
152
153    private File getNewLayerFile(AutosaveLayerInfo layer) {
154        int index = 0;
155        Date now = new Date();
156        while (true) {
157            String filename = String.format("%1$s_%2$tY%2$tm%2$td_%2$tH%2$tM%2$tS%2$tL%3$s",
158                    layer.layerFileName, now, index == 0 ? "" : '_' + index);
159            File result = new File(autosaveDir, filename+".osm");
160            try {
161                if (result.createNewFile()) {
162                    File pidFile = new File(autosaveDir, filename+".pid");
163                    try (PrintStream ps = new PrintStream(pidFile, "UTF-8")) {
164                        ps.println(ManagementFactory.getRuntimeMXBean().getName());
165                    } catch (Exception t) {
166                        Main.error(t);
167                    }
168                    return result;
169                } else {
170                    Main.warn(tr("Unable to create file {0}, other filename will be used", result.getAbsolutePath()));
171                    if (index > PROP_INDEX_LIMIT.get())
172                        throw new IOException("index limit exceeded");
173                }
174            } catch (IOException e) {
175                Main.error(tr("IOError while creating file, autosave will be skipped: {0}", e.getMessage()));
176                return null;
177            }
178            index++;
179        }
180    }
181
182    private void savelayer(AutosaveLayerInfo info) {
183        if (!info.layer.getName().equals(info.layerName)) {
184            setLayerFileName(info);
185            info.layerName = info.layer.getName();
186        }
187        if (changedDatasets.remove(info.layer.data)) {
188            File file = getNewLayerFile(info);
189            if (file != null) {
190                info.backupFiles.add(file);
191                new OsmExporter().exportData(file, info.layer, true /* no backup with appended ~ */);
192            }
193        }
194        while (info.backupFiles.size() > PROP_FILES_PER_LAYER.get()) {
195            File oldFile = info.backupFiles.remove();
196            if (!oldFile.delete()) {
197                Main.warn(tr("Unable to delete old backup file {0}", oldFile.getAbsolutePath()));
198            } else {
199                File pidFile = getPidFile(oldFile);
200                if (!pidFile.delete()) {
201                    Main.warn(tr("Unable to delete old backup file {0}", pidFile.getAbsolutePath()));
202                }
203            }
204        }
205    }
206
207    @Override
208    public void run() {
209        synchronized (layersLock) {
210            try {
211                for (AutosaveLayerInfo info: layersInfo) {
212                    savelayer(info);
213                }
214                changedDatasets.clear();
215                if (PROP_NOTIFICATION.get() && !layersInfo.isEmpty()) {
216                    displayNotification();
217                }
218            } catch (Exception t) {
219                // Don't let exception stop time thread
220                Main.error("Autosave failed:");
221                Main.error(t);
222            }
223        }
224    }
225
226    protected void displayNotification() {
227        GuiHelper.runInEDT(new Runnable() {
228            @Override
229            public void run() {
230                new Notification(tr("Your work has been saved automatically."))
231                .setDuration(Notification.TIME_SHORT)
232                .show();
233            }
234        });
235    }
236
237    @Override
238    public void activeLayerChange(Layer oldLayer, Layer newLayer) {
239        // Do nothing
240    }
241
242    private void registerNewlayer(OsmDataLayer layer) {
243        synchronized (layersLock) {
244            layer.data.addDataSetListener(datasetAdapter);
245            AutosaveLayerInfo info = new AutosaveLayerInfo();
246            info.layer = layer;
247            layersInfo.add(info);
248        }
249    }
250
251    @Override
252    public void layerAdded(Layer newLayer) {
253        if (newLayer instanceof OsmDataLayer) {
254            registerNewlayer((OsmDataLayer) newLayer);
255        }
256    }
257
258    @Override
259    public void layerRemoved(Layer oldLayer) {
260        if (oldLayer instanceof OsmDataLayer) {
261            synchronized (layersLock) {
262                OsmDataLayer osmLayer = (OsmDataLayer) oldLayer;
263                osmLayer.data.removeDataSetListener(datasetAdapter);
264                Iterator<AutosaveLayerInfo> it = layersInfo.iterator();
265                while (it.hasNext()) {
266                    AutosaveLayerInfo info = it.next();
267                    if (info.layer == osmLayer) {
268
269                        savelayer(info);
270                        File lastFile = info.backupFiles.pollLast();
271                        if (lastFile != null) {
272                            moveToDeletedLayersFolder(lastFile);
273                        }
274                        for (File file: info.backupFiles) {
275                            if (file.delete()) {
276                                getPidFile(file).delete();
277                            }
278                        }
279
280                        it.remove();
281                    }
282                }
283            }
284        }
285    }
286
287    @Override
288    public void processDatasetEvent(AbstractDatasetChangedEvent event) {
289        changedDatasets.add(event.getDataset());
290    }
291
292    private File getPidFile(File osmFile) {
293        return new File(autosaveDir, osmFile.getName().replaceFirst("[.][^.]+$", ".pid"));
294    }
295
296    /**
297     * Replies the list of .osm files still present in autosave dir, that are not currently managed by another instance of JOSM.
298     * These files are hence unsaved layers from an old instance of JOSM that crashed and may be recovered by this instance.
299     * @return The list of .osm files still present in autosave dir, that are not currently managed by another instance of JOSM
300     */
301    public List<File> getUnsavedLayersFiles() {
302        List<File> result = new ArrayList<>();
303        File[] files = autosaveDir.listFiles(OsmImporter.FILE_FILTER);
304        if (files == null)
305            return result;
306        for (File file: files) {
307            if (file.isFile()) {
308                boolean skipFile = false;
309                File pidFile = getPidFile(file);
310                if (pidFile.exists()) {
311                    try (BufferedReader reader = Files.newBufferedReader(pidFile.toPath(), StandardCharsets.UTF_8)) {
312                        String jvmId = reader.readLine();
313                        if (jvmId != null) {
314                            String pid = jvmId.split("@")[0];
315                            skipFile = jvmPerfDataFileExists(pid);
316                        }
317                    } catch (Exception t) {
318                        Main.error(t);
319                    }
320                }
321                if (!skipFile) {
322                    result.add(file);
323                }
324            }
325        }
326        return result;
327    }
328
329    private boolean jvmPerfDataFileExists(final String jvmId) {
330        File jvmDir = new File(System.getProperty("java.io.tmpdir") + File.separator + "hsperfdata_" + System.getProperty("user.name"));
331        if (jvmDir.exists() && jvmDir.canRead()) {
332            File[] files = jvmDir.listFiles(new FileFilter() {
333                @Override
334                public boolean accept(File file) {
335                    return file.getName().equals(jvmId) && file.isFile();
336                }
337            });
338            return files != null && files.length == 1;
339        }
340        return false;
341    }
342
343    public void recoverUnsavedLayers() {
344        List<File> files = getUnsavedLayersFiles();
345        final OpenFileTask openFileTsk = new OpenFileTask(files, null, tr("Restoring files"));
346        Main.worker.submit(openFileTsk);
347        Main.worker.submit(new Runnable() {
348            @Override
349            public void run() {
350                for (File f: openFileTsk.getSuccessfullyOpenedFiles()) {
351                    moveToDeletedLayersFolder(f);
352                }
353            }
354        });
355    }
356
357    /**
358     * Move file to the deleted layers directory.
359     * If moving does not work, it will try to delete the file directly.
360     * Afterwards, if the number of deleted layers gets larger than PROP_DELETED_LAYERS,
361     * some files in the deleted layers directory will be removed.
362     *
363     * @param f the file, usually from the autosave dir
364     */
365    private void moveToDeletedLayersFolder(File f) {
366        File backupFile = new File(deletedLayersDir, f.getName());
367        File pidFile = getPidFile(f);
368
369        if (backupFile.exists()) {
370            deletedLayers.remove(backupFile);
371            if (!backupFile.delete()) {
372                Main.warn(String.format("Could not delete old backup file %s", backupFile));
373            }
374        }
375        if (f.renameTo(backupFile)) {
376            deletedLayers.add(backupFile);
377            pidFile.delete();
378        } else {
379            Main.warn(String.format("Could not move autosaved file %s to %s folder", f.getName(), deletedLayersDir.getName()));
380            // we cannot move to deleted folder, so just try to delete it directly
381            if (!f.delete()) {
382                Main.warn(String.format("Could not delete backup file %s", f));
383            } else if (!pidFile.delete()) {
384                Main.warn(String.format("Could not delete PID file %s", pidFile));
385            }
386        }
387        while (deletedLayers.size() > PROP_DELETED_LAYERS.get()) {
388            File next = deletedLayers.remove();
389            if (next == null) {
390                break;
391            }
392            if (!next.delete()) {
393                Main.warn(String.format("Could not delete archived backup file %s", next));
394            }
395        }
396    }
397
398    public void discardUnsavedLayers() {
399        for (File f: getUnsavedLayersFiles()) {
400            moveToDeletedLayersFolder(f);
401        }
402    }
403}