7d734fa67993d23cfdf8fc17ff22593dbb94a489
[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
17 export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
18 type CollectionPartialUpdateOrCreate =  Partial<CollectionResource> & Pick<CollectionResource, "uuid"> |
19                                         Partial<CollectionResource> & Pick<CollectionResource, "ownerUuid">;
20
21 export const emptyCollectionPdh = 'd41d8cd98f00b204e9800998ecf8427e+0';
22
23 export class CollectionService extends TrashableResourceService<CollectionResource> {
24     constructor(serverApi: AxiosInstance, private webdavClient: WebDAV, private authService: AuthService, actions: ApiActions) {
25         super(serverApi, "collections", actions, [
26             'fileCount',
27             'fileSizeTotal',
28             'replicationConfirmed',
29             'replicationConfirmedAt',
30             'storageClassesConfirmed',
31             'storageClassesConfirmedAt',
32             'unsignedManifestText',
33             'version',
34         ]);
35     }
36
37     async get(uuid: string, showErrors?: boolean, select?: string[], session?: Session) {
38         super.validateUuid(uuid);
39         const selectParam = select || defaultCollectionSelectedFields;
40         return super.get(uuid, showErrors, selectParam, session);
41     }
42
43     create(data?: Partial<CollectionResource>, showErrors?: boolean) {
44         return super.create({ ...data, preserveVersion: true }, showErrors);
45     }
46
47     update(uuid: string, data: Partial<CollectionResource>, showErrors?: boolean) {
48         const select = [...Object.keys(data), 'version', 'modifiedAt'];
49         return super.update(uuid, { ...data, preserveVersion: true }, showErrors, select);
50     }
51
52     async files(uuid: string) {
53         const request = await this.webdavClient.propfind(`c=${uuid}`);
54         if (request.responseXML != null) {
55             return extractFilesData(request.responseXML);
56         }
57         return Promise.reject();
58     }
59
60     private combineFilePath(parts: string[]) {
61         return parts.reduce((path, part) => {
62             // Trim leading and trailing slashes
63             const trimmedPart = part.split('/').filter(Boolean).join('/');
64             if (trimmedPart.length) {
65                 const separator = path.endsWith('/') ? '' : '/';
66                 return `${path}${separator}${trimmedPart}`;
67             } else {
68                 return path;
69             }
70         }, "/");
71     }
72
73     private replaceFiles(data: CollectionPartialUpdateOrCreate, fileMap: {}, showErrors?: boolean) {
74         const payload = {
75             collection: {
76                 preserve_version: true,
77                 ...CommonService.mapKeys(snakeCase)(data),
78                 // Don't send uuid in payload when creating
79                 uuid: undefined,
80             },
81             replace_files: fileMap
82         };
83         if (data.uuid) {
84             return CommonService.defaultResponse(
85                 this.serverApi
86                     .put<CollectionResource>(`/${this.resourceType}/${data.uuid}`, payload),
87                 this.actions,
88                 true, // mapKeys
89                 showErrors
90             );
91         } else {
92             return CommonService.defaultResponse(
93                 this.serverApi
94                     .post<CollectionResource>(`/${this.resourceType}`, payload),
95                 this.actions,
96                 true, // mapKeys
97                 showErrors
98             );
99         }
100     }
101
102     async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress, targetLocation: string = '') {
103         if (collectionUuid === "" || files.length === 0) { return; }
104         // files have to be uploaded sequentially
105         for (let idx = 0; idx < files.length; idx++) {
106             await this.uploadFile(collectionUuid, files[idx], idx, onProgress, targetLocation);
107         }
108         await this.update(collectionUuid, { preserveVersion: true });
109     }
110
111     async renameFile(collectionUuid: string, collectionPdh: string, oldPath: string, newPath: string) {
112         return this.replaceFiles({uuid: collectionUuid}, {
113             [this.combineFilePath([newPath])]: `${collectionPdh}${this.combineFilePath([oldPath])}`,
114             [this.combineFilePath([oldPath])]: '',
115         });
116     }
117
118     extendFileURL = (file: CollectionDirectory | CollectionFile) => {
119         const baseUrl = this.webdavClient.getBaseUrl().endsWith('/')
120             ? this.webdavClient.getBaseUrl().slice(0, -1)
121             : this.webdavClient.getBaseUrl();
122         const apiToken = this.authService.getApiToken();
123         const encodedApiToken = apiToken ? encodeURI(apiToken) : '';
124         const userApiToken = `/t=${encodedApiToken}/`;
125         const splittedPrevFileUrl = file.url.split('/');
126         const url = `${baseUrl}/${splittedPrevFileUrl[1]}${userApiToken}${splittedPrevFileUrl.slice(2).join('/')}`;
127         return {
128             ...file,
129             url
130         };
131     }
132
133     async getFileContents(file: CollectionFile) {
134         return (await this.webdavClient.get(`c=${file.id}`)).response;
135     }
136
137     private async uploadFile(collectionUuid: string, file: File, fileId: number, onProgress: UploadProgress = () => { return; }, targetLocation: string = '') {
138         const fileURL = `c=${targetLocation !== '' ? targetLocation : collectionUuid}/${file.name}`.replace('//', '/');
139         const requestConfig = {
140             headers: {
141                 'Content-Type': 'text/octet-stream'
142             },
143             onUploadProgress: (e: ProgressEvent) => {
144                 onProgress(fileId, e.loaded, e.total, Date.now());
145             },
146         };
147         return this.webdavClient.upload(fileURL, [file], requestConfig);
148     }
149
150     deleteFiles(collectionUuid: string, files: string[], showErrors?: boolean) {
151         const optimizedFiles = files
152             .sort((a, b) => a.length - b.length)
153             .reduce((acc, currentPath) => {
154                 const parentPathFound = acc.find((parentPath) => currentPath.indexOf(`${parentPath}/`) > -1);
155
156                 if (!parentPathFound) {
157                     return [...acc, currentPath];
158                 }
159
160                 return acc;
161             }, []);
162
163         const fileMap = optimizedFiles.reduce((obj, filePath) => {
164             return {
165                 ...obj,
166                 [this.combineFilePath([filePath])]: ''
167             }
168         }, {})
169
170         return this.replaceFiles({uuid: collectionUuid}, fileMap, showErrors);
171     }
172
173     copyFiles(sourcePdh: string, files: string[], destinationCollection: CollectionPartialUpdateOrCreate, destinationPath: string, showErrors?: boolean) {
174         const fileMap = files.reduce((obj, sourceFile) => {
175             const sourceFileName = sourceFile.split('/').filter(Boolean).slice(-1).join("");
176             return {
177                 ...obj,
178                 [this.combineFilePath([destinationPath, sourceFileName])]: `${sourcePdh}${this.combineFilePath([sourceFile])}`
179             };
180         }, {});
181
182         return this.replaceFiles(destinationCollection, fileMap, showErrors);
183     }
184
185     moveFiles(sourceUuid: string, sourcePdh: string, files: string[], destinationCollection: CollectionPartialUpdateOrCreate, destinationPath: string, showErrors?: boolean) {
186         if (sourceUuid === destinationCollection.uuid) {
187             const fileMap = files.reduce((obj, sourceFile) => {
188                 const sourceFileName = sourceFile.split('/').filter(Boolean).slice(-1).join("");
189                 return {
190                     ...obj,
191                     [this.combineFilePath([destinationPath, sourceFileName])]: `${sourcePdh}${this.combineFilePath([sourceFile])}`,
192                     [this.combineFilePath([sourceFile])]: '',
193                 };
194             }, {});
195
196             return this.replaceFiles({uuid: sourceUuid}, fileMap, showErrors)
197         } else {
198             return this.copyFiles(sourcePdh, files, destinationCollection, destinationPath, showErrors)
199                 .then(() => {
200                     return this.deleteFiles(sourceUuid, files, showErrors);
201                 });
202         }
203     }
204
205     createDirectory(collectionUuid: string, path: string, showErrors?: boolean) {
206         const fileMap = {[this.combineFilePath([path])]: emptyCollectionPdh};
207
208         return this.replaceFiles({uuid: collectionUuid}, fileMap, showErrors);
209     }
210
211 }