001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.actions;
003
004import static javax.swing.JFileChooser.FILES_AND_DIRECTORIES;
005import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
006import static org.openstreetmap.josm.tools.I18n.tr;
007import static org.openstreetmap.josm.tools.I18n.trn;
008
009import java.awt.event.ActionEvent;
010import java.awt.event.KeyEvent;
011import java.io.BufferedReader;
012import java.io.File;
013import java.io.IOException;
014import java.nio.charset.StandardCharsets;
015import java.nio.file.Files;
016import java.util.ArrayList;
017import java.util.Arrays;
018import java.util.Collection;
019import java.util.Collections;
020import java.util.EnumSet;
021import java.util.HashSet;
022import java.util.LinkedHashSet;
023import java.util.LinkedList;
024import java.util.List;
025import java.util.Objects;
026import java.util.Set;
027import java.util.concurrent.Future;
028import java.util.regex.Matcher;
029import java.util.regex.Pattern;
030import java.util.stream.Stream;
031
032import javax.swing.JOptionPane;
033import javax.swing.SwingUtilities;
034import javax.swing.filechooser.FileFilter;
035
036import org.openstreetmap.josm.data.PreferencesUtils;
037import org.openstreetmap.josm.gui.HelpAwareOptionPane;
038import org.openstreetmap.josm.gui.MainApplication;
039import org.openstreetmap.josm.gui.MapFrame;
040import org.openstreetmap.josm.gui.Notification;
041import org.openstreetmap.josm.gui.PleaseWaitRunnable;
042import org.openstreetmap.josm.gui.io.importexport.AllFormatsImporter;
043import org.openstreetmap.josm.gui.io.importexport.FileImporter;
044import org.openstreetmap.josm.gui.io.importexport.Options;
045import org.openstreetmap.josm.gui.util.GuiHelper;
046import org.openstreetmap.josm.gui.widgets.AbstractFileChooser;
047import org.openstreetmap.josm.gui.widgets.FileChooserManager;
048import org.openstreetmap.josm.gui.widgets.NativeFileChooser;
049import org.openstreetmap.josm.io.OsmTransferException;
050import org.openstreetmap.josm.spi.preferences.Config;
051import org.openstreetmap.josm.tools.Logging;
052import org.openstreetmap.josm.tools.MultiMap;
053import org.openstreetmap.josm.tools.PlatformManager;
054import org.openstreetmap.josm.tools.Shortcut;
055import org.openstreetmap.josm.tools.Utils;
056import org.xml.sax.SAXException;
057
058/**
059 * Open a file chooser dialog and select a file to import.
060 *
061 * @author imi
062 * @since 1146
063 */
064public class OpenFileAction extends DiskAccessAction {
065
066    /**
067     * The {@link ExtensionFileFilter} matching .url files
068     */
069    public static final ExtensionFileFilter URL_FILE_FILTER = new ExtensionFileFilter("url", "url", tr("URL Files") + " (*.url)");
070
071    /**
072     * Create an open action. The name is "Open a file".
073     */
074    public OpenFileAction() {
075        super(tr("Open..."), "open", tr("Open a file."),
076                Shortcut.registerShortcut("system:open", tr("File: {0}", tr("Open...")), KeyEvent.VK_O, Shortcut.CTRL),
077                true, null, false);
078        setHelpId(ht("/Action/Open"));
079    }
080
081    @Override
082    public void actionPerformed(ActionEvent e) {
083        final AbstractFileChooser fc;
084        // If the user explicitly wants native file dialogs, let them use it.
085        // Rather unfortunately, this means that they will not be able to select files and directories.
086        if (FileChooserManager.PROP_USE_NATIVE_FILE_DIALOG.get()
087                // This is almost redundant, as the JDK currently doesn't support this with (all?) native file choosers.
088                && !NativeFileChooser.supportsSelectionMode(FILES_AND_DIRECTORIES)) {
089            fc = createAndOpenFileChooser(true, true, null);
090        } else {
091            fc = createAndOpenFileChooser(true, true, null, null, FILES_AND_DIRECTORIES, true, null);
092        }
093        if (fc == null)
094            return;
095        File[] files = fc.getSelectedFiles();
096        OpenFileTask task = new OpenFileTask(Arrays.asList(files), fc.getFileFilter());
097        task.setOptions(Options.RECORD_HISTORY);
098        MainApplication.worker.submit(task);
099    }
100
101    @Override
102    protected void updateEnabledState() {
103        setEnabled(true);
104    }
105
106    /**
107     * Open a list of files. The complete list will be passed to batch importers.
108     * Filenames will not be saved in history.
109     * @param fileList A list of files
110     * @return the future task
111     * @since 11986 (return task)
112     */
113    public static Future<?> openFiles(List<File> fileList) {
114        return openFiles(fileList, (Options[]) null);
115    }
116
117    /**
118     * Open a list of files. The complete list will be passed to batch importers.
119     * @param fileList A list of files
120     * @param options The options to use
121     * @return the future task
122     * @since 17534 ({@link Options})
123     */
124    public static Future<?> openFiles(List<File> fileList, Options... options) {
125        OpenFileTask task = new OpenFileTask(fileList, null);
126        task.setOptions(options);
127        return MainApplication.worker.submit(task);
128    }
129
130    /**
131     * Task to open files.
132     */
133    public static class OpenFileTask extends PleaseWaitRunnable {
134        private final List<File> files;
135        private final List<File> successfullyOpenedFiles = new ArrayList<>();
136        private final Set<String> fileHistory = new LinkedHashSet<>();
137        private final Set<String> failedAll = new HashSet<>();
138        private final FileFilter fileFilter;
139        private boolean canceled;
140        private final EnumSet<Options> options = EnumSet.noneOf(Options.class);
141
142        /**
143         * Constructs a new {@code OpenFileTask}.
144         * @param files files to open
145         * @param fileFilter file filter
146         * @param title message for the user
147         */
148        public OpenFileTask(final List<File> files, final FileFilter fileFilter, final String title) {
149            super(title, false /* don't ignore exception */);
150            this.fileFilter = fileFilter;
151            this.files = new ArrayList<>(files.size());
152            for (final File file : files) {
153                if (file.exists()) {
154                    this.files.add(PlatformManager.getPlatform().resolveFileLink(file));
155                } else if (file.getParentFile() != null) {
156                    // try to guess an extension using the specified fileFilter
157                    final File[] matchingFiles = file.getParentFile().listFiles((dir, name) ->
158                            name.startsWith(file.getName()) && fileFilter != null && fileFilter.accept(new File(dir, name)));
159                    if (matchingFiles != null && matchingFiles.length == 1) {
160                        // use the unique match as filename
161                        this.files.add(matchingFiles[0]);
162                    } else {
163                        // add original filename for error reporting later on
164                        this.files.add(file);
165                    }
166                } else {
167                    String message = tr("Unable to locate file  ''{0}''.", file.getPath());
168                    Logging.warn(message);
169                    new Notification(message).show();
170                }
171            }
172        }
173
174        /**
175         * Constructs a new {@code OpenFileTask}.
176         * @param files files to open
177         * @param fileFilter file filter
178         */
179        public OpenFileTask(List<File> files, FileFilter fileFilter) {
180            this(files, fileFilter, tr("Opening files"));
181        }
182
183        /**
184         * Set the options for the task.
185         * @param options The options to set
186         * @see Options
187         * @since 17556
188         */
189        public void setOptions(Options... options) {
190            this.options.clear();
191            if (options != null) {
192                Stream.of(options).filter(Objects::nonNull).forEach(this.options::add);
193            }
194        }
195
196        /**
197         * Determines if filename must be saved in history (for list of recently opened files).
198         * @return {@code true} if filename must be saved in history
199         */
200        public boolean isRecordHistory() {
201            return this.options.contains(Options.RECORD_HISTORY);
202        }
203
204        /**
205         * Get the options for this task
206         * @return A set of options
207         * @since 17534
208         */
209        public Set<Options> getOptions() {
210            return Collections.unmodifiableSet(this.options);
211        }
212
213        @Override
214        protected void cancel() {
215            this.canceled = true;
216        }
217
218        @Override
219        protected void finish() {
220            MapFrame map = MainApplication.getMap();
221            if (map != null) {
222                map.repaint();
223            }
224        }
225
226        protected void alertFilesNotMatchingWithImporter(Collection<File> files, FileImporter importer) {
227            final StringBuilder msg = new StringBuilder(128).append("<html>").append(
228                    trn("Cannot open {0} file with the file importer ''{1}''.",
229                        "Cannot open {0} files with the file importer ''{1}''.",
230                        files.size(),
231                        files.size(),
232                        Utils.escapeReservedCharactersHTML(importer.filter.getDescription())
233                    )
234            ).append("<br><ul>");
235            for (File f: files) {
236                msg.append("<li>").append(f.getAbsolutePath()).append("</li>");
237            }
238            msg.append("</ul></html>");
239
240            HelpAwareOptionPane.showMessageDialogInEDT(
241                    MainApplication.getMainFrame(),
242                    msg.toString(),
243                    tr("Warning"),
244                    JOptionPane.WARNING_MESSAGE,
245                    ht("/Action/Open#ImporterCantImportFiles")
246            );
247        }
248
249        protected void alertFilesWithUnknownImporter(Collection<File> files) {
250            final StringBuilder msg = new StringBuilder(128).append("<html>").append(
251                    trn("Cannot open {0} file because file does not exist or no suitable file importer is available.",
252                        "Cannot open {0} files because files do not exist or no suitable file importer is available.",
253                        files.size(),
254                        files.size()
255                    )
256            ).append("<br><ul>");
257            for (File f: files) {
258                msg.append("<li>").append(f.getAbsolutePath()).append(" (<i>")
259                   .append(f.exists() ? tr("no importer") : tr("does not exist"))
260                   .append("</i>)</li>");
261            }
262            msg.append("</ul></html>");
263
264            HelpAwareOptionPane.showMessageDialogInEDT(
265                    MainApplication.getMainFrame(),
266                    msg.toString(),
267                    tr("Warning"),
268                    JOptionPane.WARNING_MESSAGE,
269                    ht("/Action/Open#MissingImporterForFiles")
270            );
271        }
272
273        @Override
274        protected void realRun() throws SAXException, IOException, OsmTransferException {
275            if (Utils.isEmpty(files)) return;
276
277            /**
278             * Find the importer with the chosen file filter
279             */
280            FileImporter chosenImporter = null;
281            if (fileFilter != null) {
282                for (FileImporter importer : ExtensionFileFilter.getImporters()) {
283                    if (fileFilter.equals(importer.filter)) {
284                        chosenImporter = importer;
285                    }
286                }
287            }
288            /**
289             * If the filter hasn't been changed in the dialog, chosenImporter is null now.
290             * When the filter has been set explicitly to AllFormatsImporter, treat this the same.
291             */
292            if (chosenImporter instanceof AllFormatsImporter) {
293                chosenImporter = null;
294            }
295            getProgressMonitor().setTicksCount(files.size());
296
297            if (chosenImporter != null) {
298                // The importer was explicitly chosen, so use it.
299                List<File> filesNotMatchingWithImporter = new LinkedList<>();
300                List<File> filesMatchingWithImporter = new LinkedList<>();
301                for (final File f : files) {
302                    if (!chosenImporter.acceptFile(f)) {
303                        if (f.isDirectory()) {
304                            SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(MainApplication.getMainFrame(), tr(
305                                    "<html>Cannot open directory ''{0}''.<br>Please select a file.</html>",
306                                    f.getAbsolutePath()), tr("Open file"), JOptionPane.ERROR_MESSAGE));
307                            // TODO when changing to Java 6: Don't cancel the task here but use different modality. (Currently 2 dialogs
308                            // would block each other.)
309                            return;
310                        } else {
311                            filesNotMatchingWithImporter.add(f);
312                        }
313                    } else {
314                        filesMatchingWithImporter.add(f);
315                    }
316                }
317
318                if (!filesNotMatchingWithImporter.isEmpty()) {
319                    alertFilesNotMatchingWithImporter(filesNotMatchingWithImporter, chosenImporter);
320                }
321                if (!filesMatchingWithImporter.isEmpty()) {
322                    importData(chosenImporter, filesMatchingWithImporter);
323                }
324            } else {
325                // find appropriate importer
326                MultiMap<FileImporter, File> importerMap = new MultiMap<>();
327                List<File> filesWithUnknownImporter = new LinkedList<>();
328                List<File> urlFiles = new LinkedList<>();
329                FILES: for (File f : files) {
330                    for (FileImporter importer : ExtensionFileFilter.getImporters()) {
331                        if (importer.acceptFile(f)) {
332                            importerMap.put(importer, f);
333                            continue FILES;
334                        }
335                    }
336                    if (URL_FILE_FILTER.accept(f)) {
337                        urlFiles.add(f);
338                    } else {
339                        filesWithUnknownImporter.add(f);
340                    }
341                }
342                if (!filesWithUnknownImporter.isEmpty()) {
343                    alertFilesWithUnknownImporter(filesWithUnknownImporter);
344                }
345                List<FileImporter> importers = new ArrayList<>(importerMap.keySet());
346                Collections.sort(importers);
347                Collections.reverse(importers);
348
349                for (FileImporter importer : importers) {
350                    importData(importer, new ArrayList<>(importerMap.get(importer)));
351                }
352
353                Pattern urlPattern = Pattern.compile(".*(https?://.*)");
354                for (File urlFile: urlFiles) {
355                    try (BufferedReader reader = Files.newBufferedReader(urlFile.toPath(), StandardCharsets.UTF_8)) {
356                        String line;
357                        while ((line = reader.readLine()) != null) {
358                            Matcher m = urlPattern.matcher(line);
359                            if (m.matches()) {
360                                String url = m.group(1);
361                                MainApplication.getMenu().openLocation.openUrl(false, url);
362                            }
363                        }
364                    } catch (IOException | RuntimeException | LinkageError e) {
365                        Logging.error(e);
366                        GuiHelper.runInEDT(
367                                () -> new Notification(Utils.getRootCause(e).getMessage()).setIcon(JOptionPane.ERROR_MESSAGE).show());
368                    }
369                }
370            }
371
372            if (this.options.contains(Options.RECORD_HISTORY)) {
373                Collection<String> oldFileHistory = Config.getPref().getList("file-open.history");
374                fileHistory.addAll(oldFileHistory);
375                // remove the files which failed to load from the list
376                fileHistory.removeAll(failedAll);
377                int maxsize = Math.max(0, Config.getPref().getInt("file-open.history.max-size", 15));
378                PreferencesUtils.putListBounded(Config.getPref(), "file-open.history", maxsize, new ArrayList<>(fileHistory));
379            }
380        }
381
382        /**
383         * Import data files with the given importer.
384         * @param importer file importer
385         * @param files data files to import
386         */
387        public void importData(FileImporter importer, List<File> files) {
388            importer.setOptions(this.options.toArray(new Options[0]));
389            if (importer.isBatchImporter()) {
390                if (canceled) return;
391                String msg = trn("Opening {0} file...", "Opening {0} files...", files.size(), files.size());
392                getProgressMonitor().setCustomText(msg);
393                getProgressMonitor().indeterminateSubTask(msg);
394                if (importer.importDataHandleExceptions(files, getProgressMonitor().createSubTaskMonitor(files.size(), false))) {
395                    successfullyOpenedFiles.addAll(files);
396                }
397            } else {
398                for (File f : files) {
399                    if (canceled) return;
400                    getProgressMonitor().indeterminateSubTask(tr("Opening file ''{0}'' ...", f.getAbsolutePath()));
401                    if (importer.importDataHandleExceptions(f, getProgressMonitor().createSubTaskMonitor(1, false))) {
402                        successfullyOpenedFiles.add(f);
403                    }
404                }
405            }
406            if (this.options.contains(Options.RECORD_HISTORY) && !importer.isBatchImporter()) {
407                for (File f : files) {
408                    try {
409                        if (successfullyOpenedFiles.contains(f)) {
410                            fileHistory.add(f.getCanonicalPath());
411                        } else {
412                            failedAll.add(f.getCanonicalPath());
413                        }
414                    } catch (IOException e) {
415                        Logging.warn(e);
416                    }
417                }
418            }
419        }
420
421        /**
422         * Replies the list of files that have been successfully opened.
423         * @return The list of files that have been successfully opened.
424         */
425        public List<File> getSuccessfullyOpenedFiles() {
426            return successfullyOpenedFiles;
427        }
428    }
429}