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