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