Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 13 additions & 93 deletions core/src/main/java/org/jenkins/ui/icon/IconSet.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,15 @@

import edu.umd.cs.findbugs.annotations.CheckForNull;
import edu.umd.cs.findbugs.annotations.NonNull;
import hudson.Functions;
import hudson.PluginWrapper;
import hudson.Util;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
import jenkins.model.Jenkins;
import org.apache.commons.io.IOUtils;
import org.apache.commons.jelly.JellyContext;
import org.apache.commons.lang.StringUtils;
import org.jenkins.ui.symbol.Symbol;
import org.jenkins.ui.symbol.SymbolRequest;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;

Expand All @@ -54,15 +47,12 @@ public class IconSet {


public static final IconSet icons = new IconSet();
// keyed by plugin name / core, and then symbol name returning the SVG as a string
private static final Map<String, Map<String, String>> SYMBOLS = new ConcurrentHashMap<>();

private Map<String, Icon> iconsByCSSSelector = new ConcurrentHashMap<>();
private Map<String, Icon> iconsByUrl = new ConcurrentHashMap<>();
private Map<String, Icon> iconsByClassSpec = new ConcurrentHashMap<>();
private Map<String, Icon> coreIcons = new ConcurrentHashMap<>();

private static final String PLACEHOLDER_SVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"ionicon\" height=\"48\" viewBox=\"0 0 512 512\"><title>Close</title><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"32\" d=\"M368 368L144 144M368 144L144 368\"/></svg>";
private static final Icon NO_ICON = new Icon("_", "_", "_");

public IconSet() {
Expand All @@ -76,90 +66,20 @@ public static void initPageVariables(JellyContext context) {
context.setVariable("icons", icons);
}

private static String prependTitleIfRequired(String icon, String title) {
if (StringUtils.isNotBlank(title)) {
return "<span class=\"jenkins-visually-hidden\">" + Util.xmlEscape(title) + "</span>" + icon;
}
return icon;
}

// for Jelly
@SuppressWarnings("unused")
@Restricted(NoExternalUse.class)
public static String getSymbol(String name, String title, String tooltip, String htmlTooltip, String classes, String pluginName, String id) {
String translatedName = cleanName(name);

String identifier = Util.fixEmpty(pluginName) == null ? "core" : pluginName;
Map<String, String> symbolsForLookup = SYMBOLS.computeIfAbsent(identifier, (key) -> new ConcurrentHashMap<>());

if (symbolsForLookup.containsKey(translatedName)) {
String symbol = symbolsForLookup.get(translatedName);
symbol = symbol.replaceAll("(class=\").*?(\")", "$1$2");
symbol = symbol.replaceAll("(tooltip=\").*?(\")", "");
symbol = symbol.replaceAll("(data-html-tooltip=\").*?(\")", "");
symbol = symbol.replaceAll("(id=\").*?(\")", "");
if (!tooltip.isEmpty() && htmlTooltip.isEmpty()) {
symbol = symbol.replaceAll("<svg", "<svg tooltip=\"" + Functions.htmlAttributeEscape(tooltip) + "\"");
}
if (!htmlTooltip.isEmpty()) {
symbol = symbol.replaceAll("<svg", "<svg data-html-tooltip=\"" + Functions.htmlAttributeEscape(htmlTooltip) + "\"");
}
if (!id.isEmpty()) {
symbol = symbol.replaceAll("<svg", "<svg id=\"" + Functions.htmlAttributeEscape(id) + "\"");
}
symbol = symbol.replaceAll("<svg", "<svg class=\"" + Functions.htmlAttributeEscape(classes) + "\"");
return prependTitleIfRequired(symbol, title);
}

// Load symbol if it exists
InputStream inputStream = getClassLoader(identifier).getResourceAsStream("images/symbols/" + translatedName + ".svg");
String symbol = null;

try {
if (inputStream != null) {
symbol = IOUtils.toString(inputStream, StandardCharsets.UTF_8);
}
} catch (IOException e) {
// ignored
}
if (symbol == null) {
symbol = PLACEHOLDER_SVG;
}

symbol = symbol.replaceAll("(<title>).*(</title>)", "$1$2");
symbol = symbol.replaceAll("(class=\").*?(\")", "$1$2");
symbol = symbol.replaceAll("(tooltip=\").*?(\")", "$1$2");
symbol = symbol.replaceAll("(data-html-tooltip=\").*?(\")", "$1$2");
symbol = symbol.replaceAll("(id=\").*?(\")", "");
if (!tooltip.isEmpty() && htmlTooltip.isEmpty()) {
symbol = symbol.replaceAll("<svg", "<svg tooltip=\"" + Functions.htmlAttributeEscape(tooltip) + "\"");
}
if (!htmlTooltip.isEmpty()) {
symbol = symbol.replaceAll("<svg", "<svg data-html-tooltip=\"" + Functions.htmlAttributeEscape(htmlTooltip) + "\"");
}
if (!id.isEmpty()) {
symbol = symbol.replaceAll("<svg", "<svg id=\"" + Functions.htmlAttributeEscape(id) + "\"");
}
symbol = symbol.replaceAll("<svg", "<svg aria-hidden=\"true\"");
symbol = symbol.replaceAll("<svg", "<svg class=\"" + Functions.htmlAttributeEscape(classes) + "\"");
symbol = symbol.replace("stroke:#000", "stroke:currentColor");

symbolsForLookup.put(translatedName, symbol);
SYMBOLS.put(identifier, symbolsForLookup);

return prependTitleIfRequired(symbol, title);
}

private static ClassLoader getClassLoader(String pluginName) {
if (pluginName.equals("core")) {
return IconSet.class.getClassLoader();
}

PluginWrapper plugin = Jenkins.get().getPluginManager().getPlugin(pluginName);
if (plugin != null) {
return plugin.classLoader;
}

return IconSet.class.getClassLoader();
return Symbol.get(new SymbolRequest.Builder()
.withName(IconSet.cleanName(name))
.withTitle(title)
.withTooltip(tooltip)
.withHtmlTooltip(htmlTooltip)
.withClasses(classes)
.withPluginName(pluginName)
.withId(id)
.build()
);
}

public IconSet addIcon(Icon icon) {
Expand Down
114 changes: 114 additions & 0 deletions core/src/main/java/org/jenkins/ui/symbol/Symbol.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package org.jenkins.ui.symbol;

import edu.umd.cs.findbugs.annotations.CheckForNull;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import hudson.Functions;
import hudson.PluginWrapper;
import hudson.Util;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import jenkins.model.Jenkins;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;

/**
* Helper class to load symbols from Jenkins core or plugins.
* @since TODO
*/
public final class Symbol {
private static final Logger LOGGER = Logger.getLogger(Symbol.class.getName());
// keyed by plugin name / core, and then symbol name returning the SVG as a string
private static final Map<String, Map<String, String>> SYMBOLS = new ConcurrentHashMap<>();
static final String PLACEHOLDER_SVG =
"<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"ionicon\" height=\"48\" viewBox=\"0 0 512 512\"><title>Close</title><path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"32\" d=\"M368 368L144 144M368 144L144 368\"/></svg>";

/**
* A substring of the placeholder so that tests can match it.
*/
static final String PLACEHOLDER_MATCHER = "M368 368L144 144M368 144L144 368";

private Symbol() {}

/**
* Generates the svg markup for the given symbol name and attributes.
* @param request the symbol request object.
* @return The svg markup for the symbol.
* @since TODO
*/
public static String get(@NonNull SymbolRequest request) {
String name = request.getName();
String title = request.getTitle();
String tooltip = request.getTooltip();
String htmlTooltip = request.getHtmlTooltip();
String classes = request.getClasses();
String pluginName = request.getPluginName();
String id = request.getId();

String identifier = StringUtils.defaultIfBlank(pluginName, "core");

String symbol = SYMBOLS
.computeIfAbsent(identifier, key -> new ConcurrentHashMap<>())
.computeIfAbsent(name, key -> loadSymbol(identifier, key));
if (StringUtils.isNotBlank(tooltip) && StringUtils.isBlank(htmlTooltip)) {
symbol = symbol.replaceAll("<svg", "<svg tooltip=\"" + Functions.htmlAttributeEscape(tooltip) + "\"");
}
if (StringUtils.isNotBlank(htmlTooltip)) {
symbol = symbol.replaceAll("<svg", "<svg data-html-tooltip=\"" + Functions.htmlAttributeEscape(htmlTooltip) + "\"");
}
if (StringUtils.isNotBlank(id)) {
symbol = symbol.replaceAll("<svg", "<svg id=\"" + Functions.htmlAttributeEscape(id) + "\"");
}
if (StringUtils.isNotBlank(classes)) {
symbol = symbol.replaceAll("<svg", "<svg class=\"" + Functions.htmlAttributeEscape(classes) + "\"");
}
if (StringUtils.isNotBlank(title)) {
symbol = "<span class=\"jenkins-visually-hidden\">" + Util.xmlEscape(title) + "</span>" + symbol;
}
return symbol;
}


@SuppressFBWarnings(value = {"NP_LOAD_OF_KNOWN_NULL_VALUE", "RCN_REDUNDANT_NULLCHECK_OF_NULL_VALUE"}, justification = "Spotbugs doesn't grok try-with-resources")
private static String loadSymbol(String namespace, String name) {
String markup = PLACEHOLDER_SVG;
ClassLoader classLoader = getClassLoader(namespace);
if (classLoader != null) {
try (InputStream inputStream = classLoader.getResourceAsStream("images/symbols/" + name + ".svg")) {
if (inputStream != null) {
markup = IOUtils.toString(inputStream, StandardCharsets.UTF_8);
} else {
LOGGER.log(Level.FINE, "Missing symbol " + name + " in " + namespace);
}
} catch (IOException e) {
LOGGER.log(Level.FINE, "Failed to load symbol " + name, e);
}
}
return markup.replaceAll("(<title>).*?(</title>)", "$1$2")
.replaceAll("<svg", "<svg aria-hidden=\"true\"")
.replaceAll("(class=\").*?(\")", "")
.replaceAll("(tooltip=\").*?(\")", "")
.replaceAll("(data-html-tooltip=\").*?(\")", "")
.replaceAll("(id=\").*?(\")", "")
.replace("stroke:#000", "stroke:currentColor");
Copy link
Member

@timja timja Jun 23, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NotMyFault might this be the root cause of this PR instead? #6686

replace instead of replaceAll?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dot of the price tag has no stroke defined, this replacement pattern wouldn't apply at all.

Copy link
Member

@daniel-beck daniel-beck Jun 23, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace replaces all, it's just not a regex. (Although the first arg seems to assume it's never #000000? Is that not a potential problem?)

Copy link
Member Author

@Vlatombe Vlatombe Jun 23, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that 3 core symbols have stroke:#000 (lock-closed.svg, play.svg and power.svg)

And it appears twice in lock-closed.svg and power.svg so this could be updated. Not sure what is best between just fixing the svg files compared to changing this code.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dot of the price tag has no stroke defined, this replacement pattern wouldn't apply at all.

ah my bad.

it could be yes although I guess it's an assumption based on what ionicon's does

Copy link
Member Author

@Vlatombe Vlatombe Jun 23, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like everything is fine so far. However having some logic to validate symbols at build time would be interesting.

What comes to my mind:

  • Check that each path has at least one of fill,stroke defined and set to either none or currentColor. Could be defined either through attribute or through style.

}

@CheckForNull
private static ClassLoader getClassLoader(@NonNull String pluginName) {
if ("core".equals(pluginName)) {
return Symbol.class.getClassLoader();
} else {
PluginWrapper plugin = Jenkins.get().getPluginManager().getPlugin(pluginName);
if (plugin != null) {
return plugin.classLoader;
} else {
return null;
}
}
}
}
Loading