diff --git a/package-lock.json b/package-lock.json index 6eb6cbd..2f20eab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,16 @@ "license": "ISC", "dependencies": { "@types/better-sqlite3": "^7.6.12", + "@types/deepmerge": "^2.1.0", "@types/log4js": "^0.0.33", "@types/node": "^22.13.9", "better-sqlite3": "^11.8.1", + "deepmerge": "^4.3.1", "discord.js": "^14.18.0", "dotenv": "^16.4.7", "esbuild": "^0.25.0", - "log4js": "^6.9.1" + "log4js": "^6.9.1", + "object-path-set": "^1.0.2" } }, "node_modules/@discordjs/builders": { @@ -607,6 +610,12 @@ "@types/node": "*" } }, + "node_modules/@types/deepmerge": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/deepmerge/-/deepmerge-2.1.0.tgz", + "integrity": "sha512-/0Ct/q5g+SgaACZ+A0ylY3071nEBN7QDnTWiCtaB3fx24UpoAQXf25yNVloOYVUis7jytM1F1WC78+EOwXkQJQ==", + "license": "MIT" + }, "node_modules/@types/express": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz", @@ -843,6 +852,15 @@ "node": ">=4.0.0" } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/detect-libc": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", @@ -1130,6 +1148,12 @@ "node": ">=10" } }, + "node_modules/object-path-set": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/object-path-set/-/object-path-set-1.0.2.tgz", + "integrity": "sha512-kvjSaWTxrT4h66JIf4zR4LPxLCEBny0WIP/JIbQ6nqdI8qwfDcNV9vafjWqWBWL+tNlpLB9XyWCkxydCf/wuMw==", + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index 42c6a26..ac28e85 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,15 @@ }, "dependencies": { "@types/better-sqlite3": "^7.6.12", + "@types/deepmerge": "^2.1.0", "@types/log4js": "^0.0.33", "@types/node": "^22.13.9", "better-sqlite3": "^11.8.1", + "deepmerge": "^4.3.1", "discord.js": "^14.18.0", "dotenv": "^16.4.7", "esbuild": "^0.25.0", - "log4js": "^6.9.1" + "log4js": "^6.9.1", + "object-path-set": "^1.0.2" } } diff --git a/source/Container/Container.ts b/source/Container/Container.ts index 536e7cd..ace9c92 100644 --- a/source/Container/Container.ts +++ b/source/Container/Container.ts @@ -5,12 +5,13 @@ export class Container { public set(instance: T, name: string|null = null): void { - this.instances.set(name ?? instance.constructor.name, instance); + const settingName = name ?? instance.constructor.name; + this.instances.set(settingName.toLowerCase(), instance); } public get(name: string): T { - return this.instances.get(name); + return this.instances.get(name.toLowerCase()); } static getInstance(): Container { diff --git a/source/Container/Services.ts b/source/Container/Services.ts index 1865775..51c1597 100644 --- a/source/Container/Services.ts +++ b/source/Container/Services.ts @@ -6,6 +6,7 @@ import path from "node:path"; import {GroupRepository} from "../Repositories/GroupRepository"; import {PlaydateRepository} from "../Repositories/PlaydateRepository"; import {GuildEmojiRoleManager} from "discord.js"; +import {GroupConfigurationRepository} from "../Repositories/GroupConfigurationRepository"; export enum ServiceHint { App, @@ -56,5 +57,6 @@ export class Services { const db = container.get(DatabaseConnection.name); container.set(new GroupRepository(db)); container.set(new PlaydateRepository(db, container.get(GroupRepository.name))) + container.set(new GroupConfigurationRepository(db, container.get(GroupRepository.name))) } } \ No newline at end of file diff --git a/source/Database/definitions.ts b/source/Database/definitions.ts index d975cac..6085381 100644 --- a/source/Database/definitions.ts +++ b/source/Database/definitions.ts @@ -1,10 +1,12 @@ import Groups from "./tables/Groups"; import {DatabaseDefinition} from "./DatabaseDefinition"; import Playdate from "./tables/Playdate"; +import GroupConfiguration from "./tables/GroupConfiguration"; const definitions = new Set([ Groups, - Playdate + Playdate, + GroupConfiguration ]); export default definitions; \ No newline at end of file diff --git a/source/Database/tables/GroupConfiguration.ts b/source/Database/tables/GroupConfiguration.ts index 82e3be0..2ef476f 100644 --- a/source/Database/tables/GroupConfiguration.ts +++ b/source/Database/tables/GroupConfiguration.ts @@ -1,6 +1,6 @@ import {DatabaseDefinition} from "../DatabaseDefinition"; -export type DBGroup = { +export type DBGroupConfiguration = { id: number; groupid: number; key: string; @@ -8,7 +8,7 @@ export type DBGroup = { } const dbDefinition: DatabaseDefinition = { - name: "groups", + name: "groupConfiguration", columns: [ { name: "id", diff --git a/source/Discord/Commands/Groups.ts b/source/Discord/Commands/Groups.ts index 0ba6948..be96284 100644 --- a/source/Discord/Commands/Groups.ts +++ b/source/Discord/Commands/Groups.ts @@ -13,6 +13,10 @@ import {Container} from "../../Container/Container"; import {GroupSelection} from "../CommandPartials/GroupSelection"; import {UserError} from "../UserError"; import {ArrayUtils} from "../../Utilities/ArrayUtils"; +import {GroupConfigurationRenderer} from "../../Groups/GroupConfigurationRenderer"; +import {GroupConfigurationHandler} from "../../Groups/GroupConfigurationHandler"; +import {GroupConfigurationTransformers} from "../../Groups/GroupConfigurationTransformers"; +import {GroupConfigurationRepository} from "../../Repositories/GroupConfigurationRepository"; export class GroupCommand implements Command, ChatInteractionCommand, AutocompleteCommand { private static GOODBYE_MESSAGES: string[] = [ @@ -57,7 +61,8 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple .addIntegerOption(GroupSelection.createOptionSetup()) ); } - execute(interaction: ChatInputCommandInteraction): Promise { + + async execute(interaction: ChatInputCommandInteraction): Promise { switch (interaction.options.getSubcommand()) { case "create": this.create(interaction); @@ -66,10 +71,11 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple this.list(interaction); break; case "remove": - this.remove(interaction); + await this.remove(interaction); break; case "config": - this.runConfigurator(interaction); + await this.runConfigurator(interaction); + break; default: throw new Error("Unsupported command"); } @@ -159,9 +165,17 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple } } - private runConfigurator(interaction: ChatInputCommandInteraction) { + private async runConfigurator(interaction: ChatInputCommandInteraction) { const group = GroupSelection.getGroup(interaction); + const configurationRenderer = new GroupConfigurationRenderer( + new GroupConfigurationHandler( + Container.get(GroupConfigurationRepository.name), + group + ), + new GroupConfigurationTransformers(), + ) + await configurationRenderer.setup(interaction); } } \ No newline at end of file diff --git a/source/Discord/DiscordClient.ts b/source/Discord/DiscordClient.ts index 76f77c9..6209c39 100644 --- a/source/Discord/DiscordClient.ts +++ b/source/Discord/DiscordClient.ts @@ -38,13 +38,6 @@ export class DiscordClient { }) this.client.on(Events.InteractionCreate, async (interaction: Interaction) => { - const command = this.commands.getCommand(interaction.commandName); - - if (command === null) { - Container.get("logger").error(`Could not find command for '${interaction.commandName}'`); - return; - } - const method = this.findCommandMethod(interaction); if (!method) { Container.get("logger").error(`Could not find method for '${interaction.commandName}'`); @@ -77,7 +70,7 @@ export class DiscordClient { try { await command.execute(interaction) } - catch (e: Error) { + catch (e: any) { Container.get("logger").error(e) let userMessage = ":x: There was an error while executing this command!"; diff --git a/source/Groups/GroupConfigurationHandler.ts b/source/Groups/GroupConfigurationHandler.ts new file mode 100644 index 0000000..92fb69e --- /dev/null +++ b/source/Groups/GroupConfigurationHandler.ts @@ -0,0 +1,68 @@ +import {RuntimeGroupConfiguration} from "./RuntimeGroupConfiguration"; +import {GroupConfigurationRepository} from "../Repositories/GroupConfigurationRepository"; +import {GroupModel} from "../Models/GroupModel"; +import {GroupConfigurationResult, GroupConfigurationTransformers} from "./GroupConfigurationTransformers"; +// @ts-ignore +import setPath from 'object-path-set'; +import deepmerge from "deepmerge"; +import {Nullable} from "../types/Nullable"; + +export class GroupConfigurationHandler { + private static DEFAULT_CONFIGURATION: RuntimeGroupConfiguration = { + channels: null, + locale: new Intl.Locale('en-GB'), + } + + private readonly transformers: GroupConfigurationTransformers = new GroupConfigurationTransformers(); + + constructor( + private readonly repository: GroupConfigurationRepository, + private readonly group: GroupModel + ) { } + + + public saveConfiguration(path: string, value: string): void { + const configuration = this.repository.findConfigurationByPath(this.group, path); + + if (configuration) { + this.repository.update( + { + ...configuration, + value: value + } + ) + return; + } + + this.repository.create({ + group: this.group, + key: path, + value: value, + }); + } + + public getConfiguration(): RuntimeGroupConfiguration { + return deepmerge(GroupConfigurationHandler.DEFAULT_CONFIGURATION, this.getDatabaseConfiguration()); + } + + public getConfigurationByPath(path: string): Nullable { + const configuration = this.repository.findConfigurationByPath(this.group, path); + if (!configuration) { + return null; + } + + return this.transformers.getValue(configuration); + } + + private getDatabaseConfiguration(): Partial { + const values = this.repository.findGroupConfigurations(this.group); + const configuration: Partial = {}; + + values.forEach((configValue) => { + const value = this.transformers.getValue(configValue); + setPath(configuration, configValue.key, value); + }) + + return configuration; + } +} \ No newline at end of file diff --git a/source/Groups/GroupConfigurationRenderer.ts b/source/Groups/GroupConfigurationRenderer.ts new file mode 100644 index 0000000..fe65feb --- /dev/null +++ b/source/Groups/GroupConfigurationRenderer.ts @@ -0,0 +1,334 @@ +import {GroupConfigurationTransformers, TransformerType} from "./GroupConfigurationTransformers"; +import {GroupConfigurationHandler} from "./GroupConfigurationHandler"; +import { + ActionRowBuilder, + AnyComponentBuilder, AnySelectMenuInteraction, + APISelectMenuComponent, + ButtonBuilder, + ButtonStyle, channelMention, + ChannelSelectMenuBuilder, ChannelSelectMenuInteraction, + ChannelType, + ChatInputCommandInteraction, + EmbedBuilder, + InteractionCallbackResponse, + InteractionEditReplyOptions, + InteractionReplyOptions, + InteractionUpdateOptions, italic, + SelectMenuBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + UserSelectMenuBuilder +} from "discord.js"; +import {Logger} from "log4js"; +import {Container} from "../Container/Container"; +import {Nullable} from "../types/Nullable"; +import GroupConfiguration from "../Database/tables/GroupConfiguration"; +import { + BaseSelectMenuBuilder, + MentionableSelectMenuBuilder, + MessageActionRowComponentBuilder, + RoleSelectMenuBuilder +} from "@discordjs/builders"; +import {unwatchFile} from "node:fs"; +import {UserError} from "../Discord/UserError"; +import {RuntimeGroupConfiguration} from "./RuntimeGroupConfiguration"; +import {ChannelId} from "../types/DiscordTypes"; + +type UIElementCollection = Record; +type UIElement = { + label: string, + key: string, + description: string, + childrenElements?: UIElementCollection, + isConfiguration?: true +} + +export class GroupConfigurationRenderer { + private static MOVETO_COMMAND = 'moveto_'; + private static SETVALUE_COMMAND = 'setvalue_'; + private static MOVEBACK_COMMAND = 'back'; + + private breadcrumbs: string[] = []; + + private static UI_ELEMENTS : UIElementCollection = { + channels: { + label: 'Channels', + key: 'channels', + description: "Provides settings to define in what channels the bot sends messages, when not directly interacting with it.", + childrenElements: { + newPlaydates: { + label: 'New Playdates', + key: 'newPlaydates', + description: "Sets the channel, where the group get notified when new Playdates are set.", + isConfiguration: true + }, + playdateReminders: { + label: 'Playdate Reminders', + key: 'playdateReminders', + description: "Sets the channel, where the group gets reminded of upcoming playdates.", + isConfiguration: true + } + } + }, + locale: { + label: "Locale", + key: 'locale', + description: "Provides locale to be used for this group. This mostly sets how the dates are displayed, but this can also be later used for translations.", + isConfiguration: true + } + } + + constructor( + private readonly configurationHandler: GroupConfigurationHandler, + private readonly transformers: GroupConfigurationTransformers, + ) {} + + public async setup(interaction: ChatInputCommandInteraction) { + let response = await interaction.reply(this.getReplyOptions()); + + let exit = false; + let eventResponse; + const filter = i => i.user.id === interaction.user.id; + do { + + if (eventResponse) { + response = await eventResponse.update(this.getReplyOptions()); + } + + try { + + eventResponse = await response.resource?.message?.awaitMessageComponent({ + dispose: true, + filter: filter, + time: 60_000 + }); + } catch (e) { + Container.get("logger").error("awaiting message component failed: ", e) + } + if (!eventResponse || eventResponse.customId === 'exit') { + exit = true; + continue; + } + + if (eventResponse.customId === GroupConfigurationRenderer.MOVEBACK_COMMAND) { + this.breadcrumbs.pop() + continue; + } + + if (eventResponse.customId.startsWith(GroupConfigurationRenderer.MOVETO_COMMAND)) { + this.breadcrumbs.push( + eventResponse.customId.substring(GroupConfigurationRenderer.MOVETO_COMMAND.length) + ) + continue; + } + + if (eventResponse.customId.startsWith(GroupConfigurationRenderer.SETVALUE_COMMAND)) { + this.handleSelection(eventResponse); + continue; + } + + } while(!exit); + + if (eventResponse) { + try { + await eventResponse.update( + this.getReplyOptions() + ); + } catch (e) { + + } + await eventResponse.deleteReply(); + + return; + } + + if (interaction.replied) { + await interaction.deleteReply(); + } + } + + private getReplyOptions(): InteractionUpdateOptions & InteractionReplyOptions & { withResponse: true } { + const embed = this.createEmbed(); + embed.setAuthor({ + name: "/ " + this.breadcrumbs.join(" / ") + }); + + const exitButton = new ButtonBuilder() + .setLabel("Exit") + .setStyle(ButtonStyle.Danger) + .setCustomId("exit"); + + const actionrow = new ActionRowBuilder() + + if (this.breadcrumbs.length > 0) { + const backButton = new ButtonBuilder() + .setLabel("Back") + .setStyle(ButtonStyle.Secondary) + .setCustomId(GroupConfigurationRenderer.MOVEBACK_COMMAND); + + actionrow.addComponents(backButton) + } + actionrow.addComponents(exitButton) + + return { + content: "", + embeds: [embed], + components: [...this.createActionRowBuildersForMenu(), actionrow], + withResponse: true, + }; + } + + private createEmbed(): EmbedBuilder { + const {currentCollection, currentElement} = this.findCurrentUI(); + + if (currentElement === null) { + return new EmbedBuilder() + .setTitle("Group Configuration") + .setDescription("This UI allows you to change settings for your group.") + } + + const embed = new EmbedBuilder() + .setTitle(currentElement?.label ?? '') + .setDescription(currentElement?.description ?? ''); + + if (currentElement?.isConfiguration ?? false) { + embed.addFields( + { name: "Current Value", value: this.getCurrentValueAsUI(), inline: false } + ) + } + + return embed; + } + + private getCurrentValueAsUI(): string { + const path = this.breadcrumbs.join("."); + const value = this.configurationHandler.getConfigurationByPath(path); + + if (value === undefined) return italic("None"); + + const type = this.transformers.getTransformerType(path); + + if (type === undefined) { + throw new Error("Could not find the type for " + path); + } + + + const displaynames = new Intl.DisplayNames(["en"], { type: "language" }); + switch (type) { + case TransformerType.Locale: + return displaynames.of((value).baseName) ?? "Unknown"; + case TransformerType.Channel: + return channelMention(value); + + default: + return "None"; + } + } + + private createActionRowBuildersForMenu() : ActionRowBuilder[] { + const {currentCollection, currentElement} = this.findCurrentUI(); + + if (currentElement?.isConfiguration ?? false) { + return [ + new ActionRowBuilder() + .addComponents(this.getSelectForBreadcrumbs(currentElement)) + ] + } + + return [ + new ActionRowBuilder() + .setComponents( + ...Object.values(currentCollection).map(elem => new ButtonBuilder() + .setLabel(elem.label) + .setStyle(ButtonStyle.Primary) + .setCustomId(GroupConfigurationRenderer.MOVETO_COMMAND + elem.key) + ) + ) + ] + } + + private getSelectForBreadcrumbs(currentElement: UIElement): ChannelSelectMenuBuilder | MentionableSelectMenuBuilder | RoleSelectMenuBuilder | StringSelectMenuBuilder | UserSelectMenuBuilder { + const breadcrumbPath = this.breadcrumbs.join('.') + const transformerType = this.transformers.getTransformerType(breadcrumbPath); + if (transformerType === undefined) { + throw new Error(`Can not find transformer type for ${breadcrumbPath}`) + } + + switch (transformerType) { + case TransformerType.Locale: + const options = [ + 'en-US', + 'fr-FR', + 'it-IT', + 'de-DE' + ] + const displaynames = new Intl.DisplayNames(["en"], { type: "language" }); + return new StringSelectMenuBuilder() + .setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath) + .setOptions( + options.map(intl => new StringSelectMenuOptionBuilder() + .setLabel(displaynames.of(intl)) + .setValue(intl) + ) + ) + case TransformerType.Channel: + return new ChannelSelectMenuBuilder() + .setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath) + .setChannelTypes(ChannelType.GuildText) + .setPlaceholder("New Value"); + + default: + return new StringSelectMenuBuilder() + .setCustomId("...") + .setOptions( + new StringSelectMenuOptionBuilder() + .setLabel("Nothing to see here") + .setValue("0") + ) + } + } + + private handleSelection(interaction: AnySelectMenuInteraction) { + const path = interaction.customId.substring(GroupConfigurationRenderer.SETVALUE_COMMAND.length); + + const savingValue = this.getSaveValue(interaction, path); + Container.get("logger").debug(`Saving '${savingValue}' to '${path}'`); + + this.configurationHandler.saveConfiguration(path, savingValue); + } + + private getSaveValue(interaction: AnySelectMenuInteraction, path: string): string { + const transformerType = this.transformers.getTransformerType(path); + if (transformerType === undefined || transformerType === null) { + throw new Error(`Can not find transformer type for ${path}`) + } + + switch (transformerType) { + case TransformerType.Locale: + case TransformerType.Channel: + return interaction.values.join('; '); + default: + throw new Error("Unhandled select menu"); + } + } + + private findCurrentUI(): {currentElement: Nullable, currentCollection: UIElementCollection } { + let currentCollection: UIElementCollection = GroupConfigurationRenderer.UI_ELEMENTS; + let currentElement: Nullable = null; + + for (const breadcrumb of this.breadcrumbs) { + currentElement = currentCollection[breadcrumb]; + + if (currentElement.isConfiguration ?? false) { + break; + } + + currentCollection = currentElement.childrenElements ?? {}; + } + + return { + currentElement, + currentCollection, + } + } +} \ No newline at end of file diff --git a/source/Groups/GroupConfigurationTransformers.ts b/source/Groups/GroupConfigurationTransformers.ts new file mode 100644 index 0000000..04092ee --- /dev/null +++ b/source/Groups/GroupConfigurationTransformers.ts @@ -0,0 +1,61 @@ +import {ChannelId} from "../types/DiscordTypes"; +import {GroupConfigurationModel} from "../Models/GroupConfigurationModel"; +import {config} from "dotenv"; +import {transform} from "esbuild"; +import {Nullable} from "../types/Nullable"; +import {ArrayUtils} from "../Utilities/ArrayUtils"; + +export enum TransformerType { + Locale, + Channel, +} + +type GroupConfigurationTransformer = { + path: string[]; + type: TransformerType, +} + +export type GroupConfigurationResult = + ChannelId | Intl.Locale + +export class GroupConfigurationTransformers { + static TRANSFORMERS: GroupConfigurationTransformer[] = [ + { + path: ['channels', 'newPlaydates'], + type: TransformerType.Channel, + }, + { + path: ['channels', 'playdateReminders'], + type: TransformerType.Channel, + }, + { + path: ['locale'], + type: TransformerType.Locale, + } + ]; + + public getValue(configValue: GroupConfigurationModel): GroupConfigurationResult { + const transformerType = this.getTransformerType(configValue.key); + if (transformerType === undefined || transformerType === null) { + throw new Error(`Can't find transformer for ${configValue.key}`); + } + + switch (transformerType) { + case TransformerType.Locale: + return new Intl.Locale(configValue.value) + case TransformerType.Channel: + return configValue.value; + } + } + + public getTransformerType(configKey: string): Nullable { + const path = configKey.split('.'); + return GroupConfigurationTransformers.TRANSFORMERS.find( + transformer => { + return ArrayUtils.arraysEqual(transformer.path, path); + } + )?.type; + } + + +} \ No newline at end of file diff --git a/source/Groups/RuntimeGroupConfiguration.d.ts b/source/Groups/RuntimeGroupConfiguration.d.ts new file mode 100644 index 0000000..966c597 --- /dev/null +++ b/source/Groups/RuntimeGroupConfiguration.d.ts @@ -0,0 +1,9 @@ +export type RuntimeGroupConfiguration = { + channels: Nullable, + locale: Intl.Locale, +}; + +export type ChannelRuntimeGroupConfiguration = { + newPlaydates: ChannelId, + playdateReminders: ChannelId +} \ No newline at end of file diff --git a/source/Repositories/GroupConfigurationRepository.ts b/source/Repositories/GroupConfigurationRepository.ts new file mode 100644 index 0000000..173037e --- /dev/null +++ b/source/Repositories/GroupConfigurationRepository.ts @@ -0,0 +1,65 @@ +import {Repository} from "./Repository"; +import GroupConfiguration, {DBGroupConfiguration} from "../Database/tables/GroupConfiguration"; +import {GroupConfigurationModel} from "../Models/GroupConfigurationModel"; +import { GroupModel } from "../Models/GroupModel"; +import {Nullable} from "../types/Nullable"; +import {DatabaseConnection} from "../Database/DatabaseConnection"; +import {GroupRepository} from "./GroupRepository"; + +export class GroupConfigurationRepository extends Repository { + + constructor( + protected readonly database: DatabaseConnection, + private readonly groupRepository: GroupRepository, + ) { + super( + database, + GroupConfiguration + ); + } + + public findGroupConfigurations(group: GroupModel): GroupConfigurationModel[] { + return this.database.fetchAll(` + SELECT * FROM groupConfiguration WHERE groupid = ?`, + group.id + ).map((config) => { + return this.convertToModelType(config, group); + }) + } + + public findConfigurationByPath(group: GroupModel, path: string): Nullable { + const result = this.database.fetch(` + SELECT * FROM groupConfiguration WHERE groupid = ? AND key = ?`, + group.id, + path + ); + + if (!result) { + return null; + } + + return this.convertToModelType(result, group); + } + + + protected convertToModelType(intermediateModel: DBGroupConfiguration | undefined, group: Nullable = null): GroupConfigurationModel { + if (!intermediateModel) { + throw new Error("No intermediate model provided"); + } + + return { + id: intermediateModel.id, + group: group ?? this.groupRepository.getById(intermediateModel.id), + key: intermediateModel.key, + value: intermediateModel.value, + } + } + + protected convertToCreateObject(instance: Partial): object { + return { + groupid: instance.group?.id ?? undefined, + key: instance.key ?? undefined, + value: instance.value ?? undefined, + } + } +} \ No newline at end of file diff --git a/source/Repositories/Repository.ts b/source/Repositories/Repository.ts index bbd1dd8..fbae56b 100644 --- a/source/Repositories/Repository.ts +++ b/source/Repositories/Repository.ts @@ -33,6 +33,30 @@ export class Repository&{id: number}): boolean { + const columnNames = this.schema.columns.filter((column) => { + return !column.primaryKey + }).map((column) => { + return column.name; + }); + + const createObject = this.convertToCreateObject(instance); + const keys = Object.keys(createObject); + const missingColumns = columnNames.filter((columnName) => { + return !keys.includes(columnName); + }) + + if (missingColumns.length > 0) { + throw new Error("Can't create instance, due to missing column values: " + missingColumns); + } + + const sql = `UPDATE ${this.schema.name} + SET ${Object.keys(createObject).map((key) => `${key} = ?`).join(',')} + WHERE id = ?`; + const result = this.database.execute(sql, ...Object.values(createObject), instance.id); + return result.lastInsertRowid; + } + public getById(id: number): Nullable { const sql = `SELECT * FROM ${this.schema.name} WHERE id = ? LIMIT 1`; return this.convertToModelType(this.database.fetch(sql, id)); diff --git a/source/Utilities/ArrayUtils.ts b/source/Utilities/ArrayUtils.ts index d3aacbd..6f4db30 100644 --- a/source/Utilities/ArrayUtils.ts +++ b/source/Utilities/ArrayUtils.ts @@ -3,4 +3,15 @@ export class ArrayUtils { const index = Math.floor(Math.random() * array.length); return array[index]; } + + public static arraysEqual(a: Array, b: Array): boolean { + if (a === b) return true; + if (a == null || b == null) return false; + if (a.length !== b.length) return false; + + for (var i = 0; i < a.length; ++i) { + if (a[i] !== b[i]) return false; + } + return true; + } } \ No newline at end of file diff --git a/source/types/DiscordTypes.ts b/source/types/DiscordTypes.ts index 85cfab8..d326178 100644 --- a/source/types/DiscordTypes.ts +++ b/source/types/DiscordTypes.ts @@ -6,4 +6,6 @@ export type GuildMember = { export type Role = { server: string; roleid: string; -}; \ No newline at end of file +}; + +export type ChannelId = string; \ No newline at end of file