Merge branch '15768-multi-select-operations'
[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         const request = await this.keepWebdavClient.propfind(`c=${uuid}`);
57         if (request.responseXML != null) {
58             return extractFilesData(request.responseXML);
59         }
60
61         return Promise.reject();
62     }
63
64     private combineFilePath(parts: string[]) {
65         return parts.reduce((path, part) => {
66             // Trim leading and trailing slashes
67             const trimmedPart = part.split("/").filter(Boolean).join("/");
68             if (trimmedPart.length) {
69                 const separator = path.endsWith("/") ? "" : "/";
70                 return `${path}${separator}${trimmedPart}`;
71             } else {
72                 return path;
73             }
74         }, "/");
75     }
76
77     private replaceFiles(data: CollectionPartialUpdateOrCreate, fileMap: {}, showErrors?: boolean) {
78         const payload = {
79             collection: {
80                 preserve_version: true,
81                 ...CommonService.mapKeys(snakeCase)(data),
82                 // Don't send uuid in payload when creating
83                 uuid: undefined,
84             },
85             replace_files: fileMap,
86         };
87         if (data.uuid) {
88             return CommonService.defaultResponse(
89                 this.serverApi.put<CollectionResource>(`/${this.resourceType}/${data.uuid}`, payload),
90                 this.actions,
91                 true, // mapKeys
92                 showErrors
93             );
94         } else {
95             return CommonService.defaultResponse(
96                 this.serverApi.post<CollectionResource>(`/${this.resourceType}`, payload),
97                 this.actions,
98                 true, // mapKeys
99                 showErrors
100             );
101         }
102     }
103
104     async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress, targetLocation: string = "") {
105         if (collectionUuid === "" || files.length === 0) {
106             return;
107         }
108         // files have to be uploaded sequentially
109         for (let idx = 0; idx < files.length; idx++) {
110             await this.uploadFile(collectionUuid, files[idx], idx, onProgress, targetLocation);
111         }
112         await this.update(collectionUuid, { preserveVersion: true });
113     }
114
115     async renameFile(collectionUuid: string, collectionPdh: string, oldPath: string, newPath: string) {
116         return this.replaceFiles(
117             { uuid: collectionUuid },
118             {
119                 [this.combineFilePath([newPath])]: `${collectionPdh}${this.combineFilePath([oldPath])}`,
120                 [this.combineFilePath([oldPath])]: "",
121             }
122         );
123     }
124
125     extendFileURL = (file: CollectionDirectory | CollectionFile) => {
126         const baseUrl = this.keepWebdavClient.getBaseUrl().endsWith("/")
127             ? this.keepWebdavClient.getBaseUrl().slice(0, -1)
128             : this.keepWebdavClient.getBaseUrl();
129         const apiToken = this.authService.getApiToken();
130         const encodedApiToken = apiToken ? encodeURI(apiToken) : "";
131         const userApiToken = `/t=${encodedApiToken}/`;
132         const splittedPrevFileUrl = file.url.split("/");
133         const url = `${baseUrl}/${splittedPrevFileUrl[1]}${userApiToken}${splittedPrevFileUrl.slice(2).join("/")}`;
134         return {
135             ...file,
136             url,
137         };
138     };
139
140     async getFileContents(file: CollectionFile) {
141         return (await this.keepWebdavClient.get(`c=${file.id}`)).response;
142     }
143
144     private async uploadFile(
145         collectionUuid: string,
146         file: File,
147         fileId: number,
148         onProgress: UploadProgress = () => {
149             return;
150         },
151         targetLocation: string = ""
152     ) {
153         const fileURL = `c=${targetLocation !== "" ? targetLocation : collectionUuid}/${file.name}`.replace("//", "/");
154         const requestConfig = {
155             headers: {
156                 "Content-Type": "text/octet-stream",
157             },
158             onUploadProgress: (e: ProgressEvent) => {
159                 onProgress(fileId, e.loaded, e.total, Date.now());
160             },
161         };
162         return this.keepWebdavClient.upload(fileURL, [file], requestConfig);
163     }
164
165     deleteFiles(collectionUuid: string, files: string[], showErrors?: boolean) {
166         const optimizedFiles = files
167             .sort((a, b) => a.length - b.length)
168             .reduce((acc, currentPath) => {
169                 const parentPathFound = acc.find(parentPath => currentPath.indexOf(`${parentPath}/`) > -1);
170
171                 if (!parentPathFound) {
172                     return [...acc, currentPath];
173                 }
174
175                 return acc;
176             }, []);
177
178         const fileMap = optimizedFiles.reduce((obj, filePath) => {
179             return {
180                 ...obj,
181                 [this.combineFilePath([filePath])]: "",
182             };
183         }, {});
184
185         return this.replaceFiles({ uuid: collectionUuid }, fileMap, showErrors);
186     }
187
188     copyFiles(
189         sourcePdh: string,
190         files: string[],
191         destinationCollection: CollectionPartialUpdateOrCreate,
192         destinationPath: string,
193         showErrors?: boolean
194     ) {
195         const fileMap = files.reduce((obj, sourceFile) => {
196             const fileBasename = sourceFile.split("/").filter(Boolean).slice(-1).join("");
197             return {
198                 ...obj,
199                 [this.combineFilePath([destinationPath, fileBasename])]: `${sourcePdh}${this.combineFilePath([sourceFile])}`,
200             };
201         }, {});
202
203         return this.replaceFiles(destinationCollection, fileMap, showErrors);
204     }
205
206     moveFiles(
207         sourceUuid: string,
208         sourcePdh: string,
209         files: string[],
210         destinationCollection: CollectionPartialUpdateOrCreate,
211         destinationPath: string,
212         showErrors?: boolean
213     ) {
214         if (sourceUuid === destinationCollection.uuid) {
215             let errors: CommonResourceServiceError[] = [];
216             const fileMap = files.reduce((obj, sourceFile) => {
217                 const fileBasename = sourceFile.split("/").filter(Boolean).slice(-1).join("");
218                 const fileDestinationPath = this.combineFilePath([destinationPath, fileBasename]);
219                 const fileSourcePath = this.combineFilePath([sourceFile]);
220                 const fileSourceUri = `${sourcePdh}${fileSourcePath}`;
221
222                 if (fileDestinationPath !== fileSourcePath) {
223                     return {
224                         ...obj,
225                         [fileDestinationPath]: fileSourceUri,
226                         [fileSourcePath]: "",
227                     };
228                 } else {
229                     errors.push(CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME);
230                     return obj;
231                 }
232             }, {});
233
234             if (errors.length === 0) {
235                 return this.replaceFiles({ uuid: sourceUuid }, fileMap, showErrors);
236             } else {
237                 return Promise.reject({ errors });
238             }
239         } else {
240             return this.copyFiles(sourcePdh, files, destinationCollection, destinationPath, showErrors).then(() => {
241                 return this.deleteFiles(sourceUuid, files, showErrors);
242             });
243         }
244     }
245
246     createDirectory(collectionUuid: string, path: string, showErrors?: boolean) {
247         const fileMap = { [this.combineFilePath([path])]: emptyCollectionPdh };
248
249         return this.replaceFiles({ uuid: collectionUuid }, fileMap, showErrors);
250     }
251 }