blob: 3588e455c9f43650aeb3d0325b3a14babcb6caa0 [file] [log] [blame]
/*******************************************************************************
* 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 &rarr; quicklink */
private Map<String, Quicklink> quicklinks = new LinkedHashMap<>();
/** bundle symbolic name &rarr; 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() {
}
}