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.InputStreamReader; 014import java.io.PrintWriter; 015import java.net.MalformedURLException; 016import java.net.URL; 017import java.nio.charset.StandardCharsets; 018import java.util.ArrayList; 019import java.util.Arrays; 020import java.util.Collection; 021import java.util.Collections; 022import java.util.HashSet; 023import java.util.LinkedList; 024import java.util.List; 025import java.util.Optional; 026import java.util.Set; 027import java.util.stream.Collectors; 028 029import javax.swing.JLabel; 030import javax.swing.JOptionPane; 031import javax.swing.JPanel; 032import javax.swing.JScrollPane; 033 034import org.openstreetmap.josm.data.Preferences; 035import org.openstreetmap.josm.gui.PleaseWaitRunnable; 036import org.openstreetmap.josm.gui.progress.NullProgressMonitor; 037import org.openstreetmap.josm.gui.progress.ProgressMonitor; 038import org.openstreetmap.josm.gui.util.GuiHelper; 039import org.openstreetmap.josm.gui.widgets.JosmTextArea; 040import org.openstreetmap.josm.io.OsmTransferException; 041import org.openstreetmap.josm.spi.preferences.Config; 042import org.openstreetmap.josm.tools.GBC; 043import org.openstreetmap.josm.tools.HttpClient; 044import org.openstreetmap.josm.tools.Logging; 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 HttpClient connection; 057 private List<PluginInformation> availablePlugins; 058 private boolean displayErrMsg; 059 060 protected final void init(Collection<String> sites, boolean displayErrMsg) { 061 this.sites = Optional.ofNullable(sites).orElseGet(Collections::emptySet); 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(",", Config.getPref().getList("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 final URL url = new URL(site); 161 if ("https".equals(url.getProtocol()) || "http".equals(url.getProtocol())) { 162 connection = HttpClient.create(url).useCache(false); 163 final HttpClient.Response response = connection.connect(); 164 content = response.fetchContent(); 165 if (response.getResponseCode() != 200) { 166 throw new IOException(tr("Unsuccessful HTTP request")); 167 } 168 return content; 169 } else { 170 // e.g. when downloading from a file:// URL, we can't use HttpClient 171 try (InputStreamReader in = new InputStreamReader(url.openConnection().getInputStream(), StandardCharsets.UTF_8)) { 172 final StringBuilder sb = new StringBuilder(); 173 final char[] buffer = new char[8192]; 174 int numChars; 175 while ((numChars = in.read(buffer)) >= 0) { 176 sb.append(buffer, 0, numChars); 177 if (canceled) { 178 return null; 179 } 180 } 181 return sb.toString(); 182 } 183 } 184 185 } catch (MalformedURLException e) { 186 if (canceled) return null; 187 Logging.error(e); 188 return null; 189 } catch (IOException e) { 190 if (canceled) return null; 191 handleIOException(monitor, e, content); 192 return null; 193 } finally { 194 synchronized (this) { 195 if (connection != null) { 196 connection.disconnect(); 197 } 198 connection = null; 199 } 200 monitor.finishTask(); 201 } 202 } 203 204 private void handleIOException(final ProgressMonitor monitor, IOException e, String details) { 205 final String msg = e.getMessage(); 206 if (details == null || details.isEmpty()) { 207 Logging.error(e.getClass().getSimpleName()+": " + msg); 208 } else { 209 Logging.error(msg + " - Details:\n" + details); 210 } 211 212 if (displayErrMsg) { 213 displayErrorMessage(monitor, msg, details, tr("Plugin list download error"), tr("JOSM failed to download plugin list:")); 214 } 215 } 216 217 private static void displayErrorMessage(final ProgressMonitor monitor, final String msg, final String details, final String title, 218 final String firstMessage) { 219 GuiHelper.runInEDTAndWait(() -> { 220 JPanel panel = new JPanel(new GridBagLayout()); 221 panel.add(new JLabel(firstMessage), GBC.eol().insets(0, 0, 0, 10)); 222 StringBuilder b = new StringBuilder(); 223 for (String part : msg.split("(?<=\\G.{200})")) { 224 b.append(part).append('\n'); 225 } 226 panel.add(new JLabel("<html><body width=\"500\"><b>"+b.toString().trim()+"</b></body></html>"), GBC.eol().insets(0, 0, 0, 10)); 227 if (details != null && !details.isEmpty()) { 228 panel.add(new JLabel(tr("Details:")), GBC.eol().insets(0, 0, 0, 10)); 229 JosmTextArea area = new JosmTextArea(details); 230 area.setEditable(false); 231 area.setLineWrap(true); 232 area.setWrapStyleWord(true); 233 JScrollPane scrollPane = new JScrollPane(area); 234 scrollPane.setPreferredSize(new Dimension(500, 300)); 235 panel.add(scrollPane, GBC.eol().fill()); 236 } 237 JOptionPane.showMessageDialog(monitor.getWindowParent(), panel, title, JOptionPane.ERROR_MESSAGE); 238 }); 239 } 240 241 /** 242 * Writes the list of plugins to a cache file 243 * 244 * @param site the site from where the list was downloaded 245 * @param list the downloaded list 246 */ 247 protected void cachePluginList(String site, String list) { 248 File pluginDir = Preferences.main().getPluginsDirectory(); 249 if (!pluginDir.exists() && !pluginDir.mkdirs()) { 250 Logging.warn(tr("Failed to create plugin directory ''{0}''. Cannot cache plugin list from plugin site ''{1}''.", 251 pluginDir.toString(), site)); 252 } 253 File cacheFile = createSiteCacheFile(pluginDir, site); 254 getProgressMonitor().subTask(tr("Writing plugin list to local cache ''{0}''", cacheFile.toString())); 255 try (PrintWriter writer = new PrintWriter(cacheFile, StandardCharsets.UTF_8.name())) { 256 writer.write(list); 257 writer.flush(); 258 } catch (IOException e) { 259 // just failed to write the cache file. No big deal, but log the exception anyway 260 Logging.error(e); 261 } 262 } 263 264 /** 265 * Filter information about deprecated plugins from the list of downloaded 266 * plugins 267 * 268 * @param plugins the plugin informations 269 * @return the plugin informations, without deprecated plugins 270 */ 271 protected List<PluginInformation> filterDeprecatedPlugins(List<PluginInformation> plugins) { 272 List<PluginInformation> ret = new ArrayList<>(plugins.size()); 273 Set<String> deprecatedPluginNames = new HashSet<>(); 274 for (PluginHandler.DeprecatedPlugin p : PluginHandler.DEPRECATED_PLUGINS) { 275 deprecatedPluginNames.add(p.name); 276 } 277 for (PluginInformation plugin: plugins) { 278 if (deprecatedPluginNames.contains(plugin.name)) { 279 continue; 280 } 281 ret.add(plugin); 282 } 283 return ret; 284 } 285 286 protected List<PluginInformation> filterIrrelevantPlugins(List<PluginInformation> plugins) { 287 return plugins.stream().filter(PluginInformation::isForCurrentPlatform).collect(Collectors.toList()); 288 } 289 290 /** 291 * Parses the plugin list 292 * 293 * @param site the site from where the list was downloaded 294 * @param doc the document with the plugin list 295 */ 296 protected void parsePluginListDocument(String site, String doc) { 297 try { 298 getProgressMonitor().subTask(tr("Parsing plugin list from site ''{0}''", site)); 299 InputStream in = new ByteArrayInputStream(doc.getBytes(StandardCharsets.UTF_8)); 300 List<PluginInformation> pis = new PluginListParser().parse(in); 301 availablePlugins.addAll(filterIrrelevantPlugins(filterDeprecatedPlugins(pis))); 302 } catch (PluginListParseException e) { 303 Logging.error(tr("Failed to parse plugin list document from site ''{0}''. Skipping site. Exception was: {1}", site, e.toString())); 304 Logging.error(e); 305 } 306 } 307 308 @Override 309 protected void realRun() throws SAXException, IOException, OsmTransferException { 310 if (sites == null) return; 311 getProgressMonitor().setTicksCount(sites.size() * 3); 312 313 // collect old cache files and remove if no longer in use 314 List<File> siteCacheFiles = new LinkedList<>(); 315 for (String location : PluginInformation.getPluginLocations()) { 316 File[] f = new File(location).listFiles( 317 (FilenameFilter) (dir, name) -> name.matches("^([0-9]+-)?site.*\\.txt$") || 318 name.matches("^([0-9]+-)?site.*-icons\\.zip$") 319 ); 320 if (f != null && f.length > 0) { 321 siteCacheFiles.addAll(Arrays.asList(f)); 322 } 323 } 324 325 File pluginDir = Preferences.main().getPluginsDirectory(); 326 for (String site: sites) { 327 String printsite = site.replaceAll("%<(.*)>", ""); 328 getProgressMonitor().subTask(tr("Processing plugin list from site ''{0}''", printsite)); 329 String list = downloadPluginList(site, getProgressMonitor().createSubTaskMonitor(0, false)); 330 if (canceled) return; 331 siteCacheFiles.remove(createSiteCacheFile(pluginDir, site)); 332 if (list != null) { 333 getProgressMonitor().worked(1); 334 cachePluginList(site, list); 335 if (canceled) return; 336 getProgressMonitor().worked(1); 337 parsePluginListDocument(site, list); 338 if (canceled) return; 339 getProgressMonitor().worked(1); 340 if (canceled) return; 341 } 342 } 343 // remove old stuff or whole update process is broken 344 for (File file: siteCacheFiles) { 345 Utils.deleteFile(file); 346 } 347 } 348 349 /** 350 * Replies true if the task was canceled 351 * @return <code>true</code> if the task was stopped by the user 352 */ 353 public boolean isCanceled() { 354 return canceled; 355 } 356 357 /** 358 * Replies the list of plugins described in the downloaded plugin lists 359 * 360 * @return the list of plugins 361 * @since 5601 362 */ 363 public List<PluginInformation> getAvailablePlugins() { 364 return availablePlugins; 365 } 366}