1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
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";
18 export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
19 type CollectionPartialUpdateOrCreate = Partial<CollectionResource> & Pick<CollectionResource, "uuid"> |
20 Partial<CollectionResource> & Pick<CollectionResource, "ownerUuid">;
22 export const emptyCollectionPdh = 'd41d8cd98f00b204e9800998ecf8427e+0';
23 export const SOURCE_DESTINATION_EQUAL_ERROR_MESSAGE = 'Source and destination cannot be the same';
25 export class CollectionService extends TrashableResourceService<CollectionResource> {
26 constructor(serverApi: AxiosInstance, private keepWebdavClient: WebDAV, private authService: AuthService, actions: ApiActions) {
27 super(serverApi, "collections", actions, [
30 'replicationConfirmed',
31 'replicationConfirmedAt',
32 'storageClassesConfirmed',
33 'storageClassesConfirmedAt',
34 'unsignedManifestText',
39 async get(uuid: string, showErrors?: boolean, select?: string[], session?: Session) {
40 super.validateUuid(uuid);
41 const selectParam = select || defaultCollectionSelectedFields;
42 return super.get(uuid, showErrors, selectParam, session);
45 create(data?: Partial<CollectionResource>, showErrors?: boolean) {
46 return super.create({ ...data, preserveVersion: true }, showErrors);
49 update(uuid: string, data: Partial<CollectionResource>, showErrors?: boolean) {
50 const select = [...Object.keys(data), 'version', 'modifiedAt'];
51 return super.update(uuid, { ...data, preserveVersion: true }, showErrors, select);
54 async files(uuid: string) {
55 const request = await this.keepWebdavClient.propfind(`c=${uuid}`);
56 if (request.responseXML != null) {
57 return extractFilesData(request.responseXML);
59 return Promise.reject();
62 private combineFilePath(parts: string[]) {
63 return parts.reduce((path, part) => {
64 // Trim leading and trailing slashes
65 const trimmedPart = part.split('/').filter(Boolean).join('/');
66 if (trimmedPart.length) {
67 const separator = path.endsWith('/') ? '' : '/';
68 return `${path}${separator}${trimmedPart}`;
75 private replaceFiles(data: CollectionPartialUpdateOrCreate, fileMap: {}, showErrors?: boolean) {
78 preserve_version: true,
79 ...CommonService.mapKeys(snakeCase)(data),
80 // Don't send uuid in payload when creating
83 replace_files: fileMap
86 return CommonService.defaultResponse(
88 .put<CollectionResource>(`/${this.resourceType}/${data.uuid}`, payload),
94 return CommonService.defaultResponse(
96 .post<CollectionResource>(`/${this.resourceType}`, payload),
104 async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress, targetLocation: string = '') {
105 if (collectionUuid === "" || files.length === 0) { return; }
106 // files have to be uploaded sequentially
107 for (let idx = 0; idx < files.length; idx++) {
108 await this.uploadFile(collectionUuid, files[idx], idx, onProgress, targetLocation);
110 await this.update(collectionUuid, { preserveVersion: true });
113 async renameFile(collectionUuid: string, collectionPdh: string, oldPath: string, newPath: string) {
114 return this.replaceFiles({uuid: collectionUuid}, {
115 [this.combineFilePath([newPath])]: `${collectionPdh}${this.combineFilePath([oldPath])}`,
116 [this.combineFilePath([oldPath])]: '',
120 extendFileURL = (file: CollectionDirectory | CollectionFile) => {
121 const baseUrl = this.keepWebdavClient.getBaseUrl().endsWith('/')
122 ? this.keepWebdavClient.getBaseUrl().slice(0, -1)
123 : this.keepWebdavClient.getBaseUrl();
124 const apiToken = this.authService.getApiToken();
125 const encodedApiToken = apiToken ? encodeURI(apiToken) : '';
126 const userApiToken = `/t=${encodedApiToken}/`;
127 const splittedPrevFileUrl = file.url.split('/');
128 const url = `${baseUrl}/${splittedPrevFileUrl[1]}${userApiToken}${splittedPrevFileUrl.slice(2).join('/')}`;
135 async getFileContents(file: CollectionFile) {
136 return (await this.keepWebdavClient.get(`c=${file.id}`)).response;
139 private async uploadFile(collectionUuid: string, file: File, fileId: number, onProgress: UploadProgress = () => { return; }, targetLocation: string = '') {
140 const fileURL = `c=${targetLocation !== '' ? targetLocation : collectionUuid}/${file.name}`.replace('//', '/');
141 const requestConfig = {
143 'Content-Type': 'text/octet-stream'
145 onUploadProgress: (e: ProgressEvent) => {
146 onProgress(fileId, e.loaded, e.total, Date.now());
149 return this.keepWebdavClient.upload(fileURL, [file], requestConfig);
152 deleteFiles(collectionUuid: string, files: string[], showErrors?: boolean) {
153 const optimizedFiles = files
154 .sort((a, b) => a.length - b.length)
155 .reduce((acc, currentPath) => {
156 const parentPathFound = acc.find((parentPath) => currentPath.indexOf(`${parentPath}/`) > -1);
158 if (!parentPathFound) {
159 return [...acc, currentPath];
165 const fileMap = optimizedFiles.reduce((obj, filePath) => {
168 [this.combineFilePath([filePath])]: ''
172 return this.replaceFiles({uuid: collectionUuid}, fileMap, showErrors);
175 copyFiles(sourcePdh: string, files: string[], destinationCollection: CollectionPartialUpdateOrCreate, destinationPath: string, showErrors?: boolean) {
176 const fileMap = files.reduce((obj, sourceFile) => {
177 const fileBasename = sourceFile.split('/').filter(Boolean).slice(-1).join("");
180 [this.combineFilePath([destinationPath, fileBasename])]: `${sourcePdh}${this.combineFilePath([sourceFile])}`
184 return this.replaceFiles(destinationCollection, fileMap, showErrors);
187 moveFiles(sourceUuid: string, sourcePdh: string, files: string[], destinationCollection: CollectionPartialUpdateOrCreate, destinationPath: string, showErrors?: boolean) {
188 if (sourceUuid === destinationCollection.uuid) {
189 let errors: CommonResourceServiceError[] = [];
190 const fileMap = files.reduce((obj, sourceFile) => {
191 const fileBasename = sourceFile.split('/').filter(Boolean).slice(-1).join("");
192 const fileDestinationPath = this.combineFilePath([destinationPath, fileBasename]);
193 const fileSourcePath = this.combineFilePath([sourceFile]);
194 const fileSourceUri = `${sourcePdh}${fileSourcePath}`;
197 if (fileDestinationPath !== fileSourcePath) {
200 [fileDestinationPath]: fileSourceUri,
201 [fileSourcePath]: '',
204 errors.push(CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME);
209 if (errors.length === 0) {
210 return this.replaceFiles({uuid: sourceUuid}, fileMap, showErrors)
212 return Promise.reject({errors});
215 return this.copyFiles(sourcePdh, files, destinationCollection, destinationPath, showErrors)
217 return this.deleteFiles(sourceUuid, files, showErrors);
222 createDirectory(collectionUuid: string, path: string, showErrors?: boolean) {
223 const fileMap = {[this.combineFilePath([path])]: emptyCollectionPdh};
225 return this.replaceFiles({uuid: collectionUuid}, fileMap, showErrors);