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.Image;
007import java.awt.image.BufferedImage;
008import java.io.File;
009import java.io.FileInputStream;
010import java.io.IOException;
011import java.io.InputStream;
012import java.lang.reflect.Constructor;
013import java.lang.reflect.InvocationTargetException;
014import java.net.MalformedURLException;
015import java.net.URL;
016import java.text.MessageFormat;
017import java.util.ArrayList;
018import java.util.Collection;
019import java.util.LinkedList;
020import java.util.List;
021import java.util.Map;
022import java.util.TreeMap;
023import java.util.jar.Attributes;
024import java.util.jar.JarInputStream;
025import java.util.jar.Manifest;
026
027import javax.swing.ImageIcon;
028
029import org.openstreetmap.josm.Main;
030import org.openstreetmap.josm.data.Version;
031import org.openstreetmap.josm.tools.ImageProvider;
032import org.openstreetmap.josm.tools.LanguageInfo;
033import org.openstreetmap.josm.tools.Utils;
034
035/**
036 * Encapsulate general information about a plugin. This information is available
037 * without the need of loading any class from the plugin jar file.
038 *
039 * @author imi
040 */
041public class PluginInformation {
042
043    /** The plugin jar file. */
044    public File file = null;
045    /** The plugin name. */
046    public String name = null;
047    /** The lowest JOSM version required by this plugin (from plugin list). **/
048    public int mainversion = 0;
049    /** The lowest JOSM version required by this plugin (from locally available jar). **/
050    public int localmainversion = 0;
051    /** The plugin class name. */
052    public String className = null;
053    public boolean oldmode = false;
054    /** The list of required plugins, separated by ';' (from plugin list). */
055    public String requires = null;
056    /** The list of required plugins, separated by ';' (from locally available jar). */
057    public String localrequires = null;
058    /** The plugin link (for documentation). */
059    public String link = null;
060    /** The plugin description. */
061    public String description = null;
062    /** Determines if the plugin must be loaded early or not. */
063    public boolean early = false;
064    /** The plugin author. */
065    public String author = null;
066    /** The plugin stage, determining the loading sequence order of plugins. */
067    public int stage = 50;
068    /** The plugin version (from plugin list). **/
069    public String version = null;
070    /** The plugin version (from locally available jar). **/
071    public String localversion = null;
072    /** The plugin download link. */
073    public String downloadlink = null;
074    public String iconPath;
075    /** The plugin icon. */
076    public ImageIcon icon;
077    public List<URL> libraries = new LinkedList<>();
078    public final Map<String, String> attr = new TreeMap<>();
079
080    private static final ImageIcon emptyIcon = new ImageIcon(new BufferedImage(24, 24, BufferedImage.TYPE_INT_ARGB));
081
082    /**
083     * Creates a plugin information object by reading the plugin information from
084     * the manifest in the plugin jar.
085     *
086     * The plugin name is derived from the file name.
087     *
088     * @param file the plugin jar file
089     * @throws PluginException if reading the manifest fails
090     */
091    public PluginInformation(File file) throws PluginException{
092        this(file, file.getName().substring(0, file.getName().length()-4));
093    }
094
095    /**
096     * Creates a plugin information object for the plugin with name {@code name}.
097     * Information about the plugin is extracted from the manifest file in the plugin jar
098     * {@code file}.
099     * @param file the plugin jar
100     * @param name the plugin name
101     * @throws PluginException thrown if reading the manifest file fails
102     */
103    public PluginInformation(File file, String name) throws PluginException {
104        if (!PluginHandler.isValidJar(file)) {
105            throw new PluginException(name, tr("Invalid jar file ''{0}''", file));
106        }
107        this.name = name;
108        this.file = file;
109        try (
110            FileInputStream fis = new FileInputStream(file);
111            JarInputStream jar = new JarInputStream(fis)
112        ) {
113            Manifest manifest = jar.getManifest();
114            if (manifest == null)
115                throw new PluginException(name, tr("The plugin file ''{0}'' does not include a Manifest.", file.toString()));
116            scanManifest(manifest, false);
117            libraries.add(0, Utils.fileToURL(file));
118        } catch (IOException e) {
119            throw new PluginException(name, e);
120        }
121    }
122
123    /**
124     * Creates a plugin information object by reading plugin information in Manifest format
125     * from the input stream {@code manifestStream}.
126     *
127     * @param manifestStream the stream to read the manifest from
128     * @param name the plugin name
129     * @param url the download URL for the plugin
130     * @throws PluginException thrown if the plugin information can't be read from the input stream
131     */
132    public PluginInformation(InputStream manifestStream, String name, String url) throws PluginException {
133        this.name = name;
134        try {
135            Manifest manifest = new Manifest();
136            manifest.read(manifestStream);
137            if(url != null) {
138                downloadlink = url;
139            }
140            scanManifest(manifest, url != null);
141        } catch (IOException e) {
142            throw new PluginException(name, e);
143        }
144    }
145
146    /**
147     * Updates the plugin information of this plugin information object with the
148     * plugin information in a plugin information object retrieved from a plugin
149     * update site.
150     *
151     * @param other the plugin information object retrieved from the update site
152     */
153    public void updateFromPluginSite(PluginInformation other) {
154        this.mainversion = other.mainversion;
155        this.className = other.className;
156        this.requires = other.requires;
157        this.link = other.link;
158        this.description = other.description;
159        this.early = other.early;
160        this.author = other.author;
161        this.stage = other.stage;
162        this.version = other.version;
163        this.downloadlink = other.downloadlink;
164        this.icon = other.icon;
165        this.iconPath = other.iconPath;
166        this.libraries = other.libraries;
167        this.attr.clear();
168        this.attr.putAll(other.attr);
169    }
170
171    /**
172     * Updates the plugin information of this plugin information object with the
173     * plugin information in a plugin information object retrieved from a plugin
174     * jar.
175     *
176     * @param other the plugin information object retrieved from the jar file
177     * @since 5601
178     */
179    public void updateFromJar(PluginInformation other) {
180        updateLocalInfo(other);
181        if (other.icon != null) {
182            this.icon = other.icon;
183        }
184        this.early = other.early;
185        this.className = other.className;
186        this.libraries = other.libraries;
187        this.stage = other.stage;
188    }
189
190    private final void scanManifest(Manifest manifest, boolean oldcheck) {
191        String lang = LanguageInfo.getLanguageCodeManifest();
192        Attributes attr = manifest.getMainAttributes();
193        className = attr.getValue("Plugin-Class");
194        String s = attr.getValue(lang+"Plugin-Link");
195        if (s == null) {
196            s = attr.getValue("Plugin-Link");
197        }
198        if (s != null) {
199            try {
200                new URL(s);
201            } catch (MalformedURLException e) {
202                Main.info(tr("Invalid URL ''{0}'' in plugin {1}", s, name));
203                s = null;
204            }
205        }
206        link = s;
207        requires = attr.getValue("Plugin-Requires");
208        s = attr.getValue(lang+"Plugin-Description");
209        if (s == null) {
210            s = attr.getValue("Plugin-Description");
211            if (s != null) {
212                try {
213                    s = tr(s);
214                } catch (IllegalArgumentException e) {
215                    Main.info(tr("Invalid plugin description ''{0}'' in plugin {1}", s, name));
216                }
217            }
218        } else {
219            s = MessageFormat.format(s, (Object[]) null);
220        }
221        description = s;
222        early = Boolean.parseBoolean(attr.getValue("Plugin-Early"));
223        String stageStr = attr.getValue("Plugin-Stage");
224        stage = stageStr == null ? 50 : Integer.parseInt(stageStr);
225        version = attr.getValue("Plugin-Version");
226        s = attr.getValue("Plugin-Mainversion");
227        if (s != null) {
228            try {
229                mainversion = Integer.parseInt(s);
230            } catch(NumberFormatException e) {
231                Main.warn(tr("Invalid plugin main version ''{0}'' in plugin {1}", s, name));
232            }
233        } else {
234            Main.warn(tr("Missing plugin main version in plugin {0}", name));
235        }
236        author = attr.getValue("Author");
237        iconPath = attr.getValue("Plugin-Icon");
238        if (iconPath != null && file != null) {
239            // extract icon from the plugin jar file
240            icon = new ImageProvider(iconPath).setArchive(file).setMaxWidth(24).setMaxHeight(24).setOptional(true).get();
241        }
242        if (oldcheck && mainversion > Version.getInstance().getVersion()) {
243            int myv = Version.getInstance().getVersion();
244            for (Map.Entry<Object, Object> entry : attr.entrySet()) {
245                try {
246                    String key = ((Attributes.Name)entry.getKey()).toString();
247                    if (key.endsWith("_Plugin-Url")) {
248                        int mv = Integer.parseInt(key.substring(0,key.length()-11));
249                        if (mv <= myv && (mv > mainversion || mainversion > myv)) {
250                            String v = (String)entry.getValue();
251                            int i = v.indexOf(';');
252                            if (i > 0) {
253                                downloadlink = v.substring(i+1);
254                                mainversion = mv;
255                                version = v.substring(0,i);
256                                oldmode = true;
257                            }
258                        }
259                    }
260                }
261                catch(Exception e) {
262                    Main.error(e);
263                }
264            }
265        }
266
267        String classPath = attr.getValue(Attributes.Name.CLASS_PATH);
268        if (classPath != null) {
269            for (String entry : classPath.split(" ")) {
270                File entryFile;
271                if (new File(entry).isAbsolute() || file == null) {
272                    entryFile = new File(entry);
273                } else {
274                    entryFile = new File(file.getParent(), entry);
275                }
276
277                libraries.add(Utils.fileToURL(entryFile));
278            }
279        }
280        for (Object o : attr.keySet()) {
281            this.attr.put(o.toString(), attr.getValue(o.toString()));
282        }
283    }
284
285    /**
286     * Replies the description as HTML document, including a link to a web page with
287     * more information, provided such a link is available.
288     *
289     * @return the description as HTML document
290     */
291    public String getDescriptionAsHtml() {
292        StringBuilder sb = new StringBuilder();
293        sb.append("<html><body>");
294        sb.append(description == null ? tr("no description available") : description);
295        if (link != null) {
296            sb.append(" <a href=\"").append(link).append("\">").append(tr("More info...")).append("</a>");
297        }
298        if (downloadlink != null && !downloadlink.startsWith("http://svn.openstreetmap.org/applications/editors/josm/dist/")
299        && !downloadlink.startsWith("http://trac.openstreetmap.org/browser/applications/editors/josm/dist/")) {
300            sb.append("<p>&nbsp;</p><p>"+tr("<b>Plugin provided by an external source:</b> {0}", downloadlink)+"</p>");
301        }
302        sb.append("</body></html>");
303        return sb.toString();
304    }
305
306    /**
307     * Loads and instantiates the plugin.
308     *
309     * @param klass the plugin class
310     * @return the instantiated and initialized plugin
311     * @throws PluginException if the plugin cannot be loaded or instanciated
312     */
313    public PluginProxy load(Class<?> klass) throws PluginException {
314        try {
315            Constructor<?> c = klass.getConstructor(PluginInformation.class);
316            Object plugin = c.newInstance(this);
317            return new PluginProxy(plugin, this);
318        } catch(NoSuchMethodException | IllegalAccessException | InstantiationException | InvocationTargetException e) {
319            throw new PluginException(name, e);
320        }
321    }
322
323    /**
324     * Loads the class of the plugin.
325     *
326     * @param classLoader the class loader to use
327     * @return the loaded class
328     * @throws PluginException if the class cannot be loaded
329     */
330    public Class<?> loadClass(ClassLoader classLoader) throws PluginException {
331        if (className == null)
332            return null;
333        try {
334            return Class.forName(className, true, classLoader);
335        } catch (NoClassDefFoundError | ClassNotFoundException | ClassCastException e) {
336            throw new PluginException(name, e);
337        }
338    }
339
340    /**
341     * Try to find a plugin after some criterias. Extract the plugin-information
342     * from the plugin and return it. The plugin is searched in the following way:
343     *<ol>
344     *<li>first look after an MANIFEST.MF in the package org.openstreetmap.josm.plugins.&lt;plugin name&gt;
345     *    (After removing all fancy characters from the plugin name).
346     *    If found, the plugin is loaded using the bootstrap classloader.</li>
347     *<li>If not found, look for a jar file in the user specific plugin directory
348     *    (~/.josm/plugins/&lt;plugin name&gt;.jar)</li>
349     *<li>If not found and the environment variable JOSM_RESOURCES + "/plugins/" exist, look there.</li>
350     *<li>Try for the java property josm.resources + "/plugins/" (set via java -Djosm.plugins.path=...)</li>
351     *<li>If the environment variable ALLUSERSPROFILE and APPDATA exist, look in
352     *    ALLUSERSPROFILE/&lt;the last stuff from APPDATA&gt;/JOSM/plugins.
353     *    (*sic* There is no easy way under Windows to get the All User's application
354     *    directory)</li>
355     *<li>Finally, look in some typical unix paths:<ul>
356     *    <li>/usr/local/share/josm/plugins/</li>
357     *    <li>/usr/local/lib/josm/plugins/</li>
358     *    <li>/usr/share/josm/plugins/</li>
359     *    <li>/usr/lib/josm/plugins/</li></ul></li>
360     *</ol>
361     * If a plugin class or jar file is found earlier in the list but seem not to
362     * be working, an PluginException is thrown rather than continuing the search.
363     * This is so JOSM can detect broken user-provided plugins and do not go silently
364     * ignore them.
365     *
366     * The plugin is not initialized. If the plugin is a .jar file, it is not loaded
367     * (only the manifest is extracted). In the classloader-case, the class is
368     * bootstraped (e.g. static {} - declarations will run. However, nothing else is done.
369     *
370     * @param pluginName The name of the plugin (in all lowercase). E.g. "lang-de"
371     * @return Information about the plugin or <code>null</code>, if the plugin
372     *         was nowhere to be found.
373     * @throws PluginException In case of broken plugins.
374     */
375    public static PluginInformation findPlugin(String pluginName) throws PluginException {
376        String name = pluginName;
377        name = name.replaceAll("[-. ]", "");
378        try (InputStream manifestStream = PluginInformation.class.getResourceAsStream("/org/openstreetmap/josm/plugins/"+name+"/MANIFEST.MF")) {
379            if (manifestStream != null) {
380                return new PluginInformation(manifestStream, pluginName, null);
381            }
382        } catch (IOException e) {
383            Main.warn(e);
384        }
385
386        Collection<String> locations = getPluginLocations();
387
388        for (String s : locations) {
389            File pluginFile = new File(s, pluginName + ".jar");
390            if (pluginFile.exists()) {
391                return new PluginInformation(pluginFile);
392            }
393        }
394        return null;
395    }
396
397    /**
398     * Returns all possible plugin locations.
399     * @return all possible plugin locations.
400     */
401    public static Collection<String> getPluginLocations() {
402        Collection<String> locations = Main.pref.getAllPossiblePreferenceDirs();
403        Collection<String> all = new ArrayList<>(locations.size());
404        for (String s : locations) {
405            all.add(s+"plugins");
406        }
407        return all;
408    }
409
410    /**
411     * Replies true if the plugin with the given information is most likely outdated with
412     * respect to the referenceVersion.
413     *
414     * @param referenceVersion the reference version. Can be null if we don't know a
415     * reference version
416     *
417     * @return true, if the plugin needs to be updated; false, otherweise
418     */
419    public boolean isUpdateRequired(String referenceVersion) {
420        if (this.downloadlink == null) return false;
421        if (this.version == null && referenceVersion!= null)
422            return true;
423        if (this.version != null && !this.version.equals(referenceVersion))
424            return true;
425        return false;
426    }
427
428    /**
429     * Replies true if this this plugin should be updated/downloaded because either
430     * it is not available locally (its local version is null) or its local version is
431     * older than the available version on the server.
432     *
433     * @return true if the plugin should be updated
434     */
435    public boolean isUpdateRequired() {
436        if (this.downloadlink == null) return false;
437        if (this.localversion == null) return true;
438        return isUpdateRequired(this.localversion);
439    }
440
441    protected boolean matches(String filter, String value) {
442        if (filter == null) return true;
443        if (value == null) return false;
444        return value.toLowerCase().contains(filter.toLowerCase());
445    }
446
447    /**
448     * Replies true if either the name, the description, or the version match (case insensitive)
449     * one of the words in filter. Replies true if filter is null.
450     *
451     * @param filter the filter expression
452     * @return true if this plugin info matches with the filter
453     */
454    public boolean matches(String filter) {
455        if (filter == null) return true;
456        String[] words = filter.split("\\s+");
457        for (String word: words) {
458            if (matches(word, name)
459                    || matches(word, description)
460                    || matches(word, version)
461                    || matches(word, localversion))
462                return true;
463        }
464        return false;
465    }
466
467    /**
468     * Replies the name of the plugin.
469     * @return The plugin name
470     */
471    public String getName() {
472        return name;
473    }
474
475    /**
476     * Sets the name
477     * @param name
478     */
479    public void setName(String name) {
480        this.name = name;
481    }
482
483    /**
484     * Replies the plugin icon, scaled to 24x24 pixels.
485     * @return the plugin icon, scaled to 24x24 pixels.
486     */
487    public ImageIcon getScaledIcon() {
488        if (icon == null)
489            return emptyIcon;
490        return new ImageIcon(icon.getImage().getScaledInstance(24, 24, Image.SCALE_SMOOTH));
491    }
492
493    @Override
494    public final String toString() {
495        return getName();
496    }
497
498    private static List<String> getRequiredPlugins(String pluginList) {
499        List<String> requiredPlugins = new ArrayList<>();
500        if (pluginList != null) {
501            for (String s : pluginList.split(";")) {
502                String plugin = s.trim();
503                if (!plugin.isEmpty()) {
504                    requiredPlugins.add(plugin);
505                }
506            }
507        }
508        return requiredPlugins;
509    }
510
511    /**
512     * Replies the list of plugins required by the up-to-date version of this plugin.
513     * @return List of plugins required. Empty if no plugin is required.
514     * @since 5601
515     */
516    public List<String> getRequiredPlugins() {
517        return getRequiredPlugins(requires);
518    }
519
520    /**
521     * Replies the list of plugins required by the local instance of this plugin.
522     * @return List of plugins required. Empty if no plugin is required.
523     * @since 5601
524     */
525    public List<String> getLocalRequiredPlugins() {
526        return getRequiredPlugins(localrequires);
527    }
528
529    /**
530     * Updates the local fields ({@link #localversion}, {@link #localmainversion}, {@link #localrequires})
531     * to values contained in the up-to-date fields ({@link #version}, {@link #mainversion}, {@link #requires})
532     * of the given PluginInformation.
533     * @param info The plugin information to get the data from.
534     * @since 5601
535     */
536    public void updateLocalInfo(PluginInformation info) {
537        if (info != null) {
538            this.localversion = info.version;
539            this.localmainversion = info.mainversion;
540            this.localrequires = info.requires;
541        }
542    }
543}