diff --git a/package.json b/package.json index e9efa08..6e52cd3 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@aws-sdk/client-s3": "^3.511.0", "@aws-sdk/s3-request-presigner": "^3.511.0", "@jupyterlab/application": "^4.0.0", + "@jupyterlab/coreutils": "^6.2.2", "@lumino/coreutils": "2.1.2" }, "devDependencies": { diff --git a/src/icons.ts b/src/icons.ts index d11af11..eeddd9c 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -3,11 +3,11 @@ import driveSvg from '../style/driveIconFileBrowser.svg'; import newDriveSvg from '../style/newDriveIcon.svg'; export const DriveIcon = new LabIcon({ - name: '@jupyter/jupydrive-s3:drive', + name: 'jupydrive-s3:drive', svgstr: driveSvg }); export const NewDriveIcon = new LabIcon({ - name: '@jupyter/jupydrive-s3:new-drive', + name: 'jupydrive-s3:new-drive', svgstr: newDriveSvg }); diff --git a/src/s3.ts b/src/s3.ts new file mode 100644 index 0000000..1a79867 --- /dev/null +++ b/src/s3.ts @@ -0,0 +1,720 @@ +import { Contents } from '@jupyterlab/services'; +import { PathExt } from '@jupyterlab/coreutils'; +import { + CopyObjectCommand, + DeleteObjectCommand, + ListObjectsV2Command, + GetObjectCommand, + PutObjectCommand, + HeadObjectCommand, + HeadObjectCommandOutput, + S3Client +} from '@aws-sdk/client-s3'; + +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; + +export interface IRegisteredFileTypes { + [fileExtension: string]: { + fileType: string; + fileMimeTypes: string[]; + fileFormat: string; + }; +} + +interface IContentsList { + [fileName: string]: Contents.IModel; +} + +let data: Contents.IModel = { + name: '', + path: '', + last_modified: '', + created: '', + content: null, + format: null, + mimetype: '', + size: 0, + writable: true, + type: '' +}; + +/** + * Get the presigned URL for an S3 object. + * + * @param s3Client: The S3Client used to send commands. + * @param bucketName: The name of bucket. + * @param path: The path to the object. + * + * @returns: A promise which resolves with presigned URL. + */ +export const presignedS3Url = async ( + s3Client: S3Client, + bucketName: string, + path: string +): Promise<string> => { + // retrieve object from S3 bucket + const getCommand = new GetObjectCommand({ + Bucket: bucketName, + Key: path, + ResponseContentDisposition: 'attachment', + ResponseContentType: 'application/octet-stream' + }); + await s3Client.send(getCommand); + + // get pre-signed URL of S3 file + const presignedUrl = await getSignedUrl(s3Client, getCommand); + return presignedUrl; +}; + +/** + * Get list of contents of root or directory. + * + * @param s3Client: The S3Client used to send commands. + * @param bucketName: The bucket name. + * @param root: The path to the directory acting as root. + * @param registeredFileTypes: The list containing all registered file types. + * @param path: The path to the directory (optional). + * + * @returns: A promise which resolves with the contents model. + */ +export const listS3Contents = async ( + s3Client: S3Client, + bucketName: string, + root: string, + registeredFileTypes: IRegisteredFileTypes, + path?: string +): Promise<Contents.IModel> => { + const fileList: IContentsList = {}; + + // listing contents of folder + const command = new ListObjectsV2Command({ + Bucket: bucketName, + Prefix: path ? PathExt.join(root, path) : root + }); + + let isTruncated: boolean | undefined = true; + + while (isTruncated) { + const { Contents, IsTruncated, NextContinuationToken } = + await s3Client.send(command); + + if (Contents) { + Contents.forEach(c => { + // check if we are dealing with the files inside a subfolder + if ( + c.Key !== root + '/' && + c.Key !== path + '/' && + c.Key !== root + '/' + path + '/' + ) { + const fileName = c + .Key!.replace( + (root ? root + '/' : '') + (path ? path + '/' : ''), + '' + ) + .split('/')[0]; + const [fileType, fileMimeType, fileFormat] = Private.getFileType( + PathExt.extname(PathExt.basename(fileName)), + registeredFileTypes + ); + + fileList[fileName] = fileList[fileName] ?? { + name: fileName, + path: path ? PathExt.join(path, fileName) : fileName, + last_modified: c.LastModified!.toISOString(), + created: '', + content: !fileName.split('.')[1] ? [] : null, + format: fileFormat as Contents.FileFormat, + mimetype: fileMimeType, + size: c.Size!, + writable: true, + type: fileType + }; + } + }); + } + if (isTruncated) { + isTruncated = IsTruncated; + } + command.input.ContinuationToken = NextContinuationToken; + } + + data = { + name: path ? PathExt.basename(path) : bucketName, + path: path ? path + '/' : bucketName, + last_modified: '', + created: '', + content: Object.values(fileList), + format: 'json', + mimetype: '', + size: undefined, + writable: true, + type: 'directory' + }; + + return data; +}; + +/** + * Retrieve contents of a file. + * + * @param s3Client: The S3Client used to send commands. + * @param bucketName: The bucket name. + * @param root: The path to the directory acting as root. + * @param path: The path to to file. + * @param registeredFileTypes: The list containing all registered file types. + * + * @returns: A promise which resolves with the file contents model. + */ +export const getS3FileContents = async ( + s3Client: S3Client, + bucketName: string, + root: string, + path: string, + registeredFileTypes: IRegisteredFileTypes +): Promise<Contents.IModel> => { + // retrieving contents and metadata of file + const command = new GetObjectCommand({ + Bucket: bucketName, + Key: PathExt.join(root, path) + }); + + const response = await s3Client.send(command); + + if (response) { + const date: string = response.LastModified!.toISOString(); + const [fileType, fileMimeType, fileFormat] = Private.getFileType( + PathExt.extname(PathExt.basename(path)), + registeredFileTypes + ); + + let fileContents: string | Uint8Array; + + // for certain media type files, extract content as byte array and decode to base64 to view in JupyterLab + if (fileFormat === 'base64' || fileType === 'PDF') { + fileContents = await response.Body!.transformToByteArray(); + fileContents = btoa( + fileContents.reduce( + (data, byte) => data + String.fromCharCode(byte), + '' + ) + ); + } else { + fileContents = await response.Body!.transformToString(); + } + + data = { + name: PathExt.basename(path), + path: PathExt.join(root, path), + last_modified: date, + created: '', + content: fileContents, + format: fileFormat as Contents.FileFormat, + mimetype: fileMimeType, + size: response.ContentLength!, + writable: true, + type: fileType + }; + } + + return data; +}; + +/** + * Create a new file or directory or save a file. + * + * When saving a file, the options parameter is needed. + * + * @param s3Client: The S3Client used to send commands. + * @param bucketName: The bucket name. + * @param root: The path to the directory acting as root. + * @param name: The name of file or directory to be created or saved. + * @param path: The path to to file or directory. + * @param body: The new contents of the file. + * @param registeredFileTypes: The list containing all registered file types. + * @param options: The optional parameteres of saving a file or directory (optional). + * + * @returns A promise which resolves with the new file or directory contents model. + */ +export const createS3Object = async ( + s3Client: S3Client, + bucketName: string, + root: string, + name: string, + path: string, + body: string | Blob, + registeredFileTypes: IRegisteredFileTypes, + options?: Partial<Contents.IModel> +): Promise<Contents.IModel> => { + path = PathExt.join(root, path); + + const [fileType, fileMimeType, fileFormat] = Private.getFileType( + PathExt.extname(PathExt.basename(name)), + registeredFileTypes + ); + + // checking if we are creating a new file or saving an existing one (overwrriting) + if (options) { + body = Private.formatBody(options, fileFormat, fileType, fileMimeType); + } + + await s3Client.send( + new PutObjectCommand({ + Bucket: bucketName, + Key: path + (PathExt.extname(name) === '' ? '/' : ''), + Body: body, + CacheControl: options ? 'no-cache' : undefined + }) + ); + + data = { + name: name, + path: PathExt.join(path, name), + last_modified: new Date().toISOString(), + created: new Date().toISOString(), + content: path.split('.').length === 1 ? [] : body, + format: fileFormat as Contents.FileFormat, + mimetype: fileMimeType, + size: typeof body === 'string' ? body.length : body.size, + writable: true, + type: fileType + }; + + return data; +}; + +/** + * Deleting a file or directory. + * + * @param s3Client: The S3Client used to send commands. + * @param bucketName: The bucket name. + * @param root: The path to the directory acting as root. + * @param path: The path to to file or directory. + */ +export const deleteS3Objects = async ( + s3Client: S3Client, + bucketName: string, + root: string, + path: string +): Promise<void> => { + path = PathExt.join(root, path); + + // get list of contents with given prefix (path) + const command = new ListObjectsV2Command({ + Bucket: bucketName, + Prefix: PathExt.extname(path) === '' ? path + '/' : path + }); + + let isTruncated: boolean | undefined = true; + + while (isTruncated) { + const { Contents, IsTruncated, NextContinuationToken } = + await s3Client.send(command); + + if (Contents) { + await Promise.all( + Contents.map(c => { + // delete each file with given path + Private.deleteFile(s3Client, bucketName, c.Key!); + }) + ); + } + if (isTruncated) { + isTruncated = IsTruncated; + } + command.input.ContinuationToken = NextContinuationToken; + } +}; + +/** + * Check whether an object (file or directory) exists within given S3 bucket. + * + * Used before renaming a file to avoid overwriting and when setting the root. + * + * @param s3Client: The S3Client used to send commands. + * @param bucketName: The bucket name. + * @param root: The path to the directory acting as root. + * @param path: The path to to file or directory. + * + * @returns A promise which resolves or rejects depending on the existance of the object. + */ +export const checkS3Object = async ( + s3Client: S3Client, + bucketName: string, + root: string, + path?: string +): Promise<HeadObjectCommandOutput> => { + return await s3Client.send( + new HeadObjectCommand({ + Bucket: bucketName, + Key: path ? PathExt.join(root, path) : root + '/' // check whether we are looking at an object or the root + }) + ); +}; + +/** + * Rename a file or directory. + * + * @param s3Client: The S3Client used to send commands. + * @param bucketName: The bucket name. + * @param root: The path to the directory acting as root. + * @param oldLocalPath: The old path of the object. + * @param newLocalPath: The new path of the object. + * @param newFileName: The new object name. + * @param registeredFileTypes: The list containing all registered file types. + * + * @returns A promise which resolves with the new object contents model. + */ +export const renameS3Objects = async ( + s3Client: S3Client, + bucketName: string, + root: string, + oldLocalPath: string, + newLocalPath: string, + newFileName: string, + registeredFileTypes: IRegisteredFileTypes +): Promise<Contents.IModel> => { + newLocalPath = PathExt.join(root, newLocalPath); + oldLocalPath = PathExt.join(root, oldLocalPath); + + const isDir: boolean = PathExt.extname(oldLocalPath) === ''; + + if (isDir) { + newLocalPath = newLocalPath.substring(0, newLocalPath.length - 1); + } + newLocalPath = + newLocalPath.substring(0, newLocalPath.lastIndexOf('/') + 1) + newFileName; + + const [fileType, fileMimeType, fileFormat] = Private.getFileType( + PathExt.extname(PathExt.basename(newFileName)), + registeredFileTypes + ); + + // list contents of path - contents of directory or one file + const command = new ListObjectsV2Command({ + Bucket: bucketName, + Prefix: oldLocalPath + }); + + let isTruncated: boolean | undefined = true; + + while (isTruncated) { + const { Contents, IsTruncated, NextContinuationToken } = + await s3Client.send(command); + + if (Contents) { + // retrieve information of file or directory + const fileContents = await s3Client.send( + new GetObjectCommand({ + Bucket: bucketName, + Key: Contents[0].Key! + }) + ); + + const body = await fileContents.Body?.transformToString(); + + data = { + name: newFileName, + path: newLocalPath, + last_modified: fileContents.LastModified!.toISOString(), + created: '', + content: body ? body : [], + format: fileFormat as Contents.FileFormat, + mimetype: fileMimeType, + size: fileContents.ContentLength!, + writable: true, + type: fileType + }; + + const promises = Contents.map(async c => { + const remainingFilePath = c.Key!.substring(oldLocalPath.length); + // wait for copy action to resolve, delete original file only if it succeeds + await Private.copyFile( + s3Client, + bucketName, + remainingFilePath, + oldLocalPath, + newLocalPath + ); + return Private.deleteFile( + s3Client, + bucketName, + oldLocalPath + remainingFilePath + ); + }); + await Promise.all(promises); + } + if (isTruncated) { + isTruncated = IsTruncated; + } + command.input.ContinuationToken = NextContinuationToken; + } + + return data; +}; + +/** + * Copy a file or directory to a new location within the bucket or to another bucket. + * + * If no additional bucket name is provided, the content will be copied to the default bucket. + * + * @param s3Client: The S3Client used to send commands. + * @param bucketName: The bucket name. + * @param root: The path to the directory acting as root. + * @param name: The new object name. + * @param path: The original path to the object to be copied. + * @param toDir: The new path where object should be copied. + * @param registeredFileTypes: The list containing all registered file types. + * @param newBucketName: The name of the bucket where to copy the object (optional). + * + * @returns A promise which resolves with the new object contents model. + */ +export const copyS3Objects = async ( + s3Client: S3Client, + bucketName: string, + root: string, + name: string, + path: string, + toDir: string, + registeredFileTypes: IRegisteredFileTypes, + newBucketName?: string +): Promise<Contents.IModel> => { + const isDir: boolean = PathExt.extname(path) === ''; + + path = PathExt.join(root, path); + toDir = PathExt.join(root, toDir); + + name = PathExt.join(toDir, name); + path = isDir ? path + '/' : path; + + // list contents of path - contents of directory or one file + const command = new ListObjectsV2Command({ + Bucket: bucketName, + Prefix: path + }); + + let isTruncated: boolean | undefined = true; + + while (isTruncated) { + const { Contents, IsTruncated, NextContinuationToken } = + await s3Client.send(command); + + if (Contents) { + const promises = Contents.map(c => { + const remainingFilePath = c.Key!.substring(path.length); + // copy each file from old directory to new location + return Private.copyFile( + s3Client, + bucketName, + remainingFilePath, + path, + name, + newBucketName + ); + }); + await Promise.all(promises); + } + if (isTruncated) { + isTruncated = IsTruncated; + } + command.input.ContinuationToken = NextContinuationToken; + } + + const [fileType, fileMimeType, fileFormat] = Private.getFileType( + PathExt.extname(PathExt.basename(name)), + registeredFileTypes + ); + + // retrieve information of new file + const newFileContents = await s3Client.send( + new GetObjectCommand({ + Bucket: newBucketName ?? bucketName, + Key: name + }) + ); + + data = { + name: PathExt.basename(name), + path: name, + last_modified: newFileContents.LastModified!.toISOString(), + created: new Date().toISOString(), + content: await newFileContents.Body!.transformToString(), + format: fileFormat as Contents.FileFormat, + mimetype: fileMimeType, + size: newFileContents.ContentLength!, + writable: true, + type: fileType + }; + + return data; +}; + +/** + * Count number of appeareances of object name. + * + * @param s3Client: The S3Client used to send commands. + * @param bucketName: The bucket name. + * @param root: The path to the directory acting as root. + * @param path: The path to the object. + * @param originalName: The original name of the object (before it was incremented). + * + * @returns A promise which resolves with the number of appeareances of object. + */ +export const countS3ObjectNameAppearances = async ( + s3Client: S3Client, + bucketName: string, + root: string, + path: string, + originalName: string +): Promise<number> => { + let counter: number = 0; + path = PathExt.join(root, path); + + // count number of name appearances + const command = new ListObjectsV2Command({ + Bucket: bucketName, + Prefix: path.substring(0, path.lastIndexOf('/')) + }); + + let isTruncated: boolean | undefined = true; + + while (isTruncated) { + const { Contents, IsTruncated, NextContinuationToken } = + await s3Client.send(command); + + if (Contents) { + Contents.forEach(c => { + const fileName = c + .Key!.replace((root ? root + '/' : '') + (path ? path + '/' : ''), '') + .split('/')[0]; + if ( + fileName.substring(0, originalName.length + 1).includes(originalName) + ) { + counter += 1; + } + }); + } + if (isTruncated) { + isTruncated = IsTruncated; + } + command.input.ContinuationToken = NextContinuationToken; + } + + return counter; +}; + +namespace Private { + /** + * Helping function to define file type, mimetype and format based on file extension. + * @param extension file extension (e.g.: txt, ipynb, csv) + * @returns + */ + export function getFileType( + extension: string, + registeredFileTypes: IRegisteredFileTypes + ) { + let fileType: string = 'text'; + let fileMimetype: string = 'text/plain'; + let fileFormat: string = 'text'; + + if (registeredFileTypes[extension]) { + fileType = registeredFileTypes[extension].fileType; + fileMimetype = registeredFileTypes[extension].fileMimeTypes[0]; + fileFormat = registeredFileTypes[extension].fileFormat; + } + + // the file format for notebooks appears as json, but should be text + if (extension === '.ipynb') { + fileFormat = 'text'; + } + + return [fileType, fileMimetype, fileFormat]; + } + + /** + * Helping function for deleting files inside + * a directory, in the case of deleting the directory. + * + * @param filePath complete path of file to delete + */ + export async function deleteFile( + s3Client: S3Client, + bucketName: string, + filePath: string + ) { + await s3Client.send( + new DeleteObjectCommand({ + Bucket: bucketName, + Key: filePath + }) + ); + } + + /** + * Helping function for copying the files inside a directory + * to a new location, in the case of renaming or copying a directory. + * + * @param remainingFilePath remaining path of file to be copied + * @param oldPath old path of file + * @param newPath new path of file + */ + export async function copyFile( + s3Client: S3Client, + bucketName: string, + remainingFilePath: string, + oldPath: string, + newPath: string, + newBucketName?: string + ) { + await s3Client.send( + new CopyObjectCommand({ + Bucket: newBucketName ? newBucketName : bucketName, + CopySource: PathExt.join(bucketName, oldPath, remainingFilePath), + Key: PathExt.join(newPath, remainingFilePath) + }) + ); + } + + /** + * Helping function used for formatting the body of files. + * + * @param options: The parameteres for saving a file. + * @param fileFormat: The registered file format. + * @param fileType: The registered file type. + * @param fileMimeType: The registered file mimetype. + * + * @returns The formatted content (body). + */ + export function formatBody( + options: Partial<Contents.IModel>, + fileFormat: string, + fileType: string, + fileMimeType: string + ) { + let body: string | Blob; + if (options.format === 'json') { + body = JSON.stringify(options.content, null, 2); + } else if ( + options.format === 'base64' && + (fileFormat === 'base64' || fileType === 'PDF') + ) { + // transform base64 encoding to a utf-8 array for saving and storing in S3 bucket + const byteCharacters = atob(options.content); + const byteArrays = []; + + for (let offset = 0; offset < byteCharacters.length; offset += 512) { + const slice = byteCharacters.slice(offset, offset + 512); + const byteNumbers = new Array(slice.length); + for (let i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + byteArrays.push(byteArray); + } + + body = new Blob(byteArrays, { type: fileMimeType }); + } else { + body = options.content; + } + return body; + } +} diff --git a/src/s3contents.ts b/src/s3contents.ts index f985bf4..b7d902e 100644 --- a/src/s3contents.ts +++ b/src/s3contents.ts @@ -1,21 +1,26 @@ import { Signal, ISignal } from '@lumino/signaling'; import { Contents, ServerConnection } from '@jupyterlab/services'; -import { URLExt } from '@jupyterlab/coreutils'; +import { URLExt, PathExt } from '@jupyterlab/coreutils'; import { JupyterFrontEnd } from '@jupyterlab/application'; import { - CopyObjectCommand, - DeleteObjectCommand, S3Client, - ListObjectsV2Command, GetBucketLocationCommand, - GetObjectCommand, - PutObjectCommand, - HeadObjectCommand, S3ClientConfig } from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { + checkS3Object, + createS3Object, + copyS3Objects, + countS3ObjectNameAppearances, + deleteS3Objects, + presignedS3Url, + renameS3Objects, + listS3Contents, + IRegisteredFileTypes, + getS3FileContents +} from './s3'; let data: Contents.IModel = { name: '', @@ -30,18 +35,6 @@ let data: Contents.IModel = { type: '' }; -export interface IRegisteredFileTypes { - [fileExtension: string]: { - fileType: string; - fileMimeTypes: string[]; - fileFormat: string; - }; -} - -interface IContentsList { - [fileName: string]: Contents.IModel; -} - export class Drive implements Contents.IDrive { /** * Construct a new drive object. @@ -235,18 +228,8 @@ export class Drive implements Contents.IDrive { * path if necessary. */ async getDownloadUrl(path: string): Promise<string> { - const getCommand = new GetObjectCommand({ - Bucket: this._name, - Key: path, - ResponseContentDisposition: 'attachment', - ResponseContentType: 'application/octet-stream' - }); - - await this._s3Client.send(getCommand); - - // get pre-signed URL of S3 file - const signedUrl = await getSignedUrl(this._s3Client, getCommand); - return signedUrl; + const url = await presignedS3Url(this._s3Client, this._name, path); + return url; } /** @@ -266,179 +249,36 @@ export class Drive implements Contents.IDrive { ): Promise<Contents.IModel> { path = path.replace(this._name + '/', ''); - // check if we are getting the list of files from the drive + // getting the list of files from the root if (!path) { - const fileList: IContentsList = {}; - - const command = new ListObjectsV2Command({ - Bucket: this._name, - Prefix: this._root - }); - - let isTruncated: boolean | undefined = true; - - while (isTruncated) { - const { Contents, IsTruncated, NextContinuationToken } = - await this._s3Client.send(command); - - if (Contents) { - Contents.forEach(c => { - if (c.Key! !== this._root + '/') { - const fileName = ( - this._root === '' - ? c.Key! - : c.Key!.replace(this._root + '/', '') - ).split('/')[0]; - const [fileType, fileMimeType, fileFormat] = this.getFileType( - fileName.split('.')[1] - ); - - fileList[fileName] = fileList[fileName] ?? { - name: fileName, - path: fileName, - last_modified: c.LastModified!.toISOString(), - created: '', - content: !fileName.split('.')[1] ? [] : null, - format: fileFormat as Contents.FileFormat, - mimetype: fileMimeType, - size: c.Size!, - writable: true, - type: fileType - }; - } - }); - } - if (isTruncated) { - isTruncated = IsTruncated; - } - command.input.ContinuationToken = NextContinuationToken; - } - - data = { - name: this._name, - path: this._name, - last_modified: '', - created: '', - content: Object.values(fileList), - format: 'json', - mimetype: '', - size: undefined, - writable: true, - type: 'directory' - }; + data = await listS3Contents( + this._s3Client, + this._name, + this._root, + this._registeredFileTypes + ); } else { - const splitPath = path.split('/'); - const currentPath = splitPath[splitPath.length - 1]; + const currentPath = PathExt.basename(path); // listing contents of a folder - if (currentPath.indexOf('.') === -1) { - const fileList: IContentsList = {}; - - const command = new ListObjectsV2Command({ - Bucket: this._name, - Prefix: (this._root ? this._root + '/' : '') + path + '/' - }); - - let isTruncated: boolean | undefined = true; - - while (isTruncated) { - const { Contents, IsTruncated, NextContinuationToken } = - await this._s3Client.send(command); - - if (Contents) { - Contents.forEach(c => { - // checking if we are dealing with the file inside a folder - if ( - c.Key !== path + '/' && - c.Key !== this._root + '/' + path + '/' - ) { - const fileName = c - .Key!.replace( - (this.root ? this.root + '/' : '') + path + '/', - '' - ) - .split('/')[0]; - const [fileType, fileMimeType, fileFormat] = this.getFileType( - fileName.split('.')[1] - ); - - fileList[fileName] = fileList[fileName] ?? { - name: fileName, - path: path + '/' + fileName, - last_modified: c.LastModified!.toISOString(), - created: '', - content: !fileName.split('.')[1] ? [] : null, - format: fileFormat as Contents.FileFormat, - mimetype: fileMimeType, - size: c.Size!, - writable: true, - type: fileType - }; - } - }); - } - if (isTruncated) { - isTruncated = IsTruncated; - } - command.input.ContinuationToken = NextContinuationToken; - } - - data = { - name: currentPath, - path: path + '/', - last_modified: '', - created: '', - content: Object.values(fileList), - format: 'json', - mimetype: '', - size: undefined, - writable: true, - type: 'directory' - }; + if (PathExt.extname(currentPath) === '') { + data = await listS3Contents( + this._s3Client, + this._name, + this.root, + this.registeredFileTypes, + path + ); } // getting the contents of a specific file else { - const command = new GetObjectCommand({ - Bucket: this._name, - Key: this._root ? this._root + '/' + path : path - }); - - const response = await this._s3Client.send(command); - - if (response) { - const date: string = response.LastModified!.toISOString(); - const [fileType, fileMimeType, fileFormat] = this.getFileType( - currentPath.split('.')[1] - ); - - let fileContents: string | Uint8Array; - - // for certain media type files, extract content as byte array and decode to base64 to view in JupyterLab - if (fileFormat === 'base64' || fileType === 'PDF') { - fileContents = await response.Body!.transformToByteArray(); - fileContents = btoa( - fileContents.reduce( - (data, byte) => data + String.fromCharCode(byte), - '' - ) - ); - } else { - fileContents = await response.Body!.transformToString(); - } - - data = { - name: currentPath, - path: this._root ? this._root + '/' + path : path, - last_modified: date, - created: '', - content: fileContents, - format: fileFormat as Contents.FileFormat, - mimetype: fileMimeType, - size: response.ContentLength!, - writable: true, - type: fileType - }; - } + data = await getS3FileContents( + this._s3Client, + this._name, + this._root, + path, + this.registeredFileTypes + ); } } @@ -457,112 +297,26 @@ export class Drive implements Contents.IDrive { async newUntitled( options: Contents.ICreateOptions = {} ): Promise<Contents.IModel> { - const body = ''; - let { path } = options; - const { type, ext } = options; - path = this._root ? (path ? this._root + '/' + path : this._root) : path; - // get current list of contents of drive - const content: Contents.IModel[] = []; - - const command = new ListObjectsV2Command({ - Bucket: this._name - }); - - let isTruncated: boolean | undefined = true; - - while (isTruncated) { - const { Contents, IsTruncated, NextContinuationToken } = - await this._s3Client.send(command); - - if (Contents) { - Contents.forEach(c => { - const [fileType, fileMimeType, fileFormat] = this.getFileType( - c.Key!.split('.')[1] - ); - - content.push({ - name: c.Key!, - path: URLExt.join(this._name, c.Key!), - last_modified: c.LastModified!.toISOString(), - created: '', - content: !c.Key!.split('.')[1] ? [] : null, - format: fileFormat as Contents.FileFormat, - mimetype: fileMimeType, - size: c.Size!, - writable: true, - type: fileType - }); - }); - } - if (isTruncated) { - isTruncated = IsTruncated; - } - command.input.ContinuationToken = NextContinuationToken; - } - - const old_data: Contents.IModel = { - name: this._name, - path: '', - last_modified: '', - created: '', - content: content, - format: 'json', - mimetype: '', - size: undefined, - writable: true, - type: 'directory' - }; - - if (type !== undefined) { - if (type !== 'directory') { - const name = this.incrementUntitledName(old_data, { path, type, ext }); - await this.s3Client.send( - new PutObjectCommand({ - Bucket: this._name, - Key: path ? path + '/' + name : name, - Body: body - }) - ); - - const [fileType, fileMimeType, fileFormat] = this.getFileType(ext!); - - data = { - name: name, - path: path + '/' + name, - last_modified: new Date().toISOString(), - created: new Date().toISOString(), - content: body, - format: fileFormat as Contents.FileFormat, - mimetype: fileMimeType, - size: body.length, - writable: true, - type: fileType - }; - } else { - // creating a new directory - const name = this.incrementUntitledName(old_data, { path, type, ext }); - await this.s3Client.send( - new PutObjectCommand({ - Bucket: this._name, - Key: path ? path + '/' + name + '/' : name + '/', - Body: body - }) - ); + const old_data = await listS3Contents( + this._s3Client, + this._name, + '', // we consider the root undefined in order to retrieve the complete list of contents + this.registeredFileTypes + ); - data = { - name: name, - path: path + '/' + name, - last_modified: new Date().toISOString(), - created: new Date().toISOString(), - content: [], - format: 'json', - mimetype: 'text/directory', - size: undefined, - writable: true, - type: type - }; - } + if (options.type !== undefined) { + // get incremented untitled name + const name = this.incrementUntitledName(old_data, options); + data = await createS3Object( + this._s3Client, + this._name, + this._root, + name, + options.path ? PathExt.join(options.path, name) : name, + '', // create new file with empty body, + this.registeredFileTypes + ); } else { console.warn('Type of new element is undefined'); } @@ -636,36 +390,7 @@ export class Drive implements Contents.IDrive { * @returns A promise which resolves when the file is deleted. */ async delete(localPath: string): Promise<void> { - localPath = this._root - ? localPath - ? this._root + '/' + localPath - : this._root - : localPath; - // get list of contents with given prefix (path) - const command = new ListObjectsV2Command({ - Bucket: this._name, - Prefix: localPath.split('.').length === 1 ? localPath + '/' : localPath - }); - - let isTruncated: boolean | undefined = true; - - while (isTruncated) { - const { Contents, IsTruncated, NextContinuationToken } = - await this._s3Client.send(command); - - if (Contents) { - await Promise.all( - Contents.map(c => { - // delete each file with given path - this.delete_file(c.Key!); - }) - ); - } - if (isTruncated) { - isTruncated = IsTruncated; - } - command.input.ContinuationToken = NextContinuationToken; - } + await deleteS3Objects(this._s3Client, this._name, this._root, localPath); this._fileChanged.emit({ type: 'delete', @@ -692,98 +417,30 @@ export class Drive implements Contents.IDrive { newLocalPath: string, options: Contents.ICreateOptions = {} ): Promise<Contents.IModel> { - let newFileName = - newLocalPath.indexOf('/') >= 0 - ? newLocalPath.split('/')[newLocalPath.split('/').length - 1] - : newLocalPath; - newLocalPath = this._root ? this._root + '/' + newLocalPath : newLocalPath; - oldLocalPath = this._root ? this._root + '/' + oldLocalPath : oldLocalPath; - - const [fileType, fileMimeType, fileFormat] = this.getFileType( - newFileName!.split('.')[1] - ); + let newFileName = PathExt.basename(newLocalPath); - const isDir: boolean = oldLocalPath.split('.').length === 1; - - // check if file with new name already exists - try { - await this._s3Client.send( - new HeadObjectCommand({ - Bucket: this._name, - Key: newLocalPath - }) - ); - console.log('File name already exists!'); - // construct new incremented name and it's corresponding path - newFileName = await this.incrementName(newLocalPath, isDir, this._name); - if (isDir) { - newLocalPath = newLocalPath.substring(0, newLocalPath.length - 1); - } - newLocalPath = isDir - ? newLocalPath.substring(0, newLocalPath.lastIndexOf('/') + 1) + - newFileName + - '/' - : newLocalPath.substring(0, newLocalPath.lastIndexOf('/') + 1) + - newFileName; - } catch (e) { - // function throws error as the file name doesn't exist - console.log("Name doesn't exist!"); - } - - // list contents of path - contents of directory or one file - const command = new ListObjectsV2Command({ - Bucket: this._name, - Prefix: oldLocalPath - }); - - let isTruncated: boolean | undefined = true; - - while (isTruncated) { - const { Contents, IsTruncated, NextContinuationToken } = - await this._s3Client.send(command); - - if (Contents) { - // retrieve information of file or directory - const fileContents = await this._s3Client.send( - new GetObjectCommand({ - Bucket: this._name, - Key: Contents[0].Key! - }) + await checkS3Object(this._s3Client, this._name, this._root, newLocalPath) + .then(async () => { + console.log('File name already exists!'); + // construct new incremented name + newFileName = await this.incrementName(newLocalPath, this._name); + }) + .catch(() => { + // function throws error as the file name doesn't exist + console.log("Name doesn't exist!"); + }) + .finally(async () => { + // once the name has been incremented if needed, proceed with the renaming + data = await renameS3Objects( + this._s3Client, + this._name, + this._root, + oldLocalPath, + newLocalPath, + newFileName, + this._registeredFileTypes ); - - const body = await fileContents.Body?.transformToString(); - - data = { - name: newFileName, - path: newLocalPath, - last_modified: fileContents.LastModified!.toISOString(), - created: '', - content: body ? body : [], - format: fileFormat as Contents.FileFormat, - mimetype: fileMimeType, - size: fileContents.ContentLength!, - writable: true, - type: fileType - }; - - const promises = Contents.map(async c => { - const remainingFilePath = c.Key!.substring(oldLocalPath.length); - // wait for copy action to resolve, delete original file only if it succeeds - await this.copy_file( - remainingFilePath, - oldLocalPath, - newLocalPath, - this._name - ); - return this.delete_file(oldLocalPath + remainingFilePath); - }); - await Promise.all(promises); - } - if (isTruncated) { - isTruncated = IsTruncated; - } - command.input.ContinuationToken = NextContinuationToken; - } + }); this._fileChanged.emit({ type: 'rename', @@ -799,70 +456,39 @@ export class Drive implements Contents.IDrive { * * @param localPath - Path to file. * - * @param isDir - Whether the content is a directory or a file. - * * @param bucketName - The name of the bucket where content is moved. + * + * @param root - The root of the bucket, if it exists. */ - async incrementName(localPath: string, isDir: boolean, bucketName: string) { - let counter: number = 0; + async incrementName(localPath: string, bucketName: string) { + const isDir: boolean = PathExt.extname(localPath) === ''; let fileExtension: string = ''; let originalName: string = ''; // check if we are dealing with a directory if (isDir) { localPath = localPath.substring(0, localPath.length - 1); - originalName = localPath.split('/')[localPath.split('/').length - 1]; + originalName = PathExt.basename(localPath); } // dealing with a file else { // extract name from path - originalName = localPath.split('/')[localPath.split('/').length - 1]; + originalName = PathExt.basename(localPath); // eliminate file extension - fileExtension = - originalName.split('.')[originalName.split('.').length - 1]; + fileExtension = PathExt.extname(originalName); originalName = originalName.split('.')[originalName.split('.').length - 2]; } - // count number of name appearances - const command = new ListObjectsV2Command({ - Bucket: bucketName, - Prefix: localPath.substring(0, localPath.lastIndexOf('/')) - }); - - let isTruncated: boolean | undefined = true; - - while (isTruncated) { - const { Contents, IsTruncated, NextContinuationToken } = - await this._s3Client.send(command); - - if (Contents) { - Contents.forEach(c => { - // check if we are dealing with a directory - if (c.Key![c.Key!.length - 1] === '/') { - c.Key! = c.Key!.substring(0, c.Key!.length - 1); - } - // check if the name of the file or directory matches the original name - if ( - c - .Key!.substring( - c.Key!.lastIndexOf('/') + 1, - c.Key!.lastIndexOf('/') + 1 + originalName.length - ) - .includes(originalName) - ) { - counter += 1; - } - }); - } - if (isTruncated) { - isTruncated = IsTruncated; - } - command.input.ContinuationToken = NextContinuationToken; - } - + const counter = await countS3ObjectNameAppearances( + this._s3Client, + bucketName, + this._root, + localPath, + originalName + ); let newName = counter ? originalName + counter : originalName; - newName = isDir ? newName + '/' : newName + '.' + fileExtension; + newName = isDir ? newName + '/' : newName + fileExtension; return newName; } @@ -886,65 +512,19 @@ export class Drive implements Contents.IDrive { localPath: string, options: Partial<Contents.IModel> = {} ): Promise<Contents.IModel> { - const fileName = - localPath.indexOf('/') === -1 - ? localPath - : localPath.split('/')[localPath.split.length - 1]; - localPath = this._root ? this._root + '/' + localPath : localPath; - - const [fileType, fileMimeType, fileFormat] = this.getFileType( - fileName.split('.')[1] + const fileName = PathExt.basename(localPath); + + data = await createS3Object( + this._s3Client, + this._name, + this._root, + fileName, + localPath, + options.content, + this._registeredFileTypes, + options ); - let body: string | Blob; - if (options.format === 'json') { - body = JSON.stringify(options?.content, null, 2); - } else if ( - options.format === 'base64' && - (fileFormat === 'base64' || fileType === 'PDF') - ) { - // transform base64 encoding to a utf-8 array for saving and storing in S3 bucket - const byteCharacters = atob(options.content); - const byteArrays = []; - - for (let offset = 0; offset < byteCharacters.length; offset += 512) { - const slice = byteCharacters.slice(offset, offset + 512); - const byteNumbers = new Array(slice.length); - for (let i = 0; i < slice.length; i++) { - byteNumbers[i] = slice.charCodeAt(i); - } - const byteArray = new Uint8Array(byteNumbers); - byteArrays.push(byteArray); - } - - body = new Blob(byteArrays, { type: fileMimeType }); - } else { - body = options?.content; - } - - // save file with new content by overwritting existing file - await this._s3Client.send( - new PutObjectCommand({ - Bucket: this._name, - Key: localPath, - Body: body, - CacheControl: 'no-cache' - }) - ); - - data = { - name: fileName, - path: localPath, - last_modified: new Date().toISOString(), - created: '', - content: body, - format: fileFormat as Contents.FileFormat, - mimetype: fileMimeType, - size: typeof body === 'string' ? body.length : body.size, - writable: true, - type: fileType - }; - this._fileChanged.emit({ type: 'save', oldValue: null, @@ -959,21 +539,16 @@ export class Drive implements Contents.IDrive { * * @param path - The original file path. * - * @param isDir - The boolean marking if we are dealing with a file or directory. - * * @param bucketName - The name of the bucket where content is moved. * * @returns A promise which resolves with the new name when the * file is copied. */ - async incrementCopyName( - copiedItemPath: string, - isDir: boolean, - bucketName: string - ) { + async incrementCopyName(copiedItemPath: string, bucketName: string) { + const isDir: boolean = PathExt.extname(copiedItemPath) === ''; + // extracting original file name - const originalFileName = - copiedItemPath.split('/')[copiedItemPath.split('/').length - 1]; + const originalFileName = PathExt.basename(copiedItemPath); // constructing new file name and path with -Copy string const newFileName = isDir @@ -982,19 +557,13 @@ export class Drive implements Contents.IDrive { '-Copy.' + originalFileName.split('.')[1]; - const newFilePath = isDir - ? copiedItemPath.substring(0, copiedItemPath.lastIndexOf('/') + 1) + - newFileName + - '/' - : copiedItemPath.substring(0, copiedItemPath.lastIndexOf('/') + 1) + - newFileName; + const newFilePath = + copiedItemPath.substring(0, copiedItemPath.lastIndexOf('/') + 1) + + newFileName + + (isDir ? '/' : ''); // getting incremented name of Copy in case of duplicates - const incrementedName = await this.incrementName( - newFilePath, - isDir, - bucketName - ); + const incrementedName = await this.incrementName(newFilePath, bucketName); return incrementedName; } @@ -1014,72 +583,19 @@ export class Drive implements Contents.IDrive { toDir: string, options: Contents.ICreateOptions = {} ): Promise<Contents.IModel> { - path = this._root ? this._root + '/' + path : path; - toDir = this._root ? this._root + (toDir ? '/' + toDir : toDir) : toDir; - - const isDir: boolean = path.split('.').length === 1; - // construct new file or directory name for the copy - let newFileName = await this.incrementCopyName(path, isDir, this._name); - newFileName = toDir !== '' ? toDir + '/' + newFileName : newFileName; - path = isDir ? path + '/' : path; - - // list contents of path - contents of directory or one file - const command = new ListObjectsV2Command({ - Bucket: this._name, - Prefix: path - }); - - let isTruncated: boolean | undefined = true; - - while (isTruncated) { - const { Contents, IsTruncated, NextContinuationToken } = - await this._s3Client.send(command); - - if (Contents) { - const promises = Contents.map(c => { - const remainingFilePath = c.Key!.substring(path.length); - // copy each file from old directory to new location - return this.copy_file( - remainingFilePath, - path, - newFileName, - this._name - ); - }); - await Promise.all(promises); - } - if (isTruncated) { - isTruncated = IsTruncated; - } - command.input.ContinuationToken = NextContinuationToken; - } - - const [fileType, fileMimeType, fileFormat] = this.getFileType( - newFileName.split('.')[1] + const newFileName = await this.incrementCopyName(path, this._name); + + data = await copyS3Objects( + this._s3Client, + this._name, + this._root, + newFileName, + path, + toDir, + this._registeredFileTypes ); - // retrieve information of new file - const newFileContents = await this._s3Client.send( - new GetObjectCommand({ - Bucket: this._name, - Key: newFileName - }) - ); - - data = { - name: newFileName, - path: toDir + '/' + newFileName, - last_modified: newFileContents.LastModified!.toISOString(), - created: new Date().toISOString(), - content: await newFileContents.Body!.transformToString(), - format: fileFormat as Contents.FileFormat, - mimetype: fileMimeType, - size: newFileContents.ContentLength!, - writable: true, - type: fileType - }; - this._fileChanged.emit({ type: 'new', oldValue: null, @@ -1107,77 +623,19 @@ export class Drive implements Contents.IDrive { bucketName: string, options: Contents.ICreateOptions = {} ): Promise<Contents.IModel> { - path = - path[path.length - 1] === '/' ? path.substring(0, path.length - 1) : path; - path = this._root ? this._root + (path ? '/' + path : path) : path; - - // list contents of path - contents of directory or one file - const command = new ListObjectsV2Command({ - Bucket: this._name, - Prefix: path - }); - - let isTruncated: boolean | undefined = true; - - while (isTruncated) { - const { Contents, IsTruncated, NextContinuationToken } = - await this._s3Client.send(command); - - if (Contents) { - const isDir: boolean = - Contents.length > 1 || - Contents![0].Key![Contents![0].Key!.length - 1] === '/' - ? true - : false; - const newFileName = await this.incrementCopyName( - path, - isDir, - bucketName - ); - path = isDir ? path + '/' : path; - - const promises = Contents.map(c => { - const remainingFilePath = c.Key!.substring(path.length); - // copy each file from old directory to new location - return this.copy_file( - remainingFilePath, - path, - newFileName, - bucketName - ); - }); - await Promise.all(promises); - - const [fileType, fileMimeType, fileFormat] = this.getFileType( - newFileName.split('.')[1] - ); - - // retrieve information of new file - const newFileContents = await this._s3Client.send( - new GetObjectCommand({ - Bucket: bucketName, - Key: toDir !== '' ? toDir + '/' + newFileName : newFileName - }) - ); - - data = { - name: newFileName, - path: toDir + '/' + newFileName, - last_modified: newFileContents.LastModified!.toISOString(), - created: new Date().toISOString(), - content: await newFileContents.Body!.transformToString(), - format: fileFormat as Contents.FileFormat, - mimetype: fileMimeType, - size: newFileContents.ContentLength!, - writable: true, - type: fileType - }; - } - if (isTruncated) { - isTruncated = IsTruncated; - } - command.input.ContinuationToken = NextContinuationToken; - } + // construct new file or directory name for the copy + const newFileName = await this.incrementCopyName(path, bucketName); + + data = await copyS3Objects( + this._s3Client, + this._name, + this._root, + newFileName, + path, + toDir, + this._registeredFileTypes, + bucketName + ); this._fileChanged.emit({ type: 'new', @@ -1254,43 +712,6 @@ export class Drive implements Contents.IDrive { ); return (response?.LocationConstraint as string) ?? ''; } - /** - * Helping function for copying the files inside a directory - * to a new location, in the case of renaming or copying a directory. - * - * @param remainingFilePath remaining path of file to be copied - * @param oldPath old path of file - * @param newPath new path of file - */ - private async copy_file( - remainingFilePath: string, - oldPath: string, - newPath: string, - bucketName: string - ) { - await this._s3Client.send( - new CopyObjectCommand({ - Bucket: bucketName, - CopySource: this._name + '/' + oldPath + remainingFilePath, - Key: newPath + remainingFilePath - }) - ); - } - - /** - * Helping function for deleting files inside - * a directory, in the case of deleting or renaming a directory. - * - * @param filePath complete path of file to delete - */ - private async delete_file(filePath: string) { - await this.s3Client.send( - new DeleteObjectCommand({ - Bucket: this._name, - Key: filePath - }) - ); - } /** * Get all registered file types and store them accordingly with their file @@ -1315,7 +736,6 @@ export class Drive implements Contents.IDrive { // store the mimetype and fileformat for each file extension fileType.extensions.forEach(extension => { - extension = extension.split('.')[1]; if (!this._registeredFileTypes[extension]) { this._registeredFileTypes[extension] = { fileType: fileType.name, @@ -1327,31 +747,6 @@ export class Drive implements Contents.IDrive { } } - /** - * Helping function to define file type, mimetype and format based on file extension. - * @param extension file extension (e.g.: txt, ipynb, csv) - * @returns - */ - private getFileType(extension: string) { - let fileType: string = 'text'; - let fileMimetype: string = 'text/plain'; - let fileFormat: string = 'text'; - extension = extension ?? ''; - - if (this._registeredFileTypes[extension]) { - fileType = this._registeredFileTypes[extension].fileType; - fileMimetype = this._registeredFileTypes[extension].fileMimeTypes[0]; - fileFormat = this._registeredFileTypes[extension].fileFormat; - } - - // the file format for notebooks appears as json, but should be text - if (extension === 'ipynb') { - fileFormat = 'text'; - } - - return [fileType, fileMimetype, fileFormat]; - } - /** * Helping function which formats root by removing all leading or trailing * backslashes and checking if given path to directory exists. @@ -1366,16 +761,10 @@ export class Drive implements Contents.IDrive { } // reformat the path to arbitrary root so it has no leading or trailing / - root = root.replace(/^\/+|\/+$/g, ''); - + root = PathExt.removeSlash(PathExt.normalize(root)); // check if directory exists within bucket try { - await this._s3Client.send( - new HeadObjectCommand({ - Bucket: this._name, - Key: root + '/' - }) - ); + checkS3Object(this._s3Client, this._name, root); // the directory exists, root is formatted correctly return root; } catch (error) { diff --git a/yarn.lock b/yarn.lock index 21b9d43..f4a5aea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8597,6 +8597,7 @@ __metadata: "@aws-sdk/s3-request-presigner": ^3.511.0 "@jupyterlab/application": ^4.0.0 "@jupyterlab/builder": ^4.0.0 + "@jupyterlab/coreutils": ^6.2.2 "@jupyterlab/testutils": ^4.0.0 "@lumino/coreutils": 2.1.2 "@types/jest": ^29.2.0