diff --git a/src/commands/general/calendar.ts b/src/commands/general/calendar.ts index e999f26c..fdc8bce4 100644 --- a/src/commands/general/calendar.ts +++ b/src/commands/general/calendar.ts @@ -19,13 +19,13 @@ import * as fs from 'fs'; import { retrieveEvents } from '@root/src/lib/auth'; import { downloadEvents, - Filter, filterCalendarEvents, generateCalendarButtons, generateCalendarEmbeds, generateEventSelectButtons, generateCalendarFilterMessage, - Event } from '@root/src/lib/utils/calendarUtils'; + updateCalendarEmbed } from '@root/src/lib/utils/calendarUtils'; +import { CalendarEvent, Filter } from '@root/src/lib/types/Calendar'; // Global constants const MONGO_URI = process.env.DB_CONN_STRING || ''; @@ -51,7 +51,8 @@ export default class extends Command { async run(interaction: ChatInputCommandInteraction): Promise { // Local variables let currentPage = 0; - let selectedEvents: Event[] = []; + let downloadPressed = false; + let selectedEvents: CalendarEvent[] = []; const courseCode = interaction.options.getString(this.options[0].name, this.options[0].required); const filters: Filter[] = [ { @@ -60,7 +61,7 @@ export default class extends Command { values: ['In Person', 'Virtual'], newValues: [], flag: true, - condition: (newValues: string[], event: Event) => { + condition: (newValues: string[], event: CalendarEvent) => { const valuesToCheck = ['virtual', 'online', 'zoom']; const summary = event.calEvent.summary?.toLowerCase() || ''; const location = event.calEvent.location?.toLowerCase() || ''; @@ -74,7 +75,7 @@ export default class extends Command { values: WEEKDAYS, newValues: [], flag: true, - condition: (newValues: string[], event: Event) => { + condition: (newValues: string[], event: CalendarEvent) => { if (!event.calEvent.start?.dateTime) return false; const dt = new Date(event.calEvent.start.dateTime); const weekdayIndex = dt.getDay(); // 0 = Sunday, 1 = Monday, etc. @@ -110,13 +111,13 @@ export default class extends Command { } // Retrieve events from selected calendar - const events: Event[] = []; + const events: CalendarEvent[] = []; const retrivedEvents = await retrieveEvents(calendar.calendarId, interaction); if (retrivedEvents === null) { return; } retrivedEvents.forEach((retrivedEvent) => { - const newEvent: Event = { calEvent: retrivedEvent, calendarName: calendar.calendarName }; + const newEvent: CalendarEvent = { calEvent: retrivedEvent, calendarName: calendar.calendarName, selected: false }; if (!newEvent.calEvent.location) { newEvent.calEvent.location = '`Location not specified for this event`'; @@ -145,7 +146,7 @@ export default class extends Command { ); // Create a filtered events variable to keep the original array intact - let filteredEvents: Event[] = events; + let filteredEvents: CalendarEvent[] = events; // Create initial embed let embeds = generateCalendarEmbeds(filteredEvents, EVENTS_PER_PAGE); @@ -153,20 +154,19 @@ export default class extends Command { // Create initial componenets const initialComponents: ActionRowBuilder[] = []; - const selectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); - initialComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); - if (selectButtons) { - initialComponents.push(selectButtons); - } + initialComponents.push(generateCalendarButtons(filteredEvents, selectedEvents, currentPage, maxPage, downloadPressed)); // Send intital dm const dm = await interaction.user.createDM(); let message: Message; try { message = await dm.send({ - embeds: [embeds[currentPage]], + embeds: [embeds[currentPage].embed], components: initialComponents }); + initalReply.edit({ + content: `I sent you a DM with the calendar for **${courseCode.toUpperCase()}**` + }); } catch (error) { console.error('Failed to send DM:', error); await interaction.followUp({ @@ -177,7 +177,7 @@ export default class extends Command { } // Create pagified select menus based on the filters - let content = '**Select Filters**'; + let content = '**\nSelect Filters**'; const filterComponents = generateCalendarFilterMessage(filters); // Separate single page menus and pagified menus. Send pagified menus in a separate message @@ -199,14 +199,16 @@ export default class extends Command { maxPage = embeds.length; const newComponents: ActionRowBuilder[] = []; - const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); - newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); - if (newSelectButtons) { - newComponents.push(newSelectButtons); + newComponents.push(generateCalendarButtons(filteredEvents, selectedEvents, currentPage, maxPage, downloadPressed)); + if (downloadPressed) { + const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); + if (newSelectButtons) { + newComponents.push(newSelectButtons); + } } message.edit({ - embeds: [embeds[currentPage]], + embeds: [embeds[currentPage].embed], components: newComponents }); }, interaction, dm, content); @@ -246,6 +248,7 @@ export default class extends Command { if (btnInt.customId.startsWith('toggle-')) { const eventIndex = Number(btnInt.customId.split('-')[1]) - 1; const event = filteredEvents[(currentPage * EVENTS_PER_PAGE) + eventIndex]; + event.selected = !event.selected; if (selectedEvents.includes(event)) { selectedEvents = selectedEvents.filter(e => e !== event); const m = await dm.send(`➖ Removed **${event.calEvent.summary}**`); @@ -264,41 +267,54 @@ export default class extends Command { // Single Download button, context‑aware } else if (btnInt.customId === 'download') { - // Decide whether to download selected events or all of them - const toDownload = selectedEvents.length > 0 - ? selectedEvents - : filteredEvents; - if (toDownload.length === 0) { - await dm.send('⚠️ No events to download!'); - return; - } - - const prep = await dm.send(`⏳ Preparing ${toDownload.length} event(s)…`); - try { - // downloadEvents writes to './events.ics' - await downloadEvents(toDownload, calendar, interaction); - await prep.edit({ - content: `📥 Here are your ${toDownload.length} event(s):`, - files: ['./events.ics'] + if (downloadPressed) { + // Decide whether to download selected events or all of them + const toDownload = selectedEvents.length > 0 + ? selectedEvents + : filteredEvents; + + if (toDownload.length === 0) { + await dm.send('⚠️ No events to download!'); + return; + } + + const prep = await dm.send(`⏳ Preparing ${toDownload.length} event(s)…`); + try { + // downloadEvents writes to './events.ics' + await downloadEvents(toDownload, calendar, interaction); + await prep.edit({ + content: `📥 Here are your ${toDownload.length} event(s):`, + files: ['./events.ics'] + }); + fs.unlinkSync('./events.ics'); + } catch (e) { + console.error('Download failed:', e); + await prep.edit('⚠️ Failed to generate calendar file.'); + } + downloadPressed = false; + selectedEvents.forEach((event) => { + event.selected = false; }); - fs.unlinkSync('./events.ics'); - } catch (e) { - console.error('Download failed:', e); - await prep.edit('⚠️ Failed to generate calendar file.'); + selectedEvents = []; + embeds = updateCalendarEmbed(embeds, false); + } else { + downloadPressed = true; + embeds = updateCalendarEmbed(embeds, true); } - return; // Skip the re‑render below } // Re‑render embed & buttons for toggles / pagination const newComponents: ActionRowBuilder[] = []; - const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); - newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); - if (newSelectButtons) { - newComponents.push(newSelectButtons); + newComponents.push(generateCalendarButtons(filteredEvents, selectedEvents, currentPage, maxPage, downloadPressed)); + if (downloadPressed) { + const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); + if (newSelectButtons) { + newComponents.push(newSelectButtons); + } } await message.edit({ - embeds: [embeds[currentPage]], + embeds: [embeds[currentPage].embed], components: newComponents }); } catch (error) { @@ -326,14 +342,16 @@ export default class extends Command { maxPage = embeds.length; const newComponents: ActionRowBuilder[] = []; - const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); - newComponents.push(generateCalendarButtons(currentPage, maxPage, selectedEvents.length)); - if (newSelectButtons) { - newComponents.push(newSelectButtons); + newComponents.push(generateCalendarButtons(filteredEvents, selectedEvents, currentPage, maxPage, downloadPressed)); + if (downloadPressed) { + const newSelectButtons = generateEventSelectButtons(embeds[currentPage], filteredEvents); + if (newSelectButtons) { + newComponents.push(newSelectButtons); + } } message.edit({ - embeds: [embeds[currentPage]], + embeds: [embeds[currentPage].embed], components: newComponents }); }); diff --git a/src/lib/types/Calendar.d.ts b/src/lib/types/Calendar.d.ts new file mode 100644 index 00000000..9f20afbd --- /dev/null +++ b/src/lib/types/Calendar.d.ts @@ -0,0 +1,20 @@ +/* eslint-disable camelcase */ +export interface CalendarEvent { + calEvent: calendar_v3.Schema$Event; + calendarName: string; + selected: boolean; +} + +export interface Filter { + customId: string; + placeholder: string, + values: string[]; + newValues: string[]; + flag: boolean; + condition: (newValues: string[], event: CalendarEvent) => boolean; +} + +export interface CalendarEmbed { + embed: EmbedBuilder; + events: CalendarEvent[]; +} diff --git a/src/lib/utils/calendarUtils.ts b/src/lib/utils/calendarUtils.ts index d016dea6..7b4a7a96 100644 --- a/src/lib/utils/calendarUtils.ts +++ b/src/lib/utils/calendarUtils.ts @@ -4,30 +4,17 @@ 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; -} +import { CalendarEmbed, CalendarEvent, Filter } from '../types/Calendar'; /** * This function will filter out events based on the given filter array * - * @param {Event[]} events The events that you want to filter + * @param {CalendarEvent[]} 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[] = []; +export async function filterCalendarEvents(events: CalendarEvent[], filters: Filter[]): Promise { + const filteredEvents: CalendarEvent[] = []; let allFiltersFlags = true; @@ -48,16 +35,45 @@ export async function filterCalendarEvents(events: Event[], filters: Filter[]): return filteredEvents; } +/** + * This is a helper function update the calendar embed fields when the download button is pressed + * + * @param {CalendarEmbed[]} embeds The embeds that you want to update + * @param {boolean} add Whether or not you want to add or remove from the calendar fields + * @returns {CalendarEmbed[]} The updated embeds + */ +export function updateCalendarEmbed(embeds: CalendarEmbed[], add: boolean): CalendarEmbed[] { + if (add) { + embeds.forEach((embed) => { + const { fields } = embed.embed.data; + if (fields) { + fields.forEach((field, index) => { + field.name = `**${index + 1}.** ${field.name}`; + }); + } + }); + } else { + embeds.forEach((embed) => { + const { fields } = embed.embed.data; + if (fields) { + fields.forEach((field) => { + [, field.name] = field.name.split(/\*\*\d+\.\*\*\s/); + }); + } + }); + } + return embeds; +} + /** * 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 {CalendarEvent[]} events The events you want to display in the embed * @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 generateCalendarEmbeds(events: Event[], itemsPerPage: number): EmbedBuilder[] { - const embeds: EmbedBuilder[] = []; - let embed: EmbedBuilder; +export function generateCalendarEmbeds(events: CalendarEvent[], itemsPerPage: number): CalendarEmbed[] { + const embeds: CalendarEmbed[] = []; // 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) { @@ -66,7 +82,7 @@ export function generateCalendarEmbeds(events: Event[], itemsPerPage: number): E if (events.length) { // Pagify events array - const pagifiedEvents: Event[][] = []; + const pagifiedEvents: CalendarEvent[][] = []; for (let i = 0; i < events.length; i += itemsPerPage) { pagifiedEvents.push(events.slice(i, i + itemsPerPage)); } @@ -78,27 +94,31 @@ export function generateCalendarEmbeds(events: Event[], itemsPerPage: number): E .setTitle(`Events - ${pageIndex + 1} of ${maxPages}`) .setColor('Green'); - page.forEach((event, eventIndex) => { + const newCalendarEmbed: CalendarEmbed = { embed: newEmbed, events: [] }; + + page.forEach((event) => { newEmbed.addFields({ - name: `**${eventIndex + 1}. ${event.calEvent.summary}**`, + 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} Email: ${event.calEvent.creator.email}\n` }); + newCalendarEmbed.events.push(event); }); - embeds.push(newEmbed); + embeds.push(newCalendarEmbed); }); } else { - embed = new EmbedBuilder() + const emptyEmbed = 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); + const newCalendarEmbed: CalendarEmbed = { embed: emptyEmbed, events: [] }; + embeds.push(newCalendarEmbed); } return embeds; } @@ -106,12 +126,20 @@ export function generateCalendarEmbeds(events: Event[], itemsPerPage: number): E /** * Generates pagification buttons and download buttons for the calendar embeds * + * @param {CalendarEvent[]} filteredEvents All of the filtered events + * @param {CalendarEvent[]} selectedEvents The events selected from the filtered events array (if any) * @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 + * @param {boolean} downloadPressed Whether or not the download button has been pressed * @returns {ActionRowBuilder} All of the needed buttons to control the calendar embeds */ -export function generateCalendarButtons(currentPage: number, maxPage: number, downloadCount: number): ActionRowBuilder { +export function generateCalendarButtons( + filteredEvents: CalendarEvent[], + selectedEvents: CalendarEvent[], + currentPage: number, + maxPage: number, + downloadPressed: boolean +): ActionRowBuilder { const nextButton = new ButtonBuilder() .setCustomId('next') .setLabel('Next') @@ -124,9 +152,17 @@ export function generateCalendarButtons(currentPage: number, maxPage: number, do .setStyle(ButtonStyle.Primary) .setDisabled(currentPage === 0); + let downloadLabel = 'Download Events'; + if (downloadPressed) { + downloadLabel = `Download Every Event (${filteredEvents.length})`; + if (selectedEvents.length) { + downloadLabel = `Download ${selectedEvents.length} event(s)`; + } + } + const downloadButton = new ButtonBuilder() .setCustomId('download') - .setLabel(`Download ${downloadCount ? `${downloadCount} event(s)` : 'All'}`) + .setLabel(downloadLabel) .setStyle(ButtonStyle.Success); return new ActionRowBuilder().addComponents( @@ -169,26 +205,28 @@ 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 The embed to generate buttons for - * @param {Event[]} events All of the events retrieved from the google calendar + * @param {EmbedBuilder} calendarEmbed The embed to generate buttons for + * @param {CalendarEvent[]} 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 { +export function generateEventSelectButtons(calendarEmbed: CalendarEmbed, events: CalendarEvent[]): ActionRowBuilder | void { const selectEventButtons: ButtonBuilder[] = []; + const { embed } = calendarEmbed; + const emebdEvents = calendarEmbed.events; 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; + let eventsInEmbed = emebdEvents.length; if (eventsInEmbed > 5) { eventsInEmbed = 5; } // Create buttons for each event on the page (up to 5) - for (let i = 1; i <= eventsInEmbed; i++) { + for (let i = 0; i < eventsInEmbed; i++) { const selectEvent = new ButtonBuilder() - .setCustomId(`toggle-${i}`) - .setLabel(`Select #${i}`) - .setStyle(ButtonStyle.Secondary); + .setCustomId(`toggle-${i + 1}`) + .setLabel(emebdEvents[i].selected ? `Remove #${i + 1}` : `Select #${i + 1}`) + .setStyle(emebdEvents[i].selected ? ButtonStyle.Danger : ButtonStyle.Secondary); selectEventButtons.push(selectEvent); } @@ -216,11 +254,11 @@ function formatTime(dateTimeString: string): string { /** * Creates an ics file containing all of the selected events * - * @param {Event[]} selectedEvents The selected events to download + * @param {CalendarEvent[]} selectedEvents The selected events to download * @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[], calendar: {calendarId: string, calendarName: string}, interaction: ChatInputCommandInteraction): Promise { +export async function downloadEvents(selectedEvents: CalendarEvent[], 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]));