Merge branch '21128-toolbar-context-menu'
[arvados-workbench2.git] / src / services / collection-service / collection-service.ts
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import { CollectionResource, defaultCollectionSelectedFields } from "models/collection";
6 import { AxiosInstance } from "axios";
7 import { CollectionFile, CollectionDirectory } from "models/collection-file";
8 import { WebDAV } from "common/webdav";
9 import { AuthService } from "../auth-service/auth-service";
10 import { extractFilesData } from "./collection-service-files-response";
11 import { TrashableResourceService } from "services/common-service/trashable-resource-service";
12 import { ApiActions } from "services/api/api-actions";
13 import { Session } from "models/session";
14 import { CommonService } from "services/common-service/common-service";
15 import { snakeCase } from "lodash";
16 import { CommonResourceServiceError } from "services/common-service/common-resource-service";
17
18 export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
19 type CollectionPartialUpdateOrCreate =
20     | (Partial<CollectionResource> & Pick<CollectionResource, "uuid">)
21     | (Partial<CollectionResource> & Pick<CollectionResource, "ownerUuid">);
22
23 export const emptyCollectionPdh = "d41d8cd98f00b204e9800998ecf8427e+0";
24 export const SOURCE_DESTINATION_EQUAL_ERROR_MESSAGE = "Source and destination cannot be the same";
25
26 export class CollectionService extends TrashableResourceService<CollectionResource> {
27     constructor(serverApi: AxiosInstance, private keepWebdavClient: WebDAV, private authService: AuthService, actions: ApiActions) {
28         super(serverApi, "collections", actions, [
29             "fileCount",
30             "fileSizeTotal",
31             "replicationConfirmed",
32             "replicationConfirmedAt",
33             "storageClassesConfirmed",
34             "storageClassesConfirmedAt",
35             "unsignedManifestText",
36             "version",
37         ]);
38     }
39
40     async get(uuid: string, showErrors?: boolean, select?: string[], session?: Session) {
41         super.validateUuid(uuid);
42         const selectParam = select || defaultCollectionSelectedFields;
43         return super.get(uuid, showErrors, selectParam, session);
44     }
45
46     create(data?: Partial<CollectionResource>, showErrors?: boolean) {
47         return super.create({ ...data, preserveVersion: true }, showErrors);
48     }
49
50     update(uuid: string, data: Partial<CollectionResource>, showErrors?: boolean) {
51         const select = [...Object.keys(data), "version", "modifiedAt"];
52         return super.update(uuid, { ...data, preserveVersion: true }, showErrors, select);
53     }
54
55     async files(uuid: string) {
56         try {
57             const request = await this.keepWebdavClient.propfind(`c=${uuid}`);
58             if (request.responseXML != null) {
59                 return extractFilesData(request.responseXML);
60             }
61         } catch (e) {
62             return Promise.reject(e);
63         }
64         return Promise.reject();
65     }
66
67     private combineFilePath(parts: string[]) {
68         return parts.reduce((path, part) => {
69             // Trim leading and trailing slashes
70             const trimmedPart = part.split("/").filter(Boolean).join("/");
71             if (trimmedPart.length) {
72                 const separator = path.endsWith("/") ? "" : "/";
73                 return `${path}${separator}${trimmedPart}`;
74             } else {
75                 return path;
76             }
77         }, "/");
78     }
79
80     private replaceFiles(data: CollectionPartialUpdateOrCreate, fileMap: {}, showErrors?: boolean) {
81         const payload = {
82             collection: {
83                 preserve_version: true,
84                 ...CommonService.mapKeys(snakeCase)(data),
85                 // Don't send uuid in payload when creating
86                 uuid: undefined,
87             },
88             replace_files: fileMap,
89         };
90         if (data.uuid) {
91             return CommonService.defaultResponse(
92                 this.serverApi.put<CollectionResource>(`/${this.resourceType}/${data.uuid}`, payload),
93                 this.actions,
94                 true, // mapKeys
95                 showErrors
96             );
97         } else {
98             return CommonService.defaultResponse(
99                 this.serverApi.post<CollectionResource>(`/${this.resourceType}`, payload),
100                 this.actions,
101                 true, // mapKeys
102                 showErrors
103             );
104         }
105     }
106
107     async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress, targetLocation: string = "") {
108         if (collectionUuid === "" || files.length === 0) {
109             return;
110         }
111         // files have to be uploaded sequentially
112         for (let idx = 0; idx < files.length; idx++) {
113             await this.uploadFile(collectionUuid, files[idx], idx, onProgress, targetLocation);
114         }
115         await this.update(collectionUuid, { preserveVersion: true });
116     }
117
118     async renameFile(collectionUuid: string, collectionPdh: string, oldPath: string, newPath: string) {
119         return this.replaceFiles(
120             { uuid: collectionUuid },
121             {
122                 [this.combineFilePath([newPath])]: `${collectionPdh}${this.combineFilePath([oldPath])}`,
123                 [this.combineFilePath([oldPath])]: "",
124             }
125         );
126     }
127
128     extendFileURL = (file: CollectionDirectory | CollectionFile) => {
129         const baseUrl = this.keepWebdavClient.getBaseUrl().endsWith("/")
130             ? this.keepWebdavClient.getBaseUrl().slice(0, -1)
131             : this.keepWebdavClient.getBaseUrl();
132         const apiToken = this.authService.getApiToken();
133         const encodedApiToken = apiToken ? encodeURI(apiToken) : "";
134         const userApiToken = `/t=${encodedApiToken}/`;
135         const splittedPrevFileUrl = file.url.split("/");
136         const url = `${baseUrl}/${splittedPrevFileUrl[1]}${userApiToken}${splittedPrevFileUrl.slice(2).join("/")}`;
137         return {
138             ...file,
139             url,
140         };
141     };
142
143     async getFileContents(file: CollectionFile) {
144         return (await this.keepWebdavClient.get(`c=${file.id}`)).response;
145     }
146
147     private async uploadFile(
148         collectionUuid: string,
149         file: File,
150         fileId: number,
151         onProgress: UploadProgress = () => {
152             return;
153         },
154         targetLocation: string = ""
155     ) {
156         const fileURL = `c=${targetLocation !== "" ? targetLocation : collectionUuid}/${file.name}`.replace("//", "/");
157         const requestConfig = {
158             headers: {
159                 "Content-Type": "text/octet-stream",
160             },
161             onUploadProgress: (e: ProgressEvent) => {
162                 onProgress(fileId, e.loaded, e.total, Date.now());
163             },
164         };
165         return this.keepWebdavClient.upload(fileURL, [file], requestConfig);
166     }
167
168     deleteFiles(collectionUuid: string, files: string[], showErrors?: boolean) {
169         const optimizedFiles = files
170             .sort((a, b) => a.length - b.length)
171             .reduce((acc, currentPath) => {
172                 const parentPathFound = acc.find(parentPath => currentPath.indexOf(`${parentPath}/`) > -1);
173
174                 if (!parentPathFound) {
175                     return [...acc, currentPath];
176                 }
177
178                 return acc;
179             }, []);
180
181         const fileMap = optimizedFiles.reduce((obj, filePath) => {
182             return {
183                 ...obj,
184                 [this.combineFilePath([filePath])]: "",
185             };
186         }, {});
187
188         return this.replaceFiles({ uuid: collectionUuid }, fileMap, showErrors);
189     }
190
191     copyFiles(
192         sourcePdh: string,
193         files: string[],
194         destinationCollection: CollectionPartialUpdateOrCreate,
195         destinationPath: string,
196         showErrors?: boolean
197     ) {
198         const fileMap = files.reduce((obj, sourceFile) => {
199             const fileBasename = sourceFile.split("/").filter(Boolean).slice(-1).join("");
200             return {
201                 ...obj,
202                 [this.combineFilePath([destinationPath, fileBasename])]: `${sourcePdh}${this.combineFilePath([sourceFile])}`,
203             };
204         }, {});
205
206         return this.replaceFiles(destinationCollection, fileMap, showErrors);
207     }
208
209     moveFiles(
210         sourceUuid: string,
211         sourcePdh: string,
212         files: string[],
213         destinationCollection: CollectionPartialUpdateOrCreate,
214         destinationPath: string,
215         showErrors?: boolean
216     ) {
217         if (sourceUuid === destinationCollection.uuid) {
218             let errors: CommonResourceServiceError[] = [];
219             const fileMap = files.reduce((obj, sourceFile) => {
220                 const fileBasename = sourceFile.split("/").filter(Boolean).slice(-1).join("");
221                 const fileDestinationPath = this.combineFilePath([destinationPath, fileBasename]);
222                 const fileSourcePath = this.combineFilePath([sourceFile]);
223                 const fileSourceUri = `${sourcePdh}${fileSourcePath}`;
224
225                 if (fileDestinationPath !== fileSourcePath) {
226                     return {
227                         ...obj,
228                         [fileDestinationPath]: fileSourceUri,
229                         [fileSourcePath]: "",
230                     };
231                 } else {
232                     errors.push(CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME);
233                     return obj;
234                 }
235             }, {});
236
237             if (errors.length === 0) {
238                 return this.replaceFiles({ uuid: sourceUuid }, fileMap, showErrors);
239             } else {
240                 return Promise.reject({ errors });
241             }
242         } else {
243             return this.copyFiles(sourcePdh, files, destinationCollection, destinationPath, showErrors).then(() => {
244                 return this.deleteFiles(sourceUuid, files, showErrors);
245             });
246         }
247     }
248
249     createDirectory(collectionUuid: string, path: string, showErrors?: boolean) {
250         const fileMap = { [this.combineFilePath([path])]: emptyCollectionPdh };
251
252         return this.replaceFiles({ uuid: collectionUuid }, fileMap, showErrors);
253     }
254 }