From b4c2fe3a0e242ac76fca48f828c13f469dd9ec06 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Mon, 14 Apr 2025 13:09:21 -0400 Subject: [PATCH 01/40] Re-enabled eslint in /calendar --- src/commands/general/calendar.ts | 243 +++++++++++++++---------------- 1 file changed, 119 insertions(+), 124 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 3b74ea3b..c9403c0b 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -1,4 +1,4 @@ -/* eslint-disable */ +/* eslint-disable camelcase */ import { ChatInputCommandInteraction, ButtonBuilder, @@ -12,7 +12,7 @@ import { ButtonInteraction, CacheType, StringSelectMenuInteraction, - Message, + Message } from 'discord.js'; import { Command } from '@lib/types/Command'; import 'dotenv/config'; @@ -23,7 +23,6 @@ import { PagifiedSelectMenu } from '@root/src/lib/utils/calendarUtils'; import { calendar_v3 } from 'googleapis'; import { retrieveEvents } from '@root/src/lib/auth'; import path from 'path'; -//import event from '@root/src/models/calEvent'; // Define the Master Calendar ID constant. const MASTER_CALENDAR_ID = CALENDAR_CONFIG.MASTER_ID; @@ -43,15 +42,16 @@ interface Filter { } export default class extends Command { - name = "calendar"; - description = "Retrieve calendar events with pagination and filters"; + + name = 'calendar'; + description = 'Retrieve calendar events with pagination and filters'; options: ApplicationCommandStringOptionData[] = [ { type: ApplicationCommandOptionType.String, - name: "classname", - description: "Enter the event holder (e.g., class name).", - required: false, + name: 'classname', + description: 'Enter the event holder (e.g., class name).', + required: false } ]; @@ -61,20 +61,20 @@ export default class extends Command { // Filters calendar events based on slash command inputs and filter dropdown selections. function filterEvents(events: Event[], eventsPerPage: number, filters: Filter[]) { let temp: Event[] = []; - let filteredEvents: Event[][] = []; + const filteredEvents: Event[][] = []; let allFiltersFlags = true; - let eventHolderFlag: boolean = true; - let eventDateFlag: boolean = true; + const eventHolderFlag = true; + const eventDateFlag = true; events.forEach((event) => { const lowerCaseSummary: string = event.calEvent.summary.toLowerCase(); // Extract class name (works for "CISC108-..." and "CISC374010") const classNameMatch = lowerCaseSummary.match(/cisc\d+/i); - const extractedClassName = classNameMatch ? classNameMatch[0].toUpperCase() : ""; + const extractedClassName = classNameMatch ? classNameMatch[0].toUpperCase() : ''; // Dynamically update filter options. - const classFilter = filters.find((f) => f.customId === "class_name_menu"); + const classFilter = filters.find((f) => f.customId === 'class_name_menu'); if (extractedClassName && classFilter && !classFilter.values.includes(extractedClassName)) { classFilter.values.push(extractedClassName); } @@ -105,29 +105,29 @@ export default class extends Command { function generateEmbed(filteredEvents: Event[][], currentPage: number, maxPage: number): EmbedBuilder { let embed: EmbedBuilder; if ( - filteredEvents.length && - filteredEvents[currentPage] && - filteredEvents[currentPage].length + filteredEvents.length + && filteredEvents[currentPage] + && filteredEvents[currentPage].length ) { embed = new EmbedBuilder() .setTitle(`Events - ${currentPage + 1} of ${maxPage}`) - .setColor("Green"); + .setColor('Green'); filteredEvents[currentPage].forEach((event) => { embed.addFields({ name: `**${event.calEvent.summary}**`, value: `Date: ${new Date(event.calEvent.start.dateTime).toLocaleDateString()} Time: ${new Date(event.calEvent.start.dateTime).toLocaleTimeString()} - ${new Date(event.calEvent.end.dateTime).toLocaleTimeString()} - Location: ${event.calEvent.location ? event.calEvent.location : "`NONE`"} - Email: ${event.calEvent.creator.email}\n`, + Location: ${event.calEvent.location ? event.calEvent.location : '`NONE`'} + Email: ${event.calEvent.creator.email}\n` }); }); } else { embed = new EmbedBuilder() - .setTitle("No Events Found") - .setColor("Green") + .setTitle('No Events Found') + .setColor('Green') .addFields({ - name: "Try adjusting your filters", - value: "No events match your selections, please change them!", + name: 'Try adjusting your filters', + value: 'No events match your selections, please change them!' }); } return embed; @@ -136,31 +136,31 @@ export default class extends Command { // Generates the pagination buttons (Previous, Next, Download Calendar, Download All, Done). function generateButtons(currentPage: number, maxPage: number, downloadCount: number): ActionRowBuilder { const nextButton = new ButtonBuilder() - .setCustomId("next") - .setLabel("Next") + .setCustomId('next') + .setLabel('Next') .setStyle(ButtonStyle.Primary) .setDisabled(currentPage + 1 >= maxPage); const prevButton = new ButtonBuilder() - .setCustomId("prev") - .setLabel("Previous") + .setCustomId('prev') + .setLabel('Previous') .setStyle(ButtonStyle.Primary) .setDisabled(currentPage === 0); const downloadCal = new ButtonBuilder() - .setCustomId("download_Cal") + .setCustomId('download_Cal') .setLabel(`Download Calendar (${downloadCount})`) .setStyle(ButtonStyle.Success) .setDisabled(downloadCount === 0); const downloadAll = new ButtonBuilder() - .setCustomId("download_all") - .setLabel("Download All") + .setCustomId('download_all') + .setLabel('Download All') .setStyle(ButtonStyle.Secondary); const done = new ButtonBuilder() - .setCustomId("done") - .setLabel("Done") + .setCustomId('done') + .setLabel('Done') .setStyle(ButtonStyle.Danger); return new ActionRowBuilder().addComponents( @@ -176,7 +176,7 @@ export default class extends Command { function generateFilterMessage(filters: Filter[]) { const filterMenus: PagifiedSelectMenu[] = filters.map((filter) => { if (filter.values.length === 0) { - filter.values.push("No Data Available"); + filter.values.push('No Data Available'); } const filterMenu = new PagifiedSelectMenu(); filterMenu.createSelectMenu( @@ -189,13 +189,13 @@ export default class extends Command { ); filter.values.forEach((value) => { - let isDefault: boolean = false; + let isDefault = false; if (filter.newValues[0]) { if (filter.newValues[0].toLowerCase() === value.toLowerCase()) { isDefault = true; } } - filterMenu.addOption({label: value, value: value.toLowerCase(), default: isDefault}) + filterMenu.addOption({ label: value, value: value.toLowerCase(), default: isDefault }); }); return filterMenu; }); @@ -205,7 +205,7 @@ export default class extends Command { // Generates a row of toggle buttons – one for each event on the current page. function generateEventSelectButtons(eventsPerPage: number): ActionRowBuilder { - const selectEventButtons: ButtonBuilder[] = [] + const selectEventButtons: ButtonBuilder[] = []; // This is to ensure that the number of buttons does not exceed to the limit per row // We should probably change to a pagified select menu later on @@ -238,17 +238,15 @@ export default class extends Command { for (const calendar of calendars) { const newParentEvents = await retrieveEvents(calendar.calendarId, interaction, false); - parentEvents.push(...newParentEvents) + parentEvents.push(...newParentEvents); } - const recurrenceRules: Record = Object.fromEntries(parentEvents.map((event) => { - return [event.id, event.recurrence[0]]; - })); + const recurrenceRules: Record = Object.fromEntries(parentEvents.map((event) => [event.id, event.recurrence[0]])); const recurringIds: Set = new Set(); selectedEvents.forEach((event) => { - let append: boolean = false; + let append = false; const iCalEvent = { UID: `${Date.now()}-${Math.random().toString(36).substring(2, 10)}`, CREATED: new Date(event.calEvent.created).toISOString().replace(/[-:.]/g, ''), @@ -257,22 +255,19 @@ export default class extends Command { DTEND: `TZID=${event.calEvent.end.timeZone}:${event.calEvent.end.dateTime.replace(/[-:.]/g, '')}`, SUMMARY: event.calEvent.summary, DESCRIPTION: `Contact Email: ${event.calEvent.creator.email || 'NA'}`, - LOCATION: event.calEvent.location ? event.calEvent.location : 'NONE', + LOCATION: event.calEvent.location ? event.calEvent.location : 'NONE' }; if (!event.calEvent.recurringEventId) { - append = true - } - else { - if (!recurringIds.has(event.calEvent.recurringEventId)) { - recurringIds.add(event.calEvent.recurringEventId); - append = true; - } + append = true; + } else if (!recurringIds.has(event.calEvent.recurringEventId)) { + recurringIds.add(event.calEvent.recurringEventId); + append = true; } if (append) { - const icsFormatted = - `BEGIN:VEVENT + const icsFormatted + = `BEGIN:VEVENT UID:${iCalEvent.UID} CREATED:${iCalEvent.CREATED} DTSTAMP:${iCalEvent.DTSTAMP} @@ -298,52 +293,52 @@ export default class extends Command { fs.writeFileSync('./events.ics', icsCalendar); } - /******************************************************************************************************************/ + /** ****************************************************************************************************************/ // Initial reply to acknowledge the interaction. await interaction.reply({ - content: "Authenticating and fetching events...", - ephemeral: true, + content: 'Authenticating and fetching events...', + ephemeral: true }); // Define filters for dropdowns. const filters: Filter[] = [ { - customId: "calendar_menu", - placeholder: "Select Calendar", + customId: 'calendar_menu', + placeholder: 'Select Calendar', values: [], newValues: [], flag: true, condition: (newValues: string[], event: Event) => { - const calendarName = event.calendarName.toLowerCase() || ""; + const calendarName = event.calendarName.toLowerCase() || ''; return newValues.some((value) => calendarName === value.toLowerCase()); - }, + } }, { - customId: "class_name_menu", - placeholder: "Select Classes", + customId: 'class_name_menu', + placeholder: 'Select Classes', values: [], newValues: [interaction.options.getString('classname') ? interaction.options.getString('classname') : ''], flag: true, condition: (newValues: string[], event: Event) => { - const summary = event.calEvent.summary?.toLowerCase() || ""; + const summary = event.calEvent.summary?.toLowerCase() || ''; return newValues.some((value) => summary.includes(value.toLowerCase())); - }, + } }, { - customId: "location_type_menu", - placeholder: "Select Location Type", - values: ["In Person", "Virtual"], + customId: 'location_type_menu', + placeholder: 'Select Location Type', + values: ['In Person', 'Virtual'], newValues: [], flag: true, condition: (newValues: string[], event: Event) => { const locString = event.calEvent.summary?.toLowerCase() || ''; return newValues.some((value) => locString.includes(value.toLowerCase())); - }, + } }, { - customId: "week_menu", - placeholder: "Select Days of Week", - values: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"], + customId: 'week_menu', + placeholder: 'Select Days of Week', + values: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], newValues: [], flag: true, condition: (newValues: string[], event: Event) => { @@ -351,22 +346,22 @@ export default class extends Command { const dt = new Date(event.calEvent.start.dateTime); const weekdayIndex = dt.getDay(); // 0 = Sunday, 1 = Monday, etc. const dayName = [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday' ][weekdayIndex]; return newValues.some((value) => value.toLowerCase() === dayName.toLowerCase()); - }, - }, + } + } ]; - const MONGO_URI = process.env.DB_CONN_STRING || ""; - const DB_NAME = "CalendarDatabase"; - const COLLECTION_NAME = "calendarIds"; + const MONGO_URI = process.env.DB_CONN_STRING || ''; + const DB_NAME = 'CalendarDatabase'; + const COLLECTION_NAME = 'calendarIds'; // Fetch calendar IDs from MongoDB. async function fetchCalendars() { @@ -380,13 +375,13 @@ export default class extends Command { const calendars: {calendarId: string, calendarName: string}[] = calendarDocs.map((doc) => ({ calendarId: doc.calendarId, - calendarName: doc.calendarName || "Unnamed Calendar", + calendarName: doc.calendarName || 'Unnamed Calendar' })); if (!calendars.some((c) => c.calendarId === MASTER_CALENDAR_ID)) { calendars.push({ calendarId: MASTER_CALENDAR_ID, - calendarName: "Master Calendar", + calendarName: 'Master Calendar' }); } @@ -394,20 +389,20 @@ export default class extends Command { } // Retrieve events from all calendars in the database - let events: Event[] = []; + const events: Event[] = []; const calendars = await fetchCalendars(); - const calendarMenu = filters.find((f) => f.customId === "calendar_menu"); + const calendarMenu = filters.find((f) => f.customId === 'calendar_menu'); if (calendarMenu) { calendarMenu.values = calendars.map((c) => c.calendarName); } for (const cal of calendars) { - const retrivedEvents = await retrieveEvents(cal.calendarId, interaction) + const retrivedEvents = await retrieveEvents(cal.calendarId, interaction); if (retrivedEvents === null) { return; } retrivedEvents.forEach((retrivedEvent) => { - const newEvent: Event = {calEvent: retrivedEvent, calendarName: cal.calendarName} + const newEvent: Event = { calEvent: retrivedEvent, calendarName: cal.calendarName }; events.push(newEvent); }); } @@ -419,18 +414,18 @@ export default class extends Command { new Date(b.calEvent.start?.dateTime || b.calEvent.start?.date).getTime() ); - const eventsPerPage: number = 3; + const eventsPerPage = 3; let filteredEvents: Event[][] = filterEvents(events, eventsPerPage, filters); if (!filteredEvents.length) { await interaction.followUp({ - content: "No matching events found based on your filters. Please adjust your search criteria.", - ephemeral: true, + content: 'No matching events found based on your filters. Please adjust your search criteria.', + ephemeral: true }); return; } let maxPage: number = filteredEvents.length; - let currentPage: number = 0; + let currentPage = 0; let selectedEvents: Event[] = []; const embed = generateEmbed(filteredEvents, currentPage, maxPage); @@ -447,19 +442,19 @@ export default class extends Command { try { message = await dm.send({ embeds: [embed], - components: initialComponents, + components: initialComponents }); } catch (error) { - console.error("Failed to send DM:", error); + console.error('Failed to send DM:', error); await interaction.followUp({ content: "⚠️ I couldn't send you a DM. Please check your privacy settings.", - ephemeral: true, + ephemeral: true }); return; } const filterComponents = generateFilterMessage(filters); - let content: string = '**Select Filters**'; + let content = '**Select Filters**'; const singlePageMenus: (ActionRowBuilder | ActionRowBuilder)[] = []; filterComponents.forEach((component) => { @@ -473,7 +468,7 @@ export default class extends Command { filteredEvents = filterEvents(events, eventsPerPage, filters); currentPage = 0; maxPage = filteredEvents.length; - selectedEvents = [] + selectedEvents = []; const newEmbed = generateEmbed(filteredEvents, currentPage, maxPage); const newComponents: ActionRowBuilder[] = []; newComponents.push(generateButtons(currentPage, maxPage, selectedEvents.length)); @@ -484,12 +479,11 @@ export default class extends Command { } message.edit({ embeds: [newEmbed], - components: newComponents, + components: newComponents }); }, interaction, dm, content); content = ''; - } - else { + } else { singlePageMenus.push(component.generateActionRows()[0]); } }); @@ -502,10 +496,10 @@ export default class extends Command { components: singlePageMenus }); } catch (error) { - console.error("Failed to send DM:", error); + console.error('Failed to send DM:', error); await interaction.followUp({ content: "⚠️ I couldn't send you a DM. Please check your privacy settings.", - ephemeral: true, + ephemeral: true }); return; } @@ -518,10 +512,10 @@ export default class extends Command { const buttonCollector = message.createMessageComponentCollector({ time: 300000 }); const menuCollector = filterMessage.createMessageComponentCollector({ componentType: ComponentType.StringSelect, time: 300000 }); - buttonCollector.on("collect", async (btnInt: ButtonInteraction) => { + buttonCollector.on('collect', async (btnInt: ButtonInteraction) => { try { await btnInt.deferUpdate(); - if (btnInt.customId.startsWith("toggle-")) { + if (btnInt.customId.startsWith('toggle-')) { const eventIndex = Number(btnInt.customId.split('-')[1]) - 1; const event = filteredEvents[currentPage][eventIndex]; if (selectedEvents.some((e) => e === event)) { @@ -532,11 +526,11 @@ export default class extends Command { try { await removeMsg.delete(); } catch (err) { - console.error("Failed to delete removal message:", err); + console.error('Failed to delete removal message:', err); } }, 3000); } catch (err) { - console.error("Error sending removal message:", err); + console.error('Error sending removal message:', err); } } else { selectedEvents.push(event); @@ -546,22 +540,22 @@ export default class extends Command { try { await addMsg.delete(); } catch (err) { - console.error("Failed to delete addition message:", err); + console.error('Failed to delete addition message:', err); } }, 3000); } catch (err) { - console.error("Error sending addition message:", err); + console.error('Error sending addition message:', err); } } - } else if (btnInt.customId === "next") { + } else if (btnInt.customId === 'next') { if (currentPage + 1 >= maxPage) return; currentPage++; - } else if (btnInt.customId === "prev") { + } else if (btnInt.customId === 'prev') { if (currentPage === 0) return; currentPage--; } else if (btnInt.customId === 'download_Cal') { if (selectedEvents.length === 0) { - await dm.send("No events selected to download!"); + await dm.send('No events selected to download!'); return; } const downloadMessage = await dm.send({ content: 'Downloading selected events...' }); @@ -576,12 +570,12 @@ export default class extends Command { } catch { await downloadMessage.edit({ content: '⚠️ Failed to download events' }); } - } else if (btnInt.customId === "download_all") { + } else if (btnInt.customId === 'download_all') { if (!filteredEvents.length) { - await dm.send("No events to download!"); + await dm.send('No events to download!'); return; } - const downloadMessage = await dm.send({ content: "Downloading all events..." }); + const downloadMessage = await dm.send({ content: 'Downloading all events...' }); try { await downloadEvents(filteredEvents.flat(), calendars); const filePath = path.join('./events.ics'); @@ -591,18 +585,18 @@ export default class extends Command { }); fs.unlinkSync('./events.ics'); } catch { - await downloadMessage.edit({ content: "⚠️ Failed to download all events." }); + await downloadMessage.edit({ content: '⚠️ Failed to download all events.' }); } - } else if (btnInt.customId === "done") { + } else if (btnInt.customId === 'done') { await message.edit({ embeds: [], components: [], - content: "📅 Calendar session closed.", + content: '📅 Calendar session closed.' }); await filterMessage.edit({ embeds: [], components: [], - content: "Filters closed.", + content: 'Filters closed.' }); buttonCollector.stop(); menuCollector.stop(); @@ -619,18 +613,18 @@ export default class extends Command { } await message.edit({ embeds: [newEmbed], - components: newComponents, + components: newComponents }); } catch (error) { - console.error("Button Collector Error:", error); + console.error('Button Collector Error:', error); await btnInt.followUp({ - content: "⚠️ An error occurred while navigating through events. Please try again.", - ephemeral: true, + content: '⚠️ An error occurred while navigating through events. Please try again.', + ephemeral: true }); } }); - menuCollector.on("collect", async (i: StringSelectMenuInteraction) => { + menuCollector.on('collect', async (i: StringSelectMenuInteraction) => { await i.deferUpdate(); const filter = filters.find((f) => f.customId === i.customId); if (filter) { @@ -639,7 +633,7 @@ export default class extends Command { filteredEvents = filterEvents(events, eventsPerPage, filters); currentPage = 0; maxPage = filteredEvents.length; - selectedEvents = [] + selectedEvents = []; const newEmbed = generateEmbed(filteredEvents, currentPage, maxPage); const newComponents: ActionRowBuilder[] = []; newComponents.push(generateButtons(currentPage, maxPage, selectedEvents.length)); @@ -650,8 +644,9 @@ export default class extends Command { } message.edit({ embeds: [newEmbed], - components: newComponents, + components: newComponents }); }); } + } From ec6f973d13cf2a044e433dbcb11b007d809274bb Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Mon, 14 Apr 2025 13:21:41 -0400 Subject: [PATCH 02/40] Calendar.ts now adheres to the eslint rules --- src/commands/general/calendar.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index c9403c0b..610387b7 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -74,7 +74,7 @@ export default class extends Command { const extractedClassName = classNameMatch ? classNameMatch[0].toUpperCase() : ''; // Dynamically update filter options. - const classFilter = filters.find((f) => f.customId === 'class_name_menu'); + const classFilter = filters.find((filter) => filter.customId === 'class_name_menu'); if (extractedClassName && classFilter && !classFilter.values.includes(extractedClassName)) { classFilter.values.push(extractedClassName); } @@ -86,7 +86,7 @@ export default class extends Command { filter.flag = filter.condition(filter.newValues, event); } }); - allFiltersFlags = filters.every((f) => f.flag); + allFiltersFlags = filters.every((filter) => filter.flag); } if (allFiltersFlags && eventHolderFlag && eventDateFlag) { @@ -391,7 +391,7 @@ export default class extends Command { // Retrieve events from all calendars in the database const events: Event[] = []; const calendars = await fetchCalendars(); - const calendarMenu = filters.find((f) => f.customId === 'calendar_menu'); + const calendarMenu = filters.find((fi) => fi.customId === 'calendar_menu'); if (calendarMenu) { calendarMenu.values = calendars.map((c) => c.calendarName); } @@ -461,7 +461,7 @@ export default class extends Command { if (component.menus.length > 1) { component.generateRowsAndSendMenu(async (i) => { await i.deferUpdate(); - const filter = filters.find((f) => f.customId === i.customId); + const filter = filters.find((fi) => fi.customId === i.customId); if (filter) { filter.newValues = i.values; } @@ -626,7 +626,7 @@ export default class extends Command { menuCollector.on('collect', async (i: StringSelectMenuInteraction) => { await i.deferUpdate(); - const filter = filters.find((f) => f.customId === i.customId); + const filter = filters.find((fi) => fi.customId === i.customId); if (filter) { filter.newValues = i.values; } From 9f2e3545574a16f3955ba3ef0538565237a96b29 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Mon, 14 Apr 2025 20:31:47 -0400 Subject: [PATCH 03/40] Changed filtered events array back into a 1D array --- src/commands/general/calendar.ts | 34 ++++++++++++-------------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 610387b7..40063474 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -59,14 +59,12 @@ export default class extends Command { /** Helper Functions **/ // Filters calendar events based on slash command inputs and filter dropdown selections. - function filterEvents(events: Event[], eventsPerPage: number, filters: Filter[]) { - let temp: Event[] = []; - const filteredEvents: Event[][] = []; + async function filterEvents(events: Event[], filters: Filter[]) { + const filteredEvents: Event[] = []; let allFiltersFlags = true; - const eventHolderFlag = true; - const eventDateFlag = true; - events.forEach((event) => { + + await Promise.all(events.map(async (event) => { const lowerCaseSummary: string = event.calEvent.summary.toLowerCase(); // Extract class name (works for "CISC108-..." and "CISC374010") @@ -89,26 +87,20 @@ export default class extends Command { allFiltersFlags = filters.every((filter) => filter.flag); } - if (allFiltersFlags && eventHolderFlag && eventDateFlag) { - temp.push(event); - if (temp.length % eventsPerPage === 0) { - filteredEvents.push(temp); - temp = []; - } + if (allFiltersFlags) { + filteredEvents.push(event); } - }); - if (temp.length) filteredEvents.push(temp); + })); + return filteredEvents; } // Generates the embed for displaying events. - function generateEmbed(filteredEvents: Event[][], currentPage: number, maxPage: number): EmbedBuilder { + function generateEmbed(filteredEvents: Event[], currentPage: number, maxPage: number): EmbedBuilder { + const embeds: EmbedBuilder[] = []; let embed: EmbedBuilder; - if ( - filteredEvents.length - && filteredEvents[currentPage] - && filteredEvents[currentPage].length - ) { + + if (filteredEvents.length) { embed = new EmbedBuilder() .setTitle(`Events - ${currentPage + 1} of ${maxPage}`) .setColor('Green'); @@ -415,7 +407,7 @@ export default class extends Command { ); const eventsPerPage = 3; - let filteredEvents: Event[][] = filterEvents(events, eventsPerPage, filters); + let filteredEvents: Event[] = await filterEvents(events, filters); if (!filteredEvents.length) { await interaction.followUp({ content: 'No matching events found based on your filters. Please adjust your search criteria.', From 7f22a54f832cb8b16c15888c89e4a1e25fd0f8ab Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Mon, 14 Apr 2025 21:42:25 -0400 Subject: [PATCH 04/40] Updated calendar embed to be pagified instead of a 2D array --- src/commands/general/calendar.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 40063474..6e418ed5 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -96,15 +96,19 @@ export default class extends Command { } // Generates the embed for displaying events. - function generateEmbed(filteredEvents: Event[], currentPage: number, maxPage: number): EmbedBuilder { + function generateEmbed(filteredEvents: Event[], currentPage: number, itemsPerPage: number): EmbedBuilder { const embeds: EmbedBuilder[] = []; let embed: EmbedBuilder; if (filteredEvents.length) { + let numEmbeds = 1; + const maxPage: number = Math.ceil(filteredEvents.length / itemsPerPage); + embed = new EmbedBuilder() - .setTitle(`Events - ${currentPage + 1} of ${maxPage}`) + .setTitle(`Events - ${currentPage + numEmbeds} of ${maxPage}`) .setColor('Green'); - filteredEvents[currentPage].forEach((event) => { + + filteredEvents.forEach((event, index) => { embed.addFields({ name: `**${event.calEvent.summary}**`, value: `Date: ${new Date(event.calEvent.start.dateTime).toLocaleDateString()} @@ -112,6 +116,14 @@ export default class extends Command { Location: ${event.calEvent.location ? event.calEvent.location : '`NONE`'} Email: ${event.calEvent.creator.email}\n` }); + + if (index % itemsPerPage === 0) { + numEmbeds++; + embeds.push(embed); + embed = new EmbedBuilder() + .setTitle(`Events - ${currentPage + numEmbeds} of ${maxPage}`) + .setColor('Green'); + } }); } else { embed = new EmbedBuilder() From d9c3a25cf351aba0a24a383e7b2733578e34e404 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Mon, 14 Apr 2025 22:03:59 -0400 Subject: [PATCH 05/40] Removed done button --- src/commands/general/calendar.ts | 74 +++++++++++++------------------- 1 file changed, 31 insertions(+), 43 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 6e418ed5..1458223e 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -96,7 +96,7 @@ export default class extends Command { } // Generates the embed for displaying events. - function generateEmbed(filteredEvents: Event[], currentPage: number, itemsPerPage: number): EmbedBuilder { + function generateEmbed(filteredEvents: Event[], currentPage: number, itemsPerPage: number): EmbedBuilder[] { const embeds: EmbedBuilder[] = []; let embed: EmbedBuilder; @@ -133,8 +133,9 @@ export default class extends Command { name: 'Try adjusting your filters', value: 'No events match your selections, please change them!' }); + embeds.push(embed); } - return embed; + return embeds; } // Generates the pagination buttons (Previous, Next, Download Calendar, Download All, Done). @@ -432,12 +433,12 @@ export default class extends Command { let currentPage = 0; let selectedEvents: Event[] = []; - const embed = generateEmbed(filteredEvents, currentPage, maxPage); + let embeds = generateEmbed(filteredEvents, currentPage, eventsPerPage); const initialComponents: ActionRowBuilder[] = []; initialComponents.push(generateButtons(currentPage, maxPage, selectedEvents.length)); - if (filteredEvents[currentPage]) { - if (filteredEvents[currentPage].length) { - initialComponents.push(generateEventSelectButtons(filteredEvents[currentPage].length)); + if (embeds[currentPage]) { + if (embeds[currentPage].data.fields.length) { + initialComponents.push(generateEventSelectButtons(embeds[currentPage].data.fields.length)); } } @@ -445,7 +446,7 @@ export default class extends Command { let message: Message; try { message = await dm.send({ - embeds: [embed], + embeds: [embeds[currentPage]], components: initialComponents }); } catch (error) { @@ -469,20 +470,20 @@ export default class extends Command { if (filter) { filter.newValues = i.values; } - filteredEvents = filterEvents(events, eventsPerPage, filters); + filteredEvents = await filterEvents(events, filters); currentPage = 0; maxPage = filteredEvents.length; selectedEvents = []; - const newEmbed = generateEmbed(filteredEvents, currentPage, maxPage); + embeds = generateEmbed(filteredEvents, currentPage, eventsPerPage); const newComponents: ActionRowBuilder[] = []; newComponents.push(generateButtons(currentPage, maxPage, selectedEvents.length)); - if (filteredEvents[currentPage]) { - if (filteredEvents[currentPage].length) { - newComponents.push(generateEventSelectButtons(filteredEvents[currentPage].length)); + if (embeds[currentPage]) { + if (embeds[currentPage].data.fields.length) { + newComponents.push(generateEventSelectButtons(embeds[currentPage].data.fields.length)); } } message.edit({ - embeds: [newEmbed], + embeds: [embeds[currentPage]], components: newComponents }); }, interaction, dm, content); @@ -591,34 +592,21 @@ export default class extends Command { } catch { await downloadMessage.edit({ content: '⚠️ Failed to download all events.' }); } - } else if (btnInt.customId === 'done') { - await message.edit({ - embeds: [], - components: [], - content: '📅 Calendar session closed.' - }); - await filterMessage.edit({ - embeds: [], - components: [], - content: 'Filters closed.' - }); - buttonCollector.stop(); - menuCollector.stop(); - return; } - const newEmbed = generateEmbed(filteredEvents, currentPage, maxPage); - const newComponents: ActionRowBuilder[] = []; - newComponents.push(generateButtons(currentPage, maxPage, selectedEvents.length)); - if (filteredEvents[currentPage]) { - if (filteredEvents[currentPage].length) { - newComponents.push(generateEventSelectButtons(filteredEvents[currentPage].length)); + if (btnInt.customId === 'next' || btnInt.customId === 'prev') { + const newComponents: ActionRowBuilder[] = []; + newComponents.push(generateButtons(currentPage, maxPage, selectedEvents.length)); + if (embeds[currentPage]) { + if (embeds[currentPage].data.fields.length) { + newComponents.push(generateEventSelectButtons(embeds[currentPage].data.fields.length)); + } } + await message.edit({ + embeds: [embeds[currentPage]], + components: newComponents + }); } - await message.edit({ - embeds: [newEmbed], - components: newComponents - }); } catch (error) { console.error('Button Collector Error:', error); await btnInt.followUp({ @@ -634,20 +622,20 @@ export default class extends Command { if (filter) { filter.newValues = i.values; } - filteredEvents = filterEvents(events, eventsPerPage, filters); + filteredEvents = await filterEvents(events, filters); currentPage = 0; maxPage = filteredEvents.length; selectedEvents = []; - const newEmbed = generateEmbed(filteredEvents, currentPage, maxPage); + embeds = generateEmbed(filteredEvents, currentPage, eventsPerPage); const newComponents: ActionRowBuilder[] = []; newComponents.push(generateButtons(currentPage, maxPage, selectedEvents.length)); - if (filteredEvents[currentPage]) { - if (filteredEvents[currentPage].length) { - newComponents.push(generateEventSelectButtons(filteredEvents[currentPage].length)); + if (embeds[currentPage]) { + if (embeds[currentPage].data.fields.length) { + newComponents.push(generateEventSelectButtons(embeds[currentPage].data.fields.length)); } } message.edit({ - embeds: [newEmbed], + embeds: [embeds[currentPage]], components: newComponents }); }); From 52527cb1dff4a0b431b4dfd9a3d932391d12eb2f Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Mon, 14 Apr 2025 22:50:00 -0400 Subject: [PATCH 06/40] Embeds should now display all events properly --- src/commands/general/calendar.ts | 50 +++++++++++--------------------- 1 file changed, 17 insertions(+), 33 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 1458223e..8b5134be 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -65,27 +65,13 @@ export default class extends Command { let allFiltersFlags = true; await Promise.all(events.map(async (event) => { - const lowerCaseSummary: string = event.calEvent.summary.toLowerCase(); - - // Extract class name (works for "CISC108-..." and "CISC374010") - const classNameMatch = lowerCaseSummary.match(/cisc\d+/i); - const extractedClassName = classNameMatch ? classNameMatch[0].toUpperCase() : ''; - - // Dynamically update filter options. - const classFilter = filters.find((filter) => filter.customId === 'class_name_menu'); - if (extractedClassName && classFilter && !classFilter.values.includes(extractedClassName)) { - classFilter.values.push(extractedClassName); - } - - if (filters.length) { - filters.forEach((filter) => { - filter.flag = true; - if (filter.newValues.length) { - filter.flag = filter.condition(filter.newValues, event); - } - }); - allFiltersFlags = filters.every((filter) => filter.flag); - } + filters.forEach((filter) => { + filter.flag = true; + if (filter.newValues.length) { + filter.flag = filter.condition(filter.newValues, event); + } + }); + allFiltersFlags = filters.every((filter) => filter.flag); if (allFiltersFlags) { filteredEvents.push(event); @@ -108,6 +94,7 @@ export default class extends Command { .setTitle(`Events - ${currentPage + numEmbeds} of ${maxPage}`) .setColor('Green'); + let i = 1; filteredEvents.forEach((event, index) => { embed.addFields({ name: `**${event.calEvent.summary}**`, @@ -117,13 +104,16 @@ export default class extends Command { Email: ${event.calEvent.creator.email}\n` }); - if (index % itemsPerPage === 0) { + if (i % itemsPerPage === 0) { numEmbeds++; embeds.push(embed); embed = new EmbedBuilder() .setTitle(`Events - ${currentPage + numEmbeds} of ${maxPage}`) .setColor('Green'); + } else if (filteredEvents.length - 1 === index) { + embeds.push(embed); } + i++; }); } else { embed = new EmbedBuilder() @@ -163,17 +153,11 @@ export default class extends Command { .setLabel('Download All') .setStyle(ButtonStyle.Secondary); - const done = new ButtonBuilder() - .setCustomId('done') - .setLabel('Done') - .setStyle(ButtonStyle.Danger); - return new ActionRowBuilder().addComponents( prevButton, nextButton, downloadCal, - downloadAll, - done + downloadAll ); } @@ -429,11 +413,11 @@ export default class extends Command { return; } - let maxPage: number = filteredEvents.length; let currentPage = 0; let selectedEvents: Event[] = []; - let embeds = generateEmbed(filteredEvents, currentPage, eventsPerPage); + let maxPage: number = embeds.length; + const initialComponents: ActionRowBuilder[] = []; initialComponents.push(generateButtons(currentPage, maxPage, selectedEvents.length)); if (embeds[currentPage]) { @@ -472,9 +456,9 @@ export default class extends Command { } filteredEvents = await filterEvents(events, filters); currentPage = 0; - maxPage = filteredEvents.length; selectedEvents = []; embeds = generateEmbed(filteredEvents, currentPage, eventsPerPage); + maxPage = embeds.length; const newComponents: ActionRowBuilder[] = []; newComponents.push(generateButtons(currentPage, maxPage, selectedEvents.length)); if (embeds[currentPage]) { @@ -624,9 +608,9 @@ export default class extends Command { } filteredEvents = await filterEvents(events, filters); currentPage = 0; - maxPage = filteredEvents.length; selectedEvents = []; embeds = generateEmbed(filteredEvents, currentPage, eventsPerPage); + maxPage = embeds.length; const newComponents: ActionRowBuilder[] = []; newComponents.push(generateButtons(currentPage, maxPage, selectedEvents.length)); if (embeds[currentPage]) { From d7b6ec03838d7e283a0ca432ceb22f2c8308651f Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Tue, 15 Apr 2025 00:23:08 -0400 Subject: [PATCH 07/40] Downloading events should now work with new embeds --- src/commands/general/calendar.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 8b5134be..ad43b33c 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -506,7 +506,7 @@ export default class extends Command { await btnInt.deferUpdate(); if (btnInt.customId.startsWith('toggle-')) { const eventIndex = Number(btnInt.customId.split('-')[1]) - 1; - const event = filteredEvents[currentPage][eventIndex]; + const event = filteredEvents[(currentPage * eventsPerPage) + eventIndex]; if (selectedEvents.some((e) => e === event)) { selectedEvents.splice(selectedEvents.indexOf(event), 1); try { @@ -578,19 +578,18 @@ export default class extends Command { } } - if (btnInt.customId === 'next' || btnInt.customId === 'prev') { - const newComponents: ActionRowBuilder[] = []; - newComponents.push(generateButtons(currentPage, maxPage, selectedEvents.length)); - if (embeds[currentPage]) { - if (embeds[currentPage].data.fields.length) { - newComponents.push(generateEventSelectButtons(embeds[currentPage].data.fields.length)); - } + + const newComponents: ActionRowBuilder[] = []; + newComponents.push(generateButtons(currentPage, maxPage, selectedEvents.length)); + if (embeds[currentPage]) { + if (embeds[currentPage].data.fields.length) { + newComponents.push(generateEventSelectButtons(embeds[currentPage].data.fields.length)); } - await message.edit({ - embeds: [embeds[currentPage]], - components: newComponents - }); } + await message.edit({ + embeds: [embeds[currentPage]], + components: newComponents + }); } catch (error) { console.error('Button Collector Error:', error); await btnInt.followUp({ From 9805a7fd1ab3f3ca2faffba1ae2f80b0243071e3 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Tue, 15 Apr 2025 14:42:12 -0400 Subject: [PATCH 08/40] Made it so calendars are retrieved async --- src/commands/general/calendar.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index ad43b33c..38a58580 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -385,7 +385,7 @@ export default class extends Command { calendarMenu.values = calendars.map((c) => c.calendarName); } - for (const cal of calendars) { + await Promise.all(calendars.map(async (cal) => { const retrivedEvents = await retrieveEvents(cal.calendarId, interaction); if (retrivedEvents === null) { return; @@ -394,7 +394,17 @@ export default class extends Command { const newEvent: Event = { calEvent: retrivedEvent, calendarName: cal.calendarName }; events.push(newEvent); }); - } + })); + // for (const cal of calendars) { + // const retrivedEvents = await retrieveEvents(cal.calendarId, interaction); + // if (retrivedEvents === null) { + // return; + // } + // retrivedEvents.forEach((retrivedEvent) => { + // const newEvent: Event = { calEvent: retrivedEvent, calendarName: cal.calendarName }; + // events.push(newEvent); + // }); + // } // Sort events by their start time. events.sort( From 9441adad97ebd4d6de680d4c2ddcaa55a5b4f8c7 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Tue, 15 Apr 2025 14:46:01 -0400 Subject: [PATCH 09/40] Moved pagified select menu class into new file --- src/commands/general/calendar.ts | 15 +- src/commands/general/calreminder.ts | 2 +- src/commands/general/tainfo.ts | 2 +- src/lib/types/PagifiedSelect.ts | 258 ++++++++++++++++++++++++++++ src/lib/utils/calendarUtils.ts | 258 ---------------------------- 5 files changed, 262 insertions(+), 273 deletions(-) create mode 100644 src/lib/types/PagifiedSelect.ts diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 38a58580..49e16e14 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -19,7 +19,7 @@ import 'dotenv/config'; import { MongoClient } from 'mongodb'; import * as fs from 'fs'; import { CALENDAR_CONFIG } from '@lib/CalendarConfig'; -import { PagifiedSelectMenu } from '@root/src/lib/utils/calendarUtils'; +import { PagifiedSelectMenu } from '@root/src/lib/types/PagifiedSelect'; import { calendar_v3 } from 'googleapis'; import { retrieveEvents } from '@root/src/lib/auth'; import path from 'path'; @@ -395,18 +395,7 @@ export default class extends Command { events.push(newEvent); }); })); - // for (const cal of calendars) { - // const retrivedEvents = await retrieveEvents(cal.calendarId, interaction); - // if (retrivedEvents === null) { - // return; - // } - // retrivedEvents.forEach((retrivedEvent) => { - // const newEvent: Event = { calEvent: retrivedEvent, calendarName: cal.calendarName }; - // events.push(newEvent); - // }); - // } - - // Sort events by their start time. + events.sort( (a, b) => new Date(a.calEvent.start?.dateTime || a.calEvent.start?.date).getTime() - diff --git a/src/commands/general/calreminder.ts b/src/commands/general/calreminder.ts index c81e2797..66f909bc 100644 --- a/src/commands/general/calreminder.ts +++ b/src/commands/general/calreminder.ts @@ -13,7 +13,7 @@ import { ComponentType, } from "discord.js"; import parse from "parse-duration"; -import { PagifiedSelectMenu } from "@root/src/lib/utils/calendarUtils"; +import { PagifiedSelectMenu } from '@root/src/lib/types/PagifiedSelect'; import { retrieveEvents } from "@root/src/lib/auth"; import { calendar_v3 } from "googleapis"; diff --git a/src/commands/general/tainfo.ts b/src/commands/general/tainfo.ts index dd006045..ca89a555 100644 --- a/src/commands/general/tainfo.ts +++ b/src/commands/general/tainfo.ts @@ -9,7 +9,7 @@ import { import { Command } from "@lib/types/Command"; import "dotenv/config"; import { retrieveEvents } from "../../lib/auth"; -import { PagifiedSelectMenu } from '@root/src/lib/utils/calendarUtils'; +import { PagifiedSelectMenu } from '@root/src/lib/types/PagifiedSelect'; export default class extends Command { name = "tainfo"; diff --git a/src/lib/types/PagifiedSelect.ts b/src/lib/types/PagifiedSelect.ts new file mode 100644 index 00000000..454b8f61 --- /dev/null +++ b/src/lib/types/PagifiedSelect.ts @@ -0,0 +1,258 @@ +/** + * So as it turns out, Discord is pretty limited in what it can do + * This class was built with the goal to get around the max 25 options in a select menu + * In short, if you build select menus using this class instead of the normal way, it will automatically create new select menus as needed. + * It will also automatically create navigaiton buttons if the number of select menus is greater than 1 + * + * So how to use this thing you may ask? Well I hope the JSDoc comments bellow can help, but I'll still explain it up here + * + * Instructions: + * 1. Call the constructor ( const newMenu = new PagifiedSelectMenu(); ) + * 2. Generate the inital menu ( newMenu.createSelectMenu({customId: 'tutorial', ...other options}); ) + * 3. Add options to your menu - Note: The addOption() method will only add ONE option at a time. + * a. Create an array containing all the values you want to put into the select menu before calling this method (if you want only one option, then you don't have to do this) + * b. Iterate over the array and call the addOption() method each iteration ( myValues.forEach((val) => addOption({label: val, value: val, ...other options})) ) + * c. Profit + * 4. Congratulations you just created a pagified select menu + * 5. Send the darn thing. You can use the generateActionRows() method to generate the components neccessary to render the menu and navigations buttons + * 6. And then just pass the returned action rows into the components property when sending a message + * 7. Make sure to setup collectors so that your select menu and possible navigations buttons work + * a. Navigations buttons have the following custom_Ids next_button:[Custom ID of menu] prev_button:[Custom ID of menu] + * 8. Alternativley, you can use generateMessage() to send the message and it will take care of the collector logic for you...sort of + * a. It will create the logic for the buttons, but you have to pass in a function containing the logic for the menu collector + * b. Example: newMenu.generateMessage(collectorLogic(i) => { [your code goes here] }, interaction, rows) + * c. Note: You MUST pass in your function with i: StringSelectMenuInteraction as an argument + * 9. You can also take care of row generation and message sending using the generateRowsAndSendMenu() method + */ + +import +{ ActionRowBuilder, + APISelectMenuOption, + ButtonBuilder, + ButtonStyle, + CacheType, + ChatInputCommandInteraction, + ComponentType, + DMChannel, + InteractionResponse, + Message, + StringSelectMenuBuilder, + StringSelectMenuInteraction, + StringSelectMenuOptionBuilder } from 'discord.js'; + +export class PagifiedSelectMenu { + + menus: StringSelectMenuBuilder[]; // Array of select menus + numOptions: number; // Total number of options across all menus + maxSelected: number; // The max number of options a user can select + numPages: number; // The number of menus in the menus array + currentPage: number; // The current page number + + constructor() { + this.menus = []; + this.numOptions = 0; + this.maxSelected = 1; + this.numPages = 0; + this.currentPage = 0; + } + + /** + * Creates a blank select menu with no options + * + * @param {Object} options Contains the values that will be used to create the select menu + * @param {string} options.customId The ID of the select menu + * @param {string} options.placeHolder Optional: Text that appears on the select menu when no value has been chosen + * @param {number} options.minimumValues Optional: The minimum number values that must be selected + * @param {number} options.maximumValues Optional: The maximum number values that the select menu will accept + * @param {boolean} options.disabled Optional: Whether this select menu is disabled + * @param {APISelectMenuOption[]} option.options Optional: Sets the options for the select menu + * @returns {void} This method returns nothing + */ + createSelectMenu(options: {customId: string, placeHolder?: string, minimumValues?: number, maximumValues?: number, disabled?: boolean, options?: APISelectMenuOption[]}): void { + // Creates inital select menu + const newMenu = new StringSelectMenuBuilder() + .setCustomId(options.customId); + + // Check for optional parameters + if (options.placeHolder !== undefined) { + newMenu.setPlaceholder(options.placeHolder); + } + if (options.minimumValues !== undefined) { + newMenu.setMinValues(options.minimumValues); + } + if (options.maximumValues !== undefined) { + this.maxSelected = options.maximumValues; + newMenu.setMaxValues(options.maximumValues); + } + if (options.disabled !== undefined) { + newMenu.setDisabled(options.disabled); + } + if (options.options !== undefined) { + newMenu.setOptions(options.options); + } + + // Add menu to the list of menus + this.menus.push(newMenu); + this.numPages++; + } + + /** + * Adds an option to an available select menu. If all select menus are full, it will create a new select menu + * + * @param {Object} options Contains the values that will be used to create the select menu option + * @param {string} options.label The label that will be given to the select menu option + * @param {string} options.value The value that will be assigned to the select menu option + * @param {string} options.description Optional: Description that will appear under the select menu option + * @param {boolean} options.default Optional: Whether this option is selected by default + * @param {string} options.emoji Optional: The emoji to use + * @returns {void} This method returns nothing + */ + addOption(options: {label: string, value: string, description?: string, default?: boolean, emoji?: string}): void { + if (this.menus.length > 0) { + this.numOptions++; + + // Create a new menu every 26th value + if (this.menus[this.menus.length - 1].options.length >= 25) { + const temp = this.menus[0].data; + this.createSelectMenu( + { customId: temp.custom_id, + placeHolder: temp.placeholder, + minimumValues: temp.min_values, + disabled: temp.disabled, + options: temp.options } + ); + } + + // Create inital menu option + const lastMenu = this.menus[this.menus.length - 1]; + const newOption = new StringSelectMenuOptionBuilder() + .setLabel(options.label) + .setValue(options.value); + + // Check for optional parameters + if (options.description !== undefined) { + newOption.setDescription(options.description); + } + if (options.default !== undefined) { + newOption.setDefault(options.default); + } + if (options.emoji !== undefined) { + newOption.setEmoji(options.emoji); + } + + // Add option into menu + lastMenu.addOptions(newOption); + + lastMenu.setMaxValues(this.maxSelected < lastMenu.options.length ? this.maxSelected : lastMenu.options.length); + } + } + + /** + * Generates Discord action rows containing the string select menu and navigation buttons + * + * @returns {(ActionRowBuilder | ActionRowBuilder)[]} An array of action rows containing the string select menu and navigation buttons + */ + generateActionRows(): (ActionRowBuilder | ActionRowBuilder)[] { + const rows: (ActionRowBuilder | ActionRowBuilder)[] = []; + + // Create action row for select menu and push it to rows array + const menuRow = new ActionRowBuilder().addComponents(this.menus[this.currentPage]); + rows.push(menuRow); + + if (this.menus.length > 1) { + // Create next and previous buttons + const nextButton = new ButtonBuilder() + .setCustomId(`next_button:${this.menus[0].data.custom_id}`) + .setLabel('Next') + .setStyle(ButtonStyle.Primary) + .setDisabled(this.currentPage + 1 === this.numPages); + + const prevButton = new ButtonBuilder() + .setCustomId(`prev_button:${this.menus[0].data.custom_id}`) + .setLabel('Previous') + .setStyle(ButtonStyle.Primary) + .setDisabled(this.currentPage === 0); + + // Create action frow for buttons and push it to rows array + const pageButtons = new ActionRowBuilder().addComponents(prevButton, nextButton); + rows.push(pageButtons); + } + return rows; + } + + /** + * Generates an ephemeral message containing a select menu and navigation buttons if the select menu has more than 25 values. Handles collector logic using the passed in function + * + * @param {function(StringSelectMenuInteraction): void} collectorLogic Function containing the logic for the message collector + * @param {ChatInputCommandInteraction} interaction The Discord interaction created by the called command + * @param {(ActionRowBuilder | ActionRowBuilder)[]} rows The action rows that contains the select menu and navigation buttons + * @param {DMChannel} dmChannel Optional: Sends messages to given DM channel + * @param {string} content Optional: Sets the message content + */ + async generateMessage( + collectorLogic: (i: StringSelectMenuInteraction) => void, + interaction: ChatInputCommandInteraction, + rows: (ActionRowBuilder | ActionRowBuilder)[], + dmChannel?: DMChannel, + content?: string + ): Promise | InteractionResponse> { + let reply: Message | InteractionResponse; + + // Check if the interaction has already been replied to, or if its a DM, and send the message accordingly + if (dmChannel) { + reply = await dmChannel.send({ content: content, components: rows }); + } else if (interaction.replied) { + reply = await interaction.followUp({ content: content, components: rows, ephemeral: true }); + } else { + reply = await interaction.reply({ content: content, components: rows, ephemeral: true }); + } + + // Create menu collector + const menuCollector = reply.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + time: 60_000 + }); + + menuCollector.on('collect', async (i) => { + collectorLogic(i); + }); + + // Checks to see if there is more than 1 menu and creates button collector for navigations buttons if there is + if (this.menus.length > 1) { + const buttonCollector = reply.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 60_000 + }); + + buttonCollector.on('collect', async (i) => { + if (i.customId === `next_button:${this.menus[0].data.custom_id}`) { + await i.deferUpdate(); + this.currentPage++; + const newRows = this.generateActionRows(); + await i.editReply({ components: newRows }); + } else if (i.customId === `prev_button:${this.menus[0].data.custom_id}`) { + await i.deferUpdate(); + this.currentPage--; + const newRows = this.generateActionRows(); + await i.editReply({ components: newRows }); + } + }); + } + + return reply; + } + + /** + * Generates Discord action rows containing the string select menu and navigation buttons and + * generates an ephemeral message containing a select menu and navigation buttons if the select menu has more than 25 values. Handles collector logic using the passed in function + * + * @param {function(StringSelectMenuInteraction): void} collectorLogic Contains the logic for the message collector + * @param {ChatInputCommandInteraction} interaction The Discord interaction created by the called command + * @param {DMChannel} dmChannel Optional: Sends messages to given DM channel + * @param {string} content Optional: Sets the message content + */ + async generateRowsAndSendMenu(collectorLogic: (i: StringSelectMenuInteraction) => void, interaction: ChatInputCommandInteraction, dmChannel?: DMChannel, content?: string): Promise { + await this.generateMessage(collectorLogic, interaction, this.generateActionRows(), dmChannel, content); + } + +} diff --git a/src/lib/utils/calendarUtils.ts b/src/lib/utils/calendarUtils.ts index 454b8f61..e69de29b 100644 --- a/src/lib/utils/calendarUtils.ts +++ b/src/lib/utils/calendarUtils.ts @@ -1,258 +0,0 @@ -/** - * So as it turns out, Discord is pretty limited in what it can do - * This class was built with the goal to get around the max 25 options in a select menu - * In short, if you build select menus using this class instead of the normal way, it will automatically create new select menus as needed. - * It will also automatically create navigaiton buttons if the number of select menus is greater than 1 - * - * So how to use this thing you may ask? Well I hope the JSDoc comments bellow can help, but I'll still explain it up here - * - * Instructions: - * 1. Call the constructor ( const newMenu = new PagifiedSelectMenu(); ) - * 2. Generate the inital menu ( newMenu.createSelectMenu({customId: 'tutorial', ...other options}); ) - * 3. Add options to your menu - Note: The addOption() method will only add ONE option at a time. - * a. Create an array containing all the values you want to put into the select menu before calling this method (if you want only one option, then you don't have to do this) - * b. Iterate over the array and call the addOption() method each iteration ( myValues.forEach((val) => addOption({label: val, value: val, ...other options})) ) - * c. Profit - * 4. Congratulations you just created a pagified select menu - * 5. Send the darn thing. You can use the generateActionRows() method to generate the components neccessary to render the menu and navigations buttons - * 6. And then just pass the returned action rows into the components property when sending a message - * 7. Make sure to setup collectors so that your select menu and possible navigations buttons work - * a. Navigations buttons have the following custom_Ids next_button:[Custom ID of menu] prev_button:[Custom ID of menu] - * 8. Alternativley, you can use generateMessage() to send the message and it will take care of the collector logic for you...sort of - * a. It will create the logic for the buttons, but you have to pass in a function containing the logic for the menu collector - * b. Example: newMenu.generateMessage(collectorLogic(i) => { [your code goes here] }, interaction, rows) - * c. Note: You MUST pass in your function with i: StringSelectMenuInteraction as an argument - * 9. You can also take care of row generation and message sending using the generateRowsAndSendMenu() method - */ - -import -{ ActionRowBuilder, - APISelectMenuOption, - ButtonBuilder, - ButtonStyle, - CacheType, - ChatInputCommandInteraction, - ComponentType, - DMChannel, - InteractionResponse, - Message, - StringSelectMenuBuilder, - StringSelectMenuInteraction, - StringSelectMenuOptionBuilder } from 'discord.js'; - -export class PagifiedSelectMenu { - - menus: StringSelectMenuBuilder[]; // Array of select menus - numOptions: number; // Total number of options across all menus - maxSelected: number; // The max number of options a user can select - numPages: number; // The number of menus in the menus array - currentPage: number; // The current page number - - constructor() { - this.menus = []; - this.numOptions = 0; - this.maxSelected = 1; - this.numPages = 0; - this.currentPage = 0; - } - - /** - * Creates a blank select menu with no options - * - * @param {Object} options Contains the values that will be used to create the select menu - * @param {string} options.customId The ID of the select menu - * @param {string} options.placeHolder Optional: Text that appears on the select menu when no value has been chosen - * @param {number} options.minimumValues Optional: The minimum number values that must be selected - * @param {number} options.maximumValues Optional: The maximum number values that the select menu will accept - * @param {boolean} options.disabled Optional: Whether this select menu is disabled - * @param {APISelectMenuOption[]} option.options Optional: Sets the options for the select menu - * @returns {void} This method returns nothing - */ - createSelectMenu(options: {customId: string, placeHolder?: string, minimumValues?: number, maximumValues?: number, disabled?: boolean, options?: APISelectMenuOption[]}): void { - // Creates inital select menu - const newMenu = new StringSelectMenuBuilder() - .setCustomId(options.customId); - - // Check for optional parameters - if (options.placeHolder !== undefined) { - newMenu.setPlaceholder(options.placeHolder); - } - if (options.minimumValues !== undefined) { - newMenu.setMinValues(options.minimumValues); - } - if (options.maximumValues !== undefined) { - this.maxSelected = options.maximumValues; - newMenu.setMaxValues(options.maximumValues); - } - if (options.disabled !== undefined) { - newMenu.setDisabled(options.disabled); - } - if (options.options !== undefined) { - newMenu.setOptions(options.options); - } - - // Add menu to the list of menus - this.menus.push(newMenu); - this.numPages++; - } - - /** - * Adds an option to an available select menu. If all select menus are full, it will create a new select menu - * - * @param {Object} options Contains the values that will be used to create the select menu option - * @param {string} options.label The label that will be given to the select menu option - * @param {string} options.value The value that will be assigned to the select menu option - * @param {string} options.description Optional: Description that will appear under the select menu option - * @param {boolean} options.default Optional: Whether this option is selected by default - * @param {string} options.emoji Optional: The emoji to use - * @returns {void} This method returns nothing - */ - addOption(options: {label: string, value: string, description?: string, default?: boolean, emoji?: string}): void { - if (this.menus.length > 0) { - this.numOptions++; - - // Create a new menu every 26th value - if (this.menus[this.menus.length - 1].options.length >= 25) { - const temp = this.menus[0].data; - this.createSelectMenu( - { customId: temp.custom_id, - placeHolder: temp.placeholder, - minimumValues: temp.min_values, - disabled: temp.disabled, - options: temp.options } - ); - } - - // Create inital menu option - const lastMenu = this.menus[this.menus.length - 1]; - const newOption = new StringSelectMenuOptionBuilder() - .setLabel(options.label) - .setValue(options.value); - - // Check for optional parameters - if (options.description !== undefined) { - newOption.setDescription(options.description); - } - if (options.default !== undefined) { - newOption.setDefault(options.default); - } - if (options.emoji !== undefined) { - newOption.setEmoji(options.emoji); - } - - // Add option into menu - lastMenu.addOptions(newOption); - - lastMenu.setMaxValues(this.maxSelected < lastMenu.options.length ? this.maxSelected : lastMenu.options.length); - } - } - - /** - * Generates Discord action rows containing the string select menu and navigation buttons - * - * @returns {(ActionRowBuilder | ActionRowBuilder)[]} An array of action rows containing the string select menu and navigation buttons - */ - generateActionRows(): (ActionRowBuilder | ActionRowBuilder)[] { - const rows: (ActionRowBuilder | ActionRowBuilder)[] = []; - - // Create action row for select menu and push it to rows array - const menuRow = new ActionRowBuilder().addComponents(this.menus[this.currentPage]); - rows.push(menuRow); - - if (this.menus.length > 1) { - // Create next and previous buttons - const nextButton = new ButtonBuilder() - .setCustomId(`next_button:${this.menus[0].data.custom_id}`) - .setLabel('Next') - .setStyle(ButtonStyle.Primary) - .setDisabled(this.currentPage + 1 === this.numPages); - - const prevButton = new ButtonBuilder() - .setCustomId(`prev_button:${this.menus[0].data.custom_id}`) - .setLabel('Previous') - .setStyle(ButtonStyle.Primary) - .setDisabled(this.currentPage === 0); - - // Create action frow for buttons and push it to rows array - const pageButtons = new ActionRowBuilder().addComponents(prevButton, nextButton); - rows.push(pageButtons); - } - return rows; - } - - /** - * Generates an ephemeral message containing a select menu and navigation buttons if the select menu has more than 25 values. Handles collector logic using the passed in function - * - * @param {function(StringSelectMenuInteraction): void} collectorLogic Function containing the logic for the message collector - * @param {ChatInputCommandInteraction} interaction The Discord interaction created by the called command - * @param {(ActionRowBuilder | ActionRowBuilder)[]} rows The action rows that contains the select menu and navigation buttons - * @param {DMChannel} dmChannel Optional: Sends messages to given DM channel - * @param {string} content Optional: Sets the message content - */ - async generateMessage( - collectorLogic: (i: StringSelectMenuInteraction) => void, - interaction: ChatInputCommandInteraction, - rows: (ActionRowBuilder | ActionRowBuilder)[], - dmChannel?: DMChannel, - content?: string - ): Promise | InteractionResponse> { - let reply: Message | InteractionResponse; - - // Check if the interaction has already been replied to, or if its a DM, and send the message accordingly - if (dmChannel) { - reply = await dmChannel.send({ content: content, components: rows }); - } else if (interaction.replied) { - reply = await interaction.followUp({ content: content, components: rows, ephemeral: true }); - } else { - reply = await interaction.reply({ content: content, components: rows, ephemeral: true }); - } - - // Create menu collector - const menuCollector = reply.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - time: 60_000 - }); - - menuCollector.on('collect', async (i) => { - collectorLogic(i); - }); - - // Checks to see if there is more than 1 menu and creates button collector for navigations buttons if there is - if (this.menus.length > 1) { - const buttonCollector = reply.createMessageComponentCollector({ - componentType: ComponentType.Button, - time: 60_000 - }); - - buttonCollector.on('collect', async (i) => { - if (i.customId === `next_button:${this.menus[0].data.custom_id}`) { - await i.deferUpdate(); - this.currentPage++; - const newRows = this.generateActionRows(); - await i.editReply({ components: newRows }); - } else if (i.customId === `prev_button:${this.menus[0].data.custom_id}`) { - await i.deferUpdate(); - this.currentPage--; - const newRows = this.generateActionRows(); - await i.editReply({ components: newRows }); - } - }); - } - - return reply; - } - - /** - * Generates Discord action rows containing the string select menu and navigation buttons and - * generates an ephemeral message containing a select menu and navigation buttons if the select menu has more than 25 values. Handles collector logic using the passed in function - * - * @param {function(StringSelectMenuInteraction): void} collectorLogic Contains the logic for the message collector - * @param {ChatInputCommandInteraction} interaction The Discord interaction created by the called command - * @param {DMChannel} dmChannel Optional: Sends messages to given DM channel - * @param {string} content Optional: Sets the message content - */ - async generateRowsAndSendMenu(collectorLogic: (i: StringSelectMenuInteraction) => void, interaction: ChatInputCommandInteraction, dmChannel?: DMChannel, content?: string): Promise { - await this.generateMessage(collectorLogic, interaction, this.generateActionRows(), dmChannel, content); - } - -} From 51ad53faf9ac1028565f0f32aecf691602d136d9 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Tue, 15 Apr 2025 14:58:16 -0400 Subject: [PATCH 10/40] Moved calendar helper functions to new file --- src/commands/general/calendar.ts | 250 +------------------------------ src/lib/utils/calendarUtils.ts | 243 ++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+), 247 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 49e16e14..6e41cb02 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -2,9 +2,7 @@ import { ChatInputCommandInteraction, ButtonBuilder, - ButtonStyle, ActionRowBuilder, - EmbedBuilder, ApplicationCommandOptionType, ApplicationCommandStringOptionData, ComponentType, @@ -19,28 +17,13 @@ import 'dotenv/config'; import { MongoClient } from 'mongodb'; import * as fs from 'fs'; import { CALENDAR_CONFIG } from '@lib/CalendarConfig'; -import { PagifiedSelectMenu } from '@root/src/lib/types/PagifiedSelect'; -import { calendar_v3 } from 'googleapis'; import { retrieveEvents } from '@root/src/lib/auth'; import path from 'path'; +import { downloadEvents, Filter, filterEvents, generateButtons, generateEmbed, generateEventSelectButtons, generateFilterMessage, Event } from '@root/src/lib/utils/calendarUtils'; // Define the Master Calendar ID constant. const MASTER_CALENDAR_ID = CALENDAR_CONFIG.MASTER_ID; -interface Event { - calEvent: calendar_v3.Schema$Event; - calendarName: string; -} - -interface Filter { - customId: string; - placeholder: string, - values: string[]; - newValues: string[]; - flag: boolean; - condition: (newValues: string[], event: Event) => boolean; -} - export default class extends Command { name = 'calendar'; @@ -56,233 +39,6 @@ export default class extends Command { ]; async run(interaction: ChatInputCommandInteraction): Promise { - /** Helper Functions **/ - - // Filters calendar events based on slash command inputs and filter dropdown selections. - async function filterEvents(events: Event[], filters: Filter[]) { - const filteredEvents: Event[] = []; - - let allFiltersFlags = true; - - await Promise.all(events.map(async (event) => { - filters.forEach((filter) => { - filter.flag = true; - if (filter.newValues.length) { - filter.flag = filter.condition(filter.newValues, event); - } - }); - allFiltersFlags = filters.every((filter) => filter.flag); - - if (allFiltersFlags) { - filteredEvents.push(event); - } - })); - - return filteredEvents; - } - - // Generates the embed for displaying events. - function generateEmbed(filteredEvents: Event[], currentPage: number, itemsPerPage: number): EmbedBuilder[] { - const embeds: EmbedBuilder[] = []; - let embed: EmbedBuilder; - - if (filteredEvents.length) { - let numEmbeds = 1; - const maxPage: number = Math.ceil(filteredEvents.length / itemsPerPage); - - embed = new EmbedBuilder() - .setTitle(`Events - ${currentPage + numEmbeds} of ${maxPage}`) - .setColor('Green'); - - let i = 1; - filteredEvents.forEach((event, index) => { - embed.addFields({ - name: `**${event.calEvent.summary}**`, - value: `Date: ${new Date(event.calEvent.start.dateTime).toLocaleDateString()} - Time: ${new Date(event.calEvent.start.dateTime).toLocaleTimeString()} - ${new Date(event.calEvent.end.dateTime).toLocaleTimeString()} - Location: ${event.calEvent.location ? event.calEvent.location : '`NONE`'} - Email: ${event.calEvent.creator.email}\n` - }); - - if (i % itemsPerPage === 0) { - numEmbeds++; - embeds.push(embed); - embed = new EmbedBuilder() - .setTitle(`Events - ${currentPage + numEmbeds} of ${maxPage}`) - .setColor('Green'); - } else if (filteredEvents.length - 1 === index) { - embeds.push(embed); - } - i++; - }); - } else { - embed = new EmbedBuilder() - .setTitle('No Events Found') - .setColor('Green') - .addFields({ - name: 'Try adjusting your filters', - value: 'No events match your selections, please change them!' - }); - embeds.push(embed); - } - return embeds; - } - - // Generates the pagination buttons (Previous, Next, Download Calendar, Download All, Done). - function generateButtons(currentPage: number, maxPage: number, downloadCount: number): ActionRowBuilder { - const nextButton = new ButtonBuilder() - .setCustomId('next') - .setLabel('Next') - .setStyle(ButtonStyle.Primary) - .setDisabled(currentPage + 1 >= maxPage); - - const prevButton = new ButtonBuilder() - .setCustomId('prev') - .setLabel('Previous') - .setStyle(ButtonStyle.Primary) - .setDisabled(currentPage === 0); - - const downloadCal = new ButtonBuilder() - .setCustomId('download_Cal') - .setLabel(`Download Calendar (${downloadCount})`) - .setStyle(ButtonStyle.Success) - .setDisabled(downloadCount === 0); - - const downloadAll = new ButtonBuilder() - .setCustomId('download_all') - .setLabel('Download All') - .setStyle(ButtonStyle.Secondary); - - return new ActionRowBuilder().addComponents( - prevButton, - nextButton, - downloadCal, - downloadAll - ); - } - - // Generates filter dropdown menus. - function generateFilterMessage(filters: Filter[]) { - const filterMenus: PagifiedSelectMenu[] = filters.map((filter) => { - if (filter.values.length === 0) { - filter.values.push('No Data Available'); - } - const filterMenu = new PagifiedSelectMenu(); - filterMenu.createSelectMenu( - { - customId: filter.customId, - placeHolder: filter.placeholder, - minimumValues: 0, - maximumValues: 25 - } - ); - - filter.values.forEach((value) => { - let isDefault = false; - if (filter.newValues[0]) { - if (filter.newValues[0].toLowerCase() === value.toLowerCase()) { - isDefault = true; - } - } - filterMenu.addOption({ label: value, value: value.toLowerCase(), default: isDefault }); - }); - return filterMenu; - }); - - return filterMenus; - } - - // Generates a row of toggle buttons – one for each event on the current page. - function generateEventSelectButtons(eventsPerPage: number): ActionRowBuilder { - const selectEventButtons: ButtonBuilder[] = []; - - // This is to ensure that the number of buttons does not exceed to the limit per row - // We should probably change to a pagified select menu later on - if (eventsPerPage > 5) { - eventsPerPage = 5; - } - - // Create buttons for each event on the page (up to 5) - for (let i = 1; i <= eventsPerPage; i++) { - const selectEvent = new ButtonBuilder() - .setCustomId(`toggle-${i}`) - .setLabel(`Select #${i}`) - .setStyle(ButtonStyle.Secondary); - selectEventButtons.push(selectEvent); - } - - // Create row containing all of the select buttons - const selectRow = new ActionRowBuilder().addComponents( - ...selectEventButtons - ); - - return selectRow; - } - - // Downloads events by generating an ICS file. - // This version includes recurrence rules (if the event has them). - async function downloadEvents(selectedEvents: Event[], calendars: {calendarId: string, calendarName: string}[]) { - const formattedEvents: string[] = []; - const parentEvents: calendar_v3.Schema$Event[] = []; - - for (const calendar of calendars) { - const newParentEvents = await retrieveEvents(calendar.calendarId, interaction, false); - parentEvents.push(...newParentEvents); - } - - const recurrenceRules: Record = Object.fromEntries(parentEvents.map((event) => [event.id, event.recurrence[0]])); - - const recurringIds: Set = new Set(); - - selectedEvents.forEach((event) => { - let append = false; - const iCalEvent = { - UID: `${Date.now()}-${Math.random().toString(36).substring(2, 10)}`, - CREATED: new Date(event.calEvent.created).toISOString().replace(/[-:.]/g, ''), - DTSTAMP: event.calEvent.updated.replace(/[-:.]/g, ''), - DTSTART: `TZID=${event.calEvent.start.timeZone}:${event.calEvent.start.dateTime.replace(/[-:.]/g, '')}`, - DTEND: `TZID=${event.calEvent.end.timeZone}:${event.calEvent.end.dateTime.replace(/[-:.]/g, '')}`, - SUMMARY: event.calEvent.summary, - DESCRIPTION: `Contact Email: ${event.calEvent.creator.email || 'NA'}`, - LOCATION: event.calEvent.location ? event.calEvent.location : 'NONE' - }; - - if (!event.calEvent.recurringEventId) { - append = true; - } else if (!recurringIds.has(event.calEvent.recurringEventId)) { - recurringIds.add(event.calEvent.recurringEventId); - append = true; - } - - if (append) { - const icsFormatted - = `BEGIN:VEVENT - UID:${iCalEvent.UID} - CREATED:${iCalEvent.CREATED} - DTSTAMP:${iCalEvent.DTSTAMP} - DTSTART;${iCalEvent.DTSTART} - DTEND;${iCalEvent.DTEND} - SUMMARY:${iCalEvent.SUMMARY} - DESCRIPTION:${iCalEvent.DESCRIPTION} - LOCATION:${iCalEvent.LOCATION} - STATUS:CONFIRMED - ${event.calEvent.recurringEventId ? recurrenceRules[event.calEvent.recurringEventId] : ''} - END:VEVENT - `.replace(/\t/g, ''); - formattedEvents.push(icsFormatted); - } - }); - - const icsCalendar = `BEGIN:VCALENDAR - VERSION:2.0 - PRODID:-//YourBot//Discord Calendar//EN - ${formattedEvents.join('')} - END:VCALENDAR - `.replace(/\t/g, ''); - fs.writeFileSync('./events.ics', icsCalendar); - } - - /** ****************************************************************************************************************/ // Initial reply to acknowledge the interaction. await interaction.reply({ content: 'Authenticating and fetching events...', @@ -548,7 +304,7 @@ export default class extends Command { } const downloadMessage = await dm.send({ content: 'Downloading selected events...' }); try { - await downloadEvents(selectedEvents, calendars); + await downloadEvents(selectedEvents, calendars, interaction); const filePath = path.join('./events.ics'); await downloadMessage.edit({ content: '', @@ -565,7 +321,7 @@ export default class extends Command { } const downloadMessage = await dm.send({ content: 'Downloading all events...' }); try { - await downloadEvents(filteredEvents.flat(), calendars); + await downloadEvents(filteredEvents.flat(), calendars, interaction); const filePath = path.join('./events.ics'); await downloadMessage.edit({ content: '', diff --git a/src/lib/utils/calendarUtils.ts b/src/lib/utils/calendarUtils.ts index e69de29b..fadc63bd 100644 --- a/src/lib/utils/calendarUtils.ts +++ b/src/lib/utils/calendarUtils.ts @@ -0,0 +1,243 @@ +/* eslint-disable camelcase */ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, EmbedBuilder } from 'discord.js'; +import { calendar_v3 } from 'googleapis'; +import { retrieveEvents } from '../auth'; +import { PagifiedSelectMenu } from '../types/PagifiedSelect'; +import * as fs from 'fs'; + +export interface Event { + calEvent: calendar_v3.Schema$Event; + calendarName: string; +} + +export interface Filter { + customId: string; + placeholder: string, + values: string[]; + newValues: string[]; + flag: boolean; + condition: (newValues: string[], event: Event) => boolean; +} + +export async function filterEvents(events: Event[], filters: Filter[]): Promise { + const filteredEvents: Event[] = []; + + let allFiltersFlags = true; + + await Promise.all(events.map(async (event) => { + filters.forEach((filter) => { + filter.flag = true; + if (filter.newValues.length) { + filter.flag = filter.condition(filter.newValues, event); + } + }); + allFiltersFlags = filters.every((filter) => filter.flag); + + if (allFiltersFlags) { + filteredEvents.push(event); + } + })); + + return filteredEvents; +} + +// Generates the embed for displaying events. +export function generateEmbed(filteredEvents: Event[], currentPage: number, itemsPerPage: number): EmbedBuilder[] { + const embeds: EmbedBuilder[] = []; + let embed: EmbedBuilder; + + if (filteredEvents.length) { + let numEmbeds = 1; + const maxPage: number = Math.ceil(filteredEvents.length / itemsPerPage); + + embed = new EmbedBuilder() + .setTitle(`Events - ${currentPage + numEmbeds} of ${maxPage}`) + .setColor('Green'); + + let i = 1; + filteredEvents.forEach((event, index) => { + embed.addFields({ + name: `**${event.calEvent.summary}**`, + value: `Date: ${new Date(event.calEvent.start.dateTime).toLocaleDateString()} + Time: ${new Date(event.calEvent.start.dateTime).toLocaleTimeString()} - ${new Date(event.calEvent.end.dateTime).toLocaleTimeString()} + Location: ${event.calEvent.location ? event.calEvent.location : '`NONE`'} + Email: ${event.calEvent.creator.email}\n` + }); + + if (i % itemsPerPage === 0) { + numEmbeds++; + embeds.push(embed); + embed = new EmbedBuilder() + .setTitle(`Events - ${currentPage + numEmbeds} of ${maxPage}`) + .setColor('Green'); + } else if (filteredEvents.length - 1 === index) { + embeds.push(embed); + } + i++; + }); + } else { + embed = new EmbedBuilder() + .setTitle('No Events Found') + .setColor('Green') + .addFields({ + name: 'Try adjusting your filters', + value: 'No events match your selections, please change them!' + }); + embeds.push(embed); + } + return embeds; +} + +// Generates the pagination buttons (Previous, Next, Download Calendar, Download All, Done). +export function generateButtons(currentPage: number, maxPage: number, downloadCount: number): ActionRowBuilder { + const nextButton = new ButtonBuilder() + .setCustomId('next') + .setLabel('Next') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage + 1 >= maxPage); + + const prevButton = new ButtonBuilder() + .setCustomId('prev') + .setLabel('Previous') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === 0); + + const downloadCal = new ButtonBuilder() + .setCustomId('download_Cal') + .setLabel(`Download Calendar (${downloadCount})`) + .setStyle(ButtonStyle.Success) + .setDisabled(downloadCount === 0); + + const downloadAll = new ButtonBuilder() + .setCustomId('download_all') + .setLabel('Download All') + .setStyle(ButtonStyle.Secondary); + + return new ActionRowBuilder().addComponents( + prevButton, + nextButton, + downloadCal, + downloadAll + ); +} + +// Generates filter dropdown menus. +export function generateFilterMessage(filters: Filter[]): PagifiedSelectMenu[] { + const filterMenus: PagifiedSelectMenu[] = filters.map((filter) => { + if (filter.values.length === 0) { + filter.values.push('No Data Available'); + } + const filterMenu = new PagifiedSelectMenu(); + filterMenu.createSelectMenu( + { + customId: filter.customId, + placeHolder: filter.placeholder, + minimumValues: 0, + maximumValues: 25 + } + ); + + filter.values.forEach((value) => { + let isDefault = false; + if (filter.newValues[0]) { + if (filter.newValues[0].toLowerCase() === value.toLowerCase()) { + isDefault = true; + } + } + filterMenu.addOption({ label: value, value: value.toLowerCase(), default: isDefault }); + }); + return filterMenu; + }); + + return filterMenus; +} + +// Generates a row of toggle buttons – one for each event on the current page. +export function generateEventSelectButtons(eventsPerPage: number): ActionRowBuilder { + const selectEventButtons: ButtonBuilder[] = []; + + // This is to ensure that the number of buttons does not exceed to the limit per row + // We should probably change to a pagified select menu later on + if (eventsPerPage > 5) { + eventsPerPage = 5; + } + + // Create buttons for each event on the page (up to 5) + for (let i = 1; i <= eventsPerPage; i++) { + const selectEvent = new ButtonBuilder() + .setCustomId(`toggle-${i}`) + .setLabel(`Select #${i}`) + .setStyle(ButtonStyle.Secondary); + selectEventButtons.push(selectEvent); + } + + // Create row containing all of the select buttons + const selectRow = new ActionRowBuilder().addComponents( + ...selectEventButtons + ); + + return selectRow; +} + +// Downloads events by generating an ICS file. +// This version includes recurrence rules (if the event has them). +export async function downloadEvents(selectedEvents: Event[], calendars: {calendarId: string, calendarName: string}[], interaction: ChatInputCommandInteraction): Promise { + const formattedEvents: string[] = []; + const parentEvents: calendar_v3.Schema$Event[] = []; + + for (const calendar of calendars) { + const newParentEvents = await retrieveEvents(calendar.calendarId, interaction, false); + parentEvents.push(...newParentEvents); + } + + const recurrenceRules: Record = Object.fromEntries(parentEvents.map((event) => [event.id, event.recurrence[0]])); + + const recurringIds: Set = new Set(); + + selectedEvents.forEach((event) => { + let append = false; + const iCalEvent = { + UID: `${Date.now()}-${Math.random().toString(36).substring(2, 10)}`, + CREATED: new Date(event.calEvent.created).toISOString().replace(/[-:.]/g, ''), + DTSTAMP: event.calEvent.updated.replace(/[-:.]/g, ''), + DTSTART: `TZID=${event.calEvent.start.timeZone}:${event.calEvent.start.dateTime.replace(/[-:.]/g, '')}`, + DTEND: `TZID=${event.calEvent.end.timeZone}:${event.calEvent.end.dateTime.replace(/[-:.]/g, '')}`, + SUMMARY: event.calEvent.summary, + DESCRIPTION: `Contact Email: ${event.calEvent.creator.email || 'NA'}`, + LOCATION: event.calEvent.location ? event.calEvent.location : 'NONE' + }; + + if (!event.calEvent.recurringEventId) { + append = true; + } else if (!recurringIds.has(event.calEvent.recurringEventId)) { + recurringIds.add(event.calEvent.recurringEventId); + append = true; + } + + if (append) { + const icsFormatted + = `BEGIN:VEVENT + UID:${iCalEvent.UID} + CREATED:${iCalEvent.CREATED} + DTSTAMP:${iCalEvent.DTSTAMP} + DTSTART;${iCalEvent.DTSTART} + DTEND;${iCalEvent.DTEND} + SUMMARY:${iCalEvent.SUMMARY} + DESCRIPTION:${iCalEvent.DESCRIPTION} + LOCATION:${iCalEvent.LOCATION} + STATUS:CONFIRMED + ${event.calEvent.recurringEventId ? recurrenceRules[event.calEvent.recurringEventId] : ''} + END:VEVENT + `.replace(/\t/g, ''); + formattedEvents.push(icsFormatted); + } + }); + + const icsCalendar = `BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//YourBot//Discord Calendar//EN + ${formattedEvents.join('')} + END:VCALENDAR + `.replace(/\t/g, ''); + fs.writeFileSync('./events.ics', icsCalendar); +} From 2b0bef918fc6a36cf9768cb8725d670db25bd2be Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Tue, 15 Apr 2025 15:30:33 -0400 Subject: [PATCH 11/40] Added JSDocs to calendar helper functions --- src/commands/general/calendar.ts | 32 ++++++++++------ src/lib/utils/calendarUtils.ts | 64 +++++++++++++++++++++++++------- 2 files changed, 70 insertions(+), 26 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 6e41cb02..71bcf1a1 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -19,7 +19,15 @@ import * as fs from 'fs'; import { CALENDAR_CONFIG } from '@lib/CalendarConfig'; import { retrieveEvents } from '@root/src/lib/auth'; import path from 'path'; -import { downloadEvents, Filter, filterEvents, generateButtons, generateEmbed, generateEventSelectButtons, generateFilterMessage, Event } from '@root/src/lib/utils/calendarUtils'; +import +{ downloadEvents, + Filter, + filterCalendarEvents, + generateCalendarButtons, + generateCalendarEmbed, + generateEventSelectButtons, + generateCalendarFilterMessage, + Event } from '@root/src/lib/utils/calendarUtils'; // Define the Master Calendar ID constant. const MASTER_CALENDAR_ID = CALENDAR_CONFIG.MASTER_ID; @@ -159,7 +167,7 @@ export default class extends Command { ); const eventsPerPage = 3; - let filteredEvents: Event[] = await filterEvents(events, filters); + let filteredEvents: Event[] = await filterCalendarEvents(events, filters); if (!filteredEvents.length) { await interaction.followUp({ content: 'No matching events found based on your filters. Please adjust your search criteria.', @@ -170,11 +178,11 @@ export default class extends Command { let currentPage = 0; let selectedEvents: Event[] = []; - let embeds = generateEmbed(filteredEvents, currentPage, eventsPerPage); + let embeds = generateCalendarEmbed(filteredEvents, currentPage, eventsPerPage); let maxPage: number = embeds.length; const initialComponents: ActionRowBuilder[] = []; - initialComponents.push(generateButtons(currentPage, maxPage, selectedEvents.length)); + initialComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); if (embeds[currentPage]) { if (embeds[currentPage].data.fields.length) { initialComponents.push(generateEventSelectButtons(embeds[currentPage].data.fields.length)); @@ -197,7 +205,7 @@ export default class extends Command { return; } - const filterComponents = generateFilterMessage(filters); + const filterComponents = generateCalendarFilterMessage(filters); let content = '**Select Filters**'; const singlePageMenus: (ActionRowBuilder | ActionRowBuilder)[] = []; @@ -209,13 +217,13 @@ export default class extends Command { if (filter) { filter.newValues = i.values; } - filteredEvents = await filterEvents(events, filters); + filteredEvents = await filterCalendarEvents(events, filters); currentPage = 0; selectedEvents = []; - embeds = generateEmbed(filteredEvents, currentPage, eventsPerPage); + embeds = generateCalendarEmbed(filteredEvents, currentPage, eventsPerPage); maxPage = embeds.length; const newComponents: ActionRowBuilder[] = []; - newComponents.push(generateButtons(currentPage, maxPage, selectedEvents.length)); + newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); if (embeds[currentPage]) { if (embeds[currentPage].data.fields.length) { newComponents.push(generateEventSelectButtons(embeds[currentPage].data.fields.length)); @@ -335,7 +343,7 @@ export default class extends Command { const newComponents: ActionRowBuilder[] = []; - newComponents.push(generateButtons(currentPage, maxPage, selectedEvents.length)); + newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); if (embeds[currentPage]) { if (embeds[currentPage].data.fields.length) { newComponents.push(generateEventSelectButtons(embeds[currentPage].data.fields.length)); @@ -360,13 +368,13 @@ export default class extends Command { if (filter) { filter.newValues = i.values; } - filteredEvents = await filterEvents(events, filters); + filteredEvents = await filterCalendarEvents(events, filters); currentPage = 0; selectedEvents = []; - embeds = generateEmbed(filteredEvents, currentPage, eventsPerPage); + embeds = generateCalendarEmbed(filteredEvents, currentPage, eventsPerPage); maxPage = embeds.length; const newComponents: ActionRowBuilder[] = []; - newComponents.push(generateButtons(currentPage, maxPage, selectedEvents.length)); + newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); if (embeds[currentPage]) { if (embeds[currentPage].data.fields.length) { newComponents.push(generateEventSelectButtons(embeds[currentPage].data.fields.length)); diff --git a/src/lib/utils/calendarUtils.ts b/src/lib/utils/calendarUtils.ts index fadc63bd..dec4745f 100644 --- a/src/lib/utils/calendarUtils.ts +++ b/src/lib/utils/calendarUtils.ts @@ -19,7 +19,14 @@ export interface Filter { condition: (newValues: string[], event: Event) => boolean; } -export async function filterEvents(events: Event[], filters: Filter[]): Promise { +/** + * This function will filter out events based on the given filter array + * + * @param {Event[]} events The events that you want to filter + * @param {Filter[]} filters The filters that you want to use to filter the events + * @returns {Promise} This function will return an async promise of the filtered events in an array + */ +export async function filterCalendarEvents(events: Event[], filters: Filter[]): Promise { const filteredEvents: Event[] = []; let allFiltersFlags = true; @@ -41,21 +48,28 @@ export async function filterEvents(events: Event[], filters: Filter[]): Promise< return filteredEvents; } -// Generates the embed for displaying events. -export function generateEmbed(filteredEvents: Event[], currentPage: number, itemsPerPage: number): EmbedBuilder[] { +/** + * This function will create embeds to contain all the events passed into the function + * + * @param {Event[]} events The events you want to display in the embed + * @param {number} currentPage ... + * @param {number} itemsPerPage The number of events you want to display on one embed + * @returns {EmbedBuilder[]} Embeds containing all of the calendar events + */ +export function generateCalendarEmbed(events: Event[], currentPage: number, itemsPerPage: number): EmbedBuilder[] { const embeds: EmbedBuilder[] = []; let embed: EmbedBuilder; - if (filteredEvents.length) { + if (events.length) { let numEmbeds = 1; - const maxPage: number = Math.ceil(filteredEvents.length / itemsPerPage); + const maxPage: number = Math.ceil(events.length / itemsPerPage); embed = new EmbedBuilder() .setTitle(`Events - ${currentPage + numEmbeds} of ${maxPage}`) .setColor('Green'); let i = 1; - filteredEvents.forEach((event, index) => { + events.forEach((event, index) => { embed.addFields({ name: `**${event.calEvent.summary}**`, value: `Date: ${new Date(event.calEvent.start.dateTime).toLocaleDateString()} @@ -70,7 +84,7 @@ export function generateEmbed(filteredEvents: Event[], currentPage: number, item embed = new EmbedBuilder() .setTitle(`Events - ${currentPage + numEmbeds} of ${maxPage}`) .setColor('Green'); - } else if (filteredEvents.length - 1 === index) { + } else if (events.length - 1 === index) { embeds.push(embed); } i++; @@ -88,8 +102,15 @@ export function generateEmbed(filteredEvents: Event[], currentPage: number, item return embeds; } -// Generates the pagination buttons (Previous, Next, Download Calendar, Download All, Done). -export function generateButtons(currentPage: number, maxPage: number, downloadCount: number): ActionRowBuilder { +/** + * Generates pagification buttons and download buttons for the calendar embeds + * + * @param {number} currentPage .. + * @param {number} maxPage ... + * @param {number} downloadCount The number of selected events to be downloaded + * @returns {ActionRowBuilder} All of the needed buttons to control the calendar embeds + */ +export function generateCalendarButtons(currentPage: number, maxPage: number, downloadCount: number): ActionRowBuilder { const nextButton = new ButtonBuilder() .setCustomId('next') .setLabel('Next') @@ -121,8 +142,13 @@ export function generateButtons(currentPage: number, maxPage: number, downloadCo ); } -// Generates filter dropdown menus. -export function generateFilterMessage(filters: Filter[]): PagifiedSelectMenu[] { +/** + * Creates pagified select menus with the given filters + * + * @param {Filter[]} filters The filters to use to create the pagified select menus + * @returns {PagifiedSelectMenu[]} The created pagified select menus based on the given filters + */ +export function generateCalendarFilterMessage(filters: Filter[]): PagifiedSelectMenu[] { const filterMenus: PagifiedSelectMenu[] = filters.map((filter) => { if (filter.values.length === 0) { filter.values.push('No Data Available'); @@ -152,7 +178,12 @@ export function generateFilterMessage(filters: Filter[]): PagifiedSelectMenu[] { return filterMenus; } -// Generates a row of toggle buttons – one for each event on the current page. +/** + * Creates buttons that selects calendar events to be downloaded + * + * @param {number} eventsPerPage The number of events per embed + * @returns {ActionRowBuilder} Buttons to select what events from the embed you want to download + */ export function generateEventSelectButtons(eventsPerPage: number): ActionRowBuilder { const selectEventButtons: ButtonBuilder[] = []; @@ -179,8 +210,13 @@ export function generateEventSelectButtons(eventsPerPage: number): ActionRowBuil return selectRow; } -// Downloads events by generating an ICS file. -// This version includes recurrence rules (if the event has them). +/** + * Creates an ics file containing all of the selected events + * + * @param {Event[]} selectedEvents The selected events to download + * @param {{calendarId: string, calendarName: string}[]} calendars An arry of all of the calendars retrived from MongoDB + * @param {ChatInputCommandInteraction} interaction The interaction created by calling /calendar + */ export async function downloadEvents(selectedEvents: Event[], calendars: {calendarId: string, calendarName: string}[], interaction: ChatInputCommandInteraction): Promise { const formattedEvents: string[] = []; const parentEvents: calendar_v3.Schema$Event[] = []; From 1a506ed116e803b6b8f65d65c8ad2c4369a8291f Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Tue, 15 Apr 2025 16:41:13 -0400 Subject: [PATCH 12/40] Removed unneccsary function in calendar.ts --- src/commands/general/calendar.ts | 47 +++++++++++++------------------- src/lib/utils/calendarUtils.ts | 11 ++++---- 2 files changed, 24 insertions(+), 34 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 71bcf1a1..5f89493f 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -112,38 +112,29 @@ export default class extends Command { } ]; + // Fetch calendar IDs from MongoDB. const MONGO_URI = process.env.DB_CONN_STRING || ''; const DB_NAME = 'CalendarDatabase'; const COLLECTION_NAME = 'calendarIds'; - - // Fetch calendar IDs from MongoDB. - async function fetchCalendars() { - const client = new MongoClient(MONGO_URI, { useUnifiedTopology: true }); - await client.connect(); - const db = client.db(DB_NAME); - const collection = db.collection(COLLECTION_NAME); - - const calendarDocs = await collection.find().toArray(); - await client.close(); - - const calendars: {calendarId: string, calendarName: string}[] = calendarDocs.map((doc) => ({ - calendarId: doc.calendarId, - calendarName: doc.calendarName || 'Unnamed Calendar' - })); - - if (!calendars.some((c) => c.calendarId === MASTER_CALENDAR_ID)) { - calendars.push({ - calendarId: MASTER_CALENDAR_ID, - calendarName: 'Master Calendar' - }); - } - - return calendars; + const client = new MongoClient(MONGO_URI, { useUnifiedTopology: true }); + await client.connect(); + const db = client.db(DB_NAME); + const collection = db.collection(COLLECTION_NAME); + const calendarDocs = await collection.find().toArray(); + await client.close(); + const calendars: {calendarId: string, calendarName: string}[] = calendarDocs.map((doc) => ({ + calendarId: doc.calendarId, + calendarName: doc.calendarName || 'Unnamed Calendar' + })); + if (!calendars.some((c) => c.calendarId === MASTER_CALENDAR_ID)) { + calendars.push({ + calendarId: MASTER_CALENDAR_ID, + calendarName: 'Master Calendar' + }); } // Retrieve events from all calendars in the database const events: Event[] = []; - const calendars = await fetchCalendars(); const calendarMenu = filters.find((fi) => fi.customId === 'calendar_menu'); if (calendarMenu) { calendarMenu.values = calendars.map((c) => c.calendarName); @@ -178,7 +169,7 @@ export default class extends Command { let currentPage = 0; let selectedEvents: Event[] = []; - let embeds = generateCalendarEmbed(filteredEvents, currentPage, eventsPerPage); + let embeds = generateCalendarEmbed(filteredEvents, eventsPerPage); let maxPage: number = embeds.length; const initialComponents: ActionRowBuilder[] = []; @@ -220,7 +211,7 @@ export default class extends Command { filteredEvents = await filterCalendarEvents(events, filters); currentPage = 0; selectedEvents = []; - embeds = generateCalendarEmbed(filteredEvents, currentPage, eventsPerPage); + embeds = generateCalendarEmbed(filteredEvents, eventsPerPage); maxPage = embeds.length; const newComponents: ActionRowBuilder[] = []; newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); @@ -371,7 +362,7 @@ export default class extends Command { filteredEvents = await filterCalendarEvents(events, filters); currentPage = 0; selectedEvents = []; - embeds = generateCalendarEmbed(filteredEvents, currentPage, eventsPerPage); + embeds = generateCalendarEmbed(filteredEvents, eventsPerPage); maxPage = embeds.length; const newComponents: ActionRowBuilder[] = []; newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); diff --git a/src/lib/utils/calendarUtils.ts b/src/lib/utils/calendarUtils.ts index dec4745f..b5caeee6 100644 --- a/src/lib/utils/calendarUtils.ts +++ b/src/lib/utils/calendarUtils.ts @@ -52,11 +52,10 @@ export async function filterCalendarEvents(events: Event[], filters: Filter[]): * This function will create embeds to contain all the events passed into the function * * @param {Event[]} events The events you want to display in the embed - * @param {number} currentPage ... * @param {number} itemsPerPage The number of events you want to display on one embed * @returns {EmbedBuilder[]} Embeds containing all of the calendar events */ -export function generateCalendarEmbed(events: Event[], currentPage: number, itemsPerPage: number): EmbedBuilder[] { +export function generateCalendarEmbed(events: Event[], itemsPerPage: number): EmbedBuilder[] { const embeds: EmbedBuilder[] = []; let embed: EmbedBuilder; @@ -65,7 +64,7 @@ export function generateCalendarEmbed(events: Event[], currentPage: number, item const maxPage: number = Math.ceil(events.length / itemsPerPage); embed = new EmbedBuilder() - .setTitle(`Events - ${currentPage + numEmbeds} of ${maxPage}`) + .setTitle(`Events - ${numEmbeds} of ${maxPage}`) .setColor('Green'); let i = 1; @@ -82,7 +81,7 @@ export function generateCalendarEmbed(events: Event[], currentPage: number, item numEmbeds++; embeds.push(embed); embed = new EmbedBuilder() - .setTitle(`Events - ${currentPage + numEmbeds} of ${maxPage}`) + .setTitle(`Events - ${numEmbeds} of ${maxPage}`) .setColor('Green'); } else if (events.length - 1 === index) { embeds.push(embed); @@ -105,8 +104,8 @@ export function generateCalendarEmbed(events: Event[], currentPage: number, item /** * Generates pagification buttons and download buttons for the calendar embeds * - * @param {number} currentPage .. - * @param {number} maxPage ... + * @param {number} currentPage The current embed page + * @param {number} maxPage The total number of embeds * @param {number} downloadCount The number of selected events to be downloaded * @returns {ActionRowBuilder} All of the needed buttons to control the calendar embeds */ From 7a892d2c08b68107fa59b9b898f3da3891cf9a29 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Tue, 15 Apr 2025 17:37:04 -0400 Subject: [PATCH 13/40] Select event buttons should no longer appear if there are no events displayed --- src/commands/general/calendar.ts | 70 ++++++++++++++++---------------- src/lib/utils/calendarUtils.ts | 48 +++++++++++----------- 2 files changed, 59 insertions(+), 59 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 5f89493f..69640f88 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -24,13 +24,18 @@ import Filter, filterCalendarEvents, generateCalendarButtons, - generateCalendarEmbed, + generateCalendarEmbeds, generateEventSelectButtons, generateCalendarFilterMessage, Event } from '@root/src/lib/utils/calendarUtils'; -// Define the Master Calendar ID constant. +// Define global constants const MASTER_CALENDAR_ID = CALENDAR_CONFIG.MASTER_ID; +const MONGO_URI = process.env.DB_CONN_STRING || ''; +const DB_NAME = 'CalendarDatabase'; +const COLLECTION_NAME = 'calendarIds'; +const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; +const EVENTS_PER_PAGE = 3; export default class extends Command { @@ -91,31 +96,20 @@ export default class extends Command { { customId: 'week_menu', placeholder: 'Select Days of Week', - values: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], + values: WEEKDAYS, newValues: [], flag: true, condition: (newValues: string[], event: Event) => { if (!event.calEvent.start?.dateTime) return false; const dt = new Date(event.calEvent.start.dateTime); const weekdayIndex = dt.getDay(); // 0 = Sunday, 1 = Monday, etc. - const dayName = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday' - ][weekdayIndex]; + const dayName = WEEKDAYS[weekdayIndex]; return newValues.some((value) => value.toLowerCase() === dayName.toLowerCase()); } } ]; // Fetch calendar IDs from MongoDB. - const MONGO_URI = process.env.DB_CONN_STRING || ''; - const DB_NAME = 'CalendarDatabase'; - const COLLECTION_NAME = 'calendarIds'; const client = new MongoClient(MONGO_URI, { useUnifiedTopology: true }); await client.connect(); const db = client.db(DB_NAME); @@ -157,7 +151,6 @@ export default class extends Command { new Date(b.calEvent.start?.dateTime || b.calEvent.start?.date).getTime() ); - const eventsPerPage = 3; let filteredEvents: Event[] = await filterCalendarEvents(events, filters); if (!filteredEvents.length) { await interaction.followUp({ @@ -169,15 +162,14 @@ export default class extends Command { let currentPage = 0; let selectedEvents: Event[] = []; - let embeds = generateCalendarEmbed(filteredEvents, eventsPerPage); + let embeds = generateCalendarEmbeds(filteredEvents, EVENTS_PER_PAGE); let maxPage: number = embeds.length; const initialComponents: ActionRowBuilder[] = []; + const selectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); initialComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); - if (embeds[currentPage]) { - if (embeds[currentPage].data.fields.length) { - initialComponents.push(generateEventSelectButtons(embeds[currentPage].data.fields.length)); - } + if (selectButtons) { + initialComponents.push(selectButtons); } const dm = await interaction.user.createDM(); @@ -208,18 +200,21 @@ export default class extends Command { if (filter) { filter.newValues = i.values; } + filteredEvents = await filterCalendarEvents(events, filters); + embeds = generateCalendarEmbeds(filteredEvents, EVENTS_PER_PAGE); + currentPage = 0; selectedEvents = []; - embeds = generateCalendarEmbed(filteredEvents, eventsPerPage); maxPage = embeds.length; + const newComponents: ActionRowBuilder[] = []; + const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); - if (embeds[currentPage]) { - if (embeds[currentPage].data.fields.length) { - newComponents.push(generateEventSelectButtons(embeds[currentPage].data.fields.length)); - } + if (newSelectButtons) { + newComponents.push(newSelectButtons); } + message.edit({ embeds: [embeds[currentPage]], components: newComponents @@ -260,7 +255,7 @@ export default class extends Command { await btnInt.deferUpdate(); if (btnInt.customId.startsWith('toggle-')) { const eventIndex = Number(btnInt.customId.split('-')[1]) - 1; - const event = filteredEvents[(currentPage * eventsPerPage) + eventIndex]; + const event = filteredEvents[(currentPage * EVENTS_PER_PAGE) + eventIndex]; if (selectedEvents.some((e) => e === event)) { selectedEvents.splice(selectedEvents.indexOf(event), 1); try { @@ -334,12 +329,12 @@ export default class extends Command { const newComponents: ActionRowBuilder[] = []; + const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); - if (embeds[currentPage]) { - if (embeds[currentPage].data.fields.length) { - newComponents.push(generateEventSelectButtons(embeds[currentPage].data.fields.length)); - } + if (newSelectButtons) { + newComponents.push(newSelectButtons); } + await message.edit({ embeds: [embeds[currentPage]], components: newComponents @@ -359,18 +354,21 @@ export default class extends Command { if (filter) { filter.newValues = i.values; } + filteredEvents = await filterCalendarEvents(events, filters); + embeds = generateCalendarEmbeds(filteredEvents, EVENTS_PER_PAGE); + currentPage = 0; selectedEvents = []; - embeds = generateCalendarEmbed(filteredEvents, eventsPerPage); maxPage = embeds.length; + const newComponents: ActionRowBuilder[] = []; + const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); - if (embeds[currentPage]) { - if (embeds[currentPage].data.fields.length) { - newComponents.push(generateEventSelectButtons(embeds[currentPage].data.fields.length)); - } + if (newSelectButtons) { + newComponents.push(newSelectButtons); } + message.edit({ embeds: [embeds[currentPage]], components: newComponents diff --git a/src/lib/utils/calendarUtils.ts b/src/lib/utils/calendarUtils.ts index b5caeee6..9643ba1b 100644 --- a/src/lib/utils/calendarUtils.ts +++ b/src/lib/utils/calendarUtils.ts @@ -55,7 +55,7 @@ export async function filterCalendarEvents(events: Event[], filters: Filter[]): * @param {number} itemsPerPage The number of events you want to display on one embed * @returns {EmbedBuilder[]} Embeds containing all of the calendar events */ -export function generateCalendarEmbed(events: Event[], itemsPerPage: number): EmbedBuilder[] { +export function generateCalendarEmbeds(events: Event[], itemsPerPage: number): EmbedBuilder[] { const embeds: EmbedBuilder[] = []; let embed: EmbedBuilder; @@ -178,35 +178,37 @@ export function generateCalendarFilterMessage(filters: Filter[]): PagifiedSelect } /** - * Creates buttons that selects calendar events to be downloaded * - * @param {number} eventsPerPage The number of events per embed - * @returns {ActionRowBuilder} Buttons to select what events from the embed you want to download + * @param {EmbedBuilder} embed ... + * @param {Event[]} events ... + * @returns {ActionRowBuilder} ... */ -export function generateEventSelectButtons(eventsPerPage: number): ActionRowBuilder { +export function generateEventSelectButtons(embed: EmbedBuilder, events: Event[]): ActionRowBuilder | void { const selectEventButtons: ButtonBuilder[] = []; - // This is to ensure that the number of buttons does not exceed to the limit per row - // We should probably change to a pagified select menu later on - if (eventsPerPage > 5) { - eventsPerPage = 5; - } + if (events.length && embed) { + // This is to ensure that the number of buttons does not exceed to the limit per row + let eventsInEmbed = embed.data.fields.length; + if (eventsInEmbed > 5) { + eventsInEmbed = 5; + } - // Create buttons for each event on the page (up to 5) - for (let i = 1; i <= eventsPerPage; i++) { - const selectEvent = new ButtonBuilder() - .setCustomId(`toggle-${i}`) - .setLabel(`Select #${i}`) - .setStyle(ButtonStyle.Secondary); - selectEventButtons.push(selectEvent); - } + // Create buttons for each event on the page (up to 5) + for (let i = 1; i <= eventsInEmbed; i++) { + const selectEvent = new ButtonBuilder() + .setCustomId(`toggle-${i}`) + .setLabel(`Select #${i}`) + .setStyle(ButtonStyle.Secondary); + selectEventButtons.push(selectEvent); + } - // Create row containing all of the select buttons - const selectRow = new ActionRowBuilder().addComponents( - ...selectEventButtons - ); + // Create row containing all of the select buttons + const selectRow = new ActionRowBuilder().addComponents( + ...selectEventButtons + ); - return selectRow; + return selectRow; + } } /** From 3572a8a5174182b68fbde3c38618646b91114db3 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Tue, 15 Apr 2025 17:49:44 -0400 Subject: [PATCH 14/40] Added more documentation to calendar.ts --- src/commands/general/calendar.ts | 33 +++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 69640f88..4eb6ee18 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -52,13 +52,9 @@ export default class extends Command { ]; async run(interaction: ChatInputCommandInteraction): Promise { - // Initial reply to acknowledge the interaction. - await interaction.reply({ - content: 'Authenticating and fetching events...', - ephemeral: true - }); - - // Define filters for dropdowns. + // Define local variables + let currentPage = 0; + let selectedEvents: Event[] = []; const filters: Filter[] = [ { customId: 'calendar_menu', @@ -109,6 +105,14 @@ export default class extends Command { } ]; + // ************************************************************************************************* \\ + + // Initial reply to acknowledge the interaction. + await interaction.reply({ + content: 'Authenticating and fetching events...', + ephemeral: true + }); + // Fetch calendar IDs from MongoDB. const client = new MongoClient(MONGO_URI, { useUnifiedTopology: true }); await client.connect(); @@ -127,13 +131,14 @@ export default class extends Command { }); } - // Retrieve events from all calendars in the database + // Add all calendar menu names to calendar menu filter const events: Event[] = []; const calendarMenu = filters.find((fi) => fi.customId === 'calendar_menu'); if (calendarMenu) { calendarMenu.values = calendars.map((c) => c.calendarName); } + // Retrieve events from every calendar in the database await Promise.all(calendars.map(async (cal) => { const retrivedEvents = await retrieveEvents(cal.calendarId, interaction); if (retrivedEvents === null) { @@ -145,12 +150,14 @@ export default class extends Command { }); })); + // Sort the events by date events.sort( (a, b) => new Date(a.calEvent.start?.dateTime || a.calEvent.start?.date).getTime() - new Date(b.calEvent.start?.dateTime || b.calEvent.start?.date).getTime() ); + // Filter inital events let filteredEvents: Event[] = await filterCalendarEvents(events, filters); if (!filteredEvents.length) { await interaction.followUp({ @@ -160,11 +167,11 @@ export default class extends Command { return; } - let currentPage = 0; - let selectedEvents: Event[] = []; + // Create initial embed let embeds = generateCalendarEmbeds(filteredEvents, EVENTS_PER_PAGE); let maxPage: number = embeds.length; + // Create initial componenets const initialComponents: ActionRowBuilder[] = []; const selectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); initialComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); @@ -172,6 +179,8 @@ export default class extends Command { initialComponents.push(selectButtons); } + + // Send dm with first embed in the embeds array and the initial componenets const dm = await interaction.user.createDM(); let message: Message; try { @@ -188,9 +197,11 @@ export default class extends Command { return; } - const filterComponents = generateCalendarFilterMessage(filters); + // Create pagified select menus based on the filters let content = '**Select Filters**'; + const filterComponents = generateCalendarFilterMessage(filters); + // Separate single page menus and pagified menus. Send pagified menus in a separate message const singlePageMenus: (ActionRowBuilder | ActionRowBuilder)[] = []; filterComponents.forEach((component) => { if (component.menus.length > 1) { From c3231b92b15075f8ffeaaa21a04361afbc4b274f Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Tue, 15 Apr 2025 17:51:21 -0400 Subject: [PATCH 15/40] Removed classname filter --- src/commands/general/calendar.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 4eb6ee18..2b736570 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -67,17 +67,6 @@ export default class extends Command { return newValues.some((value) => calendarName === value.toLowerCase()); } }, - { - customId: 'class_name_menu', - placeholder: 'Select Classes', - values: [], - newValues: [interaction.options.getString('classname') ? interaction.options.getString('classname') : ''], - flag: true, - condition: (newValues: string[], event: Event) => { - const summary = event.calEvent.summary?.toLowerCase() || ''; - return newValues.some((value) => summary.includes(value.toLowerCase())); - } - }, { customId: 'location_type_menu', placeholder: 'Select Location Type', From 064eb06f6161760e58301fae94c94661d5e07b7d Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Tue, 15 Apr 2025 17:55:33 -0400 Subject: [PATCH 16/40] Removed unnecassry message edit --- src/commands/general/calendar.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 2b736570..1669b2ff 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -241,10 +241,6 @@ export default class extends Command { }); return; } - await filterMessage.edit({ - content: content, - components: singlePageMenus - }); // Create collectors for button and menu interactions. const buttonCollector = message.createMessageComponentCollector({ time: 300000 }); From 9ba9a284bb8e12f2a0f4e728aea1ae507ee994bd Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Wed, 16 Apr 2025 01:52:06 -0400 Subject: [PATCH 17/40] Modified pagified select menu method to return sent message and made the bot send an inital message to select calendar --- src/commands/general/calendar.ts | 377 ++++++++++++++++--------------- src/lib/types/PagifiedSelect.ts | 10 +- 2 files changed, 206 insertions(+), 181 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 1669b2ff..b5bb4f15 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -10,7 +10,8 @@ import { ButtonInteraction, CacheType, StringSelectMenuInteraction, - Message + Message, + InteractionResponse } from 'discord.js'; import { Command } from '@lib/types/Command'; import 'dotenv/config'; @@ -28,6 +29,7 @@ import generateEventSelectButtons, generateCalendarFilterMessage, Event } from '@root/src/lib/utils/calendarUtils'; +import { PagifiedSelectMenu } from '@root/src/lib/types/PagifiedSelect'; // Define global constants const MASTER_CALENDAR_ID = CALENDAR_CONFIG.MASTER_ID; @@ -171,205 +173,222 @@ export default class extends Command { // Send dm with first embed in the embeds array and the initial componenets const dm = await interaction.user.createDM(); - let message: Message; + let message: Message | InteractionResponse; + let filterMessage: Message; + const calendarFilter = filters.find((filter) => filter.customId === 'calendar_menu'); + const calendarSelectMenu = new PagifiedSelectMenu(); + calendarSelectMenu.createSelectMenu( + { + customId: `${calendarFilter.customId}1`, + placeHolder: calendarFilter.placeholder, + minimumValues: 0 + } + ); + calendarFilter.values.forEach((calendar) => { + calendarSelectMenu.addOption({ label: calendar, value: calendar }); + }); try { - message = await dm.send({ - embeds: [embeds[currentPage]], - components: initialComponents - }); - } catch (error) { - console.error('Failed to send DM:', error); - await interaction.followUp({ - content: "⚠️ I couldn't send you a DM. Please check your privacy settings.", - ephemeral: true - }); - return; - } + message = await calendarSelectMenu.generateRowsAndSendMenu(async (menuI) => { + if (menuI.customId === 'calendar_menu1') { + calendarFilter.newValues = menuI.values; + message.edit({ + embeds: [embeds[currentPage]], + components: initialComponents + }); - // Create pagified select menus based on the filters - let content = '**Select Filters**'; - const filterComponents = generateCalendarFilterMessage(filters); - - // Separate single page menus and pagified menus. Send pagified menus in a separate message - const singlePageMenus: (ActionRowBuilder | ActionRowBuilder)[] = []; - filterComponents.forEach((component) => { - if (component.menus.length > 1) { - component.generateRowsAndSendMenu(async (i) => { - await i.deferUpdate(); - const filter = filters.find((fi) => fi.customId === i.customId); - if (filter) { - filter.newValues = i.values; - } + // Create pagified select menus based on the filters + let content = '**Select Filters**'; + const filterComponents = generateCalendarFilterMessage(filters); + + // Separate single page menus and pagified menus. Send pagified menus in a separate message + const singlePageMenus: (ActionRowBuilder | ActionRowBuilder)[] = []; + filterComponents.forEach((component) => { + if (component.menus.length > 1) { + component.generateRowsAndSendMenu(async (i) => { + await i.deferUpdate(); + const filter = filters.find((fi) => fi.customId === i.customId); + if (filter) { + filter.newValues = i.values; + } - filteredEvents = await filterCalendarEvents(events, filters); - embeds = generateCalendarEmbeds(filteredEvents, EVENTS_PER_PAGE); + filteredEvents = await filterCalendarEvents(events, filters); + embeds = generateCalendarEmbeds(filteredEvents, EVENTS_PER_PAGE); - currentPage = 0; - selectedEvents = []; - maxPage = embeds.length; + currentPage = 0; + selectedEvents = []; + maxPage = embeds.length; - const newComponents: ActionRowBuilder[] = []; - const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); - newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); - if (newSelectButtons) { - newComponents.push(newSelectButtons); - } + const newComponents: ActionRowBuilder[] = []; + const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); + newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); + if (newSelectButtons) { + newComponents.push(newSelectButtons); + } - message.edit({ - embeds: [embeds[currentPage]], - components: newComponents + message.edit({ + embeds: [embeds[currentPage]], + components: newComponents + }); + }, interaction, dm, content); + content = ''; + } else { + singlePageMenus.push(component.generateActionRows()[0]); + } }); - }, interaction, dm, content); - content = ''; - } else { - singlePageMenus.push(component.generateActionRows()[0]); - } - }); - // Send filter message - let filterMessage: Message; - try { - filterMessage = await dm.send({ - content: content, - components: singlePageMenus - }); - } catch (error) { - console.error('Failed to send DM:', error); - await interaction.followUp({ - content: "⚠️ I couldn't send you a DM. Please check your privacy settings.", - ephemeral: true - }); - return; - } + // Send filter message + try { + filterMessage = await dm.send({ + content: content, + components: singlePageMenus + }); + } catch (error) { + console.error('Failed to send DM:', error); + await interaction.followUp({ + content: "⚠️ I couldn't send you a DM. Please check your privacy settings.", + ephemeral: true + }); + return; + } + + // Create collectors for button and menu interactions. + const buttonCollector = message.createMessageComponentCollector({ time: 300000 }); + const menuCollector = filterMessage.createMessageComponentCollector({ componentType: ComponentType.StringSelect, time: 300000 }); - // Create collectors for button and menu interactions. - const buttonCollector = message.createMessageComponentCollector({ time: 300000 }); - const menuCollector = filterMessage.createMessageComponentCollector({ componentType: ComponentType.StringSelect, time: 300000 }); - - buttonCollector.on('collect', async (btnInt: ButtonInteraction) => { - try { - await btnInt.deferUpdate(); - if (btnInt.customId.startsWith('toggle-')) { - const eventIndex = Number(btnInt.customId.split('-')[1]) - 1; - const event = filteredEvents[(currentPage * EVENTS_PER_PAGE) + eventIndex]; - if (selectedEvents.some((e) => e === event)) { - selectedEvents.splice(selectedEvents.indexOf(event), 1); + buttonCollector.on('collect', async (btnInt: ButtonInteraction) => { try { - const removeMsg = await dm.send(`Removed ${event.calEvent.summary}`); - setTimeout(async () => { + await btnInt.deferUpdate(); + if (btnInt.customId.startsWith('toggle-')) { + const eventIndex = Number(btnInt.customId.split('-')[1]) - 1; + const event = filteredEvents[(currentPage * EVENTS_PER_PAGE) + eventIndex]; + if (selectedEvents.some((e) => e === event)) { + selectedEvents.splice(selectedEvents.indexOf(event), 1); + try { + const removeMsg = await dm.send(`Removed ${event.calEvent.summary}`); + setTimeout(async () => { + try { + await removeMsg.delete(); + } catch (err) { + console.error('Failed to delete removal message:', err); + } + }, 3000); + } catch (err) { + console.error('Error sending removal message:', err); + } + } else { + selectedEvents.push(event); + try { + const addMsg = await dm.send(`Added ${event.calEvent.summary}`); + setTimeout(async () => { + try { + await addMsg.delete(); + } catch (err) { + console.error('Failed to delete addition message:', err); + } + }, 3000); + } catch (err) { + console.error('Error sending addition message:', err); + } + } + } else if (btnInt.customId === 'next') { + if (currentPage + 1 >= maxPage) return; + currentPage++; + } else if (btnInt.customId === 'prev') { + if (currentPage === 0) return; + currentPage--; + } else if (btnInt.customId === 'download_Cal') { + if (selectedEvents.length === 0) { + await dm.send('No events selected to download!'); + return; + } + const downloadMessage = await dm.send({ content: 'Downloading selected events...' }); try { - await removeMsg.delete(); - } catch (err) { - console.error('Failed to delete removal message:', err); + await downloadEvents(selectedEvents, calendars, interaction); + const filePath = path.join('./events.ics'); + await downloadMessage.edit({ + content: '', + files: [filePath] + }); + fs.unlinkSync('./events.ics'); + } catch { + await downloadMessage.edit({ content: '⚠️ Failed to download events' }); } - }, 3000); - } catch (err) { - console.error('Error sending removal message:', err); - } - } else { - selectedEvents.push(event); - try { - const addMsg = await dm.send(`Added ${event.calEvent.summary}`); - setTimeout(async () => { + } else if (btnInt.customId === 'download_all') { + if (!filteredEvents.length) { + await dm.send('No events to download!'); + return; + } + const downloadMessage = await dm.send({ content: 'Downloading all events...' }); try { - await addMsg.delete(); - } catch (err) { - console.error('Failed to delete addition message:', err); + await downloadEvents(filteredEvents.flat(), calendars, interaction); + const filePath = path.join('./events.ics'); + await downloadMessage.edit({ + content: '', + files: [filePath] + }); + fs.unlinkSync('./events.ics'); + } catch { + await downloadMessage.edit({ content: '⚠️ Failed to download all events.' }); } - }, 3000); - } catch (err) { - console.error('Error sending addition message:', err); + } + + + const newComponents: ActionRowBuilder[] = []; + const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); + newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); + if (newSelectButtons) { + newComponents.push(newSelectButtons); + } + + await message.edit({ + embeds: [embeds[currentPage]], + components: newComponents + }); + } catch (error) { + console.error('Button Collector Error:', error); + await btnInt.followUp({ + content: '⚠️ An error occurred while navigating through events. Please try again.', + ephemeral: true + }); } - } - } else if (btnInt.customId === 'next') { - if (currentPage + 1 >= maxPage) return; - currentPage++; - } else if (btnInt.customId === 'prev') { - if (currentPage === 0) return; - currentPage--; - } else if (btnInt.customId === 'download_Cal') { - if (selectedEvents.length === 0) { - await dm.send('No events selected to download!'); - return; - } - const downloadMessage = await dm.send({ content: 'Downloading selected events...' }); - try { - await downloadEvents(selectedEvents, calendars, interaction); - const filePath = path.join('./events.ics'); - await downloadMessage.edit({ - content: '', - files: [filePath] - }); - fs.unlinkSync('./events.ics'); - } catch { - await downloadMessage.edit({ content: '⚠️ Failed to download events' }); - } - } else if (btnInt.customId === 'download_all') { - if (!filteredEvents.length) { - await dm.send('No events to download!'); - return; - } - const downloadMessage = await dm.send({ content: 'Downloading all events...' }); - try { - await downloadEvents(filteredEvents.flat(), calendars, interaction); - const filePath = path.join('./events.ics'); - await downloadMessage.edit({ - content: '', - files: [filePath] - }); - fs.unlinkSync('./events.ics'); - } catch { - await downloadMessage.edit({ content: '⚠️ Failed to download all events.' }); - } - } - - - const newComponents: ActionRowBuilder[] = []; - const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); - newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); - if (newSelectButtons) { - newComponents.push(newSelectButtons); - } - - await message.edit({ - embeds: [embeds[currentPage]], - components: newComponents - }); - } catch (error) { - console.error('Button Collector Error:', error); - await btnInt.followUp({ - content: '⚠️ An error occurred while navigating through events. Please try again.', - ephemeral: true - }); - } - }); + }); - menuCollector.on('collect', async (i: StringSelectMenuInteraction) => { - await i.deferUpdate(); - const filter = filters.find((fi) => fi.customId === i.customId); - if (filter) { - filter.newValues = i.values; - } + menuCollector.on('collect', async (i: StringSelectMenuInteraction) => { + await i.deferUpdate(); + const filter = filters.find((fi) => fi.customId === i.customId); + if (filter) { + filter.newValues = i.values; + } - filteredEvents = await filterCalendarEvents(events, filters); - embeds = generateCalendarEmbeds(filteredEvents, EVENTS_PER_PAGE); + filteredEvents = await filterCalendarEvents(events, filters); + embeds = generateCalendarEmbeds(filteredEvents, EVENTS_PER_PAGE); - currentPage = 0; - selectedEvents = []; - maxPage = embeds.length; + currentPage = 0; + selectedEvents = []; + maxPage = embeds.length; - const newComponents: ActionRowBuilder[] = []; - const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); - newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); - if (newSelectButtons) { - newComponents.push(newSelectButtons); - } + const newComponents: ActionRowBuilder[] = []; + const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); + newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); + if (newSelectButtons) { + newComponents.push(newSelectButtons); + } - message.edit({ - embeds: [embeds[currentPage]], - components: newComponents + message.edit({ + embeds: [embeds[currentPage]], + components: newComponents + }); + }); + } + }, interaction, dm, '**Select Calendar**'); + } catch (error) { + console.error('Failed to send DM:', error); + await interaction.followUp({ + content: "⚠️ I couldn't send you a DM. Please check your privacy settings.", + ephemeral: true }); - }); + return; + } } } diff --git a/src/lib/types/PagifiedSelect.ts b/src/lib/types/PagifiedSelect.ts index 454b8f61..6579cca1 100644 --- a/src/lib/types/PagifiedSelect.ts +++ b/src/lib/types/PagifiedSelect.ts @@ -188,6 +188,7 @@ export class PagifiedSelectMenu { * @param {(ActionRowBuilder | ActionRowBuilder)[]} rows The action rows that contains the select menu and navigation buttons * @param {DMChannel} dmChannel Optional: Sends messages to given DM channel * @param {string} content Optional: Sets the message content + * @returns {Promise | InteractionResponse>} The message sent by the bot */ async generateMessage( collectorLogic: (i: StringSelectMenuInteraction) => void, @@ -250,9 +251,14 @@ export class PagifiedSelectMenu { * @param {ChatInputCommandInteraction} interaction The Discord interaction created by the called command * @param {DMChannel} dmChannel Optional: Sends messages to given DM channel * @param {string} content Optional: Sets the message content + * @returns {Promise | InteractionResponse>} The message sent by the bot */ - async generateRowsAndSendMenu(collectorLogic: (i: StringSelectMenuInteraction) => void, interaction: ChatInputCommandInteraction, dmChannel?: DMChannel, content?: string): Promise { - await this.generateMessage(collectorLogic, interaction, this.generateActionRows(), dmChannel, content); + async generateRowsAndSendMenu( + collectorLogic: (i: StringSelectMenuInteraction) => void, + interaction: ChatInputCommandInteraction, + dmChannel?: DMChannel, + content?: string): Promise | InteractionResponse> { + return await this.generateMessage(collectorLogic, interaction, this.generateActionRows(), dmChannel, content); } } From c9c474326214d6c59054c6772f4f5d5f916e752c Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Wed, 16 Apr 2025 02:01:49 -0400 Subject: [PATCH 18/40] Events should now be filtered after selecting inital calendar --- src/commands/general/calendar.ts | 53 ++++++++++++++++---------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index b5bb4f15..239e4cb1 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -124,9 +124,9 @@ export default class extends Command { // Add all calendar menu names to calendar menu filter const events: Event[] = []; - const calendarMenu = filters.find((fi) => fi.customId === 'calendar_menu'); - if (calendarMenu) { - calendarMenu.values = calendars.map((c) => c.calendarName); + const calendarFilter = filters.find((filter) => filter.customId === 'calendar_menu'); + if (calendarFilter) { + calendarFilter.values = calendars.map((c) => c.calendarName); } // Retrieve events from every calendar in the database @@ -148,34 +148,10 @@ export default class extends Command { new Date(b.calEvent.start?.dateTime || b.calEvent.start?.date).getTime() ); - // Filter inital events - let filteredEvents: Event[] = await filterCalendarEvents(events, filters); - if (!filteredEvents.length) { - await interaction.followUp({ - content: 'No matching events found based on your filters. Please adjust your search criteria.', - ephemeral: true - }); - return; - } - - // Create initial embed - let embeds = generateCalendarEmbeds(filteredEvents, EVENTS_PER_PAGE); - let maxPage: number = embeds.length; - - // Create initial componenets - const initialComponents: ActionRowBuilder[] = []; - const selectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); - initialComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); - if (selectButtons) { - initialComponents.push(selectButtons); - } - - // Send dm with first embed in the embeds array and the initial componenets const dm = await interaction.user.createDM(); let message: Message | InteractionResponse; let filterMessage: Message; - const calendarFilter = filters.find((filter) => filter.customId === 'calendar_menu'); const calendarSelectMenu = new PagifiedSelectMenu(); calendarSelectMenu.createSelectMenu( { @@ -189,8 +165,31 @@ export default class extends Command { }); try { message = await calendarSelectMenu.generateRowsAndSendMenu(async (menuI) => { + await menuI.deferUpdate(); if (menuI.customId === 'calendar_menu1') { calendarFilter.newValues = menuI.values; + // Filter inital events + let filteredEvents: Event[] = await filterCalendarEvents(events, filters); + if (!filteredEvents.length) { + await interaction.followUp({ + content: 'No matching events found based on your filters. Please adjust your search criteria.', + ephemeral: true + }); + return; + } + + // Create initial embed + let embeds = generateCalendarEmbeds(filteredEvents, EVENTS_PER_PAGE); + let maxPage: number = embeds.length; + + // Create initial componenets + const initialComponents: ActionRowBuilder[] = []; + const selectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); + initialComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); + if (selectButtons) { + initialComponents.push(selectButtons); + } + message.edit({ embeds: [embeds[currentPage]], components: initialComponents From a87e17f316e0753e28f93ed08e4ee49caf854f25 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Wed, 16 Apr 2025 02:05:17 -0400 Subject: [PATCH 19/40] Updated documentation in calendar.ts --- src/commands/general/calendar.ts | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 239e4cb1..85e875b4 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -148,7 +148,7 @@ export default class extends Command { new Date(b.calEvent.start?.dateTime || b.calEvent.start?.date).getTime() ); - // Send dm with first embed in the embeds array and the initial componenets + // Send inital dm to ask user to select calendar events to display const dm = await interaction.user.createDM(); let message: Message | InteractionResponse; let filterMessage: Message; @@ -167,7 +167,9 @@ export default class extends Command { message = await calendarSelectMenu.generateRowsAndSendMenu(async (menuI) => { await menuI.deferUpdate(); if (menuI.customId === 'calendar_menu1') { + // Add selected calendar into the new values array of the calendar filter calendarFilter.newValues = menuI.values; + // Filter inital events let filteredEvents: Event[] = await filterCalendarEvents(events, filters); if (!filteredEvents.length) { @@ -190,6 +192,7 @@ export default class extends Command { initialComponents.push(selectButtons); } + // Edit dm with first embed in the embeds array and the initial componenets message.edit({ embeds: [embeds[currentPage]], components: initialComponents @@ -236,19 +239,10 @@ export default class extends Command { }); // Send filter message - try { - filterMessage = await dm.send({ - content: content, - components: singlePageMenus - }); - } catch (error) { - console.error('Failed to send DM:', error); - await interaction.followUp({ - content: "⚠️ I couldn't send you a DM. Please check your privacy settings.", - ephemeral: true - }); - return; - } + filterMessage = await dm.send({ + content: content, + components: singlePageMenus + }); // Create collectors for button and menu interactions. const buttonCollector = message.createMessageComponentCollector({ time: 300000 }); From 4d0f49d9e048b8ba763aee1f3c271a58f19e4649 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Wed, 16 Apr 2025 13:46:46 -0400 Subject: [PATCH 20/40] Changed placeholder name for calendar select menu" --- src/commands/general/calendar.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 85e875b4..f0a4a709 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -60,7 +60,7 @@ export default class extends Command { const filters: Filter[] = [ { customId: 'calendar_menu', - placeholder: 'Select Calendar', + placeholder: 'Select Course', values: [], newValues: [], flag: true, @@ -155,7 +155,7 @@ export default class extends Command { const calendarSelectMenu = new PagifiedSelectMenu(); calendarSelectMenu.createSelectMenu( { - customId: `${calendarFilter.customId}1`, + customId: `${calendarFilter.customId}0`, placeHolder: calendarFilter.placeholder, minimumValues: 0 } @@ -166,7 +166,7 @@ export default class extends Command { try { message = await calendarSelectMenu.generateRowsAndSendMenu(async (menuI) => { await menuI.deferUpdate(); - if (menuI.customId === 'calendar_menu1') { + if (menuI.customId === 'calendar_menu0') { // Add selected calendar into the new values array of the calendar filter calendarFilter.newValues = menuI.values; @@ -373,7 +373,7 @@ export default class extends Command { }); }); } - }, interaction, dm, '**Select Calendar**'); + }, interaction, dm, '**Select Course**'); } catch (error) { console.error('Failed to send DM:', error); await interaction.followUp({ From 2f94dbfa861988c739ddfcd43853ac1a8d3d1ed7 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Wed, 16 Apr 2025 23:10:16 -0400 Subject: [PATCH 21/40] Removed initial calendar select menu --- src/commands/general/calendar.ts | 434 ++++++++++++++----------------- src/lib/utils/calendarUtils.ts | 8 +- 2 files changed, 203 insertions(+), 239 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index f0a4a709..4765cd20 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -10,8 +10,7 @@ import { ButtonInteraction, CacheType, StringSelectMenuInteraction, - Message, - InteractionResponse + Message } from 'discord.js'; import { Command } from '@lib/types/Command'; import 'dotenv/config'; @@ -29,7 +28,6 @@ import generateEventSelectButtons, generateCalendarFilterMessage, Event } from '@root/src/lib/utils/calendarUtils'; -import { PagifiedSelectMenu } from '@root/src/lib/types/PagifiedSelect'; // Define global constants const MASTER_CALENDAR_ID = CALENDAR_CONFIG.MASTER_ID; @@ -58,17 +56,6 @@ export default class extends Command { let currentPage = 0; let selectedEvents: Event[] = []; const filters: Filter[] = [ - { - customId: 'calendar_menu', - placeholder: 'Select Course', - values: [], - newValues: [], - flag: true, - condition: (newValues: string[], event: Event) => { - const calendarName = event.calendarName.toLowerCase() || ''; - return newValues.some((value) => calendarName === value.toLowerCase()); - } - }, { customId: 'location_type_menu', placeholder: 'Select Location Type', @@ -96,7 +83,7 @@ export default class extends Command { } ]; - // ************************************************************************************************* \\ + // ************************************************************************************************* // // Initial reply to acknowledge the interaction. await interaction.reply({ @@ -122,14 +109,8 @@ export default class extends Command { }); } - // Add all calendar menu names to calendar menu filter - const events: Event[] = []; - const calendarFilter = filters.find((filter) => filter.customId === 'calendar_menu'); - if (calendarFilter) { - calendarFilter.values = calendars.map((c) => c.calendarName); - } - // Retrieve events from every calendar in the database + const events: Event[] = []; await Promise.all(calendars.map(async (cal) => { const retrivedEvents = await retrieveEvents(cal.calendarId, interaction); if (retrivedEvents === null) { @@ -148,240 +129,229 @@ export default class extends Command { new Date(b.calEvent.start?.dateTime || b.calEvent.start?.date).getTime() ); - // Send inital dm to ask user to select calendar events to display + // Filter inital events + let filteredEvents: Event[] = await filterCalendarEvents(events, filters); + if (!filteredEvents.length) { + await interaction.followUp({ + content: 'No matching events found based on your filters. Please adjust your search criteria.', + ephemeral: true + }); + return; + } + + // Create initial embed + let embeds = generateCalendarEmbeds(filteredEvents, EVENTS_PER_PAGE); + let maxPage: number = embeds.length; + + // Create initial componenets + const initialComponents: ActionRowBuilder[] = []; + const selectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); + initialComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); + if (selectButtons) { + initialComponents.push(selectButtons); + } + + // Send intital dm const dm = await interaction.user.createDM(); - let message: Message | InteractionResponse; - let filterMessage: Message; - const calendarSelectMenu = new PagifiedSelectMenu(); - calendarSelectMenu.createSelectMenu( - { - customId: `${calendarFilter.customId}0`, - placeHolder: calendarFilter.placeholder, - minimumValues: 0 - } - ); - calendarFilter.values.forEach((calendar) => { - calendarSelectMenu.addOption({ label: calendar, value: calendar }); - }); + let message: Message; try { - message = await calendarSelectMenu.generateRowsAndSendMenu(async (menuI) => { - await menuI.deferUpdate(); - if (menuI.customId === 'calendar_menu0') { - // Add selected calendar into the new values array of the calendar filter - calendarFilter.newValues = menuI.values; - - // Filter inital events - let filteredEvents: Event[] = await filterCalendarEvents(events, filters); - if (!filteredEvents.length) { - await interaction.followUp({ - content: 'No matching events found based on your filters. Please adjust your search criteria.', - ephemeral: true - }); - return; + message = await dm.send({ + embeds: [embeds[currentPage]], + components: initialComponents + }); + } catch (error) { + console.error('Failed to send DM:', error); + await interaction.followUp({ + content: "⚠️ I couldn't send you a DM. Please check your privacy settings.", + ephemeral: true + }); + return; + } + + // Create pagified select menus based on the filters + let content = '**Select Filters**'; + const filterComponents = generateCalendarFilterMessage(filters); + + // Separate single page menus and pagified menus. Send pagified menus in a separate message + const singlePageMenus: (ActionRowBuilder | ActionRowBuilder)[] = []; + filterComponents.forEach((component) => { + if (component.menus.length > 1) { + component.generateRowsAndSendMenu(async (i) => { + await i.deferUpdate(); + const filter = filters.find((fi) => fi.customId === i.customId); + if (filter) { + filter.newValues = i.values; } - // Create initial embed - let embeds = generateCalendarEmbeds(filteredEvents, EVENTS_PER_PAGE); - let maxPage: number = embeds.length; + filteredEvents = await filterCalendarEvents(events, filters); + embeds = generateCalendarEmbeds(filteredEvents, EVENTS_PER_PAGE); + + currentPage = 0; + selectedEvents = []; + maxPage = embeds.length; - // Create initial componenets - const initialComponents: ActionRowBuilder[] = []; - const selectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); - initialComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); - if (selectButtons) { - initialComponents.push(selectButtons); + const newComponents: ActionRowBuilder[] = []; + const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); + newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); + if (newSelectButtons) { + newComponents.push(newSelectButtons); } - // Edit dm with first embed in the embeds array and the initial componenets message.edit({ embeds: [embeds[currentPage]], - components: initialComponents - }); - - // Create pagified select menus based on the filters - let content = '**Select Filters**'; - const filterComponents = generateCalendarFilterMessage(filters); - - // Separate single page menus and pagified menus. Send pagified menus in a separate message - const singlePageMenus: (ActionRowBuilder | ActionRowBuilder)[] = []; - filterComponents.forEach((component) => { - if (component.menus.length > 1) { - component.generateRowsAndSendMenu(async (i) => { - await i.deferUpdate(); - const filter = filters.find((fi) => fi.customId === i.customId); - if (filter) { - filter.newValues = i.values; - } - - filteredEvents = await filterCalendarEvents(events, filters); - embeds = generateCalendarEmbeds(filteredEvents, EVENTS_PER_PAGE); - - currentPage = 0; - selectedEvents = []; - maxPage = embeds.length; - - const newComponents: ActionRowBuilder[] = []; - const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); - newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); - if (newSelectButtons) { - newComponents.push(newSelectButtons); - } - - message.edit({ - embeds: [embeds[currentPage]], - components: newComponents - }); - }, interaction, dm, content); - content = ''; - } else { - singlePageMenus.push(component.generateActionRows()[0]); - } - }); - - // Send filter message - filterMessage = await dm.send({ - content: content, - components: singlePageMenus + components: newComponents }); + }, interaction, dm, content); + content = ''; + } else { + singlePageMenus.push(component.generateActionRows()[0]); + } + }); - // Create collectors for button and menu interactions. - const buttonCollector = message.createMessageComponentCollector({ time: 300000 }); - const menuCollector = filterMessage.createMessageComponentCollector({ componentType: ComponentType.StringSelect, time: 300000 }); + // Send filter message + let filterMessage: Message; + try { + filterMessage = await dm.send({ + content: content, + components: singlePageMenus + }); + } catch (error) { + console.error('Failed to send Filters:', error); + await interaction.followUp({ + content: "⚠️ I couldn't send you a DM. Please check your privacy settings.", + ephemeral: true + }); + return; + } - buttonCollector.on('collect', async (btnInt: ButtonInteraction) => { + // Create collectors for button and menu interactions. + const buttonCollector = message.createMessageComponentCollector({ time: 300000 }); + const menuCollector = filterMessage.createMessageComponentCollector({ componentType: ComponentType.StringSelect, time: 300000 }); + + buttonCollector.on('collect', async (btnInt: ButtonInteraction) => { + try { + await btnInt.deferUpdate(); + if (btnInt.customId.startsWith('toggle-')) { + const eventIndex = Number(btnInt.customId.split('-')[1]) - 1; + const event = filteredEvents[(currentPage * EVENTS_PER_PAGE) + eventIndex]; + if (selectedEvents.some((e) => e === event)) { + selectedEvents.splice(selectedEvents.indexOf(event), 1); try { - await btnInt.deferUpdate(); - if (btnInt.customId.startsWith('toggle-')) { - const eventIndex = Number(btnInt.customId.split('-')[1]) - 1; - const event = filteredEvents[(currentPage * EVENTS_PER_PAGE) + eventIndex]; - if (selectedEvents.some((e) => e === event)) { - selectedEvents.splice(selectedEvents.indexOf(event), 1); - try { - const removeMsg = await dm.send(`Removed ${event.calEvent.summary}`); - setTimeout(async () => { - try { - await removeMsg.delete(); - } catch (err) { - console.error('Failed to delete removal message:', err); - } - }, 3000); - } catch (err) { - console.error('Error sending removal message:', err); - } - } else { - selectedEvents.push(event); - try { - const addMsg = await dm.send(`Added ${event.calEvent.summary}`); - setTimeout(async () => { - try { - await addMsg.delete(); - } catch (err) { - console.error('Failed to delete addition message:', err); - } - }, 3000); - } catch (err) { - console.error('Error sending addition message:', err); - } - } - } else if (btnInt.customId === 'next') { - if (currentPage + 1 >= maxPage) return; - currentPage++; - } else if (btnInt.customId === 'prev') { - if (currentPage === 0) return; - currentPage--; - } else if (btnInt.customId === 'download_Cal') { - if (selectedEvents.length === 0) { - await dm.send('No events selected to download!'); - return; - } - const downloadMessage = await dm.send({ content: 'Downloading selected events...' }); + const removeMsg = await dm.send(`Removed ${event.calEvent.summary}`); + setTimeout(async () => { try { - await downloadEvents(selectedEvents, calendars, interaction); - const filePath = path.join('./events.ics'); - await downloadMessage.edit({ - content: '', - files: [filePath] - }); - fs.unlinkSync('./events.ics'); - } catch { - await downloadMessage.edit({ content: '⚠️ Failed to download events' }); + await removeMsg.delete(); + } catch (err) { + console.error('Failed to delete removal message:', err); } - } else if (btnInt.customId === 'download_all') { - if (!filteredEvents.length) { - await dm.send('No events to download!'); - return; - } - const downloadMessage = await dm.send({ content: 'Downloading all events...' }); + }, 3000); + } catch (err) { + console.error('Error sending removal message:', err); + } + } else { + selectedEvents.push(event); + try { + const addMsg = await dm.send(`Added ${event.calEvent.summary}`); + setTimeout(async () => { try { - await downloadEvents(filteredEvents.flat(), calendars, interaction); - const filePath = path.join('./events.ics'); - await downloadMessage.edit({ - content: '', - files: [filePath] - }); - fs.unlinkSync('./events.ics'); - } catch { - await downloadMessage.edit({ content: '⚠️ Failed to download all events.' }); + await addMsg.delete(); + } catch (err) { + console.error('Failed to delete addition message:', err); } - } - - - const newComponents: ActionRowBuilder[] = []; - const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); - newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); - if (newSelectButtons) { - newComponents.push(newSelectButtons); - } - - await message.edit({ - embeds: [embeds[currentPage]], - components: newComponents - }); - } catch (error) { - console.error('Button Collector Error:', error); - await btnInt.followUp({ - content: '⚠️ An error occurred while navigating through events. Please try again.', - ephemeral: true - }); + }, 3000); + } catch (err) { + console.error('Error sending addition message:', err); } - }); + } + } else if (btnInt.customId === 'next') { + if (currentPage + 1 >= maxPage) return; + currentPage++; + } else if (btnInt.customId === 'prev') { + if (currentPage === 0) return; + currentPage--; + } else if (btnInt.customId === 'download_Cal') { + if (selectedEvents.length === 0) { + await dm.send('No events selected to download!'); + return; + } + const downloadMessage = await dm.send({ content: 'Downloading selected events...' }); + try { + await downloadEvents(selectedEvents, calendars, interaction); + const filePath = path.join('./events.ics'); + await downloadMessage.edit({ + content: '', + files: [filePath] + }); + fs.unlinkSync('./events.ics'); + } catch { + await downloadMessage.edit({ content: '⚠️ Failed to download events' }); + } + } else if (btnInt.customId === 'download_all') { + if (!filteredEvents.length) { + await dm.send('No events to download!'); + return; + } + const downloadMessage = await dm.send({ content: 'Downloading all events...' }); + try { + await downloadEvents(filteredEvents.flat(), calendars, interaction); + const filePath = path.join('./events.ics'); + await downloadMessage.edit({ + content: '', + files: [filePath] + }); + fs.unlinkSync('./events.ics'); + } catch { + await downloadMessage.edit({ content: '⚠️ Failed to download all events.' }); + } + } - menuCollector.on('collect', async (i: StringSelectMenuInteraction) => { - await i.deferUpdate(); - const filter = filters.find((fi) => fi.customId === i.customId); - if (filter) { - filter.newValues = i.values; - } - filteredEvents = await filterCalendarEvents(events, filters); - embeds = generateCalendarEmbeds(filteredEvents, EVENTS_PER_PAGE); + const newComponents: ActionRowBuilder[] = []; + const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); + newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); + if (newSelectButtons) { + newComponents.push(newSelectButtons); + } - currentPage = 0; - selectedEvents = []; - maxPage = embeds.length; + await message.edit({ + embeds: [embeds[currentPage]], + components: newComponents + }); + } catch (error) { + console.error('Button Collector Error:', error); + await btnInt.followUp({ + content: '⚠️ An error occurred while navigating through events. Please try again.', + ephemeral: true + }); + } + }); - const newComponents: ActionRowBuilder[] = []; - const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); - newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); - if (newSelectButtons) { - newComponents.push(newSelectButtons); - } + menuCollector.on('collect', async (i: StringSelectMenuInteraction) => { + await i.deferUpdate(); + const filter = filters.find((fi) => fi.customId === i.customId); + if (filter) { + filter.newValues = i.values; + } - message.edit({ - embeds: [embeds[currentPage]], - components: newComponents - }); - }); - } - }, interaction, dm, '**Select Course**'); - } catch (error) { - console.error('Failed to send DM:', error); - await interaction.followUp({ - content: "⚠️ I couldn't send you a DM. Please check your privacy settings.", - ephemeral: true + filteredEvents = await filterCalendarEvents(events, filters); + embeds = generateCalendarEmbeds(filteredEvents, EVENTS_PER_PAGE); + + currentPage = 0; + selectedEvents = []; + maxPage = embeds.length; + + const newComponents: ActionRowBuilder[] = []; + const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); + newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); + if (newSelectButtons) { + newComponents.push(newSelectButtons); + } + + message.edit({ + embeds: [embeds[currentPage]], + components: newComponents }); - return; - } + }); } } diff --git a/src/lib/utils/calendarUtils.ts b/src/lib/utils/calendarUtils.ts index 9643ba1b..df9b2a16 100644 --- a/src/lib/utils/calendarUtils.ts +++ b/src/lib/utils/calendarUtils.ts @@ -163,13 +163,7 @@ export function generateCalendarFilterMessage(filters: Filter[]): PagifiedSelect ); filter.values.forEach((value) => { - let isDefault = false; - if (filter.newValues[0]) { - if (filter.newValues[0].toLowerCase() === value.toLowerCase()) { - isDefault = true; - } - } - filterMenu.addOption({ label: value, value: value.toLowerCase(), default: isDefault }); + filterMenu.addOption({ label: value, value: value.toLowerCase() }); }); return filterMenu; }); From 3b2413138fca4c81e5cf99088a374c70ffad9ef0 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Wed, 16 Apr 2025 23:36:50 -0400 Subject: [PATCH 22/40] Bot will now only retrieve 1 calendar from the database that matches inputed argument --- src/commands/general/calendar.ts | 50 ++++++++++++++------------------ src/lib/utils/calendarUtils.ts | 13 ++------- 2 files changed, 24 insertions(+), 39 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 4765cd20..913b63b9 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -16,7 +16,6 @@ import { Command } from '@lib/types/Command'; import 'dotenv/config'; import { MongoClient } from 'mongodb'; import * as fs from 'fs'; -import { CALENDAR_CONFIG } from '@lib/CalendarConfig'; import { retrieveEvents } from '@root/src/lib/auth'; import path from 'path'; import @@ -30,7 +29,6 @@ import Event } from '@root/src/lib/utils/calendarUtils'; // Define global constants -const MASTER_CALENDAR_ID = CALENDAR_CONFIG.MASTER_ID; const MONGO_URI = process.env.DB_CONN_STRING || ''; const DB_NAME = 'CalendarDatabase'; const COLLECTION_NAME = 'calendarIds'; @@ -45,9 +43,9 @@ export default class extends Command { options: ApplicationCommandStringOptionData[] = [ { type: ApplicationCommandOptionType.String, - name: 'classname', - description: 'Enter the event holder (e.g., class name).', - required: false + name: 'coursecode', + description: 'Enter the course code for the class calendar you want (e.g., cisc108).', + required: true } ]; @@ -55,6 +53,7 @@ export default class extends Command { // Define local variables let currentPage = 0; let selectedEvents: Event[] = []; + const courseCode = interaction.options.getString(this.options[0].name, this.options[0].required); const filters: Filter[] = [ { customId: 'location_type_menu', @@ -91,36 +90,29 @@ export default class extends Command { ephemeral: true }); - // Fetch calendar IDs from MongoDB. + // Fetch the calendar from the database that matches with the inputed course code. const client = new MongoClient(MONGO_URI, { useUnifiedTopology: true }); await client.connect(); const db = client.db(DB_NAME); const collection = db.collection(COLLECTION_NAME); - const calendarDocs = await collection.find().toArray(); + const calendarInDB = await collection.findOne({ calendarName: courseCode }); + console.log(calendarInDB); await client.close(); - const calendars: {calendarId: string, calendarName: string}[] = calendarDocs.map((doc) => ({ - calendarId: doc.calendarId, - calendarName: doc.calendarName || 'Unnamed Calendar' - })); - if (!calendars.some((c) => c.calendarId === MASTER_CALENDAR_ID)) { - calendars.push({ - calendarId: MASTER_CALENDAR_ID, - calendarName: 'Master Calendar' - }); - } + const calendar: {calendarId: string, calendarName: string} = { + calendarId: calendarInDB.calendarId, + calendarName: calendarInDB.calendarName + }; // Retrieve events from every calendar in the database const events: Event[] = []; - await Promise.all(calendars.map(async (cal) => { - const retrivedEvents = await retrieveEvents(cal.calendarId, interaction); - if (retrivedEvents === null) { - return; - } - retrivedEvents.forEach((retrivedEvent) => { - const newEvent: Event = { calEvent: retrivedEvent, calendarName: cal.calendarName }; - events.push(newEvent); - }); - })); + const retrivedEvents = await retrieveEvents(calendar.calendarId, interaction); + if (retrivedEvents === null) { + return; + } + retrivedEvents.forEach((retrivedEvent) => { + const newEvent: Event = { calEvent: retrivedEvent, calendarName: calendar.calendarName }; + events.push(newEvent); + }); // Sort the events by date events.sort( @@ -276,7 +268,7 @@ export default class extends Command { } const downloadMessage = await dm.send({ content: 'Downloading selected events...' }); try { - await downloadEvents(selectedEvents, calendars, interaction); + await downloadEvents(selectedEvents, calendar, interaction); const filePath = path.join('./events.ics'); await downloadMessage.edit({ content: '', @@ -293,7 +285,7 @@ export default class extends Command { } const downloadMessage = await dm.send({ content: 'Downloading all events...' }); try { - await downloadEvents(filteredEvents.flat(), calendars, interaction); + await downloadEvents(filteredEvents.flat(), calendar, interaction); const filePath = path.join('./events.ics'); await downloadMessage.edit({ content: '', diff --git a/src/lib/utils/calendarUtils.ts b/src/lib/utils/calendarUtils.ts index df9b2a16..a36c8879 100644 --- a/src/lib/utils/calendarUtils.ts +++ b/src/lib/utils/calendarUtils.ts @@ -209,20 +209,13 @@ export function generateEventSelectButtons(embed: EmbedBuilder, events: Event[]) * Creates an ics file containing all of the selected events * * @param {Event[]} selectedEvents The selected events to download - * @param {{calendarId: string, calendarName: string}[]} calendars An arry of all of the calendars retrived from MongoDB + * @param {{calendarId: string, calendarName: string}} calendar An arry of all of the calendars retrived from MongoDB * @param {ChatInputCommandInteraction} interaction The interaction created by calling /calendar */ -export async function downloadEvents(selectedEvents: Event[], calendars: {calendarId: string, calendarName: string}[], interaction: ChatInputCommandInteraction): Promise { +export async function downloadEvents(selectedEvents: Event[], calendar: {calendarId: string, calendarName: string}, interaction: ChatInputCommandInteraction): Promise { const formattedEvents: string[] = []; - const parentEvents: calendar_v3.Schema$Event[] = []; - - for (const calendar of calendars) { - const newParentEvents = await retrieveEvents(calendar.calendarId, interaction, false); - parentEvents.push(...newParentEvents); - } - + const parentEvents: calendar_v3.Schema$Event[] = await retrieveEvents(calendar.calendarId, interaction, false); const recurrenceRules: Record = Object.fromEntries(parentEvents.map((event) => [event.id, event.recurrence[0]])); - const recurringIds: Set = new Set(); selectedEvents.forEach((event) => { From c20e3ee7cac1b87c0f44fc73f4070aef38417e94 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Thu, 17 Apr 2025 00:04:52 -0400 Subject: [PATCH 23/40] Wrapped database code in try catch to notify user when course code is not in database --- src/commands/general/calendar.ts | 38 +++++++++++++++----------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 913b63b9..e92a04af 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -91,17 +91,22 @@ export default class extends Command { }); // Fetch the calendar from the database that matches with the inputed course code. - const client = new MongoClient(MONGO_URI, { useUnifiedTopology: true }); - await client.connect(); - const db = client.db(DB_NAME); - const collection = db.collection(COLLECTION_NAME); - const calendarInDB = await collection.findOne({ calendarName: courseCode }); - console.log(calendarInDB); - await client.close(); - const calendar: {calendarId: string, calendarName: string} = { - calendarId: calendarInDB.calendarId, - calendarName: calendarInDB.calendarName - }; + let calendar: {calendarId: string, calendarName: string}; + try { + const client = new MongoClient(MONGO_URI, { useUnifiedTopology: true }); + await client.connect(); + const db = client.db(DB_NAME); + const collection = db.collection(COLLECTION_NAME); + const calendarInDB = await collection.findOne({ calendarName: courseCode }); + await client.close(); + calendar = { calendarId: calendarInDB.calendarId, calendarName: calendarInDB.calendarName }; + } catch (error) { + await interaction.followUp({ + content: `There are no matching calendars with course code **${courseCode}**.`, + ephemeral: true + }); + return; + } // Retrieve events from every calendar in the database const events: Event[] = []; @@ -121,15 +126,8 @@ export default class extends Command { new Date(b.calEvent.start?.dateTime || b.calEvent.start?.date).getTime() ); - // Filter inital events - let filteredEvents: Event[] = await filterCalendarEvents(events, filters); - if (!filteredEvents.length) { - await interaction.followUp({ - content: 'No matching events found based on your filters. Please adjust your search criteria.', - ephemeral: true - }); - return; - } + // Create a filtered events variable to keep the original array intact + let filteredEvents: Event[] = events; // Create initial embed let embeds = generateCalendarEmbeds(filteredEvents, EVENTS_PER_PAGE); From 25416a7b7af1a38afb655798eb87da01c03dd2c5 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Thu, 17 Apr 2025 00:12:12 -0400 Subject: [PATCH 24/40] Updated inital reply message --- src/commands/general/calendar.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index e92a04af..935b44dd 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -85,8 +85,8 @@ export default class extends Command { // ************************************************************************************************* // // Initial reply to acknowledge the interaction. - await interaction.reply({ - content: 'Authenticating and fetching events...', + const initalReply = await interaction.reply({ + content: 'Fetching events. This may take a few moments...', ephemeral: true }); @@ -101,9 +101,8 @@ export default class extends Command { await client.close(); calendar = { calendarId: calendarInDB.calendarId, calendarName: calendarInDB.calendarName }; } catch (error) { - await interaction.followUp({ - content: `There are no matching calendars with course code **${courseCode}**.`, - ephemeral: true + await initalReply.edit({ + content: `⚠️ There are no matching calendars with course code **${courseCode}**.` }); return; } From 070568157e48bbf50a5fba4f82a549737f72971b Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Thu, 17 Apr 2025 00:19:41 -0400 Subject: [PATCH 25/40] Updated documentation in calendar utils --- src/lib/utils/calendarUtils.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib/utils/calendarUtils.ts b/src/lib/utils/calendarUtils.ts index a36c8879..31633c9f 100644 --- a/src/lib/utils/calendarUtils.ts +++ b/src/lib/utils/calendarUtils.ts @@ -172,10 +172,11 @@ export function generateCalendarFilterMessage(filters: Filter[]): PagifiedSelect } /** + * This function will generate select buttons for each event on the given embed (up to 5 events) * - * @param {EmbedBuilder} embed ... - * @param {Event[]} events ... - * @returns {ActionRowBuilder} ... + * @param {EmbedBuilder} embed The embed to generate buttons for + * @param {Event[]} events All of the events retrieved from the google calendar + * @returns {ActionRowBuilder} An action row containing all of the select butttons */ export function generateEventSelectButtons(embed: EmbedBuilder, events: Event[]): ActionRowBuilder | void { const selectEventButtons: ButtonBuilder[] = []; From f0d9d87693f37dd3248e6c209f0b76971571e340 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Thu, 17 Apr 2025 14:13:48 -0400 Subject: [PATCH 26/40] Downloading cal events should now retain ALL reccurence rules --- src/lib/utils/calendarUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/utils/calendarUtils.ts b/src/lib/utils/calendarUtils.ts index 31633c9f..a38ea440 100644 --- a/src/lib/utils/calendarUtils.ts +++ b/src/lib/utils/calendarUtils.ts @@ -216,7 +216,7 @@ export function generateEventSelectButtons(embed: EmbedBuilder, events: Event[]) export async function downloadEvents(selectedEvents: Event[], calendar: {calendarId: string, calendarName: string}, interaction: ChatInputCommandInteraction): Promise { const formattedEvents: string[] = []; const parentEvents: calendar_v3.Schema$Event[] = await retrieveEvents(calendar.calendarId, interaction, false); - const recurrenceRules: Record = Object.fromEntries(parentEvents.map((event) => [event.id, event.recurrence[0]])); + const recurrenceRules: Record = Object.fromEntries(parentEvents.map((event) => [event.id, event.recurrence])); const recurringIds: Set = new Set(); selectedEvents.forEach((event) => { @@ -251,7 +251,7 @@ export async function downloadEvents(selectedEvents: Event[], calendar: {calenda DESCRIPTION:${iCalEvent.DESCRIPTION} LOCATION:${iCalEvent.LOCATION} STATUS:CONFIRMED - ${event.calEvent.recurringEventId ? recurrenceRules[event.calEvent.recurringEventId] : ''} + ${event.calEvent.recurringEventId ? recurrenceRules[event.calEvent.recurringEventId].join('\n') : ''} END:VEVENT `.replace(/\t/g, ''); formattedEvents.push(icsFormatted); From 4c0dc541b1129a74559704ed8582ac5d7da11016 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Thu, 17 Apr 2025 18:01:45 -0400 Subject: [PATCH 27/40] Fixed bug that caused a 6 minute offset with downloaded events --- src/commands/general/calendar.ts | 4 ++-- src/lib/utils/calendarUtils.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 935b44dd..82b3eb49 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -44,7 +44,7 @@ export default class extends Command { { type: ApplicationCommandOptionType.String, name: 'coursecode', - description: 'Enter the course code for the class calendar you want (e.g., cisc108).', + description: 'Enter the course code for the class calendar you want (e.g., CISC108).', required: true } ]; @@ -107,7 +107,7 @@ export default class extends Command { return; } - // Retrieve events from every calendar in the database + // Retrieve events from selected calendar const events: Event[] = []; const retrivedEvents = await retrieveEvents(calendar.calendarId, interaction); if (retrivedEvents === null) { diff --git a/src/lib/utils/calendarUtils.ts b/src/lib/utils/calendarUtils.ts index a38ea440..bbd7edb0 100644 --- a/src/lib/utils/calendarUtils.ts +++ b/src/lib/utils/calendarUtils.ts @@ -206,6 +206,12 @@ export function generateEventSelectButtons(embed: EmbedBuilder, events: Event[]) } } +function formatTime(dateTimeString: string) { + const [date, time] = dateTimeString.split('T'); + const formattedTime = time.split(/[-+]/)[0]; + return `${date}T${formattedTime}`.replace(/[-:.]/g, ''); +} + /** * Creates an ics file containing all of the selected events * @@ -225,8 +231,8 @@ export async function downloadEvents(selectedEvents: Event[], calendar: {calenda UID: `${Date.now()}-${Math.random().toString(36).substring(2, 10)}`, CREATED: new Date(event.calEvent.created).toISOString().replace(/[-:.]/g, ''), DTSTAMP: event.calEvent.updated.replace(/[-:.]/g, ''), - DTSTART: `TZID=${event.calEvent.start.timeZone}:${event.calEvent.start.dateTime.replace(/[-:.]/g, '')}`, - DTEND: `TZID=${event.calEvent.end.timeZone}:${event.calEvent.end.dateTime.replace(/[-:.]/g, '')}`, + DTSTART: `TZID=${event.calEvent.start.timeZone}:${formatTime(event.calEvent.start.dateTime)}`, + DTEND: `TZID=${event.calEvent.end.timeZone}:${formatTime(event.calEvent.end.dateTime)}`, SUMMARY: event.calEvent.summary, DESCRIPTION: `Contact Email: ${event.calEvent.creator.email || 'NA'}`, LOCATION: event.calEvent.location ? event.calEvent.location : 'NONE' From 8c58c7c662e3a95e617c86c96439515fdbf37b3b Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Thu, 17 Apr 2025 23:57:20 -0400 Subject: [PATCH 28/40] Updated JSDoc in calendar utils --- src/commands/general/calendar.ts | 2 +- src/lib/utils/calendarUtils.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 82b3eb49..d9efeb7d 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -97,7 +97,7 @@ export default class extends Command { await client.connect(); const db = client.db(DB_NAME); const collection = db.collection(COLLECTION_NAME); - const calendarInDB = await collection.findOne({ calendarName: courseCode }); + const calendarInDB = await collection.findOne({ calendarName: courseCode.toUpperCase() }); await client.close(); calendar = { calendarId: calendarInDB.calendarId, calendarName: calendarInDB.calendarName }; } catch (error) { diff --git a/src/lib/utils/calendarUtils.ts b/src/lib/utils/calendarUtils.ts index bbd7edb0..9a74a477 100644 --- a/src/lib/utils/calendarUtils.ts +++ b/src/lib/utils/calendarUtils.ts @@ -206,7 +206,13 @@ export function generateEventSelectButtons(embed: EmbedBuilder, events: Event[]) } } -function formatTime(dateTimeString: string) { +/** + * Helper function for download events that formats the date and time properly + * + * @param {string} dateTimeString The date and time string to be formatted + * @returns {string} The formatted version of the date and time + */ +function formatTime(dateTimeString: string): string { const [date, time] = dateTimeString.split('T'); const formattedTime = time.split(/[-+]/)[0]; return `${date}T${formattedTime}`.replace(/[-:.]/g, ''); From a82275da5edf6620fc80db0cf5559bd8c533846c Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Fri, 18 Apr 2025 00:04:14 -0400 Subject: [PATCH 29/40] Updated comments in calendar.ts --- src/commands/general/calendar.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index d9efeb7d..216e8b35 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -28,7 +28,7 @@ import generateCalendarFilterMessage, Event } from '@root/src/lib/utils/calendarUtils'; -// Define global constants +// Global constants const MONGO_URI = process.env.DB_CONN_STRING || ''; const DB_NAME = 'CalendarDatabase'; const COLLECTION_NAME = 'calendarIds'; @@ -50,7 +50,7 @@ export default class extends Command { ]; async run(interaction: ChatInputCommandInteraction): Promise { - // Define local variables + // Local variables let currentPage = 0; let selectedEvents: Event[] = []; const courseCode = interaction.options.getString(this.options[0].name, this.options[0].required); From e72d5f7a475e823f50da43d053b67cdc491ee319 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Mon, 21 Apr 2025 11:36:27 -0400 Subject: [PATCH 30/40] Updated location filter logic --- src/commands/general/calendar.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 216e8b35..9ffb8805 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -62,8 +62,11 @@ export default class extends Command { newValues: [], flag: true, condition: (newValues: string[], event: Event) => { - const locString = event.calEvent.summary?.toLowerCase() || ''; - return newValues.some((value) => locString.includes(value.toLowerCase())); + const valuesToCheck = ['virtual', 'v', 'online', 'zoom']; + const summary = event.calEvent.summary?.toLowerCase() || ''; + const location = event.calEvent.location?.toLowerCase() || ''; + const isVirtual = valuesToCheck.some((value) => summary.includes(value.toLowerCase()) || location.includes(value.toLowerCase())); + return (isVirtual && newValues.includes('virtual')) || (!isVirtual && newValues.includes('in person')); } }, { From 88d7f3b7d192ee3add5bb8b499f0b129abe18738 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Mon, 21 Apr 2025 13:22:10 -0400 Subject: [PATCH 31/40] Merged changes from main --- src/commands/general/calendar.ts | 5 +- src/commands/general/calreminder.ts | 189 ++++++++++++++-------------- 2 files changed, 100 insertions(+), 94 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 9ffb8805..8d6bd4b2 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -62,7 +62,7 @@ export default class extends Command { newValues: [], flag: true, condition: (newValues: string[], event: Event) => { - const valuesToCheck = ['virtual', 'v', 'online', 'zoom']; + const valuesToCheck = ['virtual', 'online', 'zoom']; const summary = event.calEvent.summary?.toLowerCase() || ''; const location = event.calEvent.location?.toLowerCase() || ''; const isVirtual = valuesToCheck.some((value) => summary.includes(value.toLowerCase()) || location.includes(value.toLowerCase())); @@ -118,6 +118,9 @@ export default class extends Command { } retrivedEvents.forEach((retrivedEvent) => { const newEvent: Event = { calEvent: retrivedEvent, calendarName: calendar.calendarName }; + if (!newEvent.calEvent.location) { + newEvent.calEvent.location = '`Location not specified for this event`'; + } events.push(newEvent); }); diff --git a/src/commands/general/calreminder.ts b/src/commands/general/calreminder.ts index ec941b6e..2696af9f 100644 --- a/src/commands/general/calreminder.ts +++ b/src/commands/general/calreminder.ts @@ -1,6 +1,7 @@ -import { DB } from "@root/config"; -import { Command } from "@root/src/lib/types/Command"; -import { Reminder } from "@root/src/lib/types/Reminder"; +/* eslint-disable camelcase */ +import { DB } from '@root/config'; +import { Command } from '@root/src/lib/types/Command'; +import { Reminder } from '@root/src/lib/types/Reminder'; import { ActionRowBuilder, ApplicationCommandOptionData, @@ -8,25 +9,26 @@ import { ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, - ComponentType, -} from "discord.js"; -import parse from "parse-duration"; + ComponentType +} from 'discord.js'; +import parse from 'parse-duration'; import { PagifiedSelectMenu } from '@root/src/lib/types/PagifiedSelect'; -import { retrieveEvents } from "@root/src/lib/auth"; -import { calendar_v3 } from "googleapis"; -import { MongoClient } from "mongodb"; -const MONGO_URI = process.env.DB_CONN_STRING || ""; +import { retrieveEvents } from '@root/src/lib/auth'; +import { calendar_v3 } from 'googleapis'; +import { MongoClient } from 'mongodb'; +const MONGO_URI = process.env.DB_CONN_STRING || ''; export default class extends Command { - name = "calreminder"; - description = "Setup reminders for calendar events"; + + name = 'calreminder'; + description = 'Setup reminders for calendar events'; options: ApplicationCommandOptionData[] = [ { - name: "classname", - description: "Course ID", + name: 'classname', + description: 'Course ID', type: ApplicationCommandOptionType.String, - required: true, - }, + required: true + } ]; async run(interaction: ChatInputCommandInteraction): Promise { @@ -34,7 +36,7 @@ export default class extends Command { let offsetMenu: PagifiedSelectMenu; function generateMessage( - repeatInterval: "every_event" | null, + repeatInterval: 'every_event' | null, chosenEvent?: calendar_v3.Schema$Event, chosenOffset?: number, renderMenus = false, @@ -44,18 +46,18 @@ export default class extends Command { if (renderMenus) { eventMenu = new PagifiedSelectMenu(); eventMenu.createSelectMenu({ - customId: "select_event", - placeHolder: "Select an event", - minimumValues: 1, + customId: 'select_event', + placeHolder: 'Select an event', + minimumValues: 1 }); let defaultSet = false; filteredEvents.forEach((event, index) => { if (!event.start?.dateTime) return; - const isDefault = - !defaultSet && - chosenEvent?.start?.dateTime === event.start?.dateTime; + const isDefault + = !defaultSet + && chosenEvent?.start?.dateTime === event.start?.dateTime; if (isDefault) defaultSet = true; @@ -65,7 +67,7 @@ export default class extends Command { description: `Starts at: ${new Date( event.start.dateTime ).toLocaleString()}`, - default: isDefault, + default: isDefault }); }); @@ -73,32 +75,32 @@ export default class extends Command { // Create offset select menu const offsetOptions = [ - { label: "At event", value: "0" }, - { label: "10 minutes before", value: "10m" }, - { label: "30 minutes before", value: "30m" }, - { label: "1 hour before", value: "1h" }, - { label: "1 day before", value: "1d" }, + { label: 'At event', value: '0' }, + { label: '10 minutes before', value: '10m' }, + { label: '30 minutes before', value: '30m' }, + { label: '1 hour before', value: '1h' }, + { label: '1 day before', value: '1d' } ]; offsetMenu = new PagifiedSelectMenu(); offsetMenu.createSelectMenu({ - customId: "select_offset", - placeHolder: "Select reminder offset", - maximumValues: 1, + customId: 'select_offset', + placeHolder: 'Select reminder offset', + maximumValues: 1 }); let offsetDefaultSet = false; offsetOptions.forEach((option) => { - const isDefault = - !offsetDefaultSet && - chosenOffset === parse(option.value); + const isDefault + = !offsetDefaultSet + && chosenOffset === parse(option.value); if (isDefault) offsetDefaultSet = true; offsetMenu.addOption({ label: option.label, value: option.value, - default: isDefault, + default: isDefault }); }); @@ -113,22 +115,22 @@ export default class extends Command { // 3) Generate repeat button const toggleRepeatButton = new ButtonBuilder() - .setCustomId("toggle_repeat") + .setCustomId('toggle_repeat') .setLabel( - repeatInterval === "every_event" - ? "Repeat: On" - : "Repeat: Off" + repeatInterval === 'every_event' + ? 'Repeat: On' + : 'Repeat: Off' ) .setStyle(ButtonStyle.Secondary); // 4) Generate set reminder button const setReminder = new ButtonBuilder() - .setCustomId("set_reminder") - .setLabel("Set Reminder") + .setCustomId('set_reminder') + .setLabel('Set Reminder') .setStyle(ButtonStyle.Success); - const setReminderAndRepeatRow = - new ActionRowBuilder().addComponents( + const setReminderAndRepeatRow + = new ActionRowBuilder().addComponents( toggleRepeatButton, setReminder ); @@ -136,18 +138,18 @@ export default class extends Command { return [ ...eventMenuRows, ...offsetMenuRows, - setReminderAndRepeatRow, + setReminderAndRepeatRow ]; } const courseCode = interaction.options - .getString("classname") + .getString('classname') ?.toUpperCase(); if (!courseCode) { await interaction.reply({ - content: "❗ You must specify a class name.", - ephemeral: true, + content: '❗ You must specify a class name.', + ephemeral: true }); return; } @@ -156,15 +158,15 @@ export default class extends Command { let calendar: { calendarId: string; calendarName: string }; try { const client = new MongoClient(MONGO_URI, { - useUnifiedTopology: true, + useUnifiedTopology: true }); await client.connect(); - const db = client.db("CalendarDatabase"); - const collection = db.collection("calendarIds"); + const db = client.db('CalendarDatabase'); + const collection = db.collection('calendarIds'); const calendarInDB = await collection.findOne({ - calendarName: { $regex: `^${courseCode}$`, $options: "i" }, + calendarName: { $regex: `^${courseCode}$`, $options: 'i' } }); await client.close(); @@ -172,20 +174,20 @@ export default class extends Command { if (!calendarInDB) { await interaction.reply({ content: `⚠️ There are no matching calendars with course code **${courseCode}**.`, - ephemeral: true, + ephemeral: true }); return; } calendar = { calendarId: calendarInDB.calendarId, - calendarName: calendarInDB.calendarName, + calendarName: calendarInDB.calendarName }; } catch (error) { - console.error("Calendar lookup failed:", error); + console.error('Calendar lookup failed:', error); await interaction.reply({ content: `❌ Database error while fetching calendar for **${courseCode}**.`, - ephemeral: true, + ephemeral: true }); return; } @@ -196,8 +198,8 @@ export default class extends Command { if (!events || events.length === 0) { await interaction.reply({ content: - "⚠️ Failed to fetch calendar events or no events found.", - ephemeral: true, + '⚠️ Failed to fetch calendar events or no events found.', + ephemeral: true }); return; } @@ -206,7 +208,7 @@ export default class extends Command { let chosenEvent: calendar_v3.Schema$Event = null; let chosenOffset: number = null; - let repeatInterval: "every_event" = null; + let repeatInterval: 'every_event' = null; let activeReminderId: string = null; const initialComponents = generateMessage( @@ -221,24 +223,24 @@ export default class extends Command { const replyMessage = await interaction.reply({ components: initialComponents, - ephemeral: true, + ephemeral: true }); // Main collector for event & offset const collector = replyMessage.createMessageComponentCollector({ componentType: ComponentType.StringSelect, - time: 60_000, + time: 60_000 }); - collector.on("collect", async (i) => { - if (i.customId === "select_event") { - const [eventDateStr, indexStr] = i.values[0].split("::"); + collector.on('collect', async (i) => { + if (i.customId === 'select_event') { + const [, indexStr] = i.values[0].split('::'); const selectedIndex = parseInt(indexStr); chosenEvent = filteredEvents[selectedIndex]; await i.deferUpdate(); - } else if (i.customId === "select_offset") { + } else if (i.customId === 'select_offset') { const rawOffsetStr = i.values[0]; - chosenOffset = rawOffsetStr === "0" ? 0 : parse(rawOffsetStr); + chosenOffset = rawOffsetStr === '0' ? 0 : parse(rawOffsetStr); await i.deferUpdate(); } }); @@ -246,12 +248,12 @@ export default class extends Command { // Button collector for Cancel and Set Reminder const buttonCollector = replyMessage.createMessageComponentCollector({ componentType: ComponentType.Button, - time: 300_000, // 5 minutes + time: 300_000 // 5 minutes }); - buttonCollector.on("collect", async (btnInt) => { - if (btnInt.customId === "toggle_repeat") { - repeatInterval = repeatInterval ? null : "every_event"; + buttonCollector.on('collect', async (btnInt) => { + if (btnInt.customId === 'toggle_repeat') { + repeatInterval = repeatInterval ? null : 'every_event'; const updatedComponents = generateMessage( repeatInterval, @@ -263,9 +265,9 @@ export default class extends Command { ); await btnInt.update({ - components: updatedComponents, + components: updatedComponents }); - } else if (btnInt.customId === "set_reminder") { + } else if (btnInt.customId === 'set_reminder') { // If user hasn’t selected both fields, just silently acknowledge if (!chosenEvent || chosenOffset === null) { if (!btnInt.deferred && !btnInt.replied) { @@ -284,8 +286,8 @@ export default class extends Command { if (remindDate.getTime() <= Date.now()) { await btnInt.editReply({ content: - "⏰ That reminder time is in the past. No reminder was set.", - components: [], + '⏰ That reminder time is in the past. No reminder was set.', + components: [] }); collector.stop(); buttonCollector.stop(); @@ -303,11 +305,11 @@ export default class extends Command { const reminder: Reminder = { owner: btnInt.user.id, content: eventInfo, - mode: "public", + mode: 'public', expires: repeatInterval ? new Date(remindDate.getTime() + EXPIRE_BUFFER_MS) // give repeat reminders more time : remindDate, // one-time reminders - repeat: repeatInterval, + repeat: repeatInterval }; let result; @@ -317,11 +319,11 @@ export default class extends Command { .insertOne(reminder); activeReminderId = result.insertedId; } catch (err) { - console.error("Failed to insert reminder:", err); + console.error('Failed to insert reminder:', err); await btnInt.editReply({ content: - "❌ Failed to save reminder. Please try again later.", - components: [], + '❌ Failed to save reminder. Please try again later.', + components: [] }); buttonCollector.stop(); return; @@ -329,12 +331,12 @@ export default class extends Command { // Build Cancel button row const cancelButton = new ButtonBuilder() - .setCustomId("cancel_reminder") - .setLabel("Cancel Reminder") + .setCustomId('cancel_reminder') + .setLabel('Cancel Reminder') .setStyle(ButtonStyle.Danger); - const buttonRow = - new ActionRowBuilder().addComponents( + const buttonRow + = new ActionRowBuilder().addComponents( cancelButton ); @@ -346,11 +348,11 @@ export default class extends Command { repeatInterval ? `\n🔁 Repeats every event (for up to 180 days) ` - : "" + : '' }`, - components: [buttonRow], + components: [buttonRow] }); - } else if (btnInt.customId === "cancel_reminder") { + } else if (btnInt.customId === 'cancel_reminder') { try { // 1) Defer *a new reply* (ephemeral) if (!btnInt.deferred && !btnInt.replied) { @@ -366,22 +368,22 @@ export default class extends Command { // 3) Send brand new ephemeral follow-up await btnInt.followUp({ - content: "❌ Your reminder has been canceled.", - ephemeral: true, + content: '❌ Your reminder has been canceled.', + ephemeral: true }); // 4) Stop the collector buttonCollector.stop(); } catch (err) { - console.error("Failed to cancel reminder:", err); + console.error('Failed to cancel reminder:', err); } } const actions: Record void> = { - "next_button:select_event": () => eventMenu.currentPage++, - "prev_button:select_event": () => eventMenu.currentPage--, - "next_button:select_offset": () => offsetMenu.currentPage++, - "prev_button:select_offset": () => offsetMenu.currentPage--, + 'next_button:select_event': () => eventMenu.currentPage++, + 'prev_button:select_event': () => eventMenu.currentPage--, + 'next_button:select_offset': () => offsetMenu.currentPage++, + 'prev_button:select_offset': () => offsetMenu.currentPage-- }; const action = actions[btnInt.customId]; @@ -393,4 +395,5 @@ export default class extends Command { } }); } + } From f03abcf5bd12e797d66fe2ef9ab09e6ee2948d0c Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Mon, 21 Apr 2025 20:09:34 -0400 Subject: [PATCH 32/40] Downloaded events will now show the description of the event --- src/lib/utils/calendarUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/utils/calendarUtils.ts b/src/lib/utils/calendarUtils.ts index 9a74a477..3ff550b9 100644 --- a/src/lib/utils/calendarUtils.ts +++ b/src/lib/utils/calendarUtils.ts @@ -240,7 +240,7 @@ export async function downloadEvents(selectedEvents: Event[], calendar: {calenda DTSTART: `TZID=${event.calEvent.start.timeZone}:${formatTime(event.calEvent.start.dateTime)}`, DTEND: `TZID=${event.calEvent.end.timeZone}:${formatTime(event.calEvent.end.dateTime)}`, SUMMARY: event.calEvent.summary, - DESCRIPTION: `Contact Email: ${event.calEvent.creator.email || 'NA'}`, + DESCRIPTION: `${event.calEvent.description || ''} Contact Email: ${event.calEvent.creator.email || 'NA'}`, LOCATION: event.calEvent.location ? event.calEvent.location : 'NONE' }; From 7945070a904d6dcd800b9a157027fb9cad31b765 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Mon, 21 Apr 2025 20:53:04 -0400 Subject: [PATCH 33/40] Will now check room location in summary of event --- src/commands/general/calendar.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 8d6bd4b2..2d6ab263 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -120,6 +120,20 @@ export default class extends Command { const newEvent: Event = { calEvent: retrivedEvent, calendarName: calendar.calendarName }; if (!newEvent.calEvent.location) { newEvent.calEvent.location = '`Location not specified for this event`'; + + // Checks if the event summary specfies in person or a specific room in smith (either 203 or 102A) + const validRoomLocations = ['smith hall room 203', 'smith 203', 'room 203', 'smith hall room 102a', 'smith 102a', 'room 102a']; + const summary = newEvent.calEvent.summary.toLowerCase() || ''; + if (validRoomLocations.some((location) => summary.includes(location) || summary.includes('in person'))) { + const inRoom203 = ['CISC101', 'CISC103', 'CISC106', 'CISC108', 'CISC181', 'CISC210', 'CISC220', 'CISC260', 'CISC275', 'TEST']; + + // Replaces empty location field with the right location + if (inRoom203.some((course) => course === courseCode.toUpperCase())) { + newEvent.calEvent.location = 'Smith Hall Room 203'; + } else { + newEvent.calEvent.location = 'Smith Hall Room 102A'; + } + } } events.push(newEvent); }); From 1629bab5800d948262cbef94eeac8aa7f9ca3a1b Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Tue, 22 Apr 2025 21:46:37 -0400 Subject: [PATCH 34/40] Updated helper functions to work with new download system --- src/commands/general/calendar.ts | 4 ++-- src/lib/utils/calendarUtils.ts | 17 +++++------------ 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index 10f7b871..e999f26c 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -264,7 +264,7 @@ export default class extends Command { // Single Download button, context‑aware } else if (btnInt.customId === 'download') { - // Decide whether to download Selected or All + // Decide whether to download selected events or all of them const toDownload = selectedEvents.length > 0 ? selectedEvents : filteredEvents; @@ -289,7 +289,7 @@ export default class extends Command { return; // Skip the re‑render below } - // 🔄 Re‑render embed & buttons for toggles / pagination + // Re‑render embed & buttons for toggles / pagination const newComponents: ActionRowBuilder[] = []; const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); diff --git a/src/lib/utils/calendarUtils.ts b/src/lib/utils/calendarUtils.ts index 3ff550b9..0e7d2fa8 100644 --- a/src/lib/utils/calendarUtils.ts +++ b/src/lib/utils/calendarUtils.ts @@ -122,22 +122,15 @@ export function generateCalendarButtons(currentPage: number, maxPage: number, do .setStyle(ButtonStyle.Primary) .setDisabled(currentPage === 0); - const downloadCal = new ButtonBuilder() - .setCustomId('download_Cal') - .setLabel(`Download Calendar (${downloadCount})`) - .setStyle(ButtonStyle.Success) - .setDisabled(downloadCount === 0); - - const downloadAll = new ButtonBuilder() - .setCustomId('download_all') - .setLabel('Download All') - .setStyle(ButtonStyle.Secondary); + const downloadButton = new ButtonBuilder() + .setCustomId('download') + .setLabel(`Download ${downloadCount || 'all'}`) + .setStyle(ButtonStyle.Success); return new ActionRowBuilder().addComponents( prevButton, nextButton, - downloadCal, - downloadAll + downloadButton ); } From 92d4d7431c4aa889ae20dda8ce070587584ca8db Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Tue, 22 Apr 2025 21:54:46 -0400 Subject: [PATCH 35/40] Enabled eslint in tainfo.ts --- src/commands/general/tainfo.ts | 150 ++++++++++++++++----------------- 1 file changed, 74 insertions(+), 76 deletions(-) diff --git a/src/commands/general/tainfo.ts b/src/commands/general/tainfo.ts index f49eb7b6..6f1afab4 100644 --- a/src/commands/general/tainfo.ts +++ b/src/commands/general/tainfo.ts @@ -1,4 +1,3 @@ -/* eslint-disable */ import { ChatInputCommandInteraction, ApplicationCommandStringOptionData, @@ -8,19 +7,17 @@ import { ButtonStyle, ComponentType, EmbedBuilder, - TextChannel, DMChannel, - NewsChannel, - AttachmentBuilder, -} from "discord.js"; -import { Command } from "@lib/types/Command"; -import "dotenv/config"; -import { retrieveEvents } from "../../lib/auth"; + AttachmentBuilder +} from 'discord.js'; +import { Command } from '@lib/types/Command'; +import 'dotenv/config'; +import { retrieveEvents } from '../../lib/auth'; import { PagifiedSelectMenu } from '@root/src/lib/types/PagifiedSelect'; const EMAIL_REGEX = /Email:\s*([^\s]+)/i; -const CALENDAR_ID = - "c_dd28a9977da52689612627d786654e9914d35324f7fcfc928a7aab294a4a7ce3@group.calendar.google.com"; +const CALENDAR_ID + = 'c_dd28a9977da52689612627d786654e9914d35324f7fcfc928a7aab294a4a7ce3@group.calendar.google.com'; const EXPIRATION_TIME = 5 * 60 * 1000; // 5 minutes const NUM_ENTRIES_PER_PAGE = 5; @@ -35,25 +32,26 @@ function chunk(arr: T[], size: number): T[][] { } export default class extends Command { - name = "tainfo"; - description = "Retrieve TA information for a specific course"; + + name = 'tainfo'; + description = 'Retrieve TA information for a specific course'; options: ApplicationCommandStringOptionData[] = []; async run(interaction: ChatInputCommandInteraction): Promise { const classOptions = [ - "CISC106", - "CISC108", - "CISC181", - "CISC210", - "CISC220", - "CISC260", - "CISC275", + 'CISC106', + 'CISC108', + 'CISC181', + 'CISC210', + 'CISC220', + 'CISC260', + 'CISC275' ]; const classMenu = new PagifiedSelectMenu(); classMenu.createSelectMenu({ - customId: "class_menu", - placeHolder: "Select Class", + customId: 'class_menu', + placeHolder: 'Select Class' }); classOptions.forEach((opt) => classMenu.addOption({ label: opt, value: opt }) @@ -65,8 +63,8 @@ export default class extends Command { const className = i.values[0]; if (!/^cisc\d{3}$/i.test(className)) { return i.reply({ - content: "Invalid class name format.", - ephemeral: true, + content: 'Invalid class name format.', + ephemeral: true }); } @@ -81,33 +79,32 @@ export default class extends Command { !ev.summary ?.toLowerCase() .includes(className.toLowerCase()) - ) - continue; - const name = ev.summary.split("-")[1]?.trim(); + ) { continue; } + const name = ev.summary.split('-')[1]?.trim(); if (!name) continue; - const email = - ev.description?.match(EMAIL_REGEX)?.[1] || - ev.creator?.email; + const email + = ev.description?.match(EMAIL_REGEX)?.[1] + || ev.creator?.email; if (!email) continue; taSet.add(`**Name:** ${name} **Email:** ${email}`); } if (!taSet.size) { return i.editReply({ - content: `No TAs found for course **${className}**.`, + content: `No TAs found for course **${className}**.` }); } // Build dual arrays: markdown for embeds, plain for downloadable text file const taData = Array.from(taSet).map((line) => { const [rawName, rawEmail] = line - .replace(/\*\*/g, "") // remove all asterisks for plain text - .split(" "); - const name = rawName.replace(/^Name:\s*/, "").trim(); - const email = rawEmail.replace(/^Email:\s*/, "").trim(); + .replace(/\*\*/g, '') // remove all asterisks for plain text + .split(' '); + const name = rawName.replace(/^Name:\s*/, '').trim(); + const email = rawEmail.replace(/^Email:\s*/, '').trim(); return { markdown: `**Name:** ${name} **Email:** ${email}`, - plain: `Name: ${name} Email: ${email}`, + plain: `Name: ${name} Email: ${email}` }; }); @@ -126,40 +123,40 @@ export default class extends Command { pages.length })` ) - .setDescription(pages[page].join("\n\n")) - .setColor("#0099ff"); + .setDescription(pages[page].join('\n\n')) + .setColor('#0099ff'); const makeRow = () => new ActionRowBuilder().addComponents( new ButtonBuilder() - .setCustomId("prev") - .setLabel("⬅ Previous") + .setCustomId('prev') + .setLabel('⬅ Previous') .setStyle(ButtonStyle.Secondary) .setDisabled(pageIndex === 0), new ButtonBuilder() - .setCustomId("next") - .setLabel("Next ➡") + .setCustomId('next') + .setLabel('Next ➡') .setStyle(ButtonStyle.Secondary) .setDisabled(pageIndex === pages.length - 1), new ButtonBuilder() - .setCustomId("download") - .setLabel("Download .txt") + .setCustomId('download') + .setLabel('Download .txt') .setStyle(ButtonStyle.Primary), new ButtonBuilder() - .setCustomId("close") - .setLabel("Close ✖️") + .setCustomId('close') + .setLabel('Close ✖️') .setStyle(ButtonStyle.Danger) ); - const dmChannel = - (await interaction.user.createDM()) as DMChannel; + const dmChannel + = (await interaction.user.createDM()) as DMChannel; const listMessage = await dmChannel.send({ embeds: [makeEmbed(pageIndex)], - components: [makeRow()], + components: [makeRow()] }); await i.editReply({ - content: `I’ve sent you a DM with the TA info for **${className}**!`, + content: `I’ve sent you a DM with the TA info for **${className}**!` }); const dmCollector = listMessage.createMessageComponentCollector( @@ -167,76 +164,76 @@ export default class extends Command { componentType: ComponentType.Button, time: EXPIRATION_TIME, filter: (btnInt) => - btnInt.user.id === interaction.user.id, + btnInt.user.id === interaction.user.id } ); - dmCollector.on("collect", async (btn) => { + dmCollector.on('collect', async (btn) => { if ( - btn.customId === "next" && - pageIndex < pages.length - 1 + btn.customId === 'next' + && pageIndex < pages.length - 1 ) { pageIndex++; return btn.update({ embeds: [makeEmbed(pageIndex)], - components: [makeRow()], + components: [makeRow()] }); - } else if (btn.customId === "prev" && pageIndex > 0) { + } else if (btn.customId === 'prev' && pageIndex > 0) { pageIndex--; return btn.update({ embeds: [makeEmbed(pageIndex)], - components: [makeRow()], + components: [makeRow()] }); - } else if (btn.customId === "download") { - const content = taSetPlainEntries.join("\n"); + } else if (btn.customId === 'download') { + const content = taSetPlainEntries.join('\n'); const file = new AttachmentBuilder( - Buffer.from(content, "utf-8"), + Buffer.from(content, 'utf-8'), { - name: `${className}_TAs.txt`, + name: `${className}_TAs.txt` } ); return btn.reply({ files: [file], ephemeral: true }); - } else if (btn.customId === "close") { + } else if (btn.customId === 'close') { await btn.update({ - content: "Paginator closed.", - components: [], + content: 'Paginator closed.', + components: [] }); return dmCollector.stop(); } return btn.deferUpdate(); }); - dmCollector.on("end", async () => { + dmCollector.on('end', async () => { await listMessage.edit({ components: [ new ActionRowBuilder().addComponents( new ButtonBuilder() - .setCustomId("prev") - .setLabel("⬅ Previous") + .setCustomId('prev') + .setLabel('⬅ Previous') .setStyle(ButtonStyle.Primary) .setDisabled(true), new ButtonBuilder() - .setCustomId("next") - .setLabel("Next ➡") + .setCustomId('next') + .setLabel('Next ➡') .setStyle(ButtonStyle.Primary) .setDisabled(true), new ButtonBuilder() - .setCustomId("download") - .setLabel("Download .txt") + .setCustomId('download') + .setLabel('Download .txt') .setStyle(ButtonStyle.Secondary) .setDisabled(true), new ButtonBuilder() - .setCustomId("close") - .setLabel("Close ✖️") + .setCustomId('close') + .setLabel('Close ✖️') .setStyle(ButtonStyle.Danger) .setDisabled(true) - ), - ], + ) + ] }); }); } catch (err) { console.error(err); - await i.editReply("Failed to retrieve information."); + await i.editReply('Failed to retrieve information.'); } }; @@ -244,7 +241,8 @@ export default class extends Command { collectorLogic, interaction, null, - "Please select a class:" + 'Please select a class:' ); } + } From abaf889df897c0a3bdd15d976449d38956cf4d1b Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Tue, 22 Apr 2025 22:07:56 -0400 Subject: [PATCH 36/40] Updated download button label --- src/lib/utils/calendarUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/utils/calendarUtils.ts b/src/lib/utils/calendarUtils.ts index 0e7d2fa8..dc9c7973 100644 --- a/src/lib/utils/calendarUtils.ts +++ b/src/lib/utils/calendarUtils.ts @@ -124,7 +124,7 @@ export function generateCalendarButtons(currentPage: number, maxPage: number, do const downloadButton = new ButtonBuilder() .setCustomId('download') - .setLabel(`Download ${downloadCount || 'all'}`) + .setLabel(`Download ${downloadCount || 'All'}`) .setStyle(ButtonStyle.Success); return new ActionRowBuilder().addComponents( From 20d7a695dbc1bd25e0389e01721b84e410ba34f7 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Tue, 22 Apr 2025 22:28:05 -0400 Subject: [PATCH 37/40] Enabled eslint in calendar help --- src/commands/general/calendarhelp.ts | 38 ++++++++++++++-------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/commands/general/calendarhelp.ts b/src/commands/general/calendarhelp.ts index 4b34e35b..d1a64e63 100644 --- a/src/commands/general/calendarhelp.ts +++ b/src/commands/general/calendarhelp.ts @@ -1,28 +1,28 @@ -/* eslint-disable */ -import { ChatInputCommandInteraction } from "discord.js"; -import { Command } from "@root/src/lib/types/Command"; +import { ChatInputCommandInteraction } from 'discord.js'; +import { Command } from '@root/src/lib/types/Command'; export default class extends Command { - name = "calendarhelp"; - description = "Displays help information for using the /calendar command"; - - async run(interaction: ChatInputCommandInteraction): Promise { + name = 'calendarhelp'; + description = 'Displays help information for using the /calendar command'; + async run(interaction: ChatInputCommandInteraction): Promise { await interaction.reply({ content: - "**📅 Command Help: /calendar**\n\n" + - "Use the `/calendar` command to view upcoming office hour events. You'll **receive a DM** with office hour events over the next 10 days that allows for filtering and downloading events to add to your personal calendar.\n\n" + - - "**📬 Filtering in DMs:**\n" + - "`Class`, `Location Type` *(in-person/virtual)*, and `Days of Week` filtering options are available.\n\n" + - - "**🔍 Optional Command Arguments:**\n" + - "`coursecode` — Automatically filters results by course (e.g., `cisc123`)\n\n" + - - "Get started by running `/calendar`!", - - ephemeral: true, + '**📅 Command Help: /calendar**\n\n' + + 'Use the `/calendar` command to view upcoming office hour events.' + + ' You\'ll **receive a DM** with office hour events over the next 10 days that allows for filtering and downloading events to add to your personal calendar.\n\n' + + + '**📬 Filtering in DMs:**\n' + + '`Class`, `Location Type` *(in-person/virtual)*, and `Days of Week` filtering options are available.\n\n' + + + '**🔍 Optional Command Arguments:**\n' + + '`coursecode` — Automatically filters results by course (e.g., `cisc123`)\n\n' + + + 'Get started by running `/calendar`!', + + ephemeral: true }); } + } From 5ef92fd21360b00040dd3863e14969e7fccd41a1 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Wed, 23 Apr 2025 00:17:39 -0400 Subject: [PATCH 38/40] Optimized embed creation --- src/lib/utils/calendarUtils.ts | 48 ++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/src/lib/utils/calendarUtils.ts b/src/lib/utils/calendarUtils.ts index dc9c7973..341a696d 100644 --- a/src/lib/utils/calendarUtils.ts +++ b/src/lib/utils/calendarUtils.ts @@ -59,34 +59,36 @@ export function generateCalendarEmbeds(events: Event[], itemsPerPage: number): E const embeds: EmbedBuilder[] = []; let embed: EmbedBuilder; + // There can only be up to 25 fields in an embed, so this is just a check to make sure nothing breaks + if (itemsPerPage > 25) { + itemsPerPage = 25; + } + if (events.length) { - let numEmbeds = 1; - const maxPage: number = Math.ceil(events.length / itemsPerPage); + // Pagify events array + const pagifiedEvents: Event[][] = []; + for (let i = 0; i < events.length; i += itemsPerPage) { + pagifiedEvents.push(events.slice(i, i + itemsPerPage)); + } + const maxPages = pagifiedEvents.length; - embed = new EmbedBuilder() - .setTitle(`Events - ${numEmbeds} of ${maxPage}`) - .setColor('Green'); + // Create an embed for each page + pagifiedEvents.forEach((page, pageIndex) => { + const newEmbed = new EmbedBuilder() + .setTitle(`Events - ${pageIndex + 1} of ${maxPages}`) + .setColor('Green'); - let i = 1; - events.forEach((event, index) => { - embed.addFields({ - name: `**${event.calEvent.summary}**`, - value: `Date: ${new Date(event.calEvent.start.dateTime).toLocaleDateString()} - Time: ${new Date(event.calEvent.start.dateTime).toLocaleTimeString()} - ${new Date(event.calEvent.end.dateTime).toLocaleTimeString()} - Location: ${event.calEvent.location ? event.calEvent.location : '`NONE`'} - Email: ${event.calEvent.creator.email}\n` + page.forEach((event, eventIndex) => { + newEmbed.addFields({ + name: `**${eventIndex + 1}. ${event.calEvent.summary}**`, + value: `Date: ${new Date(event.calEvent.start.dateTime).toLocaleDateString()} + Time: ${new Date(event.calEvent.start.dateTime).toLocaleTimeString()} - ${new Date(event.calEvent.end.dateTime).toLocaleTimeString()} + Location: ${event.calEvent.location} + Email: ${event.calEvent.creator.email}\n` + }); }); - if (i % itemsPerPage === 0) { - numEmbeds++; - embeds.push(embed); - embed = new EmbedBuilder() - .setTitle(`Events - ${numEmbeds} of ${maxPage}`) - .setColor('Green'); - } else if (events.length - 1 === index) { - embeds.push(embed); - } - i++; + embeds.push(newEmbed); }); } else { embed = new EmbedBuilder() From 96000e634c6f0d855d9f6643b605eb1329bc21f1 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Wed, 23 Apr 2025 00:28:50 -0400 Subject: [PATCH 39/40] Updated download events label when multiple events are selected --- src/lib/utils/calendarUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/utils/calendarUtils.ts b/src/lib/utils/calendarUtils.ts index 341a696d..d016dea6 100644 --- a/src/lib/utils/calendarUtils.ts +++ b/src/lib/utils/calendarUtils.ts @@ -126,7 +126,7 @@ export function generateCalendarButtons(currentPage: number, maxPage: number, do const downloadButton = new ButtonBuilder() .setCustomId('download') - .setLabel(`Download ${downloadCount || 'All'}`) + .setLabel(`Download ${downloadCount ? `${downloadCount} event(s)` : 'All'}`) .setStyle(ButtonStyle.Success); return new ActionRowBuilder().addComponents( From af37e6099a27cccfd4fd591d718c4e56e114a4c9 Mon Sep 17 00:00:00 2001 From: TheLuckyman30 Date: Wed, 23 Apr 2025 01:19:04 -0400 Subject: [PATCH 40/40] Merged removecalendar updates from main --- src/commands/general/removecalendar.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/commands/general/removecalendar.ts b/src/commands/general/removecalendar.ts index 3ede7f50..39c7cc22 100644 --- a/src/commands/general/removecalendar.ts +++ b/src/commands/general/removecalendar.ts @@ -7,7 +7,8 @@ import { } from 'discord.js'; import { Command } from '@root/src/lib/types/Command'; import { MongoClient } from 'mongodb'; -import { PagifiedSelectMenu } from '@root/src/lib/utils/calendarUtils'; +import { PagifiedSelectMenu } from '@root/src/lib/types/PagifiedSelect'; + export default class RemoveCalendarCommand extends Command { @@ -31,7 +32,8 @@ export default class RemoveCalendarCommand extends Command { await client.close(); return; } - // 1) Build paginated select menu (auto-splits >25 & adds nav) :contentReference[oaicite:0]{index=0}​:contentReference[oaicite:1]{index=1} + + // 1) Build paginated select menu const menu = new PagifiedSelectMenu(); menu.createSelectMenu({ customId: 'select_calendar_to_remove', @@ -42,6 +44,7 @@ export default class RemoveCalendarCommand extends Command { calendarDocs.forEach((doc) => menu.addOption({ label: doc.calendarName, value: doc.calendarId }) ); + // 2) Always show Next/Prev row (disabled if only one page) const rows = menu.generateActionRows(); if (menu.numPages <= 1) { @@ -57,7 +60,8 @@ export default class RemoveCalendarCommand extends Command { .setDisabled(true); rows.push(new ActionRowBuilder().addComponents(prevBtn, nextBtn)); } - // 3) Send menu + navigation buttons :contentReference[oaicite:2]{index=2}​:contentReference[oaicite:3]{index=3} + + // 3) Send menu + navigation buttons await menu.generateMessage( async (i: StringSelectMenuInteraction) => { if (i.user.id !== interaction.user.id) return; @@ -72,7 +76,7 @@ export default class RemoveCalendarCommand extends Command { }, interaction, rows, - undefined, + null, '**Select a calendar to remove:**' ); }