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.
lib/src/util/support/path.ts

351 lines
9.5 KiB

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<boolean> {
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<void> {
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<void> {
if ( typeof data === 'string' ) {
data = Buffer.from(data, 'utf8')
}
if ( this.filesystem ) {
const stream = await this.filesystem.putStoreFileAsStream({
storePath: this.resourceLocalPath,
})
await new Promise<void>((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<WritableStream> {
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<string> {
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<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.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)
}
}*/
}