2020-03-21 13:19:48 +01:00
|
|
|
import {commandBinaries} from "./Commands";
|
2019-11-08 21:19:38 +01:00
|
|
|
import {emptyFunction, getFileExtension, IllegalArgumentError} from "./Shared";
|
2019-11-12 00:49:05 +01:00
|
|
|
import {Stream} from "./Stream";
|
2020-03-24 20:27:51 +01:00
|
|
|
import {HashProvider, User} from "./UserList";
|
2019-10-20 23:55:04 +02:00
|
|
|
|
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
|
|
|
* A file system.
|
|
|
|
*/
|
2019-10-20 23:55:04 +02:00
|
|
|
export class FileSystem {
|
2020-09-30 14:53:18 +02:00
|
|
|
static navRoot: Directory;
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
|
|
|
* The root directory.
|
|
|
|
*/
|
2019-11-04 18:19:24 +01:00
|
|
|
readonly root: Directory;
|
2019-10-20 23:55:04 +02:00
|
|
|
|
|
|
|
|
2020-09-30 14:53:18 +02:00
|
|
|
/**
|
|
|
|
* Loads the contents of my home directory based on the navigation API of fwdekker.com.
|
|
|
|
*
|
|
|
|
* @return an empty promise :'(
|
|
|
|
*/
|
|
|
|
static async loadNavApi(): Promise<any> {
|
|
|
|
await fetch("https://fwdekker.com/api/nav/")
|
|
|
|
.then(it => it.json())
|
|
|
|
.then(json => this.navRoot = this.unpack(json)[1] as Directory)
|
|
|
|
.catch(e => {
|
|
|
|
console.error("Failed to fetch navigation elements", e);
|
|
|
|
this.navRoot = new Directory();
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Unpacks the given entry from the navigation API.
|
|
|
|
*
|
|
|
|
* @param entry the entry to unpack
|
|
|
|
* @return the name and the (traversed and filled) node unpacked from the given entry
|
|
|
|
* @private
|
|
|
|
*/
|
|
|
|
private static unpack(entry: any): [string, Node] {
|
|
|
|
const name = entry.name?.toLowerCase()?.replace(/ /g, "-") ?? "";
|
|
|
|
|
|
|
|
if (entry.entries.length === 0)
|
|
|
|
return [`${name}.lnk`, new File(entry.link)];
|
|
|
|
|
|
|
|
const dir = new Directory();
|
2020-12-07 09:15:19 +01:00
|
|
|
entry.entries.forEach((child: any) => dir.add(...(this.unpack(child))));
|
2020-09-30 14:53:18 +02:00
|
|
|
return [name, dir];
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
|
|
|
* Constructs a new file system.
|
2019-10-30 22:13:28 +01:00
|
|
|
*
|
2019-10-31 22:46:42 +01:00
|
|
|
* @param root the directory to set as the root
|
2019-10-29 12:36:03 +01:00
|
|
|
*/
|
2019-10-31 22:46:42 +01:00
|
|
|
constructor(root: Directory | undefined = undefined) {
|
2020-09-30 14:53:18 +02:00
|
|
|
if (root !== undefined) {
|
2019-10-31 22:46:42 +01:00
|
|
|
this.root = root;
|
2020-09-30 14:53:18 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.root = new Directory({
|
|
|
|
"bin": Object.keys(commandBinaries)
|
|
|
|
.reduce((acc, key) => {
|
|
|
|
acc.add(key, new File(commandBinaries[key]));
|
|
|
|
return acc;
|
|
|
|
}, new Directory()),
|
|
|
|
"dev": new Directory({
|
|
|
|
"null": new NullFile(),
|
|
|
|
}),
|
|
|
|
"etc": new Directory({
|
|
|
|
"passwd": new File(
|
|
|
|
[
|
|
|
|
new User("root", HashProvider.default.hashPassword("g9PjKu"), "/root",
|
|
|
|
"You're a hacker, Harry!"),
|
|
|
|
new User("felix", HashProvider.default.hashPassword("password"), undefined,
|
|
|
|
"Who are <i>you</i>?")
|
|
|
|
].map(it => User.toString(it)).join("\n") + "\n"
|
|
|
|
),
|
|
|
|
}),
|
|
|
|
"home": new Directory({
|
|
|
|
"felix": new Directory({
|
|
|
|
"pgp.pub": new File("https://static.fwdekker.com/misc/pgp.pub.txt", "lnk"),
|
2020-12-07 08:14:23 +01:00
|
|
|
"privacy.lnk": new File("https://fwdekker.com/privacy/"),
|
2020-09-30 14:53:18 +02:00
|
|
|
"resume.pdf": new File("https://static.fwdekker.com/misc/resume.pdf", "lnk"),
|
|
|
|
}),
|
|
|
|
}),
|
|
|
|
"root": new Directory({
|
|
|
|
"password.txt": new File("root: g9PjKu"),
|
|
|
|
}),
|
|
|
|
});
|
2020-09-30 16:25:18 +02:00
|
|
|
|
|
|
|
const home = this.get(new Path("home", "felix")) as Directory;
|
|
|
|
Object.keys(FileSystem.navRoot.nodes).forEach(name => home.add(name, FileSystem.navRoot.get(name)!));
|
2019-10-30 22:13:28 +01:00
|
|
|
}
|
|
|
|
|
2019-10-21 03:13:14 +02:00
|
|
|
|
2019-11-02 18:19:10 +01:00
|
|
|
/**
|
2019-11-04 18:19:24 +01:00
|
|
|
* Adds the given node at the given path.
|
2019-11-02 18:19:10 +01:00
|
|
|
*
|
2019-11-04 18:19:24 +01:00
|
|
|
* @param target the path to add the node at
|
|
|
|
* @param node the node to add
|
|
|
|
* @param createParents `true` if and only if intermediate directories should be created if they do not exist yet
|
|
|
|
* @throws if the parent directory does not exist and `createParents` is `false`, if the parent is not a directory,
|
|
|
|
* or if there already exists a node at the given location
|
2019-11-02 18:19:10 +01:00
|
|
|
*/
|
2019-11-04 18:19:24 +01:00
|
|
|
add(target: Path, node: Node, createParents: boolean): void {
|
2019-11-13 14:47:06 +01:00
|
|
|
if (target.isDirectory && !(node instanceof Directory))
|
|
|
|
throw new IllegalArgumentError(`Cannot add non-directory at '${target}/'.`);
|
|
|
|
|
2019-11-04 18:19:24 +01:00
|
|
|
if (!this.has(target.parent)) {
|
|
|
|
if (createParents)
|
|
|
|
this.add(target.parent, new Directory(), true);
|
2019-10-21 02:25:42 +02:00
|
|
|
else
|
2019-11-06 14:47:14 +01:00
|
|
|
throw new IllegalArgumentError(`The directory '${target.parent}' does not exist.`);
|
2019-11-04 18:19:24 +01:00
|
|
|
}
|
2019-10-31 22:17:46 +01:00
|
|
|
|
2019-11-04 18:19:24 +01:00
|
|
|
const parent = this.get(target.parent);
|
2019-10-31 22:17:46 +01:00
|
|
|
if (!(parent instanceof Directory))
|
2019-11-06 14:47:14 +01:00
|
|
|
throw new IllegalArgumentError(`'${target.parent}' is not a directory.`);
|
2019-11-13 14:47:06 +01:00
|
|
|
if (parent.has(target.fileName))
|
2019-11-06 14:47:14 +01:00
|
|
|
throw new IllegalArgumentError(`A file or directory already exists at '${target}'.`);
|
2018-11-29 20:56:27 +01:00
|
|
|
|
2019-11-13 14:47:06 +01:00
|
|
|
parent.add(target.fileName, node);
|
2018-11-29 20:56:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2019-11-04 18:19:24 +01:00
|
|
|
* Copies `source` to `destination`.
|
2018-11-29 13:34:46 +01:00
|
|
|
*
|
2019-11-06 00:58:16 +01:00
|
|
|
* @param source the path to the file or directory to copy
|
|
|
|
* @param destination the path to the destination
|
2019-10-31 14:15:27 +01:00
|
|
|
* @param isRecursive if copying should happen recursively if the source is a directory
|
2019-11-04 18:19:24 +01:00
|
|
|
* @throws if the source is a directory and `isRecursive` is `false`, if the source does not exist, if the target's
|
|
|
|
* parent does not exist, if the target's parent is not a directory, or if the target already exists
|
2018-11-29 13:34:46 +01:00
|
|
|
*/
|
2019-11-06 00:58:16 +01:00
|
|
|
copy(source: Path, destination: Path, isRecursive: boolean): void {
|
2019-11-07 00:54:13 +01:00
|
|
|
if (source.isAncestorOf(destination))
|
2019-11-10 14:50:43 +01:00
|
|
|
throw new IllegalArgumentError("Cannot copy directory into itself.");
|
2018-11-29 13:34:46 +01:00
|
|
|
|
2019-11-06 00:58:16 +01:00
|
|
|
const sourceNode = this.get(source);
|
|
|
|
if (sourceNode === undefined)
|
2019-11-06 14:47:14 +01:00
|
|
|
throw new IllegalArgumentError(`File or directory '${source}' does not exist.`);
|
2019-11-06 00:58:16 +01:00
|
|
|
if (sourceNode instanceof Directory && !isRecursive)
|
2019-11-06 14:47:14 +01:00
|
|
|
throw new IllegalArgumentError(`'${source}' is a directory.`);
|
2018-11-29 13:34:46 +01:00
|
|
|
|
2019-11-06 00:58:16 +01:00
|
|
|
this.add(destination, sourceNode.copy(), false);
|
2018-11-29 13:34:46 +01:00
|
|
|
}
|
|
|
|
|
2018-11-28 22:23:11 +01:00
|
|
|
/**
|
2019-11-04 18:19:24 +01:00
|
|
|
* Returns the node at the given path, or `undefined` if the node does not exist.
|
2018-11-28 22:23:11 +01:00
|
|
|
*
|
2019-11-04 18:19:24 +01:00
|
|
|
* @param target the path of the node to return
|
2018-11-28 22:23:11 +01:00
|
|
|
*/
|
2019-11-04 18:19:24 +01:00
|
|
|
get(target: Path): Node | undefined {
|
|
|
|
if (target.toString() === "/")
|
|
|
|
return this.root;
|
2018-11-29 00:05:37 +01:00
|
|
|
|
2019-11-04 18:19:24 +01:00
|
|
|
const parent = this.get(target.parent);
|
2019-11-13 14:47:06 +01:00
|
|
|
if (!(parent instanceof Directory))
|
2019-11-04 18:19:24 +01:00
|
|
|
return undefined;
|
2018-11-28 19:51:48 +01:00
|
|
|
|
2019-11-13 14:47:06 +01:00
|
|
|
const node = parent.get(target.fileName);
|
|
|
|
if (target.isDirectory && !(node instanceof Directory))
|
|
|
|
return undefined;
|
|
|
|
|
|
|
|
return parent.get(target.fileName);
|
2018-11-28 22:23:11 +01:00
|
|
|
}
|
2018-11-28 19:51:48 +01:00
|
|
|
|
2018-11-29 13:01:55 +01:00
|
|
|
/**
|
2019-11-04 18:19:24 +01:00
|
|
|
* Returns `true` if and only if there exists a node at the given path.
|
2018-11-29 13:01:55 +01:00
|
|
|
*
|
2019-11-04 18:19:24 +01:00
|
|
|
* @param target the path to check for node presence
|
2018-11-29 13:01:55 +01:00
|
|
|
*/
|
2019-11-04 18:19:24 +01:00
|
|
|
has(target: Path): boolean {
|
2019-11-13 14:47:06 +01:00
|
|
|
return this.get(target) !== undefined;
|
2018-11-29 09:08:24 +01:00
|
|
|
}
|
|
|
|
|
2018-11-29 13:18:48 +01:00
|
|
|
/**
|
2019-11-04 18:19:24 +01:00
|
|
|
* Moves `source` to `destination`.
|
2018-11-29 13:18:48 +01:00
|
|
|
*
|
2019-11-04 18:19:24 +01:00
|
|
|
* @param source the path to the file or directory to move
|
|
|
|
* @param destination the path to the destination
|
2019-11-06 00:58:16 +01:00
|
|
|
* @throws if there is no node at `source`, if `destination` already exist, or if `destination`'s parent does not
|
|
|
|
* exist
|
2018-11-29 13:18:48 +01:00
|
|
|
*/
|
2019-11-04 18:19:24 +01:00
|
|
|
move(source: Path, destination: Path): void {
|
2019-11-07 00:54:13 +01:00
|
|
|
if (source.isAncestorOf(destination))
|
2019-11-06 14:47:14 +01:00
|
|
|
throw new IllegalArgumentError("Cannot move directory into itself.");
|
2018-11-29 13:50:36 +01:00
|
|
|
|
2019-11-06 00:58:16 +01:00
|
|
|
const sourceNode = this.get(source);
|
|
|
|
if (sourceNode === undefined)
|
2019-11-06 14:47:14 +01:00
|
|
|
throw new IllegalArgumentError(`File or directory '${source}' does not exist.`);
|
2019-11-06 00:58:16 +01:00
|
|
|
|
|
|
|
this.add(destination, sourceNode, false);
|
2019-11-07 01:21:56 +01:00
|
|
|
this.remove(source);
|
2018-11-29 09:20:23 +01:00
|
|
|
}
|
|
|
|
|
2019-11-09 22:16:04 +01:00
|
|
|
/**
|
2019-11-12 00:49:05 +01:00
|
|
|
* Opens a file stream to the file at the given path.
|
2019-11-09 22:16:04 +01:00
|
|
|
*
|
2019-11-12 00:49:05 +01:00
|
|
|
* @param target the path to the file to open a stream to
|
|
|
|
* @param mode the mode to open the file with
|
|
|
|
* @throws if the target or its parent does not exist, or if the target is not a file
|
2019-11-09 22:16:04 +01:00
|
|
|
*/
|
2019-11-12 00:49:05 +01:00
|
|
|
open(target: Path, mode: FileMode): FileStream {
|
2019-11-13 14:47:06 +01:00
|
|
|
if (!this.has(target)) {
|
|
|
|
if (mode === "append" || mode === "write")
|
|
|
|
this.add(target, new File(), false);
|
|
|
|
else
|
|
|
|
throw new IllegalArgumentError(`File '${target}' does not exist.`);
|
|
|
|
}
|
2019-11-09 22:16:04 +01:00
|
|
|
|
|
|
|
const targetNode = this.get(target);
|
|
|
|
if (!(targetNode instanceof File))
|
2019-11-13 14:47:06 +01:00
|
|
|
throw new IllegalArgumentError(`Cannot open directory '${target}'.`);
|
2019-11-09 22:16:04 +01:00
|
|
|
|
2019-11-12 00:49:05 +01:00
|
|
|
return targetNode.open(mode);
|
2019-11-09 22:16:04 +01:00
|
|
|
}
|
|
|
|
|
2018-11-28 22:23:11 +01:00
|
|
|
/**
|
2019-11-04 18:19:24 +01:00
|
|
|
* Removes a node from the file system.
|
2018-11-28 22:23:11 +01:00
|
|
|
*
|
2019-11-07 01:21:56 +01:00
|
|
|
* If the node in question does not exist, the function will return successfully.
|
|
|
|
*
|
2019-11-13 14:47:06 +01:00
|
|
|
* @param target the path to the node to be removed
|
2018-11-28 22:23:11 +01:00
|
|
|
*/
|
2019-11-13 14:47:06 +01:00
|
|
|
remove(target: Path): void {
|
|
|
|
const parent = this.get(target.parent);
|
2019-11-04 19:57:13 +01:00
|
|
|
if (!(parent instanceof Directory))
|
2019-11-07 01:21:56 +01:00
|
|
|
return;
|
2018-11-29 09:08:24 +01:00
|
|
|
|
2019-11-13 14:47:06 +01:00
|
|
|
const node = this.get(target);
|
|
|
|
if (target.isDirectory && !(node instanceof Directory))
|
|
|
|
return;
|
|
|
|
|
|
|
|
parent.remove(target.fileName);
|
2018-11-29 09:08:24 +01:00
|
|
|
}
|
2020-03-21 13:19:48 +01:00
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Determines the path of the given sources if they are moved into the given destination.
|
|
|
|
*
|
|
|
|
* @param sources the paths to the source files that are to be moved relative to the destination path
|
|
|
|
* @param destination the path into which the sources should be moved
|
|
|
|
* @return a mapping from the given source paths to the new destination paths
|
|
|
|
*/
|
|
|
|
determineMoveMappings(sources: Path[], destination: Path): [Path, Path][] {
|
|
|
|
let mappings: [Path, Path][];
|
|
|
|
if (this.has(destination)) {
|
|
|
|
// Move into directory
|
|
|
|
if (!(this.get(destination) instanceof Directory)) {
|
|
|
|
if (sources.length === 1)
|
|
|
|
throw new IllegalArgumentError(`'${destination}' already exists.`);
|
|
|
|
else
|
|
|
|
throw new IllegalArgumentError(`'${destination}' is not a directory.`);
|
|
|
|
}
|
|
|
|
|
|
|
|
mappings = sources.map(source => [source, destination.getChild(source.fileName)]);
|
|
|
|
} else {
|
|
|
|
// Move to exact location
|
|
|
|
if (sources.length !== 1)
|
|
|
|
throw new IllegalArgumentError(`'${destination}' is not a directory.`);
|
|
|
|
|
|
|
|
if (!(this.get(destination.parent) instanceof Directory))
|
|
|
|
throw new IllegalArgumentError(`'${destination.parent}' is not a directory.`);
|
|
|
|
|
|
|
|
mappings = sources.map(path => [path, destination]);
|
|
|
|
}
|
|
|
|
|
|
|
|
return mappings;
|
|
|
|
}
|
2018-11-28 22:23:11 +01:00
|
|
|
}
|
2018-11-29 00:05:37 +01:00
|
|
|
|
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
|
|
|
* A path to a node in the file system.
|
|
|
|
*/
|
2019-10-20 23:55:04 +02:00
|
|
|
export class Path {
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
2019-10-31 22:17:46 +01:00
|
|
|
* The full absolute path to the node described by this path.
|
2019-10-29 12:36:03 +01:00
|
|
|
*/
|
2019-10-31 22:17:46 +01:00
|
|
|
private readonly path: string;
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
2019-10-31 22:17:46 +01:00
|
|
|
* The absolute path to the parent node of the node described by this path.
|
2019-10-29 12:36:03 +01:00
|
|
|
*/
|
2019-10-31 22:17:46 +01:00
|
|
|
private readonly _parent: string;
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
2019-10-31 22:17:46 +01:00
|
|
|
* The name of the node described by this path.
|
2019-10-29 12:36:03 +01:00
|
|
|
*/
|
2019-10-31 22:17:46 +01:00
|
|
|
readonly fileName: string;
|
2019-11-13 14:47:06 +01:00
|
|
|
/**
|
|
|
|
* `true` if and only if the path necessarily points to a directory.
|
|
|
|
*/
|
|
|
|
readonly isDirectory: boolean;
|
2019-10-20 23:55:04 +02:00
|
|
|
|
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
|
|
|
* Constructs a new path.
|
|
|
|
*
|
2019-11-13 14:47:06 +01:00
|
|
|
* @param paths a set of strings that describe the path
|
2019-10-29 12:36:03 +01:00
|
|
|
*/
|
2019-11-04 18:19:24 +01:00
|
|
|
constructor(...paths: string[]) {
|
2019-11-11 22:59:43 +01:00
|
|
|
const path = `/${paths.join("/")}/`;
|
|
|
|
|
|
|
|
const parts = [];
|
|
|
|
let part = "";
|
|
|
|
for (let i = 0; i < path.length; i++) {
|
|
|
|
const char = path[i];
|
|
|
|
if (char !== "/") {
|
|
|
|
part += char;
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (part === ".") {
|
|
|
|
// Do nothing
|
|
|
|
} else if (part === "..") {
|
|
|
|
parts.pop();
|
|
|
|
} else if (part !== "") {
|
|
|
|
parts.push(part);
|
|
|
|
}
|
|
|
|
part = "";
|
|
|
|
}
|
|
|
|
if (part !== "")
|
|
|
|
parts.push(part);
|
|
|
|
|
|
|
|
this.path = "/" + parts.join("/");
|
2019-10-31 22:17:46 +01:00
|
|
|
this._parent = parts.slice(0, -1).join("/");
|
|
|
|
this.fileName = parts.slice(-1).join("");
|
2019-11-13 14:47:06 +01:00
|
|
|
this.isDirectory = paths[paths.length - 1].endsWith("/");
|
2019-10-31 22:17:46 +01:00
|
|
|
}
|
2018-12-06 10:29:14 +01:00
|
|
|
|
2019-11-04 18:19:24 +01:00
|
|
|
/**
|
|
|
|
* Interprets a (set of) paths that may or may not be absolute.
|
|
|
|
*
|
|
|
|
* If only `cwd` is given, a path to the `cwd` is returned.
|
|
|
|
* If the first path in `paths` starts with a `/`, a new path is returned using only `paths` and not `cwd` is
|
|
|
|
* returned.
|
|
|
|
* Otherwise, a path using first `cwd` and then `paths` is returned.
|
|
|
|
*
|
|
|
|
* @param cwd the current working directory, used as a baseline
|
|
|
|
* @param paths the paths that may or may not be absolute
|
|
|
|
*/
|
|
|
|
static interpret(cwd: string, ...paths: string[]): Path {
|
|
|
|
if (paths.length === 0)
|
|
|
|
return new Path(cwd);
|
|
|
|
if (paths[0].startsWith("/"))
|
|
|
|
return new Path(...paths);
|
|
|
|
|
|
|
|
return new Path(cwd, ...paths);
|
|
|
|
}
|
|
|
|
|
2019-10-31 22:17:46 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns the path describing the parent directory.
|
|
|
|
*/
|
|
|
|
get parent(): Path {
|
|
|
|
return new Path(this._parent);
|
2018-12-06 10:29:14 +01:00
|
|
|
}
|
|
|
|
|
2019-11-04 18:19:24 +01:00
|
|
|
/**
|
|
|
|
* Returns all ancestors of this path, starting at the parent and ending at the root.
|
|
|
|
*/
|
|
|
|
get ancestors(): Path[] {
|
|
|
|
const parents: Path[] = [];
|
|
|
|
|
|
|
|
let path: Path = this.parent;
|
|
|
|
while (path.path !== "/") {
|
|
|
|
parents.push(path);
|
|
|
|
path = path.parent;
|
|
|
|
}
|
|
|
|
if (this.path !== "/")
|
|
|
|
parents.push(path);
|
|
|
|
|
|
|
|
return parents;
|
|
|
|
}
|
|
|
|
|
2018-12-06 10:29:14 +01:00
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
2019-10-31 22:17:46 +01:00
|
|
|
* Returns a path describing the path to the desired child node of `this` path.
|
|
|
|
*
|
|
|
|
* @param child the path to the desired node relative to `this` path
|
|
|
|
*/
|
|
|
|
getChild(child: string): Path {
|
|
|
|
return new Path(this.path + "/" + child);
|
|
|
|
}
|
|
|
|
|
2019-11-07 00:54:13 +01:00
|
|
|
/**
|
|
|
|
* Returns all ancestors up to and including the given ancestor.
|
|
|
|
*
|
|
|
|
* If the given ancestor is this path, an empty array is returned.
|
|
|
|
*
|
|
|
|
* @param ancestor the last ancestor to return
|
|
|
|
*/
|
|
|
|
getAncestorsUntil(ancestor: Path): Path[] {
|
|
|
|
if (ancestor.path === this.path)
|
|
|
|
return [];
|
|
|
|
if (!ancestor.isAncestorOf(this))
|
|
|
|
throw new IllegalArgumentError("Cannot determine intermediate directories to non-ancestor.");
|
|
|
|
|
|
|
|
return this.ancestors.filter(it => !it.isAncestorOf(ancestor));
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns `true` if and only if this path is an ancestor of the given path.
|
|
|
|
*
|
|
|
|
* @param path the path to check for ancestorness
|
|
|
|
*/
|
|
|
|
isAncestorOf(path: Path): boolean {
|
|
|
|
return path.ancestors.some(path => path.path === this.path);
|
|
|
|
}
|
|
|
|
|
2019-10-31 22:17:46 +01:00
|
|
|
/**
|
|
|
|
* Returns the string representation of this path.
|
2019-10-29 12:36:03 +01:00
|
|
|
*
|
2019-11-03 17:57:57 +01:00
|
|
|
* @param escape `true` if and only if special characters should be escaped for use inside strings
|
2019-10-29 12:36:03 +01:00
|
|
|
*/
|
2019-11-02 19:58:20 +01:00
|
|
|
toString(escape: boolean = false): string {
|
|
|
|
if (!escape)
|
|
|
|
return this.path;
|
|
|
|
|
2019-11-23 13:14:32 +01:00
|
|
|
const escapes = [
|
|
|
|
["\\;", "\\;"],
|
|
|
|
["\\'", "\\\'"],
|
2020-03-17 19:22:59 +01:00
|
|
|
["\\\"", "\\\""],
|
2019-11-23 13:14:32 +01:00
|
|
|
[" ", "\\ "],
|
|
|
|
["\\\\", "\\\\"],
|
|
|
|
["\\~", "\\~"],
|
|
|
|
["\\$", "\\$"],
|
|
|
|
["\\>", "\\>"],
|
|
|
|
["\\?", "\\?"],
|
|
|
|
["\\*", "\\*"],
|
|
|
|
["\\{", "\\{"],
|
|
|
|
["\\}", "\\}"],
|
|
|
|
];
|
|
|
|
|
|
|
|
return escapes.reduce((path, instr) => path.replace(new RegExp(instr[0], "g"), instr[1]), this.path);
|
2018-12-06 10:29:14 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* A node in the file system.
|
|
|
|
*
|
|
|
|
* Nodes do not know their own name, instead they are similar to how real file systems manage nodes. The name of a node
|
|
|
|
* is determined by the directory it is in.
|
|
|
|
*/
|
2019-10-21 02:25:42 +02:00
|
|
|
export abstract class Node {
|
2019-10-30 20:59:31 +01:00
|
|
|
/**
|
|
|
|
* A string describing what kind of node this is.
|
|
|
|
*
|
|
|
|
* This string is used to determine how to deserialize a JSON string. Yes, this violates the open/closed principle.
|
|
|
|
*/
|
|
|
|
protected abstract type: string;
|
|
|
|
|
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
|
|
|
* Returns a deep copy of this node.
|
|
|
|
*/
|
2019-10-21 02:25:42 +02:00
|
|
|
abstract copy(): Node;
|
2018-11-29 12:42:14 +01:00
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
2019-10-31 23:22:37 +01:00
|
|
|
* Returns a string representation of this node given the name of and path to this node.
|
2019-10-29 12:36:03 +01:00
|
|
|
*
|
|
|
|
* @param name the name of this node
|
2019-10-31 23:22:37 +01:00
|
|
|
* @param path the path to this node
|
2019-10-29 12:36:03 +01:00
|
|
|
*/
|
2019-10-31 23:22:37 +01:00
|
|
|
abstract nameString(name: string, path: Path): string;
|
2018-11-29 12:42:14 +01:00
|
|
|
|
2019-11-06 22:27:25 +01:00
|
|
|
/**
|
|
|
|
* Recursively visits all nodes contained within this node.
|
|
|
|
*
|
|
|
|
* @param path the path to the current node
|
|
|
|
* @param fun the function to apply to each node, including this node
|
|
|
|
* @param pre the function to apply to the current node before applying the first `fun`
|
|
|
|
* @param post the function to apply to the current node after applying the last `fun`
|
|
|
|
*/
|
|
|
|
abstract visit(path: string,
|
|
|
|
fun: (node: Node, path: string) => void,
|
|
|
|
pre: (node: Node, path: string) => void,
|
|
|
|
post: (node: Node, path: string) => void): void;
|
|
|
|
|
2019-10-30 20:59:31 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns the JSON deserialization of the given string as a node.
|
|
|
|
*
|
|
|
|
* This method will automatically detect what kind of node is described by the string and will call the
|
|
|
|
* corresponding parse method for that type.
|
|
|
|
*
|
|
|
|
* @param json a JSON string or object describing a node
|
|
|
|
*/
|
|
|
|
static deserialize(json: string | any): Node {
|
|
|
|
if (typeof json === "string") {
|
|
|
|
return this.deserialize(JSON.parse(json));
|
|
|
|
} else {
|
|
|
|
switch (json["type"]) {
|
|
|
|
case "Directory":
|
|
|
|
return Directory.parse(json);
|
|
|
|
case "File":
|
|
|
|
return File.parse(json);
|
2019-11-14 00:32:42 +01:00
|
|
|
case "NullFile":
|
|
|
|
return NullFile.parse(json);
|
2019-10-30 20:59:31 +01:00
|
|
|
default:
|
2019-11-04 18:19:24 +01:00
|
|
|
throw `Unknown node type '${json["type"]}'.`;
|
2019-10-30 20:59:31 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2018-11-29 12:42:14 +01:00
|
|
|
}
|
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
|
|
|
* A directory that can contain other nodes.
|
|
|
|
*/
|
2019-10-20 23:55:04 +02:00
|
|
|
export class Directory extends Node {
|
2019-10-30 20:59:31 +01:00
|
|
|
protected type: string = "Directory";
|
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
|
|
|
* The nodes contained in this directory, indexed by name.
|
|
|
|
*
|
|
|
|
* The reflexive directory (`"."`) and parent directory (`".."`) are not stored in this field.
|
|
|
|
*/
|
2019-10-30 22:13:28 +01:00
|
|
|
private readonly _nodes: { [name: string]: Node };
|
2019-10-20 23:55:04 +02:00
|
|
|
|
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
|
|
|
* Constructs a new directory with the given nodes.
|
|
|
|
*
|
2019-10-30 20:02:39 +01:00
|
|
|
* @param nodes the nodes the directory should contain; the directory stores a shallow copy of this object
|
2019-10-29 12:36:03 +01:00
|
|
|
*/
|
2019-10-30 22:13:28 +01:00
|
|
|
constructor(nodes: { [name: string]: Node } = {}) {
|
2018-11-30 15:23:03 +01:00
|
|
|
super();
|
2018-11-29 12:42:14 +01:00
|
|
|
|
2019-11-04 19:57:13 +01:00
|
|
|
this._nodes = nodes;
|
2018-11-29 12:42:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
|
|
|
* Returns a copy of all nodes contained in this directory.
|
|
|
|
*/
|
2019-10-30 22:13:28 +01:00
|
|
|
get nodes(): { [name: string]: Node } {
|
2018-11-30 15:23:03 +01:00
|
|
|
return Object.assign({}, this._nodes);
|
2018-11-29 12:42:14 +01:00
|
|
|
}
|
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
|
|
|
* Returns the number of nodes in this directory.
|
|
|
|
*/
|
2019-10-21 02:25:42 +02:00
|
|
|
get nodeCount(): number {
|
2018-11-30 15:23:03 +01:00
|
|
|
return Object.keys(this._nodes).length;
|
2018-11-29 12:42:14 +01:00
|
|
|
}
|
|
|
|
|
2019-10-21 02:25:42 +02:00
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
2019-11-13 14:47:06 +01:00
|
|
|
* Returns the node with the given name, or `undefined` if there is no such node.
|
2019-10-29 12:36:03 +01:00
|
|
|
*
|
|
|
|
* @param name the name of the node to return
|
|
|
|
*/
|
2019-11-13 14:47:06 +01:00
|
|
|
get(name: string): Node | undefined {
|
2019-10-30 20:02:39 +01:00
|
|
|
return this._nodes[name];
|
2018-11-29 12:42:14 +01:00
|
|
|
}
|
|
|
|
|
2019-10-31 22:17:46 +01:00
|
|
|
/**
|
2019-11-12 00:57:24 +01:00
|
|
|
* Returns `true` if and only if this directory contains a node with the given name.
|
2019-10-31 22:17:46 +01:00
|
|
|
*
|
|
|
|
* @param name the name to check
|
|
|
|
*/
|
2019-11-13 14:47:06 +01:00
|
|
|
has(name: string): boolean {
|
2019-10-31 22:17:46 +01:00
|
|
|
return this._nodes.hasOwnProperty(name);
|
|
|
|
}
|
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
|
|
|
* Adds the given node with the given name to this directory.
|
|
|
|
*
|
|
|
|
* @param name the name of the node in this directory
|
|
|
|
* @param node the node to add to this directory
|
|
|
|
*/
|
2019-11-13 14:47:06 +01:00
|
|
|
add(name: string, node: Node): void {
|
2019-11-04 19:57:13 +01:00
|
|
|
if (new Path(`/${name}`).toString() === "/" || name.indexOf("/") >= 0)
|
|
|
|
throw new IllegalArgumentError(`Cannot add node with name '${name}'.`);
|
|
|
|
|
2018-11-30 15:23:03 +01:00
|
|
|
this._nodes[name] = node;
|
2018-11-29 12:42:14 +01:00
|
|
|
}
|
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
2019-11-04 18:19:24 +01:00
|
|
|
* Removes the node with the given name.
|
2019-10-29 12:36:03 +01:00
|
|
|
*
|
2019-11-04 18:19:24 +01:00
|
|
|
* @param name the name of the node to remove
|
2019-10-29 12:36:03 +01:00
|
|
|
* @throws if the given node is not contained in this directory
|
|
|
|
*/
|
2019-11-13 14:47:06 +01:00
|
|
|
remove(name: string): void {
|
2019-11-04 18:19:24 +01:00
|
|
|
if (name === "" || name === ".") {
|
2019-11-13 14:47:06 +01:00
|
|
|
Object.keys(this._nodes).forEach(node => this.remove(node));
|
2019-11-04 18:19:24 +01:00
|
|
|
return;
|
2018-11-29 12:42:14 +01:00
|
|
|
}
|
2019-11-04 18:19:24 +01:00
|
|
|
|
|
|
|
delete this._nodes[name];
|
2018-11-29 12:42:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
2019-10-21 02:25:42 +02:00
|
|
|
copy(): Directory {
|
2019-10-31 14:15:27 +01:00
|
|
|
const nodes: { [name: string]: Node } = {};
|
|
|
|
for (const name in this._nodes)
|
|
|
|
nodes[name] = this._nodes[name].copy();
|
|
|
|
|
|
|
|
return new Directory(nodes);
|
2018-11-29 12:42:14 +01:00
|
|
|
}
|
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
|
|
|
* Returns a string that contains an HTML hyperlink that runs a command to `cd` to this directory.
|
|
|
|
*
|
|
|
|
* @param name the name of this node
|
2019-10-31 23:22:37 +01:00
|
|
|
* @param path the path to this node
|
2019-10-29 12:36:03 +01:00
|
|
|
*/
|
2019-10-31 23:22:37 +01:00
|
|
|
nameString(name: string, path: Path): string {
|
2020-09-30 16:50:45 +02:00
|
|
|
return `<a href="#" class="dirLink" onclick="execute('cd ${path.toString(true)}; and ls -l')">${name}</a>`;
|
2018-11-29 12:42:14 +01:00
|
|
|
}
|
|
|
|
|
2019-11-06 22:27:25 +01:00
|
|
|
visit(path: string,
|
|
|
|
fun: (node: Node, path: string) => void,
|
|
|
|
pre: (node: Node, path: string) => void = emptyFunction,
|
|
|
|
post: (node: Node, path: string) => void = emptyFunction) {
|
|
|
|
pre(this, path);
|
|
|
|
|
|
|
|
fun(this, path);
|
|
|
|
Object.keys(this._nodes).forEach(name => this._nodes[name].visit(`${path}/${name}`, fun, pre, post));
|
|
|
|
|
|
|
|
post(this, path);
|
|
|
|
}
|
|
|
|
|
2019-10-30 20:59:31 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Parses the given object into a directory.
|
|
|
|
*
|
|
|
|
* The nodes inside the directory of the given object are also recursively parsed by this method.
|
|
|
|
*
|
|
|
|
* @param obj the object that describes a directory
|
|
|
|
*/
|
|
|
|
static parse(obj: any): Directory {
|
|
|
|
if (obj["type"] !== "Directory")
|
2019-11-04 18:19:24 +01:00
|
|
|
throw `Cannot deserialize node of type '${obj["type"]}'.`;
|
2019-10-30 20:59:31 +01:00
|
|
|
|
2019-10-30 22:13:28 +01:00
|
|
|
const nodes: { [name: string]: Node } = {};
|
2019-11-06 01:08:32 +01:00
|
|
|
for (const name of Object.getOwnPropertyNames(obj["_nodes"]))
|
|
|
|
nodes[name] = Node.deserialize(obj["_nodes"][name]);
|
2019-10-30 20:59:31 +01:00
|
|
|
|
2019-10-30 22:13:28 +01:00
|
|
|
return new Directory(nodes);
|
2019-10-30 20:59:31 +01:00
|
|
|
}
|
2018-11-29 12:42:14 +01:00
|
|
|
}
|
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
|
|
|
* A simple file without contents.
|
|
|
|
*/
|
2019-10-20 23:55:04 +02:00
|
|
|
export class File extends Node {
|
2019-10-30 20:59:31 +01:00
|
|
|
protected type: string = "File";
|
|
|
|
|
2019-11-14 23:21:06 +01:00
|
|
|
/**
|
|
|
|
* The type of file; overrides behavior inferred from the extension.
|
|
|
|
*/
|
|
|
|
private readonly mime: string | undefined;
|
|
|
|
|
2019-10-31 01:27:31 +01:00
|
|
|
/**
|
2019-11-12 01:10:53 +01:00
|
|
|
* The contents of this file. !!Do not use this field directly!!
|
2019-10-31 01:27:31 +01:00
|
|
|
*/
|
2019-11-12 01:10:53 +01:00
|
|
|
_contents: string;
|
2019-10-31 01:27:31 +01:00
|
|
|
|
2019-10-30 20:59:31 +01:00
|
|
|
|
2019-10-29 12:36:03 +01:00
|
|
|
/**
|
|
|
|
* Constructs a new file.
|
2019-11-12 01:10:53 +01:00
|
|
|
*
|
|
|
|
* @param contents the contents of this file
|
2019-11-14 23:21:06 +01:00
|
|
|
* @param mime the type of file; overrides behavior inferred from the extension.
|
2019-10-29 12:36:03 +01:00
|
|
|
*/
|
2019-11-14 23:21:06 +01:00
|
|
|
constructor(contents: string = "", mime: string | undefined = undefined) {
|
2018-11-30 15:23:03 +01:00
|
|
|
super();
|
2019-10-31 01:27:31 +01:00
|
|
|
|
2019-11-12 01:10:53 +01:00
|
|
|
this._contents = contents;
|
2019-11-14 23:21:06 +01:00
|
|
|
this.mime = mime;
|
2019-11-12 01:10:53 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns the contents of this file.
|
|
|
|
*/
|
|
|
|
get contents(): string {
|
|
|
|
return this._contents;
|
2018-11-29 12:42:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
2019-11-12 00:49:05 +01:00
|
|
|
/**
|
|
|
|
* Opens an in- and output stream to this file.
|
|
|
|
*
|
|
|
|
* @param mode the mode in which to open the file
|
|
|
|
*/
|
|
|
|
open(mode: FileMode): FileStream {
|
|
|
|
switch (mode) {
|
|
|
|
case "append":
|
|
|
|
return new FileStream(this, this.contents.length);
|
|
|
|
case "read":
|
|
|
|
return new FileStream(this, 0);
|
|
|
|
case "write":
|
2019-11-12 01:10:53 +01:00
|
|
|
this._contents = "";
|
2019-11-12 00:49:05 +01:00
|
|
|
return new FileStream(this, 0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2019-10-21 02:25:42 +02:00
|
|
|
copy(): File {
|
2019-10-31 22:17:46 +01:00
|
|
|
return new File(this.contents);
|
2018-11-29 12:42:14 +01:00
|
|
|
}
|
|
|
|
|
2019-10-31 23:22:37 +01:00
|
|
|
nameString(name: string, path: Path): string {
|
2019-11-14 23:21:06 +01:00
|
|
|
switch (this.mime ?? getFileExtension(name)) {
|
2020-12-07 09:15:19 +01:00
|
|
|
case "jsh": {
|
|
|
|
const script = `execute('${path.toString(true)}'); return false`;
|
2019-11-14 23:21:06 +01:00
|
|
|
return `<a href="#" class="fileLink" onclick="${script}">${name}</a>`;
|
|
|
|
}
|
|
|
|
case "lnk": {
|
2019-11-03 19:04:36 +01:00
|
|
|
const script = `execute('open ${path.toString(true)}'); return false`;
|
|
|
|
return `<a href="${this.contents}" class="fileLink" onclick="${script}">${name}</a>`;
|
2019-11-14 23:21:06 +01:00
|
|
|
}
|
2020-12-07 09:15:19 +01:00
|
|
|
case "txt": {
|
|
|
|
const script = `execute('cat ${path.toString(true)}')`;
|
|
|
|
return `<a href="#" class="fileLink" onclick="${script}">${name}</a>`;
|
|
|
|
}
|
2019-10-31 01:27:31 +01:00
|
|
|
default:
|
|
|
|
return name;
|
|
|
|
}
|
2018-11-29 12:42:14 +01:00
|
|
|
}
|
|
|
|
|
2019-11-06 22:27:25 +01:00
|
|
|
visit(path: string,
|
|
|
|
fun: (node: Node, path: string) => void,
|
|
|
|
pre: (node: Node, path: string) => void = emptyFunction,
|
|
|
|
post: (node: Node, path: string) => void = emptyFunction) {
|
|
|
|
pre(this, path);
|
|
|
|
fun(this, path);
|
|
|
|
post(this, path);
|
|
|
|
}
|
|
|
|
|
2019-10-30 20:59:31 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Parses the given object into a file.
|
|
|
|
*
|
|
|
|
* @param obj the object that describes a file
|
|
|
|
*/
|
|
|
|
static parse(obj: any): File {
|
|
|
|
if (obj["type"] !== "File")
|
2019-11-04 18:19:24 +01:00
|
|
|
throw `Cannot deserialize node of type '${obj["type"]}'.`;
|
2019-10-30 20:59:31 +01:00
|
|
|
|
2019-11-14 23:21:06 +01:00
|
|
|
return new File(obj["_contents"], obj["mime"]);
|
2019-10-30 20:59:31 +01:00
|
|
|
}
|
2018-11-29 12:42:14 +01:00
|
|
|
}
|
2019-11-12 00:49:05 +01:00
|
|
|
|
2019-11-12 01:10:53 +01:00
|
|
|
/**
|
|
|
|
* A file that cannot have contents.
|
|
|
|
*/
|
|
|
|
export class NullFile extends File {
|
2019-11-14 00:32:42 +01:00
|
|
|
protected type: string = "NullFile";
|
|
|
|
|
|
|
|
|
2019-11-12 01:10:53 +01:00
|
|
|
open(mode: "append" | "read" | "write"): FileStream {
|
|
|
|
return new class extends FileStream {
|
|
|
|
constructor(file: File, pointer: number) {
|
|
|
|
super(file, pointer);
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
read(count: number | undefined = undefined): string {
|
|
|
|
return "";
|
|
|
|
}
|
|
|
|
|
|
|
|
write(string: string): void {
|
|
|
|
// Do nothing
|
|
|
|
}
|
|
|
|
}(this, 0);
|
|
|
|
}
|
2019-11-14 00:32:42 +01:00
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Parses the given object into a null file.
|
|
|
|
*
|
|
|
|
* @param obj the object that describes a file
|
|
|
|
*/
|
|
|
|
static parse(obj: any): NullFile {
|
2019-11-14 20:34:22 +01:00
|
|
|
if (obj["type"] !== "NullFile")
|
2019-11-14 00:32:42 +01:00
|
|
|
throw `Cannot deserialize node of type '${obj["type"]}'.`;
|
|
|
|
|
|
|
|
return new NullFile();
|
|
|
|
}
|
2019-11-12 01:10:53 +01:00
|
|
|
}
|
|
|
|
|
2019-11-12 00:49:05 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* The mode to open a file in.
|
|
|
|
*/
|
|
|
|
export type FileMode = "append" | "read" | "write";
|
|
|
|
|
|
|
|
/**
|
|
|
|
* An in- and output stream for a file.
|
|
|
|
*/
|
|
|
|
export class FileStream extends Stream {
|
|
|
|
private readonly file: File;
|
|
|
|
private pointer: number;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A stream to interact with the contents of a file.
|
|
|
|
*
|
|
|
|
* @param file the file to open a stream to
|
|
|
|
* @param pointer the index in the file to start the stream at
|
|
|
|
*/
|
|
|
|
constructor(file: File, pointer: number = 0) {
|
|
|
|
if (pointer < 0)
|
|
|
|
throw new IllegalArgumentError("File pointer must be non-negative.");
|
|
|
|
if (pointer > file.contents.length)
|
|
|
|
throw new IllegalArgumentError("File pointer should not exceed file's size.");
|
|
|
|
|
|
|
|
super();
|
|
|
|
|
|
|
|
this.file = file;
|
|
|
|
this.pointer = pointer;
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
protected get buffer(): string {
|
|
|
|
return this.file.contents.slice(this.pointer);
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
read(count: number | undefined = undefined): string {
|
|
|
|
const input = this.peek(count ?? this.buffer.length);
|
|
|
|
this.pointer += input.length;
|
|
|
|
return input;
|
|
|
|
}
|
|
|
|
|
|
|
|
write(string: string): void {
|
|
|
|
const pre = this.file.contents.slice(0, this.pointer);
|
|
|
|
const post = this.buffer.slice(string.length);
|
|
|
|
|
2019-11-12 01:10:53 +01:00
|
|
|
this.file._contents = pre + string + post;
|
2019-11-23 00:07:57 +01:00
|
|
|
this.pointer += string.length;
|
2019-11-12 00:49:05 +01:00
|
|
|
}
|
|
|
|
}
|