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