From 8a3f1186200d22171ce4ff48c9ac185c138fb550 Mon Sep 17 00:00:00 2001 From: "Marc J. Schmidt" Date: Sun, 8 Oct 2023 21:43:14 +0200 Subject: [PATCH] feature(storage): local adapter + better docs + more methods --- packages/storage/index.ts | 2 + packages/storage/src/local-adapter.ts | 68 +++- packages/storage/src/memory-adapter.ts | 103 ++++++ packages/storage/src/storage.ts | 459 ++++++++++++++++++------- packages/storage/tests/storage.spec.ts | 135 +++++++- 5 files changed, 630 insertions(+), 137 deletions(-) create mode 100644 packages/storage/src/memory-adapter.ts diff --git a/packages/storage/index.ts b/packages/storage/index.ts index 3a7aa7071..75e69d933 100644 --- a/packages/storage/index.ts +++ b/packages/storage/index.ts @@ -1 +1,3 @@ export * from './src/storage.js'; +export * from './src/local-adapter.js'; +export * from './src/memory-adapter.js'; diff --git a/packages/storage/src/local-adapter.ts b/packages/storage/src/local-adapter.ts index 976b85e2a..e905b614c 100644 --- a/packages/storage/src/local-adapter.ts +++ b/packages/storage/src/local-adapter.ts @@ -1,4 +1,4 @@ -import { File, FileType, pathDirectory, pathNormalize, Reporter, StorageAdapter } from './storage.js'; +import { FileType, pathDirectory, pathNormalize, Reporter, StorageAdapter, StorageFile } from './storage.js'; import type * as fs from 'fs/promises'; export class StorageNodeLocalAdapter implements StorageAdapter { @@ -30,6 +30,11 @@ export class StorageNodeLocalAdapter implements StorageAdapter { await fs.rm(path); } + async makeDirectory(path: string): Promise { + const fs = await this.getFs(); + await fs.mkdir(this.getPath(path), { recursive: true }); + } + async deleteDirectory(path: string, reporter: Reporter): Promise { path = this.getPath(path); const fs = await this.getFs(); @@ -47,13 +52,13 @@ export class StorageNodeLocalAdapter implements StorageAdapter { } } - async files(path: string): Promise { + async files(path: string): Promise { const localPath = this.getPath(path); - const files: File[] = []; + const files: StorageFile[] = []; const fs = await this.getFs(); for (const name of await fs.readdir(localPath)) { - const file = new File(path + '/' + name); + const file = new StorageFile(path + '/' + name); const stat = await fs.stat(localPath + '/' + name); file.size = stat.size; file.lastModified = new Date(stat.mtime); @@ -64,10 +69,61 @@ export class StorageNodeLocalAdapter implements StorageAdapter { return files; } - async get(path: string): Promise { + async allFiles(path: string, reporter: Reporter): Promise { + const files: StorageFile[] = []; + const fs = await this.getFs(); + + const queue: string[] = [path]; + while (!reporter.aborted && queue.length) { + const currentPath = queue.shift()!; + for (const name of await fs.readdir(this.getPath(currentPath))) { + if (reporter.aborted) return files; + const file = new StorageFile(currentPath + '/' + name); + const stat = await fs.stat(this.getPath(currentPath + '/' + name)); + file.size = stat.size; + file.lastModified = new Date(stat.mtime); + file.type = stat.isFile() ? FileType.File : FileType.Directory; + files.push(file); + reporter.progress(files.length, 0); + if (file.isDirectory()) queue.push(file.path); + } + } + + return files; + } + + async directories(path: string): Promise { + return (await this.files(path)).filter(file => file.isDirectory()); + } + + async allDirectories(path: string, reporter: Reporter): Promise { + const files: StorageFile[] = []; + const fs = await this.getFs(); + + const queue: string[] = [path]; + while (!reporter.aborted && queue.length) { + const currentPath = queue.shift()!; + for (const name of await fs.readdir(this.getPath(currentPath))) { + if (reporter.aborted) return files; + const file = new StorageFile(currentPath + '/' + name); + const stat = await fs.stat(this.getPath(currentPath + '/' + name)); + if (!stat.isDirectory()) continue; + file.size = stat.size; + file.lastModified = new Date(stat.mtime); + file.type = stat.isFile() ? FileType.File : FileType.Directory; + files.push(file); + reporter.progress(files.length, 0); + if (file.isDirectory()) queue.push(file.path); + } + } + + return files; + } + + async get(path: string): Promise { const localPath = this.getPath(path); const fs = await this.getFs(); - const file = new File(path); + const file = new StorageFile(path); try { const stat = await fs.stat(localPath); file.size = stat.size; diff --git a/packages/storage/src/memory-adapter.ts b/packages/storage/src/memory-adapter.ts new file mode 100644 index 000000000..c86085f5f --- /dev/null +++ b/packages/storage/src/memory-adapter.ts @@ -0,0 +1,103 @@ +import { FileNotFound, FileType, pathDirectories, pathDirectory, Reporter, StorageAdapter, StorageFile } from './storage.js'; + +export class StorageMemoryAdapter implements StorageAdapter { + protected memory: { file: StorageFile, contents: Uint8Array }[] = []; + + async files(path: string): Promise { + return this.memory.filter(file => file.file.directory === path) + .map(v => v.file); + } + + async makeDirectory(path: string): Promise { + const directories = pathDirectories(path); + //filter out all parts that already exist + for (const dir of directories) { + const exists = await this.exists(dir); + if (exists) continue; + const file = new StorageFile(dir); + file.type = FileType.Directory; + this.memory.push({ file, contents: new Uint8Array }); + } + } + + async allFiles(path: string): Promise { + return this.memory.filter(file => file.file.inDirectory(path)) + .map(v => v.file); + } + + async directories(path: string): Promise { + return this.memory.filter(file => file.file.directory === path) + .filter(file => file.file.isDirectory()) + .map(v => v.file); + } + + async allDirectories(path: string): Promise { + return this.memory.filter(file => file.file.inDirectory(path)) + .filter(file => file.file.isDirectory()) + .map(v => v.file); + } + + async write(path: string, contents: Uint8Array, reporter: Reporter): Promise { + let file = this.memory.find(file => file.file.path === path); + if (!file) { + await this.makeDirectory(pathDirectory(path)); + file = { file: new StorageFile(path), contents }; + this.memory.push(file); + } + file.contents = contents; + file.file.size = contents.length; + file.file.lastModified = new Date(); + } + + async read(path: string, reporter: Reporter): Promise { + const file = this.memory.find(file => file.file.path === path); + if (!file) throw new FileNotFound('File not found'); + return file.contents; + } + + async exists(path: string): Promise { + return !!this.memory.find(file => file.file.path === path); + } + + async delete(path: string): Promise { + const index = this.memory.findIndex(file => file.file.path === path); + if (index === -1) throw new FileNotFound('File not found'); + this.memory.splice(index, 1); + } + + async deleteDirectory(path: string, reporter: Reporter): Promise { + const files = this.memory.filter(file => file.file.path.startsWith(path)); + reporter.progress(0, files.length); + let i = 0; + for (const file of files) { + this.memory.splice(this.memory.indexOf(file), 1); + reporter.progress(++i, files.length); + } + } + + async get(path: string): Promise { + return this.memory.find(file => file.file.path === path)?.file; + } + + async copy(source: string, destination: string, reporter: Reporter): Promise { + const files = this.memory.filter(file => file.file.path.startsWith(source)); + reporter.progress(0, files.length); + let i = 0; + for (const file of files) { + const newPath = destination + file.file.path.slice(source.length); + this.memory.push({ file: new StorageFile(newPath), contents: file.contents }); + reporter.progress(++i, files.length); + } + } + + async move(source: string, destination: string, reporter: Reporter): Promise { + const files = this.memory.filter(file => file.file.path.startsWith(source)); + reporter.progress(0, files.length); + let i = 0; + for (const file of files) { + const newPath = destination + file.file.path.slice(source.length); + file.file.path = newPath; + reporter.progress(++i, files.length); + } + } +} diff --git a/packages/storage/src/storage.ts b/packages/storage/src/storage.ts index 590d7defa..c26f186f8 100644 --- a/packages/storage/src/storage.ts +++ b/packages/storage/src/storage.ts @@ -1,4 +1,5 @@ import { asyncOperation } from '@deepkit/core'; +import { normalizePath } from 'typedoc'; export enum FileType { File, @@ -7,19 +8,10 @@ export enum FileType { Unknown, } -export function pathNormalize(path: string): string { - path = path[0] !== '/' ? '/' + path : path; - path = path.length > 1 && path[path.length - 1] === '/' ? path.slice(0, -1) : path; - return path.replace(/\/+/g, '/'); -} - -export function pathDirectory(path: string): string { - if (path === '/') return '/'; - const lastSlash = path.lastIndexOf('/'); - return lastSlash === -1 ? '' : path.slice(0, lastSlash); -} - -export class File { +/** + * Represents a file or directory in the storage system. + */ +export class StorageFile { public size?: number; public type: FileType = FileType.File; public lastModified?: Date; @@ -28,198 +20,312 @@ export class File { this.path = pathNormalize(path); } + /** + * Returns true if this file is a symbolic link. + */ isFile(): boolean { return this.type === FileType.File; } + /** + * Returns true if this file is a directory. + */ isDirectory(): boolean { return this.type === FileType.Directory; } - get name() { - const lastSlash = this.path.lastIndexOf('/'); - return lastSlash === -1 ? this.path : this.path.slice(lastSlash + 1); + /** + * Returns the name (basename) of the file. + */ + get name(): string { + return pathBasename(this.path); + } + + /** + * Returns true if this file is in the given directory. + * + * /folder/file.txt => / => true + * /folder/file.txt => /folder => true + * /folder/file.txt => /folder/ => true + * + * /folder2/file.txt => /folder/ => false + * /folder/file.txt => /folder/folder2 => false + */ + inDirectory(directory: string): boolean { + directory = pathNormalize(directory); + if (directory === '/') return true; + return (this.directory + '/').startsWith(directory + '/'); } - get directory() { + /** + * Returns the directory (dirname) of the file. + */ + get directory(): string { const lastSlash = this.path.lastIndexOf('/'); - return lastSlash === -1 ? '' : this.path.slice(0, lastSlash); + return lastSlash <= 0 ? '/' : this.path.slice(0, lastSlash); } - get extension() { + /** + * Returns the extension of the file, or an empty string if not existing or a directory. + */ + get extension(): string { + if (!this.isFile()) return ''; const lastDot = this.path.lastIndexOf('.'); return lastDot === -1 ? '' : this.path.slice(lastDot + 1); } } -export interface Progress extends Promise { +export interface Operation extends Promise { + /** + * Adds a callback that is called when the progress changes. + * Filesystem adapters might report progress on some operations, + * like read/write content, read folder, copy, move, etc. + * + * The unit of loaded and total is not defined and depends on the adapter. + * It might be bytes or the amount of files. if total=0, the total is unknown. + */ onProgress(callback: (loaded: number, total: number) => void): this; + /** + * When called the filesystem adapter tries to abort the operation. + * This is not guaranteed to work, as some adapters might not support it. + * When aborted, the promise will be rejected with an StorageOperationAborted error. + */ abort(): Promise; -} - -export interface Storage { - // write(path: string, contents: string | Uint8Array): Progress; - // - // read(path: string): Progress; - // - // delete(path: string): Promise; - // - // deleteDirectory(path: string): Promise; - - // exists(path: string): Promise; - - // files(path: string): Promise; - - allFiles(path: string): Promise; - - directories(path: string): Promise; - - allDirectories(path: string): Promise; - copy(source: string, destination: string): Promise; - - move(source: string, destination: string): Promise; - - sizeDirectory(path: string): Promise; + /** + * Returns true if the operation was aborted. + */ + aborted: boolean; } export interface StorageAdapter { - files(path: string): Promise; - + /** + * Returns all files directly in the given folder. + */ + files(path: string, reporter: Reporter): Promise; + + /** + * Returns all files in the given folder and all subfolders. + */ + allFiles(path: string, reporter: Reporter): Promise; + + /** + * Returns all directories directly in the given folder. + */ + directories(path: string, reporter: Reporter): Promise; + + /** + * Returns all directories in the given folder and all subfolders. + */ + allDirectories(path: string, reporter: Reporter): Promise; + + /** + * Creates a new directory and all parent directories if not existing. + * Does nothing if the directory already exists. + */ + makeDirectory(path: string): Promise; + + /** + * Writes the given contents to the given path. + * Ensures that all parent directories exist. + */ write(path: string, contents: Uint8Array, reporter: Reporter): Promise; + /** + * Reads the contents of the given path. + * @throws Error if the file does not exist. + */ read(path: string, reporter: Reporter): Promise; - get(path: string): Promise; + /** + * Returns the file at the given path or undefined if not existing. + */ + get(path: string): Promise; + /** + * Returns true if the file exists. + */ exists(path: string): Promise; + /** + * Deletes the file at the given path. + * Does nothing if the file does not exist. + */ delete(path: string): Promise; + /** + * Deletes the directory at the given path and all files and directories in it recursively. + * Does nothing if the directory does not exist. + */ deleteDirectory(path: string, reporter: Reporter): Promise; + /** + * Copies the file from source to destination. + * Ensures that all parent directories exist. + * If source is a directory, it copies the directory recursively. + */ copy(source: string, destination: string, reporter: Reporter): Promise; + /** + * Moves the file from source to destination. + * Ensures that all parent directories exist. + * If source is a directory, it moves the directory recursively. + */ move(source: string, destination: string, reporter: Reporter): Promise; } -export class FileNotFound extends Error { +/** + * Generic StorageError. Base of all errors thrown by the Storage system. + */ +export class StorageError extends Error { } -export type Reporter = { progress: (loaded: number, total: number) => void, onAbort: () => Promise }; +/** + * Thrown when a file or directory does not exist. + */ +export class StorageFileNotFound extends StorageError { +} + +/** + * Thrown when an operation is aborted. + */ +export class StorageOperationAborted extends StorageError { +} -export function createProgress(callback: (reporter: Reporter) => Promise): Progress { +export type Reporter = { progress: (loaded: number, total: number) => void, onAbort: () => Promise, aborted: boolean }; + +export function createProgress(callback: (reporter: Reporter) => Promise): Operation { const callbacks: ((loaded: number, total: number) => void)[] = []; const reporter = { progress: (loaded: number, total: number) => { for (const callback of callbacks) callback(loaded, total); }, - onAbort: () => Promise.resolve() + onAbort: () => Promise.resolve(), + aborted: false, }; - const promise = asyncOperation(async (resolve, reject) => { + let reject: (error: Error) => void; + const promise = asyncOperation(async (resolve, _reject) => { + reject = _reject; resolve(await callback(reporter)); - }) as Progress; + }) as Operation; promise.onProgress = (callback: (loaded: number, total: number) => void) => { callbacks.push(callback); return promise; }; + promise.aborted = false; promise.abort = async () => { + reporter.aborted = true; await reporter.onAbort(); + promise.aborted = true; + reject(new StorageOperationAborted('Operation aborted')); }; return promise; } -export class StorageMemoryAdapter implements StorageAdapter { - protected memory: { file: File, contents: Uint8Array }[] = []; - - async files(path: string): Promise { - return this.memory.filter(file => file.file.path.startsWith(path)).map(v => v.file); - } - - async write(path: string, contents: Uint8Array, reporter: Reporter): Promise { - let file = this.memory.find(file => file.file.path === path); - if (!file) { - file = { file: new File(path), contents }; - this.memory.push(file); - } - file.contents = contents; - file.file.size = contents.length; - file.file.lastModified = new Date(); - } - - async read(path: string, reporter: Reporter): Promise { - const file = this.memory.find(file => file.file.path === path); - if (!file) throw new FileNotFound('File not found'); - return file.contents; - } - - async exists(path: string): Promise { - return !!this.memory.find(file => file.file.path === path); - } - - async delete(path: string): Promise { - const index = this.memory.findIndex(file => file.file.path === path); - if (index === -1) throw new FileNotFound('File not found'); - this.memory.splice(index, 1); +export class Storage { + constructor(public adapter: StorageAdapter) { } - async deleteDirectory(path: string, reporter: Reporter): Promise { - const files = this.memory.filter(file => file.file.path.startsWith(path)); - reporter.progress(0, files.length); - let i = 0; - for (const file of files) { - this.memory.splice(this.memory.indexOf(file), 1); - reporter.progress(++i, files.length); - } + protected normalizePath(path: string): string { + return pathNormalize(path); } - async get(path: string): Promise { - return this.memory.find(file => file.file.path === path)?.file; + /** + * Returns all files directly in the given folder. + * + * Returns a Progress object that can be used to track the progress of the operation. + */ + files(path: string): Operation { + path = this.normalizePath(path); + return createProgress(async (reporter) => { + const files = await this.adapter.files(path, reporter); + files.sort(compareFileSorting); + return files; + }); } - async copy(source: string, destination: string, reporter: Reporter): Promise { - const files = this.memory.filter(file => file.file.path.startsWith(source)); - reporter.progress(0, files.length); - let i = 0; - for (const file of files) { - const newPath = destination + file.file.path.slice(source.length); - this.memory.push({ file: new File(newPath), contents: file.contents }); - reporter.progress(++i, files.length); - } + /** + * Returns all files paths in the given folder. + * + * Returns a Progress object that can be used to track the progress of the operation. + */ + fileNames(path: string): Operation { + path = this.normalizePath(path); + return createProgress(async (reporter) => { + //todo: some adapters might be able to do this more efficiently + const files = await this.adapter.files(path, reporter); + files.sort(compareFileSorting); + return files.map(v => v.path); + }); } - async move(source: string, destination: string, reporter: Reporter): Promise { - const files = this.memory.filter(file => file.file.path.startsWith(source)); - reporter.progress(0, files.length); - let i = 0; - for (const file of files) { - const newPath = destination + file.file.path.slice(source.length); - file.file.path = newPath; - reporter.progress(++i, files.length); - } + /** + * Returns all files in the given folder and all subfolders. + * + * Returns a Progress object that can be used to track the progress of the operation. + */ + allFiles(path: string): Operation { + path = this.normalizePath(path); + return createProgress(async (reporter) => { + const files = await this.adapter.allFiles(path, reporter); + files.sort(compareFileSorting); + return files; + }); } -} -export class Storage { - constructor(public adapter: StorageAdapter) { + /** + * Returns all files paths in the given folder and all subfolders. + * + * Returns a Progress object that can be used to track the progress of the operation. + */ + allFileNames(path: string): Operation { + path = this.normalizePath(path); + return createProgress(async (reporter) => { + //todo: some adapters might be able to do this more efficiently + const files = await this.adapter.allFiles(path, reporter); + files.sort(compareFileSorting); + return files.map(v => v.path); + }); } - protected normalizePath(path: string): string { - return pathNormalize(path); + /** + * Returns all directories directly in the given folder. + * + * Returns a Progress object that can be used to track the progress of the operation. + */ + directories(path: string): Operation { + path = this.normalizePath(path); + return createProgress(async (reporter) => { + return await this.adapter.directories(path, reporter); + }); } - files(path: string): Promise { + /** + * Returns all directories in the given folder and all subfolders. + * + * Returns a Progress object that can be used to track the progress of the operation. + */ + allDirectories(path: string): Operation { path = this.normalizePath(path); - return this.adapter.files(path); + return createProgress(async (reporter) => { + return await this.adapter.allDirectories(path, reporter); + }); } - write(path: string, content: Uint8Array | string): Progress { + /** + * Writes the given content to the given path. + * Ensures that all parent directories exist. + * Overwrites if already existing. + * + * Returns a Progress object that can be used to track the progress of the operation. + */ + write(path: string, content: Uint8Array | string): Operation { path = this.normalizePath(path); const buffer = typeof content === 'string' ? new TextEncoder().encode(content) : content; return createProgress(async (reporter) => { @@ -227,14 +333,24 @@ export class Storage { }); } - read(path: string): Progress { + /** + * Reads the contents of the given path as binary. + * + * Returns a Progress object that can be used to track the progress of the operation. + */ + read(path: string): Operation { path = this.normalizePath(path); return createProgress(async (reporter) => { return await this.adapter.read(path, reporter); }); } - readAsText(path: string): Progress { + /** + * Reads the contents of the given path as string. + * + * Returns a Progress object that can be used to track the progress of the operation. + */ + readAsText(path: string): Operation { path = this.normalizePath(path); return createProgress(async (reporter) => { const contents = await this.adapter.read(path, reporter); @@ -242,36 +358,57 @@ export class Storage { }); } - async get(path: string): Promise { + /** + * Returns the file at the given path. + * + * @throws StorageFileNotFound if the file does not exist. + */ + async get(path: string): Promise { path = this.normalizePath(path); const file = await this.adapter.get(path); - if (!file) throw new FileNotFound('File not found'); + if (!file) throw new StorageFileNotFound('File not found'); return file; } - getOrUndefined(path: string): Promise { + /** + * Returns the file at the given path or undefined if not existing. + */ + getOrUndefined(path: string): Promise { path = this.normalizePath(path); return this.adapter.get(path); } + /** + * Returns true if the file exists. + */ exists(path: string): Promise { path = this.normalizePath(path); return this.adapter.exists(path); } + /** + * Deletes the file at the given path. + * Does nothing if the file does not exist. + */ delete(path: string): Promise { path = this.normalizePath(path); return this.adapter.delete(path); } - deleteDirectory(path: string): Progress { + /** + * Deletes the directory at the given path and all files and directories in it recursively. + */ + deleteDirectory(path: string): Operation { path = this.normalizePath(path); return createProgress(async (reporter) => { return this.adapter.deleteDirectory(path, reporter); }); } - copy(source: string, destination: string): Progress { + /** + * Copies the file or directory from source to destination, recursively. + */ + copy(source: string, destination: string): Operation { source = this.normalizePath(source); destination = this.normalizePath(destination); return createProgress(async (reporter) => { @@ -279,11 +416,73 @@ export class Storage { }); } - move(source: string, destination: string): Progress { + /** + * Moves the file or directory from source to destination, recursively. + */ + move(source: string, destination: string): Operation { source = this.normalizePath(source); destination = this.normalizePath(destination); return createProgress(async (reporter) => { return this.adapter.move(source, destination, reporter); }); } + + /** + * Creates a new directory, and all parent directories if not existing. + */ + makeDirectory(path: string): Promise { + path = this.normalizePath(path); + return this.adapter.makeDirectory(path); + } +} + +/** + * Normalizes the given path. + * Removes duplicate slashes, removes trailing slashes, adds a leading slash. + */ +export function pathNormalize(path: string): string { + path = path[0] !== '/' ? '/' + path : path; + path = path.length > 1 && path[path.length - 1] === '/' ? path.slice(0, -1) : path; + return path.replace(/\/+/g, '/'); +} + +/** + * Returns the directory (dirname) of the given path. + */ +export function pathDirectory(path: string): string { + if (path === '/') return '/'; + const lastSlash = path.lastIndexOf('/'); + return lastSlash === -1 ? '' : path.slice(0, lastSlash); +} + +/** + * Returns the basename of the given path. + */ +export function pathBasename(path: string): string { + const lastSlash = path.lastIndexOf('/'); + return lastSlash === -1 ? path : path.slice(lastSlash + 1); +} + +/** + * A sorting comparator for StorageFile that sorts directories first, then by path. + */ +export function compareFileSorting(a: StorageFile, b: StorageFile): number { + if (a.isDirectory() && !b.isDirectory()) return -1; + if (!a.isDirectory() && b.isDirectory()) return 1; + return a.path.localeCompare(b.path); +} + +export function pathDirectories(path: string): string [] { + path = normalizePath(path); + if (path === '/') return []; + const directories: string[] = []; + for (const part of path.split('/')) { + if (!part) continue; + if (directories.length === 0) { + directories.push('/' + part); + } else { + directories.push(directories[directories.length - 1] + '/' + part); + } + } + return directories; } diff --git a/packages/storage/tests/storage.spec.ts b/packages/storage/tests/storage.spec.ts index 72bc8d157..4b37c3eec 100644 --- a/packages/storage/tests/storage.spec.ts +++ b/packages/storage/tests/storage.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@jest/globals'; -import { Storage, StorageAdapter, StorageMemoryAdapter } from '../src/storage.js'; +import { compareFileSorting, FileType, pathDirectories, Storage, StorageAdapter, StorageFile } from '../src/storage.js'; +import { StorageMemoryAdapter } from '../src/memory-adapter.js'; export let adapterFactory: () => Promise = async () => new StorageMemoryAdapter; @@ -7,6 +8,81 @@ export function setAdapterFactory(factory: () => Promise) { adapterFactory = factory; } +test('utils', async () => { + expect(pathDirectories('/')).toEqual([]); + expect(pathDirectories('/folder')).toEqual(['/folder']); + expect(pathDirectories('/folder/')).toEqual(['/folder']); + expect(pathDirectories('/folder/folder2')).toEqual(['/folder', '/folder/folder2']); +}); + +test('file API', async () => { + { + const file = new StorageFile('/file.txt'); + expect(file.path).toBe('/file.txt'); + expect(file.name).toBe('file.txt'); + expect(file.directory).toBe('/'); + expect(file.size).toBe(undefined); + expect(file.extension).toBe('txt'); + expect(file.lastModified).toBe(undefined); + expect(file.isFile()).toBe(true); + expect(file.isDirectory()).toBe(false); + expect(file.inDirectory('/')).toBe(true); + expect(file.inDirectory('/folder')).toBe(false); + expect(file.inDirectory('/file.txt')).toBe(false); + expect(file.inDirectory('/file.txt/')).toBe(false); + expect(file.inDirectory('/file.txt/abc')).toBe(false); + } + + { + const file = new StorageFile('/folder/file.txt'); + expect(file.path).toBe('/folder/file.txt'); + expect(file.name).toBe('file.txt'); + expect(file.directory).toBe('/folder'); + expect(file.size).toBe(undefined); + expect(file.extension).toBe('txt'); + expect(file.lastModified).toBe(undefined); + expect(file.isFile()).toBe(true); + expect(file.isDirectory()).toBe(false); + + expect(file.inDirectory('/')).toBe(true); + expect(file.inDirectory('/folder')).toBe(true); + expect(file.inDirectory('/folder/')).toBe(true); + expect(file.inDirectory('/folder/file.txt')).toBe(false); + expect(file.inDirectory('/folder/file.txt/')).toBe(false); + expect(file.inDirectory('/folder/file.txt/abc')).toBe(false); + } + + { + const file = new StorageFile('/folder/folder2/file.txt'); + expect(file.path).toBe('/folder/folder2/file.txt'); + expect(file.name).toBe('file.txt'); + expect(file.directory).toBe('/folder/folder2'); + + expect(file.inDirectory('/')).toBe(true); + expect(file.inDirectory('/folder')).toBe(true); + expect(file.inDirectory('/folder/folder2')).toBe(true); + expect(file.inDirectory('/folder/folder2/')).toBe(true); + expect(file.inDirectory('/folder/folder')).toBe(false); + expect(file.inDirectory('/folder/folder/')).toBe(false); + } + + { + const file = new StorageFile('/folder'); + file.type = FileType.Directory; + expect(file.path).toBe('/folder'); + expect(file.name).toBe('folder'); + expect(file.directory).toBe('/'); + expect(file.size).toBe(undefined); + expect(file.extension).toBe(''); + expect(file.lastModified).toBe(undefined); + expect(file.isFile()).toBe(false); + expect(file.isDirectory()).toBe(true); + expect(file.inDirectory('/')).toBe(true); + expect(file.inDirectory('/folder')).toBe(false); + expect(file.inDirectory('/another/folder')).toBe(false); + } +}); + test('basic', async () => { const storage = new Storage(await adapterFactory()); @@ -54,6 +130,63 @@ test('basic', async () => { expect(await storage.exists('/file3.txt')).toBe(false); }); +test('recursive', async () => { + const storage = new Storage(await adapterFactory()); + + await storage.write('/file1.txt', 'contents1'); + await storage.write('/folder/file1.txt', 'contents2'); + await storage.write('/folder/file2.txt', 'contents3'); + await storage.write('/folder2/file2.txt', 'contents4'); + await storage.write('/folder2/file3.txt', 'contents5'); + await storage.write('/folder2/folder3/file4.txt', 'contents6'); + + const files = await storage.files('/'); + expect(files).toMatchObject([ + { path: '/folder', type: FileType.Directory }, + { path: '/folder2', type: FileType.Directory }, + { path: '/file1.txt', type: FileType.File, lastModified: expect.any(Date) }, + ]); + + const files2 = await storage.files('/folder2'); + expect(files2).toMatchObject([ + { path: '/folder2/folder3', type: FileType.Directory }, + { path: '/folder2/file2.txt', type: FileType.File, lastModified: expect.any(Date) }, + { path: '/folder2/file3.txt', type: FileType.File, lastModified: expect.any(Date) }, + ]); + + const files3 = await storage.allFiles('/'); + const fileNames3 = files3.map(f => f.path); + expect(fileNames3).toEqual([ + '/folder', + '/folder2', + '/folder2/folder3', + '/file1.txt', + '/folder/file1.txt', + '/folder/file2.txt', + '/folder2/file2.txt', + '/folder2/file3.txt', + '/folder2/folder3/file4.txt', + ]); + + const directories = await storage.directories('/'); + expect(directories).toMatchObject([ + { path: '/folder', type: FileType.Directory }, + { path: '/folder2', type: FileType.Directory }, + ]); + + const directories2 = await storage.directories('/folder2'); + expect(directories2).toMatchObject([ + { path: '/folder2/folder3', type: FileType.Directory }, + ]); + + const directories3 = await storage.allDirectories('/'); + expect(directories3).toMatchObject([ + { path: '/folder', type: FileType.Directory }, + { path: '/folder2', type: FileType.Directory }, + { path: '/folder2/folder3', type: FileType.Directory }, + ]); +}); + test('copy', async () => { const storage = new Storage(await adapterFactory());