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; /** * Possible prefixes for files referenced by a UniversalPath. */ export enum UniversalPathPrefix { HTTP = 'http://', HTTPS = 'https://', Local = 'file://', } /** * 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 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 _prefix!: string protected _local!: 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() { if ( this.initial.toLowerCase().startsWith('http://') ) { this._prefix = UniversalPathPrefix.HTTP } else if ( this.initial.toLowerCase().startsWith('https://') ) { this._prefix = UniversalPathPrefix.HTTPS } else if ( this.filesystem ) { this._prefix = this.filesystem.getPrefix() } else { this._prefix = UniversalPathPrefix.Local } } /** * 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() { this._local = this.initial if ( this.initial.toLowerCase().startsWith(this._prefix) ) { this._local = this._local.slice(this._prefix.length) } if ( this._prefix === UniversalPathPrefix.Local && !this._local.startsWith('/') && !this.filesystem ) { this._local = nodePath.resolve(this._local) } } /** * Return a new copy of this UniversalPath instance. */ clone() { return new UniversalPath(this.initial) } /** * Get the UniversalPathPrefix of this resource. */ get prefix() { return this._prefix } /** * Returns true if this resource refers to a file on the local filesystem. */ get isLocal() { return this._prefix === UniversalPathPrefix.Local && !this.filesystem } /** * Returns true if this resource refers to a file on a remote filesystem. */ get isRemote() { return this._prefix !== UniversalPathPrefix.Local || this.filesystem } /** * Get the non-prefixed path to this resource. */ get unqualified() { return this._local } /** * Get the path to this resource as it would be accessed from the current filesystem. */ get toLocal() { if ( this.isLocal ) { return this._local } else { return `${this.prefix}${this._local}` } } /** * Get the fully-prefixed path to this resource. */ get toRemote() { return `${this.prefix}${this._local}` } /** * 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._local += String(path) return this } /** * Cast the path to a string (fully-prefixed). */ toString() { return `${this.prefix}${this._local}` } /** * 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() { return nodePath.extname(this._local) } /** * 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() { return walk(this._local) } /** * Returns true if the given resource exists at the path. */ async exists() { if ( this.filesystem ) { const stat = await this.filesystem.stat({ storePath: this._local }) return stat.exists } try { await fs.promises.stat(this._local) return true } catch(e) { return false } } /** * Recursively create this path as a directory. Equivalent to `mkdir -p` on Linux. */ async mkdir() { if ( this.filesystem ) { await this.filesystem.mkdir({ storePath: this._local }) } else { await mkdirp(this._local) } } /** * Write the given data to this resource as a file. * @param data */ async write(data: string | Buffer) { if ( typeof data === 'string' ) data = Buffer.from(data, 'utf8') if ( this.filesystem ) { const stream = await this.filesystem.putStoreFileAsStream({ storePath: this._local }) await new Promise((res, rej) => { stream.write(data, err => { if ( err ) rej(err) else res() }) }) } else { const fd = await fs.promises.open(this._local, '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._local }) } else { return fs.createWriteStream(this._local) } } /** * Read the data from this resource's file as a string. */ async read() { let stream: ReadableStream if ( this.filesystem ) { stream = await this.filesystem.getStoreFileAsStream({ storePath: this._local }) } else { stream = fs.createReadStream(this._local) } 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._local }) } else { return fs.createReadStream(this._local) } } /*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) } }*/ }