From bc2aec5c82e25ea0876d7cbee69360da2bbe9313 Mon Sep 17 00:00:00 2001 From: Annika <56906084+AnnikaCodes@users.noreply.github.com> Date: Sun, 21 Jun 2020 21:46:11 -0700 Subject: [PATCH] Roomevents: Support event categories (#6820) --- server/chat-plugins/room-events.ts | 344 ++++++++++++++++++++++++----- server/rooms.ts | 7 +- 2 files changed, 290 insertions(+), 61 deletions(-) diff --git a/server/chat-plugins/room-events.ts b/server/chat-plugins/room-events.ts index 4f059b1168..7aaedccaf4 100644 --- a/server/chat-plugins/room-events.ts +++ b/server/chat-plugins/room-events.ts @@ -8,9 +8,33 @@ */ import {Utils} from '../../lib/utils'; -type RoomEvent = NonNullable['']; +export interface RoomEvent { + eventName: string; + date: string; + desc: string; + started: boolean; +} -function formatEvent(event: RoomEvent, showAliases?: boolean) { +export interface RoomEventAlias { + eventID: ID; +} + +export interface RoomEventCategory { + events: ID[]; +} + +function convertAliasFormat(room: Room) { + if (!room.settings.events) return; + for (const event of Object.values(room.settings.events) as AnyObject[]) { + if (!event.aliases) continue; + for (const alias of event.aliases) { + room.settings.events[alias] = {eventID: toID(event.eventName)}; + } + delete event.aliases; + } +} + +function formatEvent(room: Room, event: RoomEvent, showAliases?: boolean, showCategories?: boolean) { const timeRemaining = new Date(event.date).getTime() - new Date().getTime(); let explanation = timeRemaining.toString(); if (!timeRemaining) explanation = "The time remaining for this event is not available"; @@ -19,30 +43,57 @@ function formatEvent(event: RoomEvent, showAliases?: boolean) { if (!isNaN(timeRemaining)) { explanation = `This event will start in: ${Chat.toDurationString(timeRemaining, {precision: 2})}`; } + + const eventID = toID(event.eventName); + const aliases = getAliases(room, eventID); + const categories = getAllCategories(room).filter( + category => (room.settings.events![category] as RoomEventCategory).events.includes(eventID) + ); + let ret = ``; ret += Utils.html`${event.eventName}`; - ret += showAliases ? Utils.html`${event.aliases?.join(", ")}` : ``; + if (showAliases) ret += Utils.html`${aliases.join(", ")}`; + if (showCategories) ret += Utils.html`${categories.join(", ")}`; ret += `${Chat.formatText(event.desc, true)}`; ret += Utils.html``; return ret; } -function getAllAliases(room: Room) { +function getAliases(room: Room, eventID?: ID) { if (!room.settings.events) return []; const aliases: string[] = []; - for (const event of Object.values(room.settings.events)) { - if (event.aliases) aliases.push(...event.aliases); + for (const aliasID in room.settings.events) { + if ( + 'eventID' in room.settings.events[aliasID] && + (!eventID || (room.settings.events[aliasID] as RoomEventAlias).eventID === eventID) + ) aliases.push(aliasID); } return aliases; } + +function getAllCategories(room: Room) { + if (!room.settings.events) return []; + const categories: string[] = []; + for (const categoryID in room.settings.events) { + if ('events' in room.settings.events[categoryID]) categories.push(categoryID); + } + return categories; +} + +function getAllEvents(room: Room) { + if (!room.settings.events) return []; + const events: RoomEvent[] = []; + for (const event of Object.values(room.settings.events)) { + if ('eventName' in event) events.push(event); + } + return events; +} + function getEventID(nameOrAlias: string, room: Room): ID { let id = toID(nameOrAlias); - if (room.settings.events && !room.settings.events[id]) { - for (const possibleEvent in room.settings.events) { - if (room.settings.events[possibleEvent].aliases?.includes(id)) { - id = toID(possibleEvent); - } - } + const event = room.settings.events?.[id]; + if (event && 'eventID' in event) { + id = event.eventID; } return id; } @@ -57,12 +108,18 @@ export const commands: ChatCommands = { return this.errorReply("There are currently no planned upcoming events for this room."); } if (!this.runBroadcast()) return; - const hasAliases = getAllAliases(room).length > 0; + convertAliasFormat(room); + const hasAliases = getAliases(room).length > 0; + const hasCategories = getAllCategories(room).length > 0; let buff = ''; - buff += `${hasAliases ? `` : ``}`; - for (const i in room.settings.events) { - buff += formatEvent(room.settings.events[i], hasAliases); + buff += ''; + if (hasAliases) buff += ''; + if (hasCategories) buff += ''; + buff += ''; + + for (const event of getAllEvents(room)) { + buff += formatEvent(room, event, hasAliases, hasCategories); } buff += '
Event Name:Event Aliases:Event Description:Event Date:Event Name:Event Aliases:Event Categories:Event Description:Event Date:
'; return this.sendReply(`|raw|
${buff}
`); @@ -75,11 +132,12 @@ export const commands: ChatCommands = { if (!room.persist) return this.errorReply("This command is unavailable in temporary rooms."); if (!this.can('ban', null, room)) return false; if (!room.settings.events) room.settings.events = Object.create(null); + convertAliasFormat(room); const events = room.settings.events!; const [eventName, date, ...desc] = target.split(target.includes('|') ? '|' : ','); if (!(eventName && date && desc)) { - return this.errorReply("You're missing a command parameter - see /help roomevents for this command's syntax."); + return this.errorReply("You're missing a command parameter - to see this command's syntax, use /help roomevents."); } const dateActual = date.trim(); @@ -92,7 +150,9 @@ export const commands: ChatCommands = { const eventId = getEventID(eventName, room); if (!eventId) return this.errorReply("Event names must contain at least one alphanumerical character."); - const oldEvent = events[eventId]; + const oldEvent = room.settings.events?.[eventId] as RoomEvent; + if (oldEvent && 'events' in oldEvent) return this.errorReply(`"${eventId}" is already the name of a category.`); + const eventNameActual = (oldEvent ? oldEvent.eventName : eventName.trim()); this.privateModAction(`(${user.name} ${oldEvent ? "edited the" : "added a"} roomevent titled "${eventNameActual}".)`); this.modlog('ROOMEVENT', null, `${oldEvent ? "edited" : "added"} "${eventNameActual}"`); @@ -101,7 +161,6 @@ export const commands: ChatCommands = { date: dateActual, desc: descString, started: false, - aliases: oldEvent?.aliases, }; room.saveSettings(); }, @@ -112,19 +171,21 @@ export const commands: ChatCommands = { let [oldName, newName] = target.split(target.includes('|') ? '|' : ','); if (!(oldName && newName)) return this.errorReply("Usage: /roomevents rename [old name], [new name]"); + convertAliasFormat(room); newName = newName.trim(); const newID = toID(newName); - const oldID = (getAllAliases(room).includes(toID(oldName)) ? getEventID(oldName, room) : toID(oldName)); + const oldID = (getAliases(room).includes(toID(oldName)) ? getEventID(oldName, room) : toID(oldName)); if (newID === oldID) return this.errorReply("The new name must be different from the old one."); if (!newID) return this.errorReply("Event names must contain at least one alphanumeric character."); if (newName.length > 50) return this.errorReply("Event names should not exceed 50 characters."); const events = room.settings.events!; const eventData = events?.[oldID]; - if (!eventData) return this.errorReply(`There is no event titled "${oldName}".`); - if (events[newID] || getAllAliases(room).includes(newID)) { - return this.errorReply(`"${newName}" is already an event or alias.`); + if (!(eventData && 'eventName' in eventData)) return this.errorReply(`There is no event titled "${oldName}".`); + if (events?.[newID]) { + return this.errorReply(`"${newName}" is already an event, alias, or category.`); } + const originalName = eventData.eventName; eventData.eventName = newName; events[newID] = eventData; @@ -143,9 +204,11 @@ export const commands: ChatCommands = { return this.errorReply("There are currently no planned upcoming events for this room to start."); } if (!target) return this.errorReply("Usage: /roomevents start [event name]"); + convertAliasFormat(room); + target = toID(target); const event = room.settings.events[getEventID(target, room)]; - if (!event) return this.errorReply(`There is no event titled '${target}'. Check spelling?`); + if (!(event && 'eventName' in event)) return this.errorReply(`There is no event titled '${target}'. Check spelling?`); if (event.started) { return this.errorReply(`The event ${event.eventName} has already started.`); } @@ -175,35 +238,82 @@ export const commands: ChatCommands = { return this.errorReply("There are currently no planned upcoming events for this room to remove."); } if (!target) return this.errorReply("Usage: /roomevents remove [event name]"); - target = toID(target); - if (getAllAliases(room).includes(target)) return this.errorReply("To delete aliases, use /roomevents removealias."); - if (!room.settings.events[target]) return this.errorReply(`There is no event titled '${target}'. Check spelling?`); + const eventID = toID(target); + convertAliasFormat(room); + if (getAliases(room).includes(eventID)) return this.errorReply("To delete aliases, use /roomevents removealias."); + if (!(room.settings.events[eventID] && 'eventName' in room.settings.events[eventID])) { + return this.errorReply(`There is no event titled '${target}'. Check spelling?`); + } + delete room.settings.events[target]; + for (const alias of getAliases(room, eventID)) { + delete room.settings.events[alias]; + } + this.privateModAction(`(${user.name} removed a roomevent titled "${target}".)`); this.modlog('ROOMEVENT', null, `removed "${target}"`); room.saveSettings(); }, + view(target, room, user) { if (!room.persist) return this.errorReply("This command is unavailable in temporary rooms."); if (!room.settings.events || !Object.keys(room.settings.events).length) { return this.errorReply("There are currently no planned upcoming events for this room."); } - if (!target) return this.errorReply("Usage: /roomevents view [event name]"); - target = toID(target); - const event = room.settings.events[getEventID(target, room)]; - if (!event) return this.errorReply(`There is no event titled '${target}'. Check spelling?`); + if (!target) return this.errorReply("Usage: /roomevents view [event name, alias, or category]"); + convertAliasFormat(room); + target = getEventID(target, room); + + let events: RoomEvent[] = []; + if (getAllCategories(room).includes(target)) { + for (const categoryID of Object.keys(room.settings.events)) { + const category = room.settings.events[categoryID]; + if ('events' in category && categoryID === target) { + events = category.events.map(e => room.settings.events?.[e] as RoomEvent); + break; + } + } + } else if (room.settings.events[target] && 'eventName' in room.settings.events[target]) { + events.push(room.settings.events[target] as RoomEvent); + } else { + return this.errorReply(`There is no event or category titled '${target}'. Check spelling?`); + } if (!this.runBroadcast()) return; - const hasAliases = event.aliases && event.aliases.length > 0; - const buff = `${formatEvent(event, hasAliases)}
`; + let hasAliases = false; + let hasCategories = false; + + for (const event of events) { + if (getAliases(room, toID(event.eventName)).length) hasAliases = true; + } + + for (const potentialCategory of getAllCategories(room)) { + if ( + events.map(event => toID(event.eventName)) + .filter(id => (room.settings.events?.[potentialCategory] as RoomEventCategory).events.includes(id)).length + ) hasCategories = true; break; + } + + let buff = ''; + buff += ''; + if (hasAliases) buff += ''; + if (hasCategories) buff += ''; + buff += ''; + for (const event of events) { + buff += formatEvent(room, event, hasAliases, hasCategories); + } + buff += '
Event Name:Event Aliases:Event Categories:Event Description:Event Date:
'; + this.sendReply(`|raw|
${buff}
`); - if (!this.broadcasting && user.can('ban', null, room)) { + if (!this.broadcasting && user.can('ban', null, room) && events.length === 1) { + const event = events[0]; this.sendReplyBox( Utils.html`/roomevents add ${event.eventName} |` + Utils.html`${event.date} | ${event.desc}` ); } }, + alias: 'addalias', addalias(target, room, user) { if (!room.persist) return this.errorReply("This command is unavailable in temporary rooms."); @@ -215,39 +325,144 @@ export const commands: ChatCommands = { if (!room.settings.events || Object.keys(room.settings.events).length === 0) { return this.errorReply(`There are currently no scheduled events.`); } + convertAliasFormat(room); const event = room.settings.events[eventId]; - if (!event) return this.errorReply(`There is no event titled "${eventId}".`); + if (!(event && 'eventName' in event)) return this.errorReply(`There is no event titled "${eventId}".`); + if (room.settings.events[alias]) return this.errorReply(`"${alias}" is already an event, alias, or category.`); - if (getAllAliases(room).includes(alias) || room.settings.events[alias]) { - return this.errorReply(`"${alias}" is already an event or an alias of an event.`); - } - if (!event.aliases) event.aliases = []; - event.aliases.push(alias); + room.settings.events[alias] = {eventID: eventId}; this.privateModAction(`(${user.name} added an alias "${alias}" for the roomevent "${eventId}".)`); this.modlog('ROOMEVENT', null, `alias for "${eventId}": "${alias}"`); room.saveSettings(); }, + deletealias: 'removealias', removealias(target, room, user) { if (!room.persist) return this.errorReply("This command is unavailable in temporary rooms."); if (!this.can('ban', null, room)) return false; target = toID(target); if (!target) return this.errorReply("Usage: /roomevents removealias "); - if (!getAllAliases(room).includes(target)) return this.errorReply(`${target} isn't an alias.`); - - const event = room.settings.events![getEventID(target, room)]; - if (event.aliases) { - event.aliases = event.aliases.filter(alias => alias !== target); - if (!event.aliases.length) event.aliases = undefined; + if (!room.settings.events || Object.keys(room.settings.events).length === 0) { + return this.errorReply(`There are currently no scheduled events.`); } + convertAliasFormat(room); + if (!(room.settings.events[target] && 'eventID' in room.settings.events[target])) { + return this.errorReply(`${target} isn't an alias.`); + } + delete room.settings.events[target]; this.privateModAction(`(${user.name} removed the alias "${target}")`); this.modlog('ROOMEVENT', null, `removed the alias "${target}"`); room.saveSettings(); }, + + addtocategory(target, room, user) { + if (!room.persist) return this.errorReply("This command is unavailable in temporary rooms."); + if (!this.can('ban', null, room)) return false; + const [eventId, categoryId] = target.split(target.includes('|') ? '|' : ',').map(argument => toID(argument)); + if (!(eventId && categoryId)) return this.errorReply("Usage: /roomevents addtocategory [event name], [category]."); + if (!room.settings.events || Object.keys(room.settings.events).length === 0) { + return this.errorReply(`There are currently no scheduled events.`); + } + convertAliasFormat(room); + const event = room.settings.events[getEventID(eventId, room)]; + if (!(event && 'eventName' in event)) return this.errorReply(`There is no event or alias titled "${eventId}".`); + const category = room.settings.events[categoryId]; + if (category && !('events' in category)) { + return this.errorReply(`There is already an event or alias titled "${categoryId}".`); + } + if (!category) { + return this.errorReply(`There is no category titled "${categoryId}". To create it, use /roomevents addcategory ${categoryId}.`); + } + if (category.events.includes(toID(event.eventName))) { + return this.errorReply(`The event "${eventId}" is already in the "${categoryId}" category.`); + } + category.events.push(toID(event.eventName)); + room.settings.events[categoryId] = category; + + this.privateModAction(`(${user.name} added the roomevent "${eventId}" to the category "${categoryId}".)`); + this.modlog('ROOMEVENT', null, `category for "${eventId}": "${categoryId}"`); + + room.saveSettings(); + }, + + removefromcategory(target, room, user) { + if (!room.persist) return this.errorReply("This command is unavailable in temporary rooms."); + if (!this.can('ban', null, room)) return false; + const [eventId, categoryId] = target.split(target.includes('|') ? '|' : ',').map(argument => toID(argument)); + if (!(eventId && categoryId)) { + return this.errorReply("Usage: /roomevents removefromcategory [event name], [category]."); + } + if (!room.settings.events || Object.keys(room.settings.events).length === 0) { + return this.errorReply(`There are currently no scheduled events.`); + } + convertAliasFormat(room); + const event = room.settings.events[getEventID(eventId, room)]; + if (!(event && 'eventName' in event)) return this.errorReply(`There is no event or alias titled "${eventId}".`); + + const category = room.settings.events[categoryId]; + if (category && !('events' in category)) { + return this.errorReply(`There is already an event or alias titled "${categoryId}".`); + } + if (!category) return this.errorReply(`There is no category titled "${categoryId}".`); + + if (!category.events.includes(toID(event.eventName))) { + return this.errorReply(`The event "${eventId}" isn't in the "${categoryId}" category.`); + } + category.events = category.events.filter(e => e !== eventId); + room.settings.events[categoryId] = category; + + this.privateModAction(`(${user.name} removed the roomevent "${eventId}" from the category "${categoryId}".)`); + this.modlog('ROOMEVENT', null, `category for "${eventId}": removed "${categoryId}"`); + + room.saveSettings(); + }, + + addcat: 'addcategory', + addcategory(target, room, user) { + if (!room.persist) return this.errorReply("This command is unavailable in temporary rooms."); + if (!this.can('ban', null, room)) return false; + const categoryId = toID(target); + if (!target) { + return this.errorReply("Usage: /roomevents addcategory [category name]. Categories must contain at least one alphanumeric character."); + } + convertAliasFormat(room); + if (!room.settings.events) room.settings.events = Object.create(null); + if (room.settings.events?.[categoryId]) return this.errorReply(`The category "${target}" already exists.`); + + room.settings.events![categoryId] = {events: []}; + + this.privateModAction(`(${user.name} added the category "${categoryId}".)`); + this.modlog('ROOMEVENT', null, `category: added "${categoryId}"`); + + room.saveSettings(); + }, + + deletecategory: 'removecategory', + deletecat: 'removecategory', + removecat: 'removecategory', + rmcat: 'removecategory', + removecategory(target, room, user) { + if (!room.persist) return this.errorReply("This command is unavailable in temporary rooms."); + if (!this.can('ban', null, room)) return false; + const categoryId = toID(target); + if (!target) return this.errorReply("Usage: /roomevents removecategory [category name]."); + convertAliasFormat(room); + if (!room.settings.events) room.settings.events = Object.create(null); + if (!room.settings.events?.[categoryId]) return this.errorReply(`The category "${target}" doesn't exist.`); + + delete room.settings.events?.[categoryId]; + + this.privateModAction(`(${user.name} removed the category "${categoryId}".)`); + this.modlog('ROOMEVENT', null, `category: removed "${categoryId}"`); + + room.saveSettings(); + }, + help(target, room, user) { return this.parse('/help roomevents'); }, + sortby(target, room, user) { // preconditions if (!room.persist) return this.errorReply("This command is unavailable in temporary rooms."); @@ -260,7 +475,9 @@ export const commands: ChatCommands = { let multiplier = 1; let columnName = ""; const delimited = target.split(target.includes('|') ? '|' : ','); - const sortable = Object.values(room.settings.events); + const sortable = Object.values(room.settings.events) + .filter(event => 'eventName' in event) + .map(event => event as RoomEvent); // id tokens if (delimited.length === 1) { @@ -304,8 +521,7 @@ export const commands: ChatCommands = { return this.errorReply("No or invalid column name specified. Please use one of: date, eventdate, desc, description, eventdescription, eventname, name."); } - // rebuild the room.events object - room.settings.events = {}; + // rebuild the room.settings.events object for (const sortedObj of sortable) { const eventId = toID(sortedObj.eventName); room.settings.events[eventId] = sortedObj; @@ -319,15 +535,23 @@ export const commands: ChatCommands = { return this.sendReply(resultString); }, }, - roomeventshelp: [ - `/roomevents - Displays a list of upcoming room-specific events.`, - `/roomevents add [event name] | [event date/time] | [event description] - Adds a room event. A timestamp in event date/time field like YYYY-MM-DD HH:MM±hh:mm will be displayed in user's timezone. Requires: @ # &`, - `/roomevents start [event name] - Declares to the room that the event has started. Requires: @ # &`, - `/roomevents remove [event name] - Deletes an event. Requires: @ # &`, - `/roomevents rename [old event name] | [new name] - Renames an event. Requires: @ # &`, - `/roomevents addalias [alias] | [event name] - Adds an alias for the event. Requires: @ # &`, - `/roomevents removealias [alias] - Removes an event alias. Requires: @ # &`, - `/roomevents sortby [column name] | [asc/desc (optional)] - Sorts events table by column name and an optional argument to ascending or descending order. Ascending order is default. Requires: @ # &`, - `/roomevents view [event name] - Displays information about a specific event.`, - ], + roomeventshelp() { + this.sendReply( + `|html|
Commands to manage room events.` + + `/roomevents: displays a list of upcoming room-specific events.
` + + `/roomevents add [event name] | [event date/time] | [event description]: adds a room event. A timestamp in event date/time field like YYYY-MM-DD HH:MM±hh:mm will be displayed in user's timezone. Requires: @ # &
` + + `/roomevents start [event name]: declares to the room that the event has started. Requires: @ # &
` + + `/roomevents remove [event name]: deletes an event. Requires: @ # &
` + + `/roomevents rename [old event name] | [new name]: renames an event. Requires: @ # &
` + + `/roomevents addalias [alias] | [event name]: adds an alias for the event. Requires: @ # &
` + + `/roomevents removealias [alias]: removes an event alias. Requires: @ # &
` + + `/roomevents addcategory [category]: adds an event category. Requires: @ # &
` + + `/roomevents removecategory [category]: removes an event category. Requires: @ # &
` + + `/roomevents addtocategory [event name] | [category]: adds the event to a category. Requires: @ # &
` + + `/roomevents removefromcategory [event name] | [category]: removes the event from a category. Requires: @ # &
` + + `/roomevents sortby [column name] | [asc/desc (optional)] sorts events table by column name and an optional argument to ascending or descending order. Ascending order is default. Requires: @ # &
` + + `/roomevents view [event name or category]: displays information about a specific event or category of events.` + + `
` + ); + }, }; diff --git a/server/rooms.ts b/server/rooms.ts index d75b0804cd..bbddbbbb89 100644 --- a/server/rooms.ts +++ b/server/rooms.ts @@ -80,7 +80,7 @@ export interface RoomSettings { staffRoom?: boolean; language?: string | false; slowchat?: number | false; - events?: {[k: string]: {eventName: string, date: string, desc: string, started: boolean, aliases?: string[]}}; + events?: {[k: string]: RoomEvent | RoomEventAlias | RoomEventCategory}; filterStretching?: boolean; filterEmojis?: boolean; filterCaps?: boolean; @@ -117,6 +117,9 @@ export interface RoomSettings { export type Room = GlobalRoom | GameRoom | ChatRoom; type Poll = import('./chat-plugins/poll').Poll; type Announcement = import('./chat-plugins/announcements').Announcement; +type RoomEvent = import('./chat-plugins/room-events').RoomEvent; +type RoomEventAlias = import('./chat-plugins/room-events').RoomEventAlias; +type RoomEventCategory = import('./chat-plugins/room-events').RoomEventCategory; type Tournament = import('./tournaments/index').Tournament; export abstract class BasicRoom { @@ -540,6 +543,7 @@ export class GlobalRoom extends BasicRoom { Monitor.warn(`ERROR: Room number ${i} has no data and could not be loaded.`); continue; } + // We're okay with assinging type `ID` to `RoomID` here // because the hyphens in chatrooms don't have any special // meaning, unlike in helptickets, groupchats, battles etc @@ -552,6 +556,7 @@ export class GlobalRoom extends BasicRoom { Rooms.aliases.set(alias, id); } } + this.chatRooms.push(room); if (room.settings.autojoin) this.autojoinList.push(id); if (room.settings.staffAutojoin) this.staffAutojoinList.push(id);