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