diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java index f4cb5f1d7779ef47bc38315adf5c5d5dca8f8573..2dbb673776c635926937e51ca2115555c6d93c0c 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java @@ -15,6 +15,7 @@ import it.inaf.ia2.vospace.ui.service.MainNodesHtmlGenerator; import it.inaf.ia2.vospace.ui.service.MoveOrCopyNodeModalHtmlGenerator; import it.inaf.oats.vospace.datamodel.NodeUtils; import static it.inaf.oats.vospace.datamodel.NodeUtils.urlEncodePath; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -27,6 +28,7 @@ import javax.servlet.http.HttpServletRequest; import net.ivoa.xml.uws.v1.ExecutionPhase; import net.ivoa.xml.uws.v1.JobSummary; import net.ivoa.xml.vospace.v2.ContainerNode; +import net.ivoa.xml.vospace.v2.LinkNode; import net.ivoa.xml.vospace.v2.Node; import net.ivoa.xml.vospace.v2.Param; import net.ivoa.xml.vospace.v2.Property; @@ -74,7 +76,9 @@ public class NodesController extends BaseController { listNodeData.setWritable(NodeUtils.checkIfWritable(node, principal.getName(), principal.getGroups())); - MainNodesHtmlGenerator htmlGenerator = new MainNodesHtmlGenerator(node, principal, authority); + List<Node> linkedNodes = getLinkedNodes(node, false); + + MainNodesHtmlGenerator htmlGenerator = new MainNodesHtmlGenerator(node, principal, authority, linkedNodes); listNodeData.setHtml(htmlGenerator.generateNodes()); return ResponseEntity.ok(listNodeData); @@ -91,12 +95,55 @@ public class NodesController extends BaseController { listNodeData.setWritable(NodeUtils.checkIfWritable(node, principal.getName(), principal.getGroups())); - MoveOrCopyNodeModalHtmlGenerator htmlGenerator = new MoveOrCopyNodeModalHtmlGenerator(node, targetNodes, principal, authority); + List<Node> linkedNodes = getLinkedNodes(node, true); + + MoveOrCopyNodeModalHtmlGenerator htmlGenerator = new MoveOrCopyNodeModalHtmlGenerator(node, targetNodes, principal, authority, linkedNodes); listNodeData.setHtml(htmlGenerator.generateNodes()); return ResponseEntity.ok(listNodeData); } + private List<Node> getLinkedNodes(Node node, boolean onlyDirectories) throws Exception { + + List<Node> linkedNodes = new ArrayList<>(); + if (node instanceof ContainerNode) { + ContainerNode container = (ContainerNode) node; + List<LinkNode> linksToLoad = container.getNodes().stream().filter(n -> n instanceof LinkNode) + .map(n -> (LinkNode) n).collect(Collectors.toList()); + + // it is necessary to load link nodes metadata to understand if the linked node represents + // a container, a VOSpace file or an external file + List<CompletableFuture<Node>> nodesCalls = new ArrayList<>(); + + for (LinkNode linkNode : linksToLoad) { + String prefix = "vos://" + authority; + if (linkNode.getTarget().startsWith(prefix)) { + String linkPath = linkNode.getTarget().substring(prefix.length()); + nodesCalls.add(CompletableFuture.supplyAsync(() -> client.getNode(linkPath), Runnable::run) + .exceptionally(ex -> null)); // null is returned in case of broken link + } else { + linkedNodes.add(linkNode); + } + } + + CompletableFuture.allOf(nodesCalls.toArray(CompletableFuture[]::new)).join(); + + for (CompletableFuture<Node> nodeCall : nodesCalls) { + Node linkedNode = nodeCall.get(); + + if (linkedNode != null) { + if (linkedNode instanceof ContainerNode) { + linkedNodes.add(linkedNode); + } else if (!onlyDirectories) { + linkedNodes.add(linkedNode); + } + } + } + } + + return linkedNodes; + } + @PostMapping(value = "/folder") public void newFolder(@RequestBody Map<String, Object> params) { diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MainNodesHtmlGenerator.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MainNodesHtmlGenerator.java index 0975ca3d9c9a1518cf2cb13b77c9ee3aa48f0bbf..26446f6f1c457cc0b6bdbb265b83444512c2d6cd 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MainNodesHtmlGenerator.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MainNodesHtmlGenerator.java @@ -16,8 +16,8 @@ import org.jsoup.nodes.Element; public class MainNodesHtmlGenerator extends NodesHtmlGenerator { - public MainNodesHtmlGenerator(Node node, User user, String authority) { - super(node, user, authority); + public MainNodesHtmlGenerator(Node node, User user, String authority, List<Node> linkedNodes) { + super(node, user, authority, linkedNodes); } @Override @@ -29,7 +29,10 @@ public class MainNodesHtmlGenerator extends NodesHtmlGenerator { @Override protected void addChild(Node child, Element containerElement) { - NodeInfo nodeInfo = new NodeInfo(child, user, authority); + + Node linkedNode = getLinkedNode(child); + + NodeInfo nodeInfo = new NodeInfo(child, user, authority, linkedNode); Element row = containerElement.appendElement("tr"); diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MoveOrCopyNodeModalHtmlGenerator.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MoveOrCopyNodeModalHtmlGenerator.java index 74327aee810cfcd7951b4bc6b4ea0622b60efa56..b7c11fc7b252caf7983eadbf7c66179f345b9540 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MoveOrCopyNodeModalHtmlGenerator.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MoveOrCopyNodeModalHtmlGenerator.java @@ -7,6 +7,7 @@ package it.inaf.ia2.vospace.ui.service; import it.inaf.ia2.aa.data.User; import java.util.List; +import net.ivoa.xml.vospace.v2.LinkNode; import net.ivoa.xml.vospace.v2.Node; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; @@ -15,8 +16,8 @@ public class MoveOrCopyNodeModalHtmlGenerator extends NodesHtmlGenerator { private final List<String> targetNodes; - public MoveOrCopyNodeModalHtmlGenerator(Node node, List<String> targetNodes, User user, String authority) { - super(node, user, authority); + public MoveOrCopyNodeModalHtmlGenerator(Node node, List<String> targetNodes, User user, String authority, List<Node> linkedNodes) { + super(node, user, authority, linkedNodes); this.targetNodes = targetNodes; } @@ -30,7 +31,17 @@ public class MoveOrCopyNodeModalHtmlGenerator extends NodesHtmlGenerator { @Override protected void addChild(Node child, Element containerElement) { - NodeInfo nodeInfo = new NodeInfo(child, user, authority); + + Node linkedNode = null; + if (child instanceof LinkNode) { + linkedNode = getLinkedNode(child); + if (linkedNode == null) { + // linked node is a file + return; + } + } + + NodeInfo nodeInfo = new NodeInfo(child, user, authority, linkedNode); if (!nodeInfo.isFolder() || targetNodes.contains(nodeInfo.getPath())) { return; diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodeInfo.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodeInfo.java index 5b3d6154514a8f75bc51aecaf5b947be116762a3..21a802eb60e66cc3bf1ad88c99c525c548629f64 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodeInfo.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodeInfo.java @@ -14,7 +14,9 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Optional; import java.util.stream.Collectors; +import net.ivoa.xml.vospace.v2.ContainerNode; import net.ivoa.xml.vospace.v2.DataNode; +import net.ivoa.xml.vospace.v2.LinkNode; import net.ivoa.xml.vospace.v2.Node; import net.ivoa.xml.vospace.v2.Property; import org.slf4j.Logger; @@ -26,10 +28,10 @@ public class NodeInfo { private final String authority; - private final String path; + private String path; private final String name; private final String size; - private final String type; + private String type; private final String creator; private final String groupRead; private final String groupWrite; @@ -39,8 +41,9 @@ public class NodeInfo { private final boolean busy; private final boolean writable; private final boolean deletable; + private final boolean link; - public NodeInfo(Node node, User user, String authority) { + public NodeInfo(Node node, User user, String authority, Node linkedNode) { this.authority = authority; this.path = getPath(node); this.name = URLDecoder.decode(path.substring(path.lastIndexOf("/") + 1), StandardCharsets.UTF_8); @@ -55,6 +58,15 @@ public class NodeInfo { this.busy = isBusy(node); this.writable = NodeUtils.checkIfWritable(node, user.getName(), user.getGroups()) && !busy; this.deletable = writable && !sticky && !asyncTrans; + this.link = linkedNode != null; + if (linkedNode != null) { + String prefix = "vos://" + authority; + String target = ((LinkNode) node).getTarget(); + if (linkedNode instanceof ContainerNode) { + this.path = decodePath(target, prefix); + } + this.type = linkedNode.getType(); + } } private String getPath(Node node) { @@ -67,7 +79,11 @@ public class NodeInfo { throw new VOSpaceException("Node authority is different from configured one! Configured is " + authority + ", but node URI is " + uri); } - // returns decoded path + return decodePath(uri, prefix); + } + + private String decodePath(String uri, String prefix) { + return String.join("/", Arrays.stream(uri.substring(prefix.length()).split("/")) .map(p -> URLDecoder.decode(p, StandardCharsets.UTF_8)) .collect(Collectors.toList())); @@ -192,4 +208,8 @@ public class NodeInfo { public boolean isDeletable() { return deletable; } + + public boolean isLink() { + return link; + } } diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGenerator.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGenerator.java index 0df31b92896e243166653c781a479e5083519618..8ba5b500994b34519b5e4a4b6cdaa95152c89de0 100644 --- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGenerator.java +++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGenerator.java @@ -6,7 +6,9 @@ package it.inaf.ia2.vospace.ui.service; import it.inaf.ia2.aa.data.User; +import java.util.List; import net.ivoa.xml.vospace.v2.ContainerNode; +import net.ivoa.xml.vospace.v2.LinkNode; import net.ivoa.xml.vospace.v2.Node; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; @@ -18,13 +20,15 @@ public abstract class NodesHtmlGenerator { protected final User user; protected final String authority; + private final List<Node> linkedNodes; private Element containerElement; - protected NodesHtmlGenerator(Node node, User user, String authority) { + protected NodesHtmlGenerator(Node node, User user, String authority, List<Node> linkedNodes) { this.parentNode = node; this.user = user; this.authority = authority; + this.linkedNodes = linkedNodes; } public String generateNodes() { @@ -32,7 +36,7 @@ public abstract class NodesHtmlGenerator { Document html = Jsoup.parse("<html></html>"); containerElement = createContainerElement(html); - + if (parentNode instanceof ContainerNode) { ContainerNode folder = (ContainerNode) parentNode; for (Node child : folder.getNodes()) { @@ -44,7 +48,7 @@ public abstract class NodesHtmlGenerator { } protected abstract Element createContainerElement(Document doc); - + protected abstract void addChild(Node child, Element containerElement); protected String makeJsArg(String arg) { @@ -67,7 +71,13 @@ public abstract class NodesHtmlGenerator { Element icon = parentElement.appendElement("span"); icon.addClass("icon"); - if (nodeInfo.isFolder()) { + if (nodeInfo.isLink()) { + if (nodeInfo.isFolder()) { + icon.addClass("folder-link-icon"); + } else { + icon.addClass("link-icon"); + } + } else if (nodeInfo.isFolder()) { if (nodeInfo.isAsyncTrans()) { icon.addClass("folder-x-icon"); } else { @@ -83,4 +93,12 @@ public abstract class NodesHtmlGenerator { parentElement.append(" "); } + + protected Node getLinkedNode(Node node) { + if (node instanceof LinkNode) { + return linkedNodes.stream().filter(n -> ((LinkNode) node) + .getTarget().equals(n.getUri())).findFirst().orElse(null); + } + return null; + } } diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java index 9835d7d34446d47c945911aec50b88d5363d0c56..14c1535cd28eba1f67c75f8c81a17f1a0e3de871 100644 --- a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java +++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java @@ -17,6 +17,7 @@ import net.ivoa.xml.uws.v1.ExecutionPhase; import net.ivoa.xml.uws.v1.JobSummary; import net.ivoa.xml.vospace.v2.ContainerNode; import net.ivoa.xml.vospace.v2.DataNode; +import net.ivoa.xml.vospace.v2.LinkNode; import static org.hamcrest.CoreMatchers.is; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -145,10 +146,27 @@ public class NodesControllerTest { parent.getNodes().add(child3); ContainerNode child4 = new ContainerNode(); - child4.setUri("vos://example.com!vospace/a/b/c/c3"); + child4.setUri("vos://example.com!vospace/a/b/c/c4"); parent.getNodes().add(child4); - when(client.getNode(any())).thenReturn(parent); + LinkNode child5 = new LinkNode(); + child5.setUri("vos://example.com!vospace/a/b/c/c5"); + child5.setTarget("vos://example.com!vospace/a/b/c/c2"); + parent.getNodes().add(child5); + + LinkNode child6 = new LinkNode(); + child6.setUri("vos://example.com!vospace/a/b/c/c6"); + child6.setTarget("vos://example.com!vospace/a/b/c/c1"); + parent.getNodes().add(child6); + + LinkNode child7 = new LinkNode(); + child7.setUri("vos://example.com!vospace/a/b/c/c7"); + child7.setTarget("http://external-link"); + parent.getNodes().add(child7); + + when(client.getNode(eq("/a/b/c"))).thenReturn(parent); + when(client.getNode(eq("/a/b/c/c1"))).thenReturn(child1); + when(client.getNode(eq("/a/b/c/c2"))).thenReturn(child2); String response = mockMvc.perform(get("/nodesForMoveOrCopy") .param("path", "/a/b/c") @@ -161,6 +179,9 @@ public class NodesControllerTest { assertFalse(response.contains("c2")); // data node assertFalse(response.contains("c3")); // nodeToMove assertFalse(response.contains("c4")); // nodeToMove + assertFalse(response.contains("c5")); // link to file + assertTrue(response.contains("c6")); // link to container + assertFalse(response.contains("c7")); // external link verify(client, times(1)).getNode(eq("/a/b/c")); } @@ -221,6 +242,34 @@ public class NodesControllerTest { verify(client, times(1)).createNode(any()); } + @Test + public void testListLinkNodes() throws Exception { + + ContainerNode parent = new ContainerNode(); + parent.setUri("vos://example.com!vospace"); + + LinkNode link1 = new LinkNode(); + link1.setUri("vos://example.com!vospace/link1"); + link1.setTarget("vos://example.com!vospace/myfile"); + parent.getNodes().add(link1); + + LinkNode link2 = new LinkNode(); + link2.setUri("vos://example.com!vospace/link2"); + link2.setTarget("http://external-link"); + parent.getNodes().add(link2); + + DataNode linkedNode = new DataNode(); + linkedNode.setUri("vos://example.com!vospace/myfile"); + + when(client.getNode(eq("/"))).thenReturn(parent); + when(client.getNode(eq("/myfile"))).thenReturn(linkedNode); + + mockMvc.perform(get("/nodes")) + .andExpect(status().isOk()); + + verify(client, times(1)).getNode(eq("/")); + } + private ResultActions testMoveNode() throws Exception { JobSummary job = new JobSummary(); diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/service/NodeInfoTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/service/NodeInfoTest.java index dacb50001f1da687d2e5f4722d7e31f83fa50723..2f7820820493fffe29085f602a1cf3ed32156673 100644 --- a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/service/NodeInfoTest.java +++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/service/NodeInfoTest.java @@ -50,19 +50,19 @@ public class NodeInfoTest { public void testSizePeta() { testNodeLength(6963696737150000L, "6.2 PB"); } - + @Test public void testUrlEncoding() { DataNode node = new DataNode(); node.setUri("vos://example.com!vospace/my%23node"); - NodeInfo nodeInfo = new NodeInfo(node, new User(), AUTHORITY); + NodeInfo nodeInfo = new NodeInfo(node, new User(), AUTHORITY, null); assertEquals("my#node", nodeInfo.getName()); } private void testNodeLength(long bytes, String expectedText) { DataNode node = getDataNode(); setLength(node, bytes); - NodeInfo nodeInfo = new NodeInfo(node, new User(), AUTHORITY); + NodeInfo nodeInfo = new NodeInfo(node, new User(), AUTHORITY, null); assertEquals(expectedText, nodeInfo.getSize()); } diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGeneratorTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGeneratorTest.java index 2e6a1af5d3396dcd16d26d510f5890ffbf274f4e..568ed1467680abee7e1c4deaa8fb6d34b07b43f8 100644 --- a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGeneratorTest.java +++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/service/NodesHtmlGeneratorTest.java @@ -8,8 +8,10 @@ package it.inaf.ia2.vospace.ui.service; import it.inaf.ia2.aa.data.User; import it.inaf.oats.vospace.datamodel.NodeProperties; import java.util.Arrays; +import java.util.List; import net.ivoa.xml.vospace.v2.ContainerNode; import net.ivoa.xml.vospace.v2.DataNode; +import net.ivoa.xml.vospace.v2.LinkNode; import net.ivoa.xml.vospace.v2.Node; import net.ivoa.xml.vospace.v2.Property; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -29,11 +31,31 @@ public class NodesHtmlGeneratorTest { setGroups(file1, "group1", "group2 people.name\\.surname"); parent.getNodes().add(file1); + LinkNode link1 = new LinkNode(); + link1.setUri("vos://example.com!vospace/mynode/link1"); + link1.setTarget("vos://example.com!vospace/mynode"); + setGroups(link1, "group1", "group2 people.name\\.surname"); + parent.getNodes().add(link1); + + LinkNode link2 = new LinkNode(); + link2.setUri("vos://example.com!vospace/mynode/link2"); + link2.setTarget("vos://example.com!vospace/mynode/file1"); + setGroups(link2, "group1", "group2 people.name\\.surname"); + parent.getNodes().add(link2); + + LinkNode link3 = new LinkNode(); + link3.setUri("vos://example.com!vospace/mynode/link3"); + link3.setTarget("http://external-link"); + setGroups(link3, "group1", "group2 people.name\\.surname"); + parent.getNodes().add(link3); + + List<Node> linkedNodes = List.of(parent, file1); + User user = new User(); user.setUserId("user_id"); user.setGroups(Arrays.asList("group1", "group2")); - MainNodesHtmlGenerator generator = new MainNodesHtmlGenerator(parent, user, "example.com!vospace"); + MainNodesHtmlGenerator generator = new MainNodesHtmlGenerator(parent, user, "example.com!vospace", linkedNodes); String html = generator.generateNodes(); @@ -41,6 +63,9 @@ public class NodesHtmlGeneratorTest { assertTrue(html.startsWith("<tbody id=\"nodes\">")); assertTrue(html.contains("<input type=\"checkbox\" data-node=\"/mynode/file1\" class=\"deletable\">")); + assertTrue(html.contains("<span class=\"icon folder-link-icon\"></span> <a href=\"#/nodes/mynode\">link1</a>")); + assertTrue(html.contains("<span class=\"icon link-icon\"></span> <a target=\"blank_\" href=\"download/mynode/link2\">link2</a>")); + assertTrue(html.contains("<span class=\"icon file-icon\"></span> <a target=\"blank_\" href=\"download/mynode/link3\">link3</a>")); } private void setGroups(Node node, String groupRead, String groupWrite) { diff --git a/vospace-ui-frontend/src/assets/css/fonts.css b/vospace-ui-frontend/src/assets/css/fonts.css index bdd3cecc3715c30d026ea23f1f8115c78de4a96c..e5de3177f7a8c2e4b32a244cff1d439405101726 100644 --- a/vospace-ui-frontend/src/assets/css/fonts.css +++ b/vospace-ui-frontend/src/assets/css/fonts.css @@ -48,3 +48,7 @@ .dots-menu-icon { background-image: url("data:image/svg+xml,%3Csvg data-v-41be6633='' viewBox='0 0 16 16' width='1em' height='1em' focusable='false' role='img' aria-label='three dots vertical' xmlns='http://www.w3.org/2000/svg' fill='currentColor' class='bi-three-dots-vertical mx-auto b-icon bi'%3E%3Cg data-v-41be6633=''%3E%3Cpath d='M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z'%3E%3C/path%3E%3C/g%3E%3C/svg%3E"); } + +.link-icon { + background-image: url("data:image/svg+xml,%3Csvg data-v-41be6633='' viewBox='0 0 16 16' width='1em' height='1em' focusable='false' role='img' aria-label='link45deg' xmlns='http://www.w3.org/2000/svg' fill='currentColor' class='bi-link45deg mx-auto b-icon bi'%3E%3Cg data-v-41be6633=''%3E%3Cpath d='M4.715 6.542L3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.001 1.001 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z'%3E%3C/path%3E%3Cpath d='M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 0 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 0 0-4.243-4.243L6.586 4.672z'%3E%3C/path%3E%3C/g%3E%3C/svg%3E"); +}