You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
349 lines
9.0 KiB
349 lines
9.0 KiB
3 years ago
|
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<void>((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<WritableStream> {
|
||
|
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<string>((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<ReadableStream> {
|
||
|
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)
|
||
|
}
|
||
|
}*/
|
||
|
}
|