From 5b9e1610082492fc8b48ebc726239aee97da75df Mon Sep 17 00:00:00 2001
From: Sonia Zorba <sonia.zorba@inaf.it>
Date: Tue, 31 Aug 2021 17:41:23 +0200
Subject: [PATCH] Changed backend for handling copy and move of multiple
 selected nodes

---
 .../ui/controller/NodesController.java        | 111 +++++++++++++-----
 .../java/it/inaf/ia2/vospace/ui/data/Job.java |   3 +-
 .../vospace/ui/data/MoveOrCopyRequest.java    |  39 ++++++
 .../ui/service/MainNodesHtmlGenerator.java    |   6 +
 ... => MoveOrCopyNodeModalHtmlGenerator.java} |  15 +--
 .../ui/controller/NodesControllerTest.java    |  48 ++++----
 6 files changed, 159 insertions(+), 63 deletions(-)
 create mode 100644 vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/MoveOrCopyRequest.java
 rename vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/{MoveNodeModalHtmlGenerator.java => MoveOrCopyNodeModalHtmlGenerator.java} (68%)

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 5560317..5133781 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
@@ -9,16 +9,20 @@ import it.inaf.ia2.aa.data.User;
 import it.inaf.ia2.vospace.ui.client.VOSpaceClient;
 import it.inaf.ia2.vospace.ui.data.Job;
 import it.inaf.ia2.vospace.ui.data.ListNodeData;
+import it.inaf.ia2.vospace.ui.data.MoveOrCopyRequest;
 import it.inaf.ia2.vospace.ui.exception.VOSpaceException;
 import it.inaf.ia2.vospace.ui.service.MainNodesHtmlGenerator;
-import it.inaf.ia2.vospace.ui.service.MoveNodeModalHtmlGenerator;
+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.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import javax.servlet.http.HttpServletRequest;
 import net.ivoa.xml.uws.v1.ExecutionPhase;
 import net.ivoa.xml.uws.v1.JobSummary;
