From b3d0b3a90c0fcabe891accee95c5be056e35618b Mon Sep 17 00:00:00 2001 From: Michel Fedde Date: Tue, 24 Jun 2025 21:59:55 +0200 Subject: [PATCH] feat(polish): Adds EmbedLibrary --- package-lock.json | 7 + package.json | 1 + .../Repositories/PlaydateRepository.ts | 13 +- source/Discord/Commands/Groups.ts | 55 ++++---- source/Discord/Commands/Playdates.ts | 126 +++++++++--------- source/Discord/Commands/Server.ts | 7 +- source/Discord/EmbedLibrary.ts | 64 +++++++++ source/Discord/InteractionRouter.ts | 24 ++-- source/Discord/PermissionError.ts | 24 ++-- source/Discord/UserError.ts | 24 ++-- source/Menu/MenuRenderer.ts | 21 ++- source/Menu/MenuTraversal.ts | 10 ++ 12 files changed, 240 insertions(+), 136 deletions(-) create mode 100644 source/Discord/EmbedLibrary.ts diff --git a/package-lock.json b/package-lock.json index c4da1be..8ba702b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@types/lodash": "^4.17.18", "@types/log4js": "^0.0.33", "@types/node": "^22.13.9", + "any-date-parser": "^2.2.2", "better-sqlite3": "^11.8.1", "deepmerge": "^4.3.1", "discord.js": "^14.18.0", @@ -2051,6 +2052,12 @@ "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==", "license": "MIT" }, + "node_modules/any-date-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/any-date-parser/-/any-date-parser-2.2.2.tgz", + "integrity": "sha512-ZgitJ8kchTF57Hm1PrcX/WCD5ZliRdk+KmL1YKxfHq8a8Em5GpjYtkaw/ctt0kgBYG1q9hsncQIQNUwGDuhPzw==", + "license": "ISC" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", diff --git a/package.json b/package.json index 0a15d2b..9377988 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@types/lodash": "^4.17.18", "@types/log4js": "^0.0.33", "@types/node": "^22.13.9", + "any-date-parser": "^2.2.2", "better-sqlite3": "^11.8.1", "deepmerge": "^4.3.1", "discord.js": "^14.18.0", diff --git a/source/Database/Repositories/PlaydateRepository.ts b/source/Database/Repositories/PlaydateRepository.ts index 2e2bd08..b36680f 100644 --- a/source/Database/Repositories/PlaydateRepository.ts +++ b/source/Database/Repositories/PlaydateRepository.ts @@ -35,20 +35,13 @@ export class PlaydateRepository extends Repository { return finds.map((playdate) => this.convertToModelType(playdate, group)); } - findPlaydatesInRange(fromDate: Date | number, toDate: Date | number | undefined = undefined, group: GroupModel | undefined = undefined) { - if (fromDate instanceof Date) { - fromDate = fromDate.getTime(); - } - if (toDate instanceof Date) { - toDate = toDate.getTime(); - } - + findPlaydatesInRange(fromDate: Date, toDate: Date | undefined = undefined, group: GroupModel | undefined = undefined) { let sql = `SELECT * FROM ${this.schema.name} WHERE time_from > ?`; - const params = [fromDate]; + const params = [fromDate.getTime()]; if (toDate) { sql = `${sql} AND time_from < ?` - params.push(toDate); + params.push(toDate.getTime()); } if (group) { diff --git a/source/Discord/Commands/Groups.ts b/source/Discord/Commands/Groups.ts index 24a8c62..4082b8f 100644 --- a/source/Discord/Commands/Groups.ts +++ b/source/Discord/Commands/Groups.ts @@ -5,9 +5,11 @@ import { GuildMember, GuildMemberRoleManager, InteractionReplyOptions, - MessageFlags, PermissionFlagsBits, + MessageFlags, + PermissionFlagsBits, roleMention, - SlashCommandBuilder, Snowflake, + SlashCommandBuilder, + Snowflake, time, userMention } from "discord.js"; @@ -23,7 +25,6 @@ import {PlaydateRepository} from "../../Database/Repositories/PlaydateRepository import {Nullable} from "../../types/Nullable"; import {MenuRenderer} from "../../Menu/MenuRenderer"; import {MenuItemType} from "../../Menu/MenuRenderer.types"; -import {ConfigurationMenuHandler} from "../../Configuration/Groups/ConfigurationMenuHandler"; import {MenuTraversal} from "../../Menu/MenuTraversal"; import {ConfigurationHandler} from "../../Configuration/ConfigurationHandler"; import {GroupConfigurationProvider} from "../../Configuration/Groups/GroupConfigurationProvider"; @@ -31,6 +32,7 @@ import {MenuHandler} from "../../Configuration/MenuHandler"; import {ServerConfigurationProvider} from "../../Configuration/Server/ServerConfigurationProvider"; import {ServerConfigurationRepository} from "../../Database/Repositories/ServerConfigurationRepository"; import {PermissionError} from "../PermissionError"; +import {EmbedLibrary, EmbedType} from "../EmbedLibrary"; export class GroupCommand implements Command, ChatInteractionCommand, AutocompleteCommand { private static GOODBYE_MESSAGES: string[] = [ @@ -155,7 +157,16 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple Container.get(GroupRepository.name).create(group); - interaction.reply({content: `:white_check_mark: Created group \`${name}\``, flags: MessageFlags.Ephemeral}) + interaction.reply({ + embeds: [ + EmbedLibrary.base( + 'Created group', + `:white_check_mark: Created group \`${name}\``, + EmbedType.Success + ) + ], + flags: MessageFlags.Ephemeral + }) } private allowedCreate(interaction: ChatInputCommandInteraction): boolean { @@ -192,8 +203,7 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple const playdateRepo = Container.get(PlaydateRepository.name); - const embed = new EmbedBuilder() - .setTitle("Your groups on this server:") + const embed = EmbedLibrary.base("Your groups on this server:", '', EmbedType.Info) .setFields( groups.map(group => { const nextPlaydate = playdateRepo.getNextPlaydateForGroup(group); @@ -236,16 +246,14 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple } repo.deleteGroup(group); - - const embed = new EmbedBuilder() - .setTitle("Group deleted.") - .setDescription( - `:x: Deleted \`${group.name}\`. ${ArrayUtils.chooseRandom(GroupCommand.GOODBYE_MESSAGES)}` - ) - + await interaction.reply({ embeds: [ - embed + EmbedLibrary.base( + "Group deleted", + `:x: Deleted \`${group.name}\`. ${ArrayUtils.chooseRandom(GroupCommand.GOODBYE_MESSAGES)}`, + EmbedType.Success + ) ], flags: MessageFlags.Ephemeral, }) @@ -332,7 +340,9 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple ), 'Group Configuration', "This UI allows you to change settings for your group." - ) + ), + null,null, + group.name ) menu.display(interaction); @@ -358,17 +368,14 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple group.leader.memberid = newLeader.id repo.update(group); - - - const embed = new EmbedBuilder() - .setTitle("Leadership transferred") - .setDescription( - `Leadership was successfully transferred to ${userMention(newLeader.user.id)}` - ) - + await interaction.reply({ embeds: [ - embed + EmbedLibrary.base( + 'Leadership transferred', + `Leadership was successfully transferred to ${userMention(newLeader.user.id)}`, + EmbedType.Success + ) ], flags: MessageFlags.Ephemeral, }) diff --git a/source/Discord/Commands/Playdates.ts b/source/Discord/Commands/Playdates.ts index ec581f9..58e2e8f 100644 --- a/source/Discord/Commands/Playdates.ts +++ b/source/Discord/Commands/Playdates.ts @@ -1,13 +1,13 @@ import { - SlashCommandBuilder, - CommandInteraction, - AutocompleteInteraction, - EmbedBuilder, - MessageFlags, - ChatInputCommandInteraction, - time, AttachmentBuilder, - GuildMember + AutocompleteInteraction, + ChatInputCommandInteraction, + CommandInteraction, + EmbedBuilder, + GuildMember, + MessageFlags, + SlashCommandBuilder, + time } from "discord.js"; import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command"; import {Container} from "../../Container/Container"; @@ -20,9 +20,15 @@ import * as ics from 'ics'; import ical from 'node-ical'; import {GroupConfigurationRepository} from "../../Database/Repositories/GroupConfigurationRepository"; import {GroupRepository} from "../../Database/Repositories/GroupRepository"; -import {GroupConfigurationProvider} from "../../Configuration/Groups/GroupConfigurationProvider"; -import { ConfigurationHandler } from "../../Configuration/ConfigurationHandler"; +import { + GroupConfigurationProvider, + RuntimeGroupConfiguration +} from "../../Configuration/Groups/GroupConfigurationProvider"; +import {ConfigurationHandler} from "../../Configuration/ConfigurationHandler"; import {PermissionError} from "../PermissionError"; +import {EmbedLibrary, EmbedType} from "../EmbedLibrary"; +import {GroupConfigurationModel} from "../../Database/Models/GroupConfigurationModel"; +import parser from "any-date-parser"; export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInteractionCommand { definition(): SlashCommandBuilder { @@ -36,11 +42,11 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter .addIntegerOption(GroupSelection.createOptionSetup()) .addStringOption((option) => option .setName("from") - .setDescription("Defines the start date & time. Format: YYYY-MM-DD HH:mm") + .setDescription("Defines the start date & time. Your desired format is probably support.") ) .addStringOption((option) => option .setName("to") - .setDescription("Defines the end date & time. Format: YYYY-MM-DD HH:mm") + .setDescription("Defines the end date & time. Your desired format is probably support.") ) ) .addSubcommand((subcommand) => subcommand @@ -117,18 +123,18 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter ) } - const fromDate = Date.parse(interaction.options.get("from")?.value ?? ''); - const toDate = Date.parse(interaction.options.get("to")?.value ?? ''); + const fromDate = parser.fromString(interaction.options.get("from")?.value ?? ''); + const toDate = parser.fromString(interaction.options.get("to")?.value ?? ''); - if (isNaN(fromDate)) { + if (!fromDate.isValid()) { throw new UserError("No date or invalid date format for the from parameter."); } - if (isNaN(toDate)) { + if (!fromDate.isValid()) { throw new UserError("No date or invalid date format for the to parameter."); } - if (fromDate > toDate) { + if (fromDate.getTime() > toDate.getTime()) { throw new UserError("The to-date can't be earlier than the from-date"); } @@ -141,22 +147,21 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter const playdate: Partial = { group: group, - from_time: new Date(fromDate), - to_time: new Date(toDate), + from_time: fromDate, + to_time: toDate, } playdateRepo.create(playdate); - const embed = new EmbedBuilder() - .setTitle("Created a play-date.") - .setDescription(":white_check_mark: Your playdate has been created! You and your group get notified, when its time.") - .setFields({ + const embed = EmbedLibrary.playdate( + group, + "Created a play-date.", + ":white_check_mark: Your playdate has been created! You and your group get notified, when its time.", + EmbedType.Success + ).setFields({ name: "Created playdate", value: `${time(new Date(fromDate), 'F')} - ${time(new Date(toDate), 'F')}`, - }) - .setFooter({ - text: `Group: ${group.name}` - }) + }) await interaction.reply({ embeds: [ @@ -193,20 +198,20 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter private async list(interaction: ChatInputCommandInteraction, group: GroupModel) { const playdates = Container.get(PlaydateRepository.name).findFromGroup(group); - const embed = new EmbedBuilder() - .setTitle("The next playdates:") - .setFields( - playdates.map((playdate) => { - return { - name: `${time(playdate.from_time, 'F')} - ${time(playdate.to_time, 'F')}`, - value: `${time(playdate.from_time, 'R')}` - } - }) - ) - .setFooter({ - text: `Group: ${group.name}` + const embed = EmbedLibrary.playdate( + group, + "Created a play-date.", + null, + EmbedType.Info + ).setFields( + playdates.map((playdate) => { + return { + name: `${time(playdate.from_time, 'F')} - ${time(playdate.to_time, 'F')}`, + value: `${time(playdate.from_time, 'R')}` + } }) - + ) + await interaction.reply({ embeds: [ embed @@ -236,16 +241,13 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter } repo.delete(playdateId); - - const embed = new EmbedBuilder() - .setTitle("Playdate deleted") - .setDescription( - `:x: Deleted \`${selected.from_time.toLocaleString()} - ${selected.to_time.toLocaleString()}\`` - ) - .setFooter({ - text: `Group: ${group.name}` - }) - + + const embed = EmbedLibrary.playdate( + group, + "Playdate deleted", + `:x: Deleted ${time(selected.from_time, 'F')} - ${time(selected.to_time, 'F')}`, + EmbedType.Success + ); await interaction.reply({ embeds: [ embed @@ -294,16 +296,15 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter }); } - const embed = new EmbedBuilder() - .setTitle("Imported play-dates.") - .setDescription(`:white_check_mark: Your ${playdates.length} playdates has been created! You and your group get notified, when its time.`) - .setFields({ - name: "Created playdates", - value: playdates.map((playdate) => `${time(playdate.from_time, 'F')} - ${time(playdate.to_time, 'F')}`).join('\n') - }) - .setFooter({ - text: `Group: ${group.name}` - }) + const embed = EmbedLibrary.playdate( + group, + "Imported play-dates", + `:white_check_mark: Your ${playdates.length} playdates has been created! You and your group get notified, when its time.`, + EmbedType.Success + ).setFields({ + name: "Created playdates", + value: playdates.map((playdate) => `${time(playdate.from_time, 'F')} - ${time(playdate.to_time, 'F')}`).join('\n') + }) interaction.followUp({ embeds: [embed], @@ -312,7 +313,10 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter } private async export(interaction: ChatInputCommandInteraction, group: GroupModel): Promise { - const groupConfig = new ConfigurationHandler( + const groupConfig = new ConfigurationHandler< + GroupConfigurationModel, + RuntimeGroupConfiguration + >( new GroupConfigurationProvider( Container.get(GroupConfigurationRepository.name), group diff --git a/source/Discord/Commands/Server.ts b/source/Discord/Commands/Server.ts index 86e22e1..202b476 100644 --- a/source/Discord/Commands/Server.ts +++ b/source/Discord/Commands/Server.ts @@ -60,7 +60,7 @@ export class ServerCommand implements Command, ChatInteractionCommand { children: [ { traversalKey: "allowEveryone", - label: "Group Creation", + label: "Allow Anyone", description: "Defines if all members are allowed to create groups.", } ] @@ -71,7 +71,10 @@ export class ServerCommand implements Command, ChatInteractionCommand { ), 'Server Configuration', "This UI allows you to change settings for your server." - ) + ), + null, + null, + "Server" ) menu.display(interaction); diff --git a/source/Discord/EmbedLibrary.ts b/source/Discord/EmbedLibrary.ts new file mode 100644 index 0000000..d0aa825 --- /dev/null +++ b/source/Discord/EmbedLibrary.ts @@ -0,0 +1,64 @@ +import {ColorResolvable, Colors, EmbedBuilder} from "discord.js"; +import {ArrayUtils} from "../Utilities/ArrayUtils"; +import {GroupModel} from "../Database/Models/GroupModel"; + +export enum EmbedType { + Unknown, + Success, + Info, + Error, +} + +export class EmbedLibrary { + private static TYPE_COLOR_MAP: Record = { + [EmbedType.Unknown]: Colors.Yellow, + [EmbedType.Success]: Colors.Green, + [EmbedType.Info]: Colors.Blue, + [EmbedType.Error]: Colors.Red, + } + + public static base( + title: string, + description: string|null = null, + type: EmbedType = EmbedType.Unknown + ): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle(title) + .setColor(this.TYPE_COLOR_MAP[type]) + + if (description) { + embed.setDescription(description); + } + + return embed + } + + public static playdate( + group: GroupModel, + title: string, + description: string|null = null, + type: EmbedType = EmbedType.Unknown + ): EmbedBuilder { + return this.base(title, description, type) + .setFooter({ + text: `Group: ${group.name}` + }) + } + + public static error( + error: Error + ): EmbedBuilder { + + if ("getEmbed" in error) { + return (<(error: Error) => EmbedBuilder>error.getEmbed)(error); + } + + return this.base( + "A unexpected error occurred", + ":x: There was an error while executing this command!", + EmbedType.Error + ).setFooter({ + text: "Type: Generic" + }) + } +} \ No newline at end of file diff --git a/source/Discord/InteractionRouter.ts b/source/Discord/InteractionRouter.ts index 89d1ef2..fb11eff 100644 --- a/source/Discord/InteractionRouter.ts +++ b/source/Discord/InteractionRouter.ts @@ -3,7 +3,7 @@ import { AutocompleteInteraction, ButtonInteraction, ChatInputCommandInteraction, inlineCode, - Interaction, + Interaction, InteractionReplyOptions, MessageFlags, ModalSubmitInteraction, } from "discord.js"; import Commands from "./Commands/Commands"; @@ -15,6 +15,7 @@ import {ModalInteractionEvent} from "../Events/EventClasses/ModalInteractionEven import {ComponentInteractionEvent} from "../Events/EventClasses/ComponentInteractionEvent"; import {log} from "node:util"; import {PermissionError} from "./PermissionError"; +import {EmbedLibrary} from "./EmbedLibrary"; enum InteractionRoutingType { Unrouted, @@ -94,24 +95,27 @@ export class InteractionRouter { await command.execute?.call(command, interaction); } catch (e: any) { - let userMessage = ":x: There was an error while executing this command!"; + let logErrorMessage = true; - - if ("getDiscordMessage" in e) { - userMessage = e.getDiscordMessage(e); - } if ("shouldLog" in e) { logErrorMessage = e.shouldLog; - } - + } + if (logErrorMessage) { this.logger.error(e) } + + const responseOptions: InteractionReplyOptions = { + embeds: [ + EmbedLibrary.error(e) + ], + flags: MessageFlags.Ephemeral + } if (interaction.replied || interaction.deferred) { - await interaction.followUp({content: userMessage, flags: MessageFlags.Ephemeral}); + await interaction.followUp(responseOptions); } else { - await interaction.reply({content: userMessage, flags: MessageFlags.Ephemeral}); + await interaction.reply(responseOptions); } } } diff --git a/source/Discord/PermissionError.ts b/source/Discord/PermissionError.ts index 64eb97a..d9eb971 100644 --- a/source/Discord/PermissionError.ts +++ b/source/Discord/PermissionError.ts @@ -1,4 +1,5 @@ -import {inlineCode} from "discord.js"; +import {EmbedBuilder, inlineCode} from "discord.js"; +import {EmbedLibrary, EmbedType} from "./EmbedLibrary"; export class PermissionError extends Error { shouldLog: boolean = false; @@ -10,15 +11,22 @@ export class PermissionError extends Error { super(message); } - public getDiscordMessage(e: PermissionError): string { - let userMessage = `:x: You can not perform this action! ${inlineCode(e.message)}` - if (e.tryInstead) { - userMessage += ` + public getEmbed(e: PermissionError): EmbedBuilder { + const embed = EmbedLibrary.base( + "You can not perform this action!", + inlineCode(e.message), + EmbedType.Error + ).setFooter({ + text: "Type: Permission" + }); - You can try the following: - ${inlineCode(e.tryInstead)}` + if (e.tryInstead) { + embed.addFields({ + name: "You can try the following:", + value: e.tryInstead + }) } - return userMessage; + return embed; } } \ No newline at end of file diff --git a/source/Discord/UserError.ts b/source/Discord/UserError.ts index 3699c4b..64c3770 100644 --- a/source/Discord/UserError.ts +++ b/source/Discord/UserError.ts @@ -1,4 +1,5 @@ -import {inlineCode} from "discord.js"; +import {EmbedBuilder, inlineCode} from "discord.js"; +import {EmbedLibrary, EmbedType} from "./EmbedLibrary"; export class UserError extends Error { @@ -11,16 +12,23 @@ export class UserError extends Error { super(message); } + + public getEmbed(e: UserError): EmbedBuilder { + const embed = EmbedLibrary.base( + "Please validate your request!", + inlineCode(e.message), + EmbedType.Error + ).setFooter({ + text: "Type: Request" + }); - public getDiscordMessage(e: UserError): string { - let userMessage = `:x: \`${e.message}\` - Please validate your request!` if (e.tryInstead) { - userMessage += ` - -You can try the following: -${inlineCode(e.tryInstead)}` + embed.addFields({ + name: "You can try the following:", + value: e.tryInstead + }) } - return userMessage; + return embed; } } \ No newline at end of file diff --git a/source/Menu/MenuRenderer.ts b/source/Menu/MenuRenderer.ts index 5e99b0d..8d4f040 100644 --- a/source/Menu/MenuRenderer.ts +++ b/source/Menu/MenuRenderer.ts @@ -18,12 +18,12 @@ import {ComponentInteractionEvent} from "../Events/EventClasses/ComponentInterac import {MenuTraversal} from "./MenuTraversal"; import {Prompt} from "./Modals/Prompt"; import _ from "lodash"; +import {EmbedLibrary} from "../Discord/EmbedLibrary"; export class MenuRenderer { private readonly menuId: string; private eventId: Nullable; - private exitButton: ButtonBuilder; private backButton: ButtonBuilder; private static MAX_BUTTON_PER_ROW = 5; private static MAX_ROWS = 5; @@ -33,18 +33,13 @@ export class MenuRenderer { constructor( private readonly traversal: MenuTraversal, private readonly eventHandler: EventHandler|null = null, - private readonly iconCache: Nullable = null + private readonly iconCache: Nullable = null, + private readonly rootName: string = '', ) { this.eventHandler ??= Container.get(EventHandler.name); this.iconCache ??= Container.get(IconCache.name); this.menuId = randomUUID(); - this.exitButton = new ButtonBuilder() - .setLabel("Exit") - .setStyle(ButtonStyle.Danger) - .setCustomId(this.getInteractionId("EXIT")) - .setEmoji(this.iconCache?.get("door_open_solid_white") ?? ''); - this.backButton = new ButtonBuilder() .setLabel("Back") .setStyle(ButtonStyle.Secondary) @@ -119,11 +114,11 @@ export class MenuRenderer { } private getEmbed(): EmbedBuilder { - const embed = new EmbedBuilder() - .setTitle(this.traversal.currentMenuItem.label) - .setDescription(this.traversal.currentMenuItem.description ?? '') - .setAuthor({ - name: "/ " + this.traversal.path.join(' / ') + const embed = EmbedLibrary.base( + this.traversal.currentMenuItem.label, + this.traversal.currentMenuItem.description ?? '', + ).setFooter({ + text: this.rootName + " / " + this.traversal.getTraversedLabels().join(' / ') }); if (this.traversal.currentMenuItem.type === MenuItemType.Field) { diff --git a/source/Menu/MenuTraversal.ts b/source/Menu/MenuTraversal.ts index e22a082..e34dc65 100644 --- a/source/Menu/MenuTraversal.ts +++ b/source/Menu/MenuTraversal.ts @@ -62,6 +62,16 @@ export class MenuTraversal { return this.traversalMap.get(path); } + public getTraversedLabels(): string[] { + const labels = []; + for (let i = 0; i < this.currentPath.length; i++) { + const path = this.currentPath.slice(0, i + 1); + labels.push(this.getMenuItem(this.stringifyTraversalPath(path)).label); + } + + return labels; + } + public static unstringifyTraversalPath(path: StringifiedTraversalPath): TraversalPath { return path.split('/'); }