import * as nodePath from 'path' import * as fs from 'fs' import * as mkdirp from 'mkdirp' import { Filesystem } from './path/Filesystem' import ReadableStream = NodeJS.ReadableStream; import WritableStream = NodeJS.WritableStream; /** * An item that could represent a path. */ export type PathLike = string | UniversalPath /** * Create a new UniversalPath from the given path-like segments. * @param parts */ export function universalPath(...parts: PathLike[]): UniversalPath { let [main, ...concats] = parts // eslint-disable-line prefer-const if ( !(main instanceof UniversalPath) ) { main = new UniversalPath(main) } return main.concat(...concats) } /** * Walk recursively over entries in a directory. * * Right now the types are kinda weird for async iterables. This is like an async * IterableIterable that resolves a string or another IterableIterable of the same type. * * Hence why it's separate from the UniversalPath class. * * @param dir */ export async function* walk(dir: string): any { for await (const sub of await fs.promises.opendir(dir) ) { const entry = nodePath.join(dir, sub.name) if ( sub.isDirectory() ) { yield* walk(entry) } else if ( sub.isFile() ) { yield entry } } } /** * Class representing some kind of filesystem resource. */ export class UniversalPath { protected resourcePrefix!: string protected resourceLocalPath!: string constructor( /** * The path string this path refers to. * * @example /home/user/file.txt * @example https://site.com/file.txt */ protected readonly initial: string, protected readonly filesystem?: Filesystem, ) { this.setPrefix() this.setLocal() } /** * Determine the correct prefix for this path. * @protected */ protected setPrefix(): void { if ( this.initial.toLowerCase().startsWith('http://') ) { this.resourcePrefix = 'http://' } else if ( this.initial.toLowerCase().startsWith('https://') ) { this.resourcePrefix = 'https://' } else if ( this.filesystem ) { this.resourcePrefix = this.filesystem.getPrefix() } else { this.resourcePrefix = 'file://' } } /** * Determine the "localized" string of this path. * * This is the normalized path WITHOUT the prefix. * * @example * The normalized path of "file:///home/user/file.txt" is "/home/user/file.txt". * * @protected */ protected setLocal(): void { this.resourceLocalPath = this.initial if ( this.initial.toLowerCase().startsWith(this.resourcePrefix) ) { this.resourceLocalPath = this.resourceLocalPath.slice(this.resourcePrefix.length) } if ( this.resourcePrefix === 'file://' && !this.resourceLocalPath.startsWith('/') && !this.filesystem ) { this.resourceLocalPath = nodePath.resolve(this.resourceLocalPath) } } /** * Return a new copy of this UniversalPath instance. */ clone(): UniversalPath { return new UniversalPath(this.initial) } /** * Get the string of this resource. */ get prefix(): string { return this.resourcePrefix } /** * Returns true if this resource refers to a file on the local filesystem. */ get isLocal(): boolean { return this.resourcePrefix === 'file://' && !this.filesystem } /** * Returns true if this resource refers to a file on a remote filesystem. */ get isRemote(): boolean { return Boolean(this.resourcePrefix !== 'file://' || this.filesystem) } /** * Get the non-prefixed path to this resource. */ get unqualified(): string { return this.resourceLocalPath } /** * Get the path to this resource as it would be accessed from the current filesystem. */ get toLocal(): string { if ( this.isLocal ) { return this.resourceLocalPath } else { return `${this.prefix}${this.resourceLocalPath}` } } /** * Get the fully-prefixed path to this resource. */ get toRemote(): string { return `${this.prefix}${this.resourceLocalPath}` } /** * Append and resolve the given paths to this resource and return a new UniversalPath. * * @example * ```typescript * const homeDir = universalPath('home', 'user') * * homeDir.concat('file.txt').toLocal // => /home/user/file.txt * * homeDir.concat('..', 'other_user').toLocal // => /home/other_user * ``` * * @param paths */ public concat(...paths: PathLike[]): UniversalPath { const resolved = nodePath.join(this.unqualified, ...(paths.map(p => typeof p === 'string' ? p : p.unqualified))) return new UniversalPath(`${this.prefix}${resolved}`) } /** * Append the given path-like item to this resource's path. * Unlike `concat`, this mutates the current instance. * @param path */ public append(path: PathLike): this { this.resourceLocalPath += String(path) return this } /** * Cast the path to a string (fully-prefixed). */ toString(): string { return `${this.prefix}${this.resourceLocalPath}` } /** * Get the extension of the resource referred to by this instance. * * @example * ```typescript * const myFile = universalPath('home', 'user', 'file.txt') * * myFile.ext // => 'txt' * ``` */ get ext(): string { return nodePath.extname(this.resourceLocalPath) } /** * Recursively walk all files in this directory. Must be a local resource. * * This returns an async generator function. * * @example * ```typescript * const configFiles = universalPath('home', 'user', '.config') * * for await (const configFile of configFiles.walk()) { * // configFile is a string * // ... do something ... * } * ``` */ walk(): any { return walk(this.resourceLocalPath) } /** * Returns true if the given resource exists at the path. */ async exists(): Promise { if ( this.filesystem ) { const stat = await this.filesystem.stat({ storePath: this.resourceLocalPath, }) return stat.exists } try { await fs.promises.stat(this.resourceLocalPath) return true } catch (e) { return false } } /** * Recursively create this path as a directory. Equivalent to `mkdir -p` on Linux. */ async mkdir(): Promise { if ( this.filesystem ) { await this.filesystem.mkdir({ storePath: this.resourceLocalPath, }) } else { await mkdirp(this.resourceLocalPath) } } /** * Write the given data to this resource as a file. * @param data */ async write(data: string | Buffer): Promise { if ( typeof data === 'string' ) { data = Buffer.from(data, 'utf8') } if ( this.filesystem ) { const stream = await this.filesystem.putStoreFileAsStream({ storePath: this.resourceLocalPath, }) await new Promise((res, rej) => { stream.write(data, err => { if ( err ) { rej(err) } else { res() } }) }) } else { const fd = await fs.promises.open(this.resourceLocalPath, 'w') await fd.write(data) await fd.close() } } /** * Get a writable stream to this file's contents. */ async writeStream(): Promise { if ( this.filesystem ) { return this.filesystem.putStoreFileAsStream({ storePath: this.resourceLocalPath, }) } else { return fs.createWriteStream(this.resourceLocalPath) } } /** * Read the data from this resource's file as a string. */ async read(): Promise { let stream: ReadableStream if ( this.filesystem ) { stream = await this.filesystem.getStoreFileAsStream({ storePath: this.resourceLocalPath, }) } else { stream = fs.createReadStream(this.resourceLocalPath) } const chunks: any[] = [] return new Promise((res, rej) => { stream.on('data', chunk => chunks.push(Buffer.from(chunk))) stream.on('error', rej) stream.on('end', () => res(Buffer.concat(chunks).toString('utf-8'))) }) } /** * Get a readable stream of this file's contents. */ async readStream(): Promise { if ( this.filesystem ) { return this.filesystem.getStoreFileAsStream({ storePath: this.resourceLocalPath, }) } else { return fs.createReadStream(this.resourceLocalPath) } } /* get mime_type() { return Mime.lookup(this.ext) } get content_type() { return Mime.contentType(this.ext) } get charset() { if ( this.mime_type ) { return Mime.charset(this.mime_type) } }*/ }