@@ -49,8 +53,8 @@ public class NodesController extends BaseController {
     @Value("${vospace-authority}")
     private String authority;
 
-    @Value("${maxPollingAttempts:10}")
-    private int maxPollingAttempts;
+    @Value("${pollingTimeout:15}")
+    private int pollingTimeout;
 
     @Autowired
     private VOSpaceClient client;
@@ -76,10 +80,10 @@ public class NodesController extends BaseController {
         return ResponseEntity.ok(listNodeData);
     }
 
-    @GetMapping(value = "/nodesForMove", produces = MediaType.APPLICATION_JSON_VALUE)
-    public ResponseEntity<ListNodeData> listNodesForMoveModal(@RequestParam("path") String path, @RequestParam("nodeToMove") String nodeToMove, User principal) throws Exception {
+    @GetMapping(value = "/nodesForMoveOrCopy", produces = MediaType.APPLICATION_JSON_VALUE)
+    public ResponseEntity<ListNodeData> listNodesForMoveOrCopyModal(@RequestParam("path") String path, @RequestParam("target") List<String> targetNodes, User principal) throws Exception {
 
-        LOG.debug("listNodes called for path {}", path);
+        LOG.debug("nodesForMoveOrCopy called for path {}", path);
 
         ListNodeData listNodeData = new ListNodeData();
 
@@ -87,7 +91,7 @@ public class NodesController extends BaseController {
 
         listNodeData.setWritable(NodeUtils.checkIfWritable(node, principal.getName(), principal.getGroups()));
 
-        MoveNodeModalHtmlGenerator htmlGenerator = new MoveNodeModalHtmlGenerator(node, nodeToMove, principal, authority);
+        MoveOrCopyNodeModalHtmlGenerator htmlGenerator = new MoveOrCopyNodeModalHtmlGenerator(node, targetNodes, principal, authority);
         listNodeData.setHtml(htmlGenerator.generateNodes());
 
         return ResponseEntity.ok(listNodeData);
@@ -203,40 +207,83 @@ public class NodesController extends BaseController {
         return commonParent;
     }
 
-    @PostMapping(value = "/move")
-    public ResponseEntity<Job> moveNode(@RequestBody Map<String, Object> params) {
+    @PostMapping(value = "/moveOrCopy")
+    public ResponseEntity<List<Job>> moveOrCopyNodes(@RequestBody MoveOrCopyRequest request) throws Exception {
 
-        String target = urlEncodePath(getRequiredParam(params, "target"));
-        String direction = urlEncodePath(getRequiredParam(params, "direction"));
+        CompletableFuture<JobSummary>[] futureJobs = request.getTargets().stream().map(t -> {
+            String target = urlEncodePath(t);
+            String direction = urlEncodePath(request.getDirection());
+            Transfer transfer = new Transfer();
+            transfer.setTarget("vos://" + authority + target);
+            transfer.setDirection("vos://" + authority + direction);
+            transfer.setKeepBytes(request.isKeepBytes());
+            return CompletableFuture.supplyAsync(() -> client.startTransferJob(transfer), Runnable::run);
+        }).collect(Collectors.toList()).toArray(CompletableFuture[]::new);
 
-        Transfer transfer = new Transfer();
-        transfer.setTarget("vos://" + authority + target);
-        transfer.setDirection("vos://" + authority + direction);
+        CompletableFuture.allOf(futureJobs).join();
 
-        JobSummary job = client.startTransferJob(transfer);
+        List<JobSummary> jobs = Stream.of(futureJobs).map(j -> j.join()).collect(Collectors.toList());
 
-        // Try to perform polling until completion. If it takes too much time 
-        // sends the execution phase to the UI and let it handles the polling.
-        ExecutionPhase phase;
-        int i = 0;
-        do {
-            phase = client.getJobPhase(job.getJobId());
-            if (phase == ExecutionPhase.COMPLETED) {
-                break;
-            } else if (phase == ExecutionPhase.ERROR) {
-                String errorDetail = client.getErrorDetail(job.getJobId());
-                throw new VOSpaceException("MoveNode operation failed: " + errorDetail);
-            }
+        AtomicReference<Boolean> cancelled = new AtomicReference<>(false);
+
+        CompletableFuture timeout = CompletableFuture.runAsync(() -> {
             try {
-                Thread.sleep(1000);
+                Thread.sleep(pollingTimeout * 1000);
             } catch (InterruptedException ex) {
             }
-            i++;
-        } while (i < maxPollingAttempts);
+            cancelled.set(true);
+        });
+
+        try {
+            CompletableFuture.anyOf(jobsPolling(jobs, cancelled), timeout).join();
+        } catch (CompletionException ex) {
+            if (ex.getCause() != null && ex.getCause() instanceof VOSpaceException) {
+                throw (VOSpaceException) ex.getCause();
+            }
+            throw ex;
+        }
+
+        // Try to perform polling until completion. If it takes too much time
+        // sends the execution phase to the UI and let it handles the polling.
+        Job.JobType type = request.isKeepBytes() ? Job.JobType.COPY : Job.JobType.MOVE;
 
-        job.setPhase(phase);
+        return ResponseEntity.ok(jobs.stream().map(j -> new Job(j, type))
+                .collect(Collectors.toList()));
+    }
+
+    private CompletableFuture<?> jobsPolling(List<JobSummary> jobs, AtomicReference<Boolean> cancelled) {
+        return CompletableFuture.runAsync(() -> {
+
+            List<JobSummary> uncompletedJobs;
+            do {
+                uncompletedJobs = jobs.stream()
+                        .filter(j -> ExecutionPhase.COMPLETED != j.getPhase())
+                        .collect(Collectors.toList());
+
+                if (!uncompletedJobs.isEmpty()) {
+                    try {
+                        Thread.sleep(1000);
+                    } catch (InterruptedException ex) {
+                        break;
+                    }
+                    updatePhases(uncompletedJobs);
+                }
+            } while (!uncompletedJobs.isEmpty() && !cancelled.get());
+        }, Runnable::run);
+    }
+
+    private void updatePhases(List<JobSummary> uncompletedJobs) {
+        CompletableFuture[] phaseFutures = uncompletedJobs.stream()
+                .map(job -> CompletableFuture.runAsync(() -> {
+            ExecutionPhase phase = client.getJobPhase(job.getJobId());
+            if (phase == ExecutionPhase.ERROR) {
+                String errorDetail = client.getErrorDetail(job.getJobId());
+                throw new VOSpaceException("Operation failed: " + errorDetail);
+            }
+            job.setPhase(phase);
+        }, Runnable::run)).collect(Collectors.toList()).toArray(CompletableFuture[]::new);
 
-        return ResponseEntity.ok(new Job(job, Job.JobType.MOVE));
+        CompletableFuture.allOf(phaseFutures).join();
     }
 
     protected String getPath(String prefix) {
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/Job.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/Job.java
index 06ff9ed..725bb03 100644
--- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/Job.java
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/Job.java
@@ -16,7 +16,8 @@ public class Job {
     public static enum JobType {
         ASYNC_RECALL,
         ARCHIVE,
-        MOVE
+        MOVE,
+        COPY
     }
 
     private String id;
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/MoveOrCopyRequest.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/MoveOrCopyRequest.java
new file mode 100644
index 0000000..4e2b89a
--- /dev/null
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/MoveOrCopyRequest.java
@@ -0,0 +1,39 @@
+/*
+ * This file is part of vospace-ui
+ * Copyright (C) 2021 Istituto Nazionale di Astrofisica
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package it.inaf.ia2.vospace.ui.data;
+
+import java.util.List;
+
+public class MoveOrCopyRequest {
+
+    private List<String> targets;
+    private String direction;
+    private boolean keepBytes;
+
+    public List<String> getTargets() {
+        return targets;
+    }
+
+    public void setTargets(List<String> targets) {
+        this.targets = targets;
+    }
+
+    public String getDirection() {
+        return direction;
+    }
+
+    public void setDirection(String direction) {
+        this.direction = direction;
+    }
+
+    public boolean isKeepBytes() {
+        return keepBytes;
+    }
+
+    public void setKeepBytes(boolean keepBytes) {
+        this.keepBytes = keepBytes;
+    }
+}
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 5aaef51..0975ca3 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
@@ -137,6 +137,12 @@ public class MainNodesHtmlGenerator extends NodesHtmlGenerator {
             moveBtn.attr("class", "dropdown-item");
             moveBtn.attr("onclick", "moveNode(" + nodePathJs + ")");
 
+            Element copyBtn = dropdown.appendElement("button");
+            copyBtn.text("Copy");
+            copyBtn.attr("type", "button");
+            copyBtn.attr("class", "dropdown-item");
+            copyBtn.attr("onclick", "copyNode(" + nodePathJs + ")");
+
             Element deleteBtn = dropdown.appendElement("button");
             deleteBtn.text("Delete");
             deleteBtn.attr("type", "button");
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MoveNodeModalHtmlGenerator.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MoveOrCopyNodeModalHtmlGenerator.java
similarity index 68%
rename from vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MoveNodeModalHtmlGenerator.java
rename to vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MoveOrCopyNodeModalHtmlGenerator.java
index f83d8cd..74327ae 100644
--- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MoveNodeModalHtmlGenerator.java
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/MoveOrCopyNodeModalHtmlGenerator.java
@@ -6,23 +6,24 @@
 package it.inaf.ia2.vospace.ui.service;
 
 import it.inaf.ia2.aa.data.User;
+import java.util.List;
 import net.ivoa.xml.vospace.v2.Node;
 import org.jsoup.nodes.Document;
 import org.jsoup.nodes.Element;
 
-public class MoveNodeModalHtmlGenerator extends NodesHtmlGenerator {
+public class MoveOrCopyNodeModalHtmlGenerator extends NodesHtmlGenerator {
 
-    private final String nodeNodeMovePath;
+    private final List<String> targetNodes;
 
-    public MoveNodeModalHtmlGenerator(Node node, String nodeNodeMovePath, User user, String authority) {
+    public MoveOrCopyNodeModalHtmlGenerator(Node node, List<String> targetNodes, User user, String authority) {
         super(node, user, authority);
-        this.nodeNodeMovePath = nodeNodeMovePath;
+        this.targetNodes = targetNodes;
     }
 
     @Override
     protected Element createContainerElement(Document html) {
         Element container = html.body().appendElement("div");
-        container.attr("id", "move-nodes");
+        container.attr("id", "move-or-copy-nodes");
         container.attr("class", "list-group");
         return container;
     }
@@ -31,7 +32,7 @@ public class MoveNodeModalHtmlGenerator extends NodesHtmlGenerator {
     protected void addChild(Node child, Element containerElement) {
         NodeInfo nodeInfo = new NodeInfo(child, user, authority);
 
-        if (!nodeInfo.isFolder() || nodeInfo.getPath().equals(nodeNodeMovePath)) {
+        if (!nodeInfo.isFolder() || targetNodes.contains(nodeInfo.getPath())) {
             return;
         }
 
@@ -45,7 +46,7 @@ public class MoveNodeModalHtmlGenerator extends NodesHtmlGenerator {
     private void addLink(NodeInfo nodeInfo, Element cell) {
         Element link = cell.appendElement("a");
         link.attr("href", "#");
-        link.attr("onclick", "openNodeInMoveModal(event, " + makeJsArg(nodeInfo.getPath()) + ")");
+        link.attr("onclick", "openNodeInMoveOrCopyModal(event, " + makeJsArg(nodeInfo.getPath()) + ")");
         link.text(nodeInfo.getName());
     }
 }
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 97d46f2..9835d7d 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
@@ -8,17 +8,16 @@ package it.inaf.ia2.vospace.ui.controller;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import it.inaf.ia2.aa.data.User;
 import it.inaf.ia2.vospace.ui.client.VOSpaceClient;
-import it.inaf.ia2.vospace.ui.data.Job;
+import it.inaf.ia2.vospace.ui.data.MoveOrCopyRequest;
 import it.inaf.ia2.vospace.ui.exception.VOSpaceException;
 import java.util.Arrays;
 import java.util.List;
-import java.util.Map;
 import javax.servlet.http.HttpSession;
 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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.hamcrest.CoreMatchers.is;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
@@ -41,12 +40,13 @@ import org.springframework.test.web.servlet.MockMvc;
 import org.springframework.test.web.servlet.ResultActions;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 import org.springframework.web.util.NestedServletException;
 
 @SpringBootTest
 @AutoConfigureMockMvc
-@TestPropertySource(properties = {"vospace-authority=example.com!vospace", "maxPollingAttempts=2"})
+@TestPropertySource(properties = {"vospace-authority=example.com!vospace", "pollingTimeout=2"})
 public class NodesControllerTest {
 
     private static final ObjectMapper MAPPER = new ObjectMapper();
@@ -56,7 +56,7 @@ public class NodesControllerTest {
 
     @Autowired
     private NodesController nodesController;
-    
+
     @Autowired
     private MockMvc mockMvc;
 
@@ -127,7 +127,7 @@ public class NodesControllerTest {
     }
 
     @Test
-    public void testListNodesForMoveModal() throws Exception {
+    public void testListNodesForMoveOrCopyModal() throws Exception {
 
         ContainerNode parent = new ContainerNode();
         parent.setUri("vos://example.com!vospace/a/b/c");
@@ -144,15 +144,23 @@ public class NodesControllerTest {
         child3.setUri("vos://example.com!vospace/a/b/c/c3");
         parent.getNodes().add(child3);
 
+        ContainerNode child4 = new ContainerNode();
+        child4.setUri("vos://example.com!vospace/a/b/c/c3");
+        parent.getNodes().add(child4);
+
         when(client.getNode(any())).thenReturn(parent);
 
-        String response = mockMvc.perform(get("/nodesForMove?path=/a/b/c&nodeToMove=/a/b/c/c3"))
+        String response = mockMvc.perform(get("/nodesForMoveOrCopy")
+                .param("path", "/a/b/c")
+                .param("target", "/a/b/c/c3")
+                .param("target", "/a/b/c/c4"))
                 .andExpect(status().isOk())
                 .andReturn().getResponse().getContentAsString();
 
         assertTrue(response.contains("c1")); // folder
         assertFalse(response.contains("c2")); // data node
         assertFalse(response.contains("c3")); // nodeToMove
+        assertFalse(response.contains("c4")); // nodeToMove
 
         verify(client, times(1)).getNode(eq("/a/b/c"));
     }
@@ -164,13 +172,9 @@ public class NodesControllerTest {
                 .thenReturn(ExecutionPhase.EXECUTING)
                 .thenReturn(ExecutionPhase.COMPLETED);
 
-        String response = testMoveNode()
+        testMoveNode()
                 .andExpect(status().isOk())
-                .andReturn().getResponse()
-                .getContentAsString();
-
-        Job job = MAPPER.readValue(response, Job.class);
-        assertEquals(ExecutionPhase.COMPLETED, job.getPhase());
+                .andExpect(jsonPath("$[0].phase", is("COMPLETED")));
     }
 
     @Test
@@ -179,13 +183,9 @@ public class NodesControllerTest {
         when(client.getJobPhase("job_id"))
                 .thenReturn(ExecutionPhase.EXECUTING);
 
-        String response = testMoveNode()
+        testMoveNode()
                 .andExpect(status().isOk())
-                .andReturn().getResponse()
-                .getContentAsString();
-
-        Job job = MAPPER.readValue(response, Job.class);
-        assertEquals(ExecutionPhase.EXECUTING, job.getPhase());
+                .andExpect(jsonPath("$[0].phase", is("EXECUTING")));
     }
 
     @Test
@@ -220,7 +220,7 @@ public class NodesControllerTest {
 
         verify(client, times(1)).createNode(any());
     }
-    
+
     private ResultActions testMoveNode() throws Exception {
 
         JobSummary job = new JobSummary();
@@ -228,10 +228,12 @@ public class NodesControllerTest {
 
         when(client.startTransferJob(any())).thenReturn(job);
 
-        Map<String, String> params = Map.of("target", "/path/to/target", "direction", "/path/to/direction");
+        MoveOrCopyRequest request = new MoveOrCopyRequest();
+        request.setTargets(List.of("/path/to/target"));
+        request.setDirection("/path/to/direction");
 
-        return mockMvc.perform(post("/move")
+        return mockMvc.perform(post("/moveOrCopy")
                 .contentType(MediaType.APPLICATION_JSON)
-                .content(MAPPER.writeValueAsString(params)));
+                .content(MAPPER.writeValueAsString(request)));
     }
 }
-- 
GitLab