/******************************************************************************* * Copyright (c) 2016 Manumitting Technologies Inc and others. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * * Contributors: * Manumitting Technologies Inc - initial API and implementation *******************************************************************************/ package org.eclipse.ui.intro.quicklinks; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.PrintWriter; import java.io.UnsupportedEncodingException; import java.net.MalformedURLException; import java.net.URL; import java.net.URLEncoder; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Supplier; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; import org.eclipse.core.commands.CommandManager; import org.eclipse.core.commands.ParameterizedCommand; import org.eclipse.core.commands.SerializationException; import org.eclipse.core.commands.common.NotDefinedException; import org.eclipse.core.runtime.FileLocator; import org.eclipse.core.runtime.IConfigurationElement; import org.eclipse.core.runtime.IExtension; import org.eclipse.core.runtime.IExtensionPoint; import org.eclipse.core.runtime.IExtensionRegistry; import org.eclipse.core.runtime.Platform; import org.eclipse.jface.resource.ImageDescriptor; import org.eclipse.jface.viewers.ArrayContentProvider; import org.eclipse.jface.viewers.TableViewer; import org.eclipse.osgi.util.NLS; import org.eclipse.swt.SWT; import org.eclipse.swt.graphics.Image; import org.eclipse.swt.graphics.ImageData; import org.eclipse.swt.graphics.ImageLoader; import org.eclipse.swt.widgets.Composite; import org.eclipse.ui.PlatformUI; import org.eclipse.ui.commands.ICommandImageService; import org.eclipse.ui.forms.widgets.FormToolkit; import org.eclipse.ui.forms.widgets.Section; import org.eclipse.ui.internal.intro.impl.model.AbstractIntroPartImplementation; import org.eclipse.ui.internal.intro.impl.model.IntroTheme; import org.eclipse.ui.internal.intro.impl.util.Log; import org.eclipse.ui.internal.menus.MenuHelper; import org.eclipse.ui.intro.config.IIntroContentProvider; import org.eclipse.ui.intro.config.IIntroContentProviderSite; import org.eclipse.ui.services.IServiceLocator; import org.osgi.framework.Bundle; import org.osgi.framework.FrameworkUtil; /** * An Intro content provider that populates a list of frequently-used commands * from an extension point. The appearance of these quicklinks is normally taken * from the command metadata, including the image icon, but can be tailored. * These tailorings can be made optional depending on the current theme. * * This implementation is still experimental and subject to change. Feedback * welcome as a <a href="http://eclip.se/9f">bug report on the Eclipse Bugzilla * against Platform/User Assistance</a>. */ @SuppressWarnings("restriction") public class QuicklinksViewer implements IIntroContentProvider { /** Represents the importance of an element */ enum Importance { HIGH("high", 0), MEDIUM("medium", 1), LOW("low", 2); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ String id; int level; Importance(String text, int importance) { this.id = text; this.level = importance; } public static Importance forId(String id) { for (Importance i : values()) { if (i.id.equals(id)) { return i; } } return LOW; } } /** Model holding the relevant attributes of a Quicklink element */ class Quicklink implements Comparable<Quicklink> { String commandSpec; String url; String label; String description; String iconUrl; Importance importance = Importance.MEDIUM; long rank; String resolution; public Quicklink() { } @Override public int compareTo(Quicklink b) { int impA = this.importance.level; int impB = b.importance.level; if (impA != impB) { return impA - impB; } long diff = this.rank - b.rank; if (diff > 0) { return 1; } if (diff < 0) { return -1; } return 0; } } /** * Responsible for retrieving Quicklinks and applying any icon overrides */ class ModelReader implements Supplier<List<Quicklink>> { private static final String QL_EXT_PT = "org.eclipse.ui.intro.quicklinks"; //$NON-NLS-1$ private static final String ELMT_COMMAND = "command"; //$NON-NLS-1$ private static final String ATT_ID = "id"; //$NON-NLS-1$ private static final String ELMT_URL = "url"; //$NON-NLS-1$ private static final String ATT_LOCATION = "location"; //$NON-NLS-1$ private static final String ELMT_OVERRIDE = "override"; //$NON-NLS-1$ private static final String ATT_COMMANDID = "command"; //$NON-NLS-1$ private static final String ATT_THEME = "theme"; //$NON-NLS-1$ private static final String ATT_LABEL = "label"; //$NON-NLS-1$ private static final String ATT_DESCRIPTION = "description"; //$NON-NLS-1$ private static final String ATT_ICON = "icon"; //$NON-NLS-1$ private static final String ATT_IMPORTANCE = "importance"; //$NON-NLS-1$ private static final String ATT_RESOLUTION = "resolution"; //$NON-NLS-1$ /** commandSpec/url → quicklink */ private Map<String, Quicklink> quicklinks = new LinkedHashMap<>(); /** bundle symbolic name → bundle id */ private Map<String, Long> bundleIds; private Bundle[] bundles; /** * Return the list of configured {@link Quicklink} that can be found. * * @return */ public List<Quicklink> get() { IExtension extensions[] = getExtensions(QL_EXT_PT); // Process definitions from the product bundle first Bundle productBundle = Platform.getProduct().getDefiningBundle(); if(productBundle != null) { for (IExtension ext : extensions) { if (productBundle.getSymbolicName().equals(ext.getNamespaceIdentifier())) { for (IConfigurationElement ce : ext.getConfigurationElements()) { processDefinition(ce); } } } } for (IExtension ext : extensions) { if (productBundle == null || !productBundle.getSymbolicName().equals(ext.getNamespaceIdentifier())) { for (IConfigurationElement ce : ext.getConfigurationElements()) { processDefinition(ce); } } } // Now process all command overrides for (IExtension ext : extensions) { for (IConfigurationElement ce : ext.getConfigurationElements()) { if (!ELMT_OVERRIDE.equals(ce.getName())) { continue; } String theme = ce.getAttribute(ATT_THEME); String commandSpecPattern = ce.getAttribute(ATT_COMMANDID); String icon = ce.getAttribute(ATT_ICON); if (theme != null && icon != null && Objects.equals(theme, getCurrentThemeId()) && commandSpecPattern != null) { findMatchingQuicklinks(commandSpecPattern) .forEach(ql -> ql.iconUrl = getImageURL(ce, ATT_ICON)); } } } return new ArrayList<>(quicklinks.values()); } private void processDefinition(IConfigurationElement ce) { if (!ELMT_COMMAND.equals(ce.getName()) && !ELMT_URL.equals(ce.getName())) { return; } String key = null; Quicklink ql = new Quicklink(); if (ELMT_COMMAND.equals(ce.getName())) { key = ce.getAttribute(ATT_ID); if (key == null) { Log.warning(NLS.bind("Skipping '{0}': missing {1}", ce.getName(), ATT_ID)); //$NON-NLS-1$ return; } ql.commandSpec = key; ql.label = ce.getAttribute(ATT_LABEL); ql.description = ce.getAttribute(ATT_DESCRIPTION); ql.iconUrl = getImageURL(ce, ATT_ICON); } else if (ELMT_URL.equals(ce.getName())) { key = ce.getAttribute(ATT_LOCATION); if (key == null) { Log.warning(NLS.bind("Skipping '{0}': missing {1}", ELMT_URL, ATT_LOCATION)); //$NON-NLS-1$ return; } ql.url = key; ql.label = ce.getAttribute(ATT_LABEL); ql.description = ce.getAttribute(ATT_DESCRIPTION); ql.iconUrl = getImageURL(ce, ATT_ICON); } ql.rank = getRank(ce.getContributor().getName()); if (ce.getAttribute(ATT_IMPORTANCE) != null) { ql.importance = Importance.forId(ce.getAttribute(ATT_IMPORTANCE)); } if (ce.getAttribute(ATT_RESOLUTION) != null) { ql.resolution = ce.getAttribute(ATT_RESOLUTION); } // discard if already seen quicklinks.putIfAbsent(key, ql); } /** * Find {@link Quicklink}s whose {@code commandSpec} matches the simple * wildcard pattern in {@code commandSpecPattern} * * @param commandSpecPattern * a simple wildcard pattern supporting *, ? * @return the set of matching Quicklinks */ private Stream<Quicklink> findMatchingQuicklinks(String commandSpecPattern) { // transform simple wildcards into regexp String regexp = commandSpecPattern.replace(".", "\\.").replace("(", "\\(").replace(")", "\\)") //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-5$ //$NON-NLS-6$ .replace("*", ".*"); //$NON-NLS-1$ //$NON-NLS-2$ final Pattern pattern = Pattern.compile(regexp); return quicklinks.values().stream().filter( ql -> ql.commandSpec != null && (commandSpecPattern.equals(ql.commandSpec) || pattern.matcher(ql.commandSpec).matches())); } private IExtension[] getExtensions(String extPtId) { IExtensionRegistry registry = locator.getService(IExtensionRegistry.class); IExtensionPoint extPt = registry.getExtensionPoint(extPtId); return extPt == null ? new IExtension[0] : extPt.getExtensions(); } private long getRank(String bundleSymbolicName) { if (bundleIds == null) { Bundle bundle = FrameworkUtil.getBundle(getClass()); bundleIds = new HashMap<>(); bundles = bundle.getBundleContext().getBundles(); } return bundleIds.computeIfAbsent(bundleSymbolicName, bsn -> { for (Bundle b : bundles) { if (bsn.equals(b.getSymbolicName()) && (b.getState() & (Bundle.INSTALLED | Bundle.UNINSTALLED)) == 0) { return b.getBundleId(); } } return Long.MAX_VALUE; }); } /** * @return URL to image, suitable for using in an external browser; may * be a <code>data:</code> URL; may be null */ private String getImageURL(IConfigurationElement ce, String attr) { String iconURL = MenuHelper.getIconURI(ce, attr); if (iconURL != null) { return asBrowserURL(iconURL); } return null; } } /** Source: http://stackoverflow.com/a/417184 */ private static final int MAX_URL_LENGTH = 2083; private IIntroContentProviderSite site; private IServiceLocator locator; private CommandManager manager; private ICommandImageService images; private Supplier<List<Quicklink>> model; public void init(IIntroContentProviderSite site) { this.site = site; // IIntroContentProviderSite should provide services. if (site instanceof IServiceLocator) { this.locator = (IServiceLocator) site; } else if (site instanceof AbstractIntroPartImplementation) { this.locator = ((AbstractIntroPartImplementation) site).getIntroPart().getIntroSite(); } else { this.locator = PlatformUI.getWorkbench(); } manager = locator.getService(CommandManager.class); images = locator.getService(ICommandImageService.class); model = new ModelReader(); } /** * Find the current Welcome/Intro identifier * * @return the current identifier or {@code null} if no theme */ protected String getCurrentThemeId() { if (site instanceof AbstractIntroPartImplementation) { IntroTheme theme = ((AbstractIntroPartImplementation) site).getModel().getTheme(); return theme.getId(); } return null; } public void createContent(String id, PrintWriter out) { // Content is already embedded within a <div id="..."> getQuicklinks().forEach(ql -> { try { // ah how lovely to embed HTML in code String urlEncodedCommand = asEmbeddedURL(ql); out.append("<a class='content-link'"); //$NON-NLS-1$ if (ql.commandSpec != null) { out.append(" id='").append(asCSSId(ql.commandSpec)).append("' "); //$NON-NLS-1$ //$NON-NLS-2$ } out.append(" href='"); //$NON-NLS-1$ out.append(urlEncodedCommand); out.append("'>"); //$NON-NLS-1$ if (ql.iconUrl != null) { out.append("<img class='background-image' src='").append(ql.iconUrl).append("'>"); //$NON-NLS-1$ //$NON-NLS-2$ } out.append("\n<div class='link-extra-div'></div>\n"); // UNKNOWN //$NON-NLS-1$ out.append("<span class='link-label'>"); //$NON-NLS-1$ out.append(ql.label); out.append("</span>"); //$NON-NLS-1$ if (ql.description != null) { out.append("\n<p><span class='text'>"); //$NON-NLS-1$ out.append(ql.description); out.append("</span></p>"); //$NON-NLS-1$ } out.append("</a>"); //$NON-NLS-1$ } catch (UnsupportedEncodingException e) { e.printStackTrace(); } }); } private String asEmbeddedURL(Quicklink ql) throws UnsupportedEncodingException { if (ql.url != null) { return ql.url; } String encoded = URLEncoder.encode(ql.commandSpec, "UTF-8"); //$NON-NLS-1$ if (ql.resolution != null) { encoded += "&standby=" + ql.resolution; //$NON-NLS-1$ } return "http://org.eclipse.ui.intro/execute?command=" + encoded; //$NON-NLS-1$ } /** * Transform the Eclipse Command identifier (with dots) to a CSS-compatible * class */ private String asCSSId(String commandSpec) { int indexOf = commandSpec.indexOf('('); if (indexOf > 0) { commandSpec = commandSpec.substring(0, indexOf); } return commandSpec.replace('.', '_'); } /** * Rewrite or possible extract the icon at the given URL to a stable URL * that can be embedded in HTML and rendered in a browser. May create * temporary files that will be cleaned up on exit. * * @param iconURL * @return stable URL */ private String asBrowserURL(String iconURL) { if (iconURL.startsWith("file:") || iconURL.startsWith("http:")) { //$NON-NLS-1$ //$NON-NLS-2$ return iconURL; } try { URL original = new URL(iconURL); URL toLocal = FileLocator.toFileURL(original); if (!toLocal.sameFile(original)) { return toLocal.toString(); } } catch (IOException e1) { /* ignore */ } // extract content try { return asDataURL(ImageDescriptor.createFromURL(new URL(iconURL))); } catch (MalformedURLException e) { // should probably log this return iconURL; } } /** * Write out the image as a data: URL if possible or to the file-system. * * @param descriptor * @return URL with the resulting image */ private String asDataURL(ImageDescriptor descriptor) { if (descriptor == null) { return null; } ImageData data = descriptor.getImageData(); if (data == null) { return null; } ImageLoader loader = new ImageLoader(); loader.data = new ImageData[] { data }; ByteArrayOutputStream output = new ByteArrayOutputStream(); loader.save(output, SWT.IMAGE_PNG); if (output.size() * 4 / 3 < MAX_URL_LENGTH) { // You'd think there was a more efficient way to do this... return "data:image/png;base64," + Base64.getEncoder().encodeToString(output.toByteArray()); //$NON-NLS-1$ } try { File tempFile = File.createTempFile("qlink", "png"); //$NON-NLS-1$ //$NON-NLS-2$ FileOutputStream fos = new FileOutputStream(tempFile); fos.write(output.toByteArray()); fos.close(); tempFile.deleteOnExit(); return tempFile.toURI().toString(); } catch (IOException e) { e.printStackTrace(); return null; } } public void createContent(String id, Composite parent, FormToolkit toolkit) { Section section = toolkit.createSection(parent, Section.EXPANDED); TableViewer tableViewer = new TableViewer(toolkit.createTable(section, SWT.FULL_SELECTION)); tableViewer.setLabelProvider(new URLLabelProvider() { @Override public String getText(Object element) { if (element instanceof Quicklink) { return ((Quicklink) element).label; } return super.getText(element); } @Override public Image getImage(Object element) { if (element instanceof Quicklink) { return super.getImage(((Quicklink) element).iconUrl); } return super.getImage(element); } }); tableViewer.setContentProvider(new ArrayContentProvider()); tableViewer.setInput(getQuicklinks().toArray()); } private List<Quicklink> getQuicklinks() { List<Quicklink> links = model.get(); if (links.isEmpty()) { links = generateDefaultQuicklinks(); } return links.stream().filter(this::populateQuicklink).sorted(Quicklink::compareTo) .collect(Collectors.toList()); } /** * Attempt to populate common fields given other information. For commands, * we look up information in the ICommandService and ICommandImageService. * Return false if this quicklink cannot be found and should not be shown. * * @return true if should be included */ private boolean populateQuicklink(Quicklink ql) { if (ql.commandSpec == null) { // non-commmands are fine return true; } try { ParameterizedCommand pc = manager.deserialize(ql.commandSpec); if (!pc.getCommand().isDefined()) { // not an error: just not found return false; } if (ql.label == null) { ql.label = pc.getCommand().getName(); } if (ql.description == null) { ql.description = pc.getCommand().getDescription(); } if (ql.iconUrl == null && images != null) { ImageDescriptor descriptor = images.getImageDescriptor(pc.getId()); if (descriptor != null) { String iconUrl = MenuHelper.getImageUrl(descriptor); ql.iconUrl = iconUrl != null ? asBrowserURL(iconUrl) : asDataURL(descriptor); } } return true; } catch (NotDefinedException | SerializationException e) { // exclude commands that don't exist or are mis-fashioned return false; } } /** Simplify creating a quicklink for a command */ private Quicklink forCommand(String commandSpec) { Quicklink ql = new Quicklink(); ql.commandSpec = commandSpec; return ql; } /** Simplify creating a quicklink for a command */ private Quicklink forCommand(String commandSpec, Importance importance) { Quicklink ql = new Quicklink(); ql.commandSpec = commandSpec; ql.importance = importance; return ql; } /** * Return the default commands to be shown if there is no other content * available */ private List<Quicklink> generateDefaultQuicklinks() { return Arrays.asList(forCommand("org.eclipse.oomph.setup.ui.questionnaire", Importance.HIGH), //$NON-NLS-1$ forCommand("org.eclipse.ui.cheatsheets.openCheatSheet"), //$NON-NLS-1$ forCommand("org.eclipse.ui.newWizard"), //$NON-NLS-1$ forCommand("org.eclipse.ui.file.import"), //$NON-NLS-1$ forCommand("org.eclipse.epp.mpc.ui.command.showMarketplaceWizard"), //$NON-NLS-1$ forCommand("org.eclipse.ui.edit.text.openLocalFile", Importance.LOW)); //$NON-NLS-1$ } public void dispose() { } }