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