From d93836d236416fa71ae619633e8a3e93a738178b Mon Sep 17 00:00:00 2001
From: Sonia Zorba <sonia.zorba@inaf.it>
Date: Mon, 6 Dec 2021 13:07:44 +0100
Subject: [PATCH] Implemented creation of links (single and by list of links
 upload)

---
 .../ui/controller/CreateLinksController.java  | 102 ++++++++++++++
 .../vospace/ui/data/CreateLinkRequest.java    |  37 +++++
 .../controller/CreateLinksControllerTest.java | 115 ++++++++++++++++
 .../src/test/resources/list-of-links.txt      |  75 +++++++++++
 vospace-ui-frontend/src/api/server/index.js   |  32 +++++
 vospace-ui-frontend/src/components/Main.vue   |   6 +-
 .../src/components/modal/CreateLinksModal.vue | 126 ++++++++++++++++++
 7 files changed, 492 insertions(+), 1 deletion(-)
 create mode 100644 vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/CreateLinksController.java
 create mode 100644 vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/CreateLinkRequest.java
 create mode 100644 vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/CreateLinksControllerTest.java
 create mode 100644 vospace-ui-backend/src/test/resources/list-of-links.txt
 create mode 100644 vospace-ui-frontend/src/components/modal/CreateLinksModal.vue

diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/CreateLinksController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/CreateLinksController.java
new file mode 100644
index 0000000..8d0ea52
--- /dev/null
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/CreateLinksController.java
@@ -0,0 +1,102 @@
+/*
+ * 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.controller;
+
+import it.inaf.ia2.vospace.ui.client.VOSpaceClient;
+import it.inaf.ia2.vospace.ui.data.CreateLinkRequest;
+import it.inaf.ia2.vospace.ui.exception.BadRequestException;
+import it.inaf.ia2.vospace.ui.exception.VOSpaceStatusException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import net.ivoa.xml.vospace.v2.ContainerNode;
+import net.ivoa.xml.vospace.v2.LinkNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+@RestController
+public class CreateLinksController extends BaseController {
+
+    private static final Logger LOG = LoggerFactory.getLogger(CreateLinksController.class);
+
+    @Autowired
+    private VOSpaceClient client;
+
+    @PostMapping(value = "/createLink", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
+    public ResponseEntity<?> createLink(@RequestBody CreateLinkRequest request) {
+
+        ContainerNode parent = getFolder(request.getFolder());
+
+        String uri = parent.getUri() + "/" + request.getNodeName();
+
+        LinkNode link = new LinkNode();
+        link.setUri(uri);
+        link.setTarget(request.getUrl());
+
+        client.createNode(link);
+
+        return ResponseEntity.noContent().build();
+    }
+
+    @PostMapping(value = "/uploadLinks", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    public ResponseEntity<?> uploadLinks(@RequestParam(value = "file", required = true) MultipartFile file,
+            @RequestParam("folder") String folder) throws IOException {
+
+        ContainerNode parent = getFolder(folder);
+
+        String fileContent = new String(file.getBytes());
+
+        // Execute HTTP calls for links creation in bunches of 20 calls performed in parallel
+        List<List<CompletableFuture<?>>> httpCallsGroups = new ArrayList<>();
+        List<CompletableFuture<?>> currentHttpCallsGroup = new ArrayList<>();
+        httpCallsGroups.add(currentHttpCallsGroup);
+
+        for (String url : fileContent.replaceAll("\\r\\n?", "\n").split("\n")) { // normalize newlines and split on them
+            if (!url.isBlank()) {
+
+                String fileName = url.substring(url.lastIndexOf("/") + 1);
+                String uri = parent.getUri() + "/" + fileName;
+
+                LinkNode link = new LinkNode();
+                link.setUri(uri);
+                link.setTarget(url);
+
+                if (currentHttpCallsGroup.size() > 20) {
+                    currentHttpCallsGroup = new ArrayList<>();
+                    httpCallsGroups.add(currentHttpCallsGroup);
+                }
+
+                currentHttpCallsGroup.add(CompletableFuture.supplyAsync(() -> client.createNode(link), Runnable::run));
+            }
+        }
+
+        for (List<CompletableFuture<?>> httpCallsGroup : httpCallsGroups) {
+            CompletableFuture.allOf(httpCallsGroup.toArray(CompletableFuture[]::new)).join();
+        }
+
+        return ResponseEntity.noContent().build();
+    }
+
+    private ContainerNode getFolder(String folderPath) {
+        try {
+            return (ContainerNode) client.getNode("/" + folderPath);
+        } catch (VOSpaceStatusException ex) {
+            if (ex.getHttpStatus() == 404) {
+                throw new BadRequestException("Folder parameter specified a non-existent folder: /" + folderPath);
+            }
+            throw ex;
+        }
+    }
+}
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/CreateLinkRequest.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/CreateLinkRequest.java
new file mode 100644
index 0000000..74d9e72
--- /dev/null
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/CreateLinkRequest.java
@@ -0,0 +1,37 @@
+/*
+ * 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;
+
+public class CreateLinkRequest {
+
+    private String url;
+    private String folder;
+    private String nodeName;
+
+    public String getUrl() {
+        return url;
+    }
+
+    public void setUrl(String url) {
+        this.url = url;
+    }
+
+    public String getFolder() {
+        return folder;
+    }
+
+    public void setFolder(String folder) {
+        this.folder = folder;
+    }
+
+    public String getNodeName() {
+        return nodeName;
+    }
+
+    public void setNodeName(String nodeName) {
+        this.nodeName = nodeName;
+    }
+}
diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/CreateLinksControllerTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/CreateLinksControllerTest.java
new file mode 100644
index 0000000..f1eb4d7
--- /dev/null
+++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/CreateLinksControllerTest.java
@@ -0,0 +1,115 @@
+/*
+ * 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.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.CreateLinkRequest;
+import it.inaf.ia2.vospace.ui.exception.VOSpaceStatusException;
+import net.ivoa.xml.vospace.v2.ContainerNode;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import org.mockito.Mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@TestPropertySource(properties = {"vospace-authority=example.com!vospace"})
+public class CreateLinksControllerTest {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    @MockBean
+    private VOSpaceClient client;
+
+    @Autowired
+    private MockMvc mockMvc;
+
+    @Mock
+    private User user;
+
+    @BeforeEach
+    public void setUp() {
+        when(user.getName()).thenReturn("user_id");
+    }
+
+    @Test
+    public void testCreateSingleLink() throws Exception {
+
+        ContainerNode myFolder = new ContainerNode();
+        myFolder.setUri("vos://ia2.inaf.it!vospace/path/to/myfolder");
+        when(client.getNode("/path/to/myfolder")).thenReturn(myFolder);
+
+        CreateLinkRequest request = new CreateLinkRequest();
+
+        request.setFolder("path/to/myfolder");
+        request.setUrl("http://archives.ia2.inaf.it/files/aao/SC182172.fits.gz");
+        request.setNodeName("myLink");
+
+        String requestPayload = MAPPER.writeValueAsString(request);
+
+        mockMvc.perform(post("/createLink")
+                .contentType(MediaType.APPLICATION_JSON)
+                .content(requestPayload)
+                .sessionAttr("user_data", user))
+                .andDo(print())
+                .andExpect(status().isNoContent());
+
+        verify(client, times(1)).createNode(argThat(node -> {
+            return node.getUri().equals("vos://ia2.inaf.it!vospace/path/to/myfolder/myLink");
+        }));
+    }
+
+    @Test
+    public void testUploadLinksNonExistentFolder() throws Exception {
+
+        when(client.getNode("/path/to/non-existent")).thenThrow(new VOSpaceStatusException("Not found", 404));
+
+        mockMvc.perform(multipart("/uploadLinks")
+                .file(getListOfLinksMockMultipartFile())
+                .param("folder", "path/to/non-existent")
+                .sessionAttr("user_data", user))
+                .andDo(print())
+                .andExpect(status().isBadRequest());
+    }
+
+    @Test
+    public void testUploadLinks() throws Exception {
+
+        ContainerNode myFolder = new ContainerNode();
+        when(client.getNode("/path/to/myfolder")).thenReturn(myFolder);
+
+        mockMvc.perform(multipart("/uploadLinks")
+                .file(getListOfLinksMockMultipartFile())
+                .param("folder", "path/to/myfolder")
+                .sessionAttr("user_data", user))
+                .andDo(print())
+                .andExpect(status().isNoContent());
+
+        verify(client, times(75)).createNode(any());
+    }
+
+    private MockMultipartFile getListOfLinksMockMultipartFile() throws Exception {
+        return new MockMultipartFile("file", UploadControllerTest.class.getClassLoader().getResourceAsStream("list-of-links.txt"));
+    }
+}
diff --git a/vospace-ui-backend/src/test/resources/list-of-links.txt b/vospace-ui-backend/src/test/resources/list-of-links.txt
new file mode 100644
index 0000000..0474fce
--- /dev/null
+++ b/vospace-ui-backend/src/test/resources/list-of-links.txt
@@ -0,0 +1,75 @@
+http://archives.ia2.inaf.it/files/aao/SC182159.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182160.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182161.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182169.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182170.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182171.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182172.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182173.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182174.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182175.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182176.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182177.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182178.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182338.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182339.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182340.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182341.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182342.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182343.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182344.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182345.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182346.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182347.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182348.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182349.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182350.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182351.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182352.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182353.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182354.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182355.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182356.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182162.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182163.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182164.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182165.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182166.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182167.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182168.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182357.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182358.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182359.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182360.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182361.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182362.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182363.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182364.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182365.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182366.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182367.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182368.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182369.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182370.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182371.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182372.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182373.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182374.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182375.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182376.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182377.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182378.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182379.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182380.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182381.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182382.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182386.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182383.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182384.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182385.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182387.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182388.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182389.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182390.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182391.fits.gz
+http://archives.ia2.inaf.it/files/aao/SC182392.fits.gz
diff --git a/vospace-ui-frontend/src/api/server/index.js b/vospace-ui-frontend/src/api/server/index.js
index fb22961..1d63162 100644
--- a/vospace-ui-frontend/src/api/server/index.js
+++ b/vospace-ui-frontend/src/api/server/index.js
@@ -151,6 +151,38 @@ export default {
       source
     };
   },
+  createLink(nodeUrl, folder, nodeName) {
+    let url = BASE_API_URL + 'createLink';
+    return apiRequest({
+      method: 'POST',
+      url: url,
+      withCredentials: true,
+      headers: {
+        'Cache-Control': 'no-cache'
+      },
+      data: {
+        url: nodeUrl,
+        folder,
+        nodeName
+      }
+    });
+  },
+  uploadLinks(file, path) {
+    let formData = new FormData();
+    formData.append('file', file);
+
+    let url = BASE_API_URL + 'uploadLinks?folder=' + escapePath(path);
+    return apiRequest({
+      method: 'POST',
+      url: url,
+      withCredentials: true,
+      headers: {
+        'Cache-Control': 'no-cache',
+        'Content-Type': 'multipart/form-data'
+      },
+      data: formData
+    });
+  },
   deleteNodes(paths, calledFromUploadModal = false) {
     let url = BASE_API_URL + 'delete';
     return apiRequest({
diff --git a/vospace-ui-frontend/src/components/Main.vue b/vospace-ui-frontend/src/components/Main.vue
index 9649232..163639e 100644
--- a/vospace-ui-frontend/src/components/Main.vue
+++ b/vospace-ui-frontend/src/components/Main.vue
@@ -9,6 +9,7 @@
   <div class="mb-3">
     <b-button variant="success" class="mr-2" :disabled="!writable" v-b-modal.create-folder-modal>New folder</b-button>
     <b-button variant="success" class="mr-2" :disabled="!writable" v-b-modal.upload-files-modal>Upload files</b-button>
+    <b-button variant="success" class="mr-2" :disabled="!writable" v-b-modal.create-links-modal>Create links</b-button>
     <b-dropdown variant="primary" text="Actions" v-if="actionsEnabled">
       <b-dropdown-item :disabled="!asyncButtonEnabled" @click="startAsyncRecallJob">Async recall</b-dropdown-item>
       <b-dropdown-item :disabled="!deleteButtonEnabled" @click="deleteNodes">Delete</b-dropdown-item>
@@ -48,6 +49,7 @@
   <RenameModal />
   <MoveOrCopyModal />
   <ConfirmArchiveModal />
+  <CreateLinksModal />
 </div>
 </template>
 
@@ -60,6 +62,7 @@ import ShareModal from './modal/ShareModal.vue'
 import RenameModal from './modal/RenameModal.vue'
 import MoveOrCopyModal from './modal/MoveOrCopyModal.vue'
 import ConfirmArchiveModal from './modal/ConfirmArchiveModal.vue'
+import CreateLinksModal from './modal/CreateLinksModal.vue'
 
 export default {
   components: {
@@ -71,7 +74,8 @@ export default {
     ShareModal,
     RenameModal,
     MoveOrCopyModal,
-    ConfirmArchiveModal
+    ConfirmArchiveModal,
+    CreateLinksModal
   },
   computed: {
     breadcrumbs() {
diff --git a/vospace-ui-frontend/src/components/modal/CreateLinksModal.vue b/vospace-ui-frontend/src/components/modal/CreateLinksModal.vue
new file mode 100644
index 0000000..ec1f540
--- /dev/null
+++ b/vospace-ui-frontend/src/components/modal/CreateLinksModal.vue
@@ -0,0 +1,126 @@
+<template>
+<b-modal id="create-links-modal" title="Create links" okTitle="Create" @show="reset" @ok.prevent="createLinks">
+  <div class="row">
+    <div class="col-2">
+      <label for="creationMode">Mode:</label>
+    </div>
+    <div class="col-10">
+      <b-form-group v-slot="{ creationMode }" id="creationMode">
+        <b-form-radio-group id="creation-mode-group" v-model="mode" :aria-describedby="creationMode" name="creation-mode">
+          <b-form-radio value="single">single</b-form-radio>
+          <b-form-radio value="multiple">multiple (upload list)</b-form-radio>
+        </b-form-radio-group>
+      </b-form-group>
+    </div>
+  </div>
+  <b-form inline v-if="mode === 'single'">
+    <label class="w-25" for="url-input">URL</label>
+    <b-form-input v-model.trim="url" id="url-input" class="w-75" aria-describedby="url-input-feedback" :state="urlState" v-on:input="resetErrors">
+    </b-form-input>
+    <b-form-invalid-feedback id="url-input-feedback" class="text-right">{{urlError}}</b-form-invalid-feedback>
+    <label class="w-25 mt-2" for="node-name-input">Node name</label>
+    <b-form-input v-model.trim="nodeName" id="node-name-input" class="w-75 mt-2" aria-describedby="node-name-input-feedback" :state="nodeNameState" v-on:input="resetErrors">
+    </b-form-input>
+    <b-form-invalid-feedback id="node-name-input-feedback" class="text-right">{{nodeNameError}}</b-form-invalid-feedback>
+  </b-form>
+  <b-form inline v-if="mode === 'multiple'">
+    <p>Upload list of links (separated by newlines)</p>
+    <b-form-file class="text-left" v-model="file" :multiple="false" placeholder="Choose your file or drop it here..." drop-placeholder="Drop file here..." :state="fileState"></b-form-file>
+    <b-form-invalid-feedback id="file-input-feedback" class="text-right">{{fileError}}</b-form-invalid-feedback>
+  </b-form>
+</b-modal>
+</template>
+
+<script>
+import client from 'api-client'
+
+export default {
+  data() {
+    return {
+      mode: 'single',
+      url: null,
+      nodeName: null,
+      urlError: null,
+      nodeNameError: null,
+      file: null,
+      fileError: null
+    };
+  },
+  computed: {
+    urlState() {
+      if (this.urlError) {
+        return false;
+      }
+      return null;
+    },
+    nodeNameState() {
+      if (this.nodeNameError) {
+        return false;
+      }
+      return null;
+    },
+    fileState() {
+      if (this.fileError) {
+        return false;
+      }
+      return null;
+    }
+  },
+  methods: {
+    reset() {
+      this.mode = 'single';
+      this.url = null;
+      this.nodeName = null;
+      this.file = null;
+      this.resetErrors();
+    },
+    resetErrors() {
+      this.urlError = null;
+      this.nodeNameError = null;
+      this.fileError = null;
+    },
+    createLinks() {
+      let path = this.$store.state.path;
+
+      if (this.mode === 'single') {
+        if (!this.url) {
+          this.urlError = "URL is required";
+          return;
+        }
+        try {
+          new URL(this.url);
+        } catch (_) {
+          this.urlError = "Invalid URL";
+          return;
+        }
+
+        if (!this.nodeName) {
+          this.nodeNameError = "Node name is required";
+          return;
+        } else if (/[<>?":\\/`|'*]/.test(this.nodeName)) {
+          this.nodeNameError = "Node name contains an illegal character. Following characters are not allowed: < > ? \" : \\ / | ' * `";
+          return;
+        }
+
+        client.createLink(this.url, path, this.nodeName).then(() => {
+          this.$bvModal.hide('create-links-modal');
+          // Reload current node
+          this.$store.dispatch('setPath', path);
+        });
+
+      } else {
+        if (!this.file) {
+          this.fileError = "File is required";
+          return;
+        }
+
+        client.uploadLinks(this.file, path).then(() => {
+          this.$bvModal.hide('create-links-modal');
+          // Reload current node
+          this.$store.dispatch('setPath', path);
+        });
+      }
+    }
+  }
+}
+</script>
-- 
GitLab