001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.plugins;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Dimension;
007import java.awt.GridBagLayout;
008import java.io.BufferedReader;
009import java.io.ByteArrayInputStream;
010import java.io.File;
011import java.io.FileOutputStream;
012import java.io.FilenameFilter;
013import java.io.IOException;
014import java.io.InputStream;
015import java.io.InputStreamReader;
016import java.io.OutputStream;
017import java.io.OutputStreamWriter;
018import java.io.PrintWriter;
019import java.net.HttpURLConnection;
020import java.net.MalformedURLException;
021import java.net.URL;
022import java.nio.charset.StandardCharsets;
023import java.util.ArrayList;
024import java.util.Arrays;
025import java.util.Collection;
026import java.util.Collections;
027import java.util.HashSet;
028import java.util.LinkedList;
029import java.util.List;
030
031import javax.swing.JLabel;
032import javax.swing.JOptionPane;
033import javax.swing.JPanel;
034import javax.swing.JScrollPane;
035
036import org.openstreetmap.josm.Main;
037import org.openstreetmap.josm.gui.PleaseWaitRunnable;
038import org.openstreetmap.josm.gui.progress.NullProgressMonitor;
039import org.openstreetmap.josm.gui.progress.ProgressMonitor;
040import org.openstreetmap.josm.gui.util.GuiHelper;
041import org.openstreetmap.josm.gui.widgets.JosmTextArea;
042import org.openstreetmap.josm.io.OsmTransferException;
043import org.openstreetmap.josm.tools.GBC;
044import org.openstreetmap.josm.tools.ImageProvider;
045import org.openstreetmap.josm.tools.Utils;
046import org.xml.sax.SAXException;
047
048/**
049 * An asynchronous task for downloading plugin lists from the configured plugin download sites.
050 * @since 2817
051 */
052public class ReadRemotePluginInformationTask extends PleaseWaitRunnable {
053
054    private Collection<String> sites;
055    private boolean canceled;
056    private HttpURLConnection connection;
057    private List<PluginInformation> availablePlugins;
058    private boolean displayErrMsg;
059
060    protected final void init(Collection<String> sites, boolean displayErrMsg) {
061        this.sites = sites;
062        if (sites == null) {
063            this.sites = Collections.emptySet();
064        }
065        this.availablePlugins = new LinkedList<>();
066        this.displayErrMsg = displayErrMsg;
067    }
068
069    /**
070     * Constructs a new {@code ReadRemotePluginInformationTask}.
071     *
072     * @param sites the collection of download sites. Defaults to the empty collection if null.
073     */
074    public ReadRemotePluginInformationTask(Collection<String> sites) {
075        super(tr("Download plugin list..."), false /* don't ignore exceptions */);
076        init(sites, true);
077    }
078
079    /**
080     * Constructs a new {@code ReadRemotePluginInformationTask}.
081     *
082     * @param monitor the progress monitor. Defaults to {@link NullProgressMonitor#INSTANCE} if null
083     * @param sites the collection of download sites. Defaults to the empty collection if null.
084     * @param displayErrMsg if {@code true}, a blocking error message is displayed in case of I/O exception.
085     */
086    public ReadRemotePluginInformationTask(ProgressMonitor monitor, Collection<String> sites, boolean displayErrMsg) {
087        super(tr("Download plugin list..."), monitor == null ? NullProgressMonitor.INSTANCE: monitor, false /* don't ignore exceptions */);
088        init(sites, displayErrMsg);
089    }
090
091    @Override
092    protected void cancel() {
093        canceled = true;
094        synchronized(this) {
095            if (connection != null) {
096                connection.disconnect();
097            }
098        }
099    }
100
101    @Override
102    protected void finish() {}
103
104    /**
105     * Creates the file name for the cached plugin list and the icon cache file.
106     *
107     * @param pluginDir directory of plugin for data storage
108     * @param site the name of the site
109     * @return the file name for the cache file
110     */
111    protected File createSiteCacheFile(File pluginDir, String site) {
112        String name;
113        try {
114            site = site.replaceAll("%<(.*)>", "");
115            URL url = new URL(site);
116            StringBuilder sb = new StringBuilder();
117            sb.append("site-");
118            sb.append(url.getHost()).append("-");
119            if (url.getPort() != -1) {
120                sb.append(url.getPort()).append("-");
121            }
122            String path = url.getPath();
123            for (int i =0;i<path.length(); i++) {
124                char c = path.charAt(i);
125                if (Character.isLetterOrDigit(c)) {
126                    sb.append(c);
127                } else {
128                    sb.append("_");
129                }
130            }
131            sb.append(".txt");
132            name = sb.toString();
133        } catch(MalformedURLException e) {
134            name = "site-unknown.txt";
135        }
136        return new File(pluginDir, name);
137    }
138
139    /**
140     * Downloads the list from a remote location
141     *
142     * @param site the site URL
143     * @param monitor a progress monitor
144     * @return the downloaded list
145     */
146    protected String downloadPluginList(String site, final ProgressMonitor monitor) {
147        /* replace %<x> with empty string or x=plugins (separated with comma) */
148        String pl = Utils.join(",", Main.pref.getCollection("plugins"));
149        String printsite = site.replaceAll("%<(.*)>", "");
150        if (pl != null && pl.length() != 0) {
151            site = site.replaceAll("%<(.*)>", "$1"+pl);
152        } else {
153            site = printsite;
154        }
155
156        try {
157            monitor.beginTask("");
158            monitor.indeterminateSubTask(tr("Downloading plugin list from ''{0}''", printsite));
159
160            URL url = new URL(site);
161            synchronized(this) {
162                connection = Utils.openHttpConnection(url);
163                connection.setRequestProperty("Cache-Control", "no-cache");
164                connection.setRequestProperty("Accept-Charset", "utf-8");
165            }
166            try (BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
167                StringBuilder sb = new StringBuilder();
168                String line;
169                while ((line = in.readLine()) != null) {
170                    sb.append(line).append("\n");
171                }
172                return sb.toString();
173            }
174        } catch (MalformedURLException e) {
175            if (canceled) return null;
176            Main.error(e);
177            return null;
178        } catch (IOException e) {
179            if (canceled) return null;
180            Main.addNetworkError(site, e);
181            handleIOException(monitor, e, tr("Plugin list download error"), tr("JOSM failed to download plugin list:"), displayErrMsg);
182            return null;
183        } finally {
184            synchronized(this) {
185                if (connection != null) {
186                    connection.disconnect();
187                }
188                connection = null;
189            }
190            monitor.finishTask();
191        }
192    }
193
194    private void handleIOException(final ProgressMonitor monitor, IOException e, final String title, final String firstMessage, boolean displayMsg) {
195        StringBuilder sb = new StringBuilder();
196        try (InputStream errStream = connection.getErrorStream()) {
197            if (errStream != null) {
198                try (BufferedReader err = new BufferedReader(new InputStreamReader(errStream, StandardCharsets.UTF_8))) {
199                    String line;
200                    while ((line = err.readLine()) != null) {
201                        sb.append(line).append("\n");
202                    }
203                } catch (Exception ex) {
204                    Main.error(e);
205                    Main.error(ex);
206                }
207            }
208        } catch (IOException ex) {
209            Main.warn(ex);
210        }
211        final String msg = e.getMessage();
212        final String details = sb.toString();
213        if (details.isEmpty()) {
214            Main.error(e.getClass().getSimpleName()+": " + msg);
215        } else {
216            Main.error(msg + " - Details:\n" + details);
217        }
218
219        if (displayMsg) {
220            displayErrorMessage(monitor, msg, details, title, firstMessage);
221        }
222    }
223
224    private void displayErrorMessage(final ProgressMonitor monitor, final String msg, final String details, final String title, final String firstMessage) {
225        GuiHelper.runInEDTAndWait(new Runnable() {
226            @Override public void run() {
227                JPanel panel = new JPanel(new GridBagLayout());
228                panel.add(new JLabel(firstMessage), GBC.eol().insets(0, 0, 0, 10));
229                StringBuilder b = new StringBuilder();
230                for (String part : msg.split("(?<=\\G.{200})")) {
231                    b.append(part).append("\n");
232                }
233                panel.add(new JLabel("<html><body width=\"500\"><b>"+b.toString().trim()+"</b></body></html>"), GBC.eol().insets(0, 0, 0, 10));
234                if (!details.isEmpty()) {
235                    panel.add(new JLabel(tr("Details:")), GBC.eol().insets(0, 0, 0, 10));
236                    JosmTextArea area = new JosmTextArea(details);
237                    area.setEditable(false);
238                    area.setLineWrap(true);
239                    area.setWrapStyleWord(true);
240                    JScrollPane scrollPane = new JScrollPane(area);
241                    scrollPane.setPreferredSize(new Dimension(500, 300));
242                    panel.add(scrollPane, GBC.eol().fill());
243                }
244                JOptionPane.showMessageDialog(monitor.getWindowParent(), panel, title, JOptionPane.ERROR_MESSAGE);
245            }
246        });
247    }
248
249    /**
250     * Writes the list of plugins to a cache file
251     *
252     * @param site the site from where the list was downloaded
253     * @param list the downloaded list
254     */
255    protected void cachePluginList(String site, String list) {
256        File pluginDir = Main.pref.getPluginsDirectory();
257        if (!pluginDir.exists() && !pluginDir.mkdirs()) {
258            Main.warn(tr("Failed to create plugin directory ''{0}''. Cannot cache plugin list from plugin site ''{1}''.", pluginDir.toString(), site));
259        }
260        File cacheFile = createSiteCacheFile(pluginDir, site);
261        getProgressMonitor().subTask(tr("Writing plugin list to local cache ''{0}''", cacheFile.toString()));
262        try (PrintWriter writer = new PrintWriter(new OutputStreamWriter(new FileOutputStream(cacheFile), StandardCharsets.UTF_8))) {
263            writer.write(list);
264            writer.flush();
265        } catch(IOException e) {
266            // just failed to write the cache file. No big deal, but log the exception anyway
267            Main.error(e);
268        }
269    }
270
271    /**
272     * Filter information about deprecated plugins from the list of downloaded
273     * plugins
274     *
275     * @param plugins the plugin informations
276     * @return the plugin informations, without deprecated plugins
277     */
278    protected List<PluginInformation> filterDeprecatedPlugins(List<PluginInformation> plugins) {
279        List<PluginInformation> ret = new ArrayList<>(plugins.size());
280        HashSet<String> deprecatedPluginNames = new HashSet<>();
281        for (PluginHandler.DeprecatedPlugin p : PluginHandler.DEPRECATED_PLUGINS) {
282            deprecatedPluginNames.add(p.name);
283        }
284        for (PluginInformation plugin: plugins) {
285            if (deprecatedPluginNames.contains(plugin.name)) {
286                continue;
287            }
288            ret.add(plugin);
289        }
290        return ret;
291    }
292
293    /**
294     * Parses the plugin list
295     *
296     * @param site the site from where the list was downloaded
297     * @param doc the document with the plugin list
298     */
299    protected void parsePluginListDocument(String site, String doc) {
300        try {
301            getProgressMonitor().subTask(tr("Parsing plugin list from site ''{0}''", site));
302            InputStream in = new ByteArrayInputStream(doc.getBytes(StandardCharsets.UTF_8));
303            List<PluginInformation> pis = new PluginListParser().parse(in);
304            availablePlugins.addAll(filterDeprecatedPlugins(pis));
305        } catch (PluginListParseException e) {
306            Main.error(tr("Failed to parse plugin list document from site ''{0}''. Skipping site. Exception was: {1}", site, e.toString()));
307            Main.error(e);
308        }
309    }
310
311    @Override
312    protected void realRun() throws SAXException, IOException, OsmTransferException {
313        if (sites == null) return;
314        getProgressMonitor().setTicksCount(sites.size() * 3);
315        File pluginDir = Main.pref.getPluginsDirectory();
316
317        // collect old cache files and remove if no longer in use
318        List<File> siteCacheFiles = new LinkedList<>();
319        for (String location : PluginInformation.getPluginLocations()) {
320            File [] f = new File(location).listFiles(
321                    new FilenameFilter() {
322                        @Override
323                        public boolean accept(File dir, String name) {
324                            return name.matches("^([0-9]+-)?site.*\\.txt$") ||
325                            name.matches("^([0-9]+-)?site.*-icons\\.zip$");
326                        }
327                    }
328            );
329            if(f != null && f.length > 0) {
330                siteCacheFiles.addAll(Arrays.asList(f));
331            }
332        }
333
334        for (String site: sites) {
335            String printsite = site.replaceAll("%<(.*)>", "");
336            getProgressMonitor().subTask(tr("Processing plugin list from site ''{0}''", printsite));
337            String list = downloadPluginList(site, getProgressMonitor().createSubTaskMonitor(0, false));
338            if (canceled) return;
339            siteCacheFiles.remove(createSiteCacheFile(pluginDir, site));
340            if (list != null) {
341                getProgressMonitor().worked(1);
342                cachePluginList(site, list);
343                if (canceled) return;
344                getProgressMonitor().worked(1);
345                parsePluginListDocument(site, list);
346                if (canceled) return;
347                getProgressMonitor().worked(1);
348                if (canceled) return;
349            }
350        }
351        // remove old stuff or whole update process is broken
352        for (File file: siteCacheFiles) {
353            file.delete();
354        }
355    }
356
357    /**
358     * Replies true if the task was canceled
359     * @return <code>true</code> if the task was stopped by the user
360     */
361    public boolean isCanceled() {
362        return canceled;
363    }
364
365    /**
366     * Replies the list of plugins described in the downloaded plugin lists
367     *
368     * @return  the list of plugins
369     * @since 5601
370     */
371    public List<PluginInformation> getAvailablePlugins() {
372        return availablePlugins;
373    }
374}