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