diff --git a/init-debug-environment.sh b/init-debug-environment.sh index 0a61009a..3d0f35a6 100755 --- a/init-debug-environment.sh +++ b/init-debug-environment.sh @@ -2,7 +2,7 @@ set -e # assumes other repos were cloned next to this repo (and executed) -ln -rfs ../downloader/eventfiles . +ln -rfs ../eventfiles . ln -rfs ../mensa-data . mkdir -p userconfig diff --git a/source/lib/all-events.ts b/source/lib/all-events.ts index 563ac4ad..33b077e9 100644 --- a/source/lib/all-events.ts +++ b/source/lib/all-events.ts @@ -1,40 +1,113 @@ import {readFile} from 'node:fs/promises'; +import {pull} from './git.ts'; +import {typedEntries} from './javascript-helper.ts'; +import type {EventDirectory, EventEntry, EventId} from './types.ts'; -async function getAll(): Promise { - const data = await readFile('eventfiles/all.txt', 'utf8'); - const list = data.split('\n').filter(element => element !== ''); - return list; -} +let directory: EventDirectory = {}; +let namesOfEvents: Readonly> = {}; + +setInterval(async () => update(), 1000 * 60 * 30); // Every 30 minutes +await update(); +console.log(new Date(), 'eventfiles loaded'); -export async function count(): Promise { - const allEvents = await getAll(); - return allEvents.length; +async function update() { + await pull( + 'eventfiles', + 'https://github.com/HAWHHCalendarBot/eventfiles.git', + ); + const directoryString = await readFile('eventfiles/directory.json', 'utf8'); + directory = JSON.parse(directoryString) as EventDirectory; + namesOfEvents = await generateMapping(); } -export async function exists(name: string): Promise { - const allEvents = await getAll(); - return allEvents.includes(name); +async function generateMapping(): Promise>> { + const namesOfEvents: Record = {}; + + function collect(directory: EventDirectory) { + for (const subDirectory of Object.values(directory.subDirectories ?? {})) { + collect(subDirectory); + } + + Object.assign(namesOfEvents, directory.events ?? {}); + } + + collect(directory); + return namesOfEvents; } -export async function nonExisting(names: readonly string[]): Promise { - const allEvents = new Set(await getAll()); - const result: string[] = []; - for (const event of names) { - if (!allEvents.has(event)) { - result.push(event); +function getSubdirectory(path: string[]): EventDirectory | undefined { + let resolvedDirectory = directory; + + for (const part of path) { + const subDirectory = resolvedDirectory.subDirectories?.[part]; + if (subDirectory === undefined) { + return undefined; } + + resolvedDirectory = subDirectory; + } + + return resolvedDirectory; +} + +export function directoryHasContent(directory: EventDirectory): boolean { + const events = Object.keys(directory.events ?? {}).length; + const subDirectories = Object.keys(directory.subDirectories ?? {}).length; + return events > 0 || subDirectories > 0; +} + +export function directoryExists(path: string[]): boolean { + if (path.length === 0) { + // Toplevel always exists + return true; } - return result; + const directory = getSubdirectory(path); + return Boolean(directory && directoryHasContent(directory)); } -export async function find( - pattern: string | RegExp, - ignore: readonly string[] = [], -): Promise { - const allEvents = await getAll(); +export function getEventName(id: EventId): string { + return namesOfEvents[id] ?? id; +} + +export function count(): number { + return Object.keys(namesOfEvents).length; +} + +export function exists(id: EventId): boolean { + return id in namesOfEvents; +} + +export function find( + path: string[], + pattern: string | RegExp | undefined, +): EventDirectory { + if (!pattern) { + return getSubdirectory(path) ?? {}; + } + const regex = new RegExp(pattern, 'i'); - const filtered = allEvents.filter(event => - regex.test(event) && !ignore.includes(event)); - return filtered; + const accumulator: Record = {}; + + function collect(directory: EventDirectory) { + for (const [eventId, name] of typedEntries(directory.events ?? {})) { + if (regex.test(name)) { + accumulator[eventId] = name; + } + } + + for (const subDirectory of Object.values(directory.subDirectories ?? {})) { + collect(subDirectory); + } + } + + collect(getSubdirectory(path) ?? {}); + return { + events: Object.fromEntries(typedEntries(accumulator).sort((a, b) => a[1].localeCompare(b[1]))), + }; +} + +export async function loadEvents(eventId: EventId): Promise { + const content = await readFile(`eventfiles/events/${eventId}.json`, 'utf8'); + return JSON.parse(content) as EventEntry[]; } diff --git a/source/lib/change-helper.ts b/source/lib/change-helper.ts index 52d9d1a9..63fec56e 100644 --- a/source/lib/change-helper.ts +++ b/source/lib/change-helper.ts @@ -1,6 +1,6 @@ -import {readFile} from 'node:fs/promises'; import {html as format} from 'telegram-format'; -import type {Change, EventEntry, NaiveDateTime} from './types.ts'; +import {getEventName} from './all-events.ts'; +import type {Change, EventId, NaiveDateTime} from './types.ts'; export function generateChangeDescription(change: Change): string { let text = ''; @@ -28,11 +28,11 @@ export function generateChangeDescription(change: Change): string { } export function generateChangeText( - name: string, + eventId: EventId, date: NaiveDateTime | undefined, change: Change, ): string { - let text = generateChangeTextHeader(name, date); + let text = generateChangeTextHeader(eventId, date); if (Object.keys(change).length > 0) { text += '\nÄnderungen:\n'; @@ -43,13 +43,13 @@ export function generateChangeText( } export function generateChangeTextHeader( - name: string, + eventId: EventId, date: NaiveDateTime | undefined, ): string { let text = ''; text += format.bold('Veranstaltungsänderung'); text += '\n'; - text += format.bold(format.escape(name)); + text += format.bold(format.escape(getEventName(eventId))); if (date) { text += ` ${date}`; } @@ -59,19 +59,8 @@ export function generateChangeTextHeader( } export function generateShortChangeText( - name: string, + eventId: EventId, date: NaiveDateTime, ): string { - return `${name} ${date}`; -} - -export async function loadEvents(eventname: string): Promise { - try { - const filename = eventname.replaceAll('/', '-'); - const content = await readFile(`eventfiles/${filename}.json`, 'utf8'); - return JSON.parse(content) as EventEntry[]; - } catch (error) { - console.error('ERROR while loading events for change date picker', error); - return []; - } + return `${getEventName(eventId)} ${date}`; } diff --git a/source/lib/git.ts b/source/lib/git.ts new file mode 100644 index 00000000..55a6b2f7 --- /dev/null +++ b/source/lib/git.ts @@ -0,0 +1,16 @@ +import {exec} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import {promisify} from 'node:util'; + +const run = promisify(exec); + +export async function pull( + directory: string, + remoteUrl: string, +): Promise { + try { + await (existsSync(`${directory}/.git`) + ? run(`git -C ${directory} pull`) + : run(`git clone -q --depth 1 ${remoteUrl} ${directory}`)); + } catch {} +} diff --git a/source/lib/javascript-helper.ts b/source/lib/javascript-helper.ts new file mode 100644 index 00000000..be4a03df --- /dev/null +++ b/source/lib/javascript-helper.ts @@ -0,0 +1,11 @@ +export function typedKeys(record: Readonly>>): K[] { + return Object.keys(record) as K[]; +} + +export function typedEntries(record: Readonly>>): Array<[K, V]> { + if (!record) { + return []; + } + + return (Object.entries(record) as unknown[]) as Array<[K, V]>; +} diff --git a/source/lib/mensa-git.ts b/source/lib/mensa-git.ts deleted file mode 100644 index 4992066a..00000000 --- a/source/lib/mensa-git.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {exec} from 'node:child_process'; -import {existsSync} from 'node:fs'; -import {promisify} from 'node:util'; - -const run = promisify(exec); - -export async function pull(): Promise { - try { - await (existsSync('mensa-data/.git') - ? run('git -C mensa-data pull') - : run('git clone -q --depth 1 https://github.com/HAWHHCalendarBot/mensa-data.git mensa-data')); - } catch {} -} diff --git a/source/lib/mensa-helper.ts b/source/lib/mensa-helper.ts index 573bf675..58ef2820 100644 --- a/source/lib/mensa-helper.ts +++ b/source/lib/mensa-helper.ts @@ -1,4 +1,5 @@ import {arrayFilterUnique} from 'array-filter-unique'; +import {typedEntries} from './javascript-helper.ts'; import type {Meal} from './meal.ts'; import type {MealWishes, MensaPriceClass, MensaSettings} from './types.ts'; @@ -74,7 +75,7 @@ export function mealNameToHtml( export function mealAdditivesToHtml(meals: readonly Meal[]): string { return meals .flatMap(meal => - Object.entries(meal.Additives).map(([short, full]) => `${short}: ${full}`)) + typedEntries(meal.Additives).map(([short, full]) => `${short}: ${full}`)) .sort() .filter(arrayFilterUnique()) .join('\n'); diff --git a/source/lib/mensa-meals.ts b/source/lib/mensa-meals.ts index c40c3e42..e66913fd 100644 --- a/source/lib/mensa-meals.ts +++ b/source/lib/mensa-meals.ts @@ -1,6 +1,18 @@ import {readdir, readFile} from 'node:fs/promises'; +import {pull} from './git.ts'; import type {Meal} from './meal.ts'; +setInterval(async () => pullMensaData(), 1000 * 60 * 30); // Every 30 minutes +await pullMensaData(); +console.log(new Date(), 'mensa-data loaded'); + +async function pullMensaData(): Promise { + await pull( + 'mensa-data', + 'https://github.com/HAWHHCalendarBot/mensa-data.git', + ); +} + export async function getCanteenList(): Promise { const found = await readdir('mensa-data', {withFileTypes: true}); const dirs = found diff --git a/source/lib/types.ts b/source/lib/types.ts index 0750ba02..93f1571e 100644 --- a/source/lib/types.ts +++ b/source/lib/types.ts @@ -19,11 +19,17 @@ export type MyContext = & ContextFlavour; export type Session = { - adminBroadcast?: number; // Message ID - adminuserquicklook?: number; // User ID + /** Message ID */ + adminBroadcast?: number; + /** User ID */ + adminuserquicklook?: number; adminuserquicklookfilter?: string; - eventfilter?: string; - generateChangeName?: string; + eventAdd?: { + filter?: string; + /** Currently selected subdirectory */ + path: string[]; + }; + generateChangeEventId?: EventId; generateChangeDate?: NaiveDateTime; generateChange?: Change; page?: number; @@ -37,7 +43,7 @@ export type Session = { export type Userconfig = { readonly admin?: true; calendarfileSuffix: string; - events: Record; + events: Record; mensa: MensaSettings; removedEvents?: RemovedEventsDisplayStyle; }; @@ -82,6 +88,15 @@ export type MensaSettings = MealWishes & { showAdditives?: boolean; }; +export type EventId = `${number}_${number | string}`; + +export type EventDirectory = { + /** Maps the directory name to its content */ + readonly subDirectories?: Readonly>; + /** Maps `EventId` to the human-readable name */ + readonly events?: Readonly>; +}; + export type EventEntry = { readonly name: string; readonly location: string; diff --git a/source/menu/about.ts b/source/menu/about.ts index a3fd912d..9bfa5cae 100644 --- a/source/menu/about.ts +++ b/source/menu/about.ts @@ -10,7 +10,7 @@ export const menu = new MenuTemplate(async ctx => { const canteens = await getCanteenList(); const canteenCount = canteens.length; - const eventCount = await allEvents.count(); + const eventCount = allEvents.count(); const websiteLink = format.url( 'hawhh.de/calendarbot/', diff --git a/source/menu/admin/user-quicklook.ts b/source/menu/admin/user-quicklook.ts index d3f89059..30e80af6 100644 --- a/source/menu/admin/user-quicklook.ts +++ b/source/menu/admin/user-quicklook.ts @@ -110,7 +110,7 @@ menu.select('u', { return Object.fromEntries(allChats.map(chat => [chat.id, nameOfUser(chat)])); }, isSet: (ctx, selected) => ctx.session.adminuserquicklook === Number(selected), - async set(ctx, selected) { + set(ctx, selected) { ctx.session.adminuserquicklook = Number(selected); return true; }, diff --git a/source/menu/events/add.ts b/source/menu/events/add.ts index 133a7e11..2da3ecb7 100644 --- a/source/menu/events/add.ts +++ b/source/menu/events/add.ts @@ -9,29 +9,44 @@ import { import {html as format} from 'telegram-format'; import { count as allEventsCount, + directoryExists, + directoryHasContent, exists as allEventsExists, find as allEventsFind, + getEventName, } from '../../lib/all-events.ts'; -import {backMainButtons} from '../../lib/inline-menu.ts'; -import type {MyContext} from '../../lib/types.ts'; - -const MAX_RESULT_ROWS = 10; -const RESULT_COLUMNS = 2; +import {BACK_BUTTON_TEXT} from '../../lib/inline-menu.ts'; +import {typedEntries} from '../../lib/javascript-helper.ts'; +import type {EventId, MyContext} from '../../lib/types.ts'; export const bot = new Composer(); -export const menu = new MenuTemplate(async ctx => { - const total = await allEventsCount(); +export const menu = new MenuTemplate(ctx => { + ctx.session.eventAdd ??= {path: []}; + while (!directoryExists(ctx.session.eventAdd.path)) { + ctx.session.eventAdd.path.pop(); + } let text = format.bold('Veranstaltungen'); - text += '\nWelche Events möchtest du hinzufügen?'; + for (const segment of ctx.session.eventAdd.path) { + text += '\n🗂️ ' + segment; + } + + text += '\n\nWelche Events möchtest du hinzufügen?'; text += '\n\n'; try { - if (ctx.session.eventfilter === undefined) { - text += `Ich habe ${total} Veranstaltungen. Nutze den Filter um die Auswahl einzugrenzen.`; + if (ctx.session.eventAdd.filter) { + const filteredEvents = allEventsFind( + ctx.session.eventAdd.path, + ctx.session.eventAdd.filter, + ); + const eventCount = Object.keys(filteredEvents.events ?? {}).length; + text += `Mit deinem Filter konnte ich ${eventCount} passende Veranstaltungen finden.`; + } else if (ctx.session.eventAdd.path.length === 0) { + const total = allEventsCount(); + text += `Ich habe ${total} Veranstaltungen. Nutze den Filter oder die Ordner um die Auswahl einzugrenzen.`; } else { - const filteredEvents = await findEvents(ctx); - text += `Mit deinem Filter konnte ich ${filteredEvents.length} passende Veranstaltungen finden.`; + text += 'Nutze den Filter oder die Ordner um die Auswahl einzugrenzen.'; } } catch (error) { const errorText = error instanceof Error ? error.message : String(error); @@ -42,17 +57,12 @@ export const menu = new MenuTemplate(async ctx => { return {text, parse_mode: format.parse_mode}; }); -async function findEvents(ctx: MyContext): Promise { - const filter = ctx.session.eventfilter ?? '.+'; - const ignore = Object.keys(ctx.userconfig.mine.events); - return allEventsFind(filter, ignore); -} - const question = new StatelessQuestion( 'events-add-filter', async (ctx, path) => { if (ctx.message.text) { - ctx.session.eventfilter = ctx.message.text; + ctx.session.eventAdd ??= {path: []}; + ctx.session.eventAdd.filter = ctx.message.text; } await replyMenuToContext(menu, ctx, path); @@ -63,14 +73,14 @@ bot.use(question.middleware()); menu.interact('filter', { text(ctx) { - return ctx.session.eventfilter - ? `🔎 Filter: ${ctx.session.eventfilter}` - : '🔎 Filter'; + return ctx.session.eventAdd?.filter + ? `🔎 Filter: ${ctx.session.eventAdd.filter}` + : '🔎 Ab hier filtern'; }, async do(ctx, path) { await question.replyWithHTML( ctx, - 'Wonach möchtest du die Veranstaltungen filtern?', + 'Wonach möchtest du die Veranstaltungen in diesem Verzeichnis filtern?', getMenuOfPath(path), ); await deleteMenuFromContext(ctx); @@ -79,45 +89,96 @@ menu.interact('filter', { }); menu.interact('filter-clear', { - text: 'Filter aufheben', joinLastRow: true, - hide: ctx => ctx.session.eventfilter === undefined, + text: 'Filter aufheben', + hide: ctx => ctx.session.eventAdd?.filter === undefined, do(ctx) { - delete ctx.session.eventfilter; + delete ctx.session.eventAdd?.filter; return true; }, }); -menu.choose('a', { - maxRows: MAX_RESULT_ROWS, - columns: RESULT_COLUMNS, - async choices(ctx) { +menu.choose('list', { + maxRows: 10, + columns: 1, + choices(ctx) { try { - const all = await findEvents(ctx); - return Object.fromEntries(all.map(event => [event.replaceAll('/', ';'), event])); + ctx.session.eventAdd ??= {path: []}; + const filteredEvents = allEventsFind( + ctx.session.eventAdd.path, + ctx.session.eventAdd.filter, + ); + const subDirectoryItems = typedEntries(filteredEvents.subDirectories ?? {}).map(([name, directory], i) => { + if (!directoryHasContent(directory)) { + return ['x' + i, '🚫 ' + name]; + } + + return [ + 'd' + i + ' ' + name.replaceAll('/', '').slice(0, 48), + '🗂️ ' + name, + ]; + }); + const eventItems = typedEntries(filteredEvents.events ?? {}).map(([eventId, name]) => + eventId in ctx.userconfig.mine.events + ? ['e' + eventId, '✅ ' + name] + : ['e' + eventId, '📅 ' + name]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Object.fromEntries([...subDirectoryItems, ...eventItems]); } catch { return {}; } }, async do(ctx, key) { - const event = key.replaceAll(';', '/'); - const isExisting = await allEventsExists(event); - const isAlreadyInCalendar = Object.keys(ctx.userconfig.mine.events) - .includes(event); - - if (!isExisting) { - await ctx.answerCallbackQuery(`${event} existiert nicht!`); + if (key.startsWith('e')) { + const eventId = key.slice(1) as EventId; + if (!allEventsExists(eventId)) { + await ctx.answerCallbackQuery(`Event mit Id ${eventId} existiert nicht!`); + return true; + } + + const eventName = getEventName(eventId); + if (eventId in ctx.userconfig.mine.events) { + await ctx.answerCallbackQuery(`${eventName} ist bereits in deinem Kalender!`); + return true; + } + + ctx.userconfig.mine.events[eventId] = {}; + await ctx.answerCallbackQuery(`${eventName} wurde zu deinem Kalender hinzugefügt.`); return true; } - if (isAlreadyInCalendar) { - await ctx.answerCallbackQuery(`${event} ist bereits in deinem Kalender!`); + if (key.startsWith('x')) { + await ctx.answerCallbackQuery('Dieses Verzeichnis ist leer.'); + return false; + } + + const directoryMatch = /^d(\d+) (.+)$/.exec(key); + if (directoryMatch) { + const index = Number(directoryMatch[1]); + const prefix = directoryMatch[2]; + + // Inline-menu choices() ensures that the clicked key still exists. As the name is included in the prefix part of the key this can only fail if an event with exactly the same prefix is placed on the same index. This will prevent clicks on not anymore existing choices like directory.json changed or ctx.session lost after bot restart. + if (!ctx.session.eventAdd || !prefix) { + // Will never happen as choices() is called first to ensure only existing choices are clicked + return true; + } + + const filteredEvents = allEventsFind( + ctx.session.eventAdd.path, + ctx.session.eventAdd.filter, + ); + const filteredSubDirectories = typedEntries(filteredEvents.subDirectories ?? {}); + const chosenSubDirectory = filteredSubDirectories[index]?.[0]; + if (!chosenSubDirectory) { + // Will never happen as choices() is called first to ensure only existing choices are clicked + return true; + } + + ctx.session.eventAdd.path.push(chosenSubDirectory); return true; } - ctx.userconfig.mine.events[event] = {}; - await ctx.answerCallbackQuery(`${event} wurde zu deinem Kalender hinzugefügt.`); - return true; + return true; // Unknown state }, getCurrentPage: ctx => ctx.session.page, setPage(ctx, page) { @@ -125,4 +186,24 @@ menu.choose('a', { }, }); -menu.manualRow(backMainButtons); +menu.interact('back', { + text: BACK_BUTTON_TEXT, + do(ctx) { + if (!ctx.session.eventAdd || ctx.session.eventAdd.path.length === 0) { + delete ctx.session.eventAdd; + return '..'; + } + + ctx.session.eventAdd.path.pop(); + return true; + }, +}); + +menu.interact('top', { + joinLastRow: true, + text: '🔝 zur Übersicht…', + do(ctx) { + delete ctx.session.eventAdd; + return '..'; + }, +}); diff --git a/source/menu/events/changes/add/index.ts b/source/menu/events/changes/add/index.ts index a7e90abc..3d1c9a08 100644 --- a/source/menu/events/changes/add/index.ts +++ b/source/menu/events/changes/add/index.ts @@ -7,11 +7,14 @@ import { MenuTemplate, replyMenuToContext, } from 'grammy-inline-menu'; -import { - generateChangeText, - loadEvents, -} from '../../../../lib/change-helper.ts'; -import type {MyContext, NaiveDateTime} from '../../../../lib/types.ts'; +import {loadEvents} from '../../../../lib/all-events.ts'; +import {generateChangeText} from '../../../../lib/change-helper.ts'; +import {typedKeys} from '../../../../lib/javascript-helper.ts'; +import type { + EventId, + MyContext, + NaiveDateTime, +} from '../../../../lib/types.ts'; import {createTimeSelectionSubmenuButtons} from './time-selector.ts'; export const bot = new Composer(); @@ -19,23 +22,23 @@ export const menu = new MenuTemplate(ctx => { ctx.session.generateChange ??= {}; if (ctx.match) { - ctx.session.generateChangeName = ctx.match[1]!.replaceAll(';', '/'); + ctx.session.generateChangeEventId = ctx.match[1]! as EventId; } - if (!ctx.session.generateChangeName) { + if (!ctx.session.generateChangeEventId) { throw new Error('Something fishy'); } - const name = ctx.session.generateChangeName; + const eventId = ctx.session.generateChangeEventId; let text = ''; if (!ctx.session.generateChangeDate) { text = 'Zu welchem Termin willst du eine Änderung hinzufügen?'; - const changeDates = Object.keys(ctx.userconfig.mine.events[name]?.changes ?? {}); + const changeDates = typedKeys(ctx.userconfig.mine.events[eventId]?.changes ?? {}); if (changeDates.length > 0) { text - += '\n\nFolgende Termine habe bereits eine Veränderung. Entferne die Veränderung zuerst, bevor du eine neue erstellen kannst.'; + += '\n\nFolgende Termine haben bereits eine Veränderung. Entferne die Veränderung zuerst, bevor du eine neue erstellen kannst.'; text += '\n'; changeDates.sort(); @@ -43,9 +46,9 @@ export const menu = new MenuTemplate(ctx => { } } - if (ctx.session.generateChangeName && ctx.session.generateChangeDate) { + if (ctx.session.generateChangeEventId && ctx.session.generateChangeDate) { text = generateChangeText( - ctx.session.generateChangeName, + ctx.session.generateChangeEventId, ctx.session.generateChangeDate, ctx.session.generateChange, ); @@ -56,41 +59,40 @@ export const menu = new MenuTemplate(ctx => { }); function hidePickDateStep(ctx: MyContext): boolean { - const name = ctx.session.generateChangeName; + const eventId = ctx.session.generateChangeEventId; const date = ctx.session.generateChangeDate; - return !name || Boolean(date); + return !eventId || Boolean(date); } function hideGenerateChangeStep(ctx: MyContext): boolean { - const name = ctx.session.generateChangeName; + const eventId = ctx.session.generateChangeEventId; const date = ctx.session.generateChangeDate; - return !name || !date; + return !eventId || !date; } function generationDataIsValid(ctx: MyContext): boolean { - const name = ctx.session.generateChangeName; + const eventId = ctx.session.generateChangeEventId; const date = ctx.session.generateChangeDate; - if (!name || !date) { + if (!eventId || !date) { return false; } - const keys = Object.keys(ctx.session.generateChange ?? []); // There have to some changes than that in order to do something. - return keys.length > 0; + return Object.keys(ctx.session.generateChange ?? []).length > 0; } menu.choose('date', { columns: 2, hide: hidePickDateStep, async choices(ctx) { - const name = ctx.match![1]!.replaceAll(';', '/'); + const eventId = ctx.match![1]! as EventId; if (ctx.session.generateChangeDate) { // Date already selected return {}; } - const existingChangeDates = new Set(Object.keys(ctx.userconfig.mine.events[name]?.changes ?? {})); - const events = await loadEvents(name); + const existingChangeDates = new Set(typedKeys(ctx.userconfig.mine.events[eventId]?.changes ?? {})); + const events = await loadEvents(eventId); const dates = events .map(o => o.startTime) .filter(o => !existingChangeDates.has(o)) @@ -196,14 +198,14 @@ menu.interact('finish', { }); async function finish(ctx: MyContext): Promise { - const name = ctx.match![1]!.replaceAll(';', '/'); + const eventId = ctx.match![1]! as EventId; const date = ctx.session.generateChangeDate!; const change = ctx.session.generateChange!; - ctx.userconfig.mine.events[name] ??= {}; - ctx.userconfig.mine.events[name].changes ??= {}; + ctx.userconfig.mine.events[eventId] ??= {}; + ctx.userconfig.mine.events[eventId].changes ??= {}; - const alreadyExists = ctx.userconfig.mine.events[name].changes[date]; + const alreadyExists = ctx.userconfig.mine.events[eventId].changes[date]; if (alreadyExists) { // Dont do something when there is already a change for the date // This shouldn't occour but it can when the user adds a shared change @@ -212,7 +214,7 @@ async function finish(ctx: MyContext): Promise { return true; } - ctx.userconfig.mine.events[name].changes[date] = change; + ctx.userconfig.mine.events[eventId].changes[date] = change; delete ctx.session.generateChange; return `../d:${date}/`; diff --git a/source/menu/events/changes/details.ts b/source/menu/events/changes/details.ts index 19bc8071..4ece2d97 100644 --- a/source/menu/events/changes/details.ts +++ b/source/menu/events/changes/details.ts @@ -4,41 +4,46 @@ import { generateShortChangeText, } from '../../../lib/change-helper.ts'; import {backMainButtons} from '../../../lib/inline-menu.ts'; -import type {Change, MyContext, NaiveDateTime} from '../../../lib/types.ts'; +import type { + Change, + EventId, + MyContext, + NaiveDateTime, +} from '../../../lib/types.ts'; -function getChangeFromContext(ctx: MyContext): [string, NaiveDateTime, Change | undefined] { - const name = ctx.match![1]!.replaceAll(';', '/'); +function getChangeFromContext(ctx: MyContext): [EventId, NaiveDateTime, Change | undefined] { + const eventId = ctx.match![1]! as EventId; const date = ctx.match![2]! as NaiveDateTime; - const details = ctx.userconfig.mine.events[name]?.changes?.[date]; - return [name, date, details]; + const details = ctx.userconfig.mine.events[eventId]?.changes?.[date]; + return [eventId, date, details]; } export const menu = new MenuTemplate(ctx => { - const [name, date, change] = getChangeFromContext(ctx); + const [eventId, date, change] = getChangeFromContext(ctx); if (!change) { return 'Change does not exist anymore'; } - const text = generateChangeText(name, date, change); + const text = generateChangeText(eventId, date, change); return {text, parse_mode: 'HTML'}; }); menu.switchToChat({ text: 'Teilen…', query(ctx) { - const [name, date] = getChangeFromContext(ctx); - return generateShortChangeText(name, date); + const [eventId, date] = getChangeFromContext(ctx); + return generateShortChangeText(eventId, date); }, hide(ctx) { - const [_name, _date, change] = getChangeFromContext(ctx); + const [_eventId, _date, change] = getChangeFromContext(ctx); return !change; }, }); menu.interact('r', { text: '⚠️ Änderung entfernen', async do(ctx) { - const [name, date] = getChangeFromContext(ctx); - delete ctx.userconfig.mine.events[name]?.changes?.[date]; + const [eventId, date] = getChangeFromContext(ctx); + delete ctx.userconfig.mine.events[eventId]?.changes?.[date]; await ctx.answerCallbackQuery('Änderung wurde entfernt.'); return '..'; }, diff --git a/source/menu/events/changes/index.ts b/source/menu/events/changes/index.ts index a51e9d24..305c8d62 100644 --- a/source/menu/events/changes/index.ts +++ b/source/menu/events/changes/index.ts @@ -1,19 +1,21 @@ import {Composer} from 'grammy'; import {MenuTemplate} from 'grammy-inline-menu'; import {html as format} from 'telegram-format'; +import {getEventName} from '../../../lib/all-events.ts'; import {backMainButtons} from '../../../lib/inline-menu.ts'; -import type {MyContext} from '../../../lib/types.ts'; +import {typedKeys} from '../../../lib/javascript-helper.ts'; +import type {EventId, MyContext} from '../../../lib/types.ts'; import * as changeAdd from './add/index.ts'; import * as changeDetails from './details.ts'; export const bot = new Composer(); export const menu = new MenuTemplate(ctx => { - const event = ctx.match![1]!.replaceAll(';', '/'); + const eventId = ctx.match![1]! as EventId; let text = ''; text += format.bold('Veranstaltungsänderungen'); text += '\n'; - text += format.escape(event); + text += format.escape(getEventName(eventId)); text += '\n\n'; text += format.escape(ctx.t('changes-help')); @@ -27,8 +29,8 @@ menu.submenu('a', changeAdd.menu, {text: '➕ Änderung hinzufügen'}); menu.chooseIntoSubmenu('d', changeDetails.menu, { columns: 1, choices(ctx) { - const event = ctx.match![1]!.replaceAll(';', '/'); - return Object.keys(ctx.userconfig.mine.events[event]?.changes ?? {}); + const eventId = ctx.match![1]! as EventId; + return typedKeys(ctx.userconfig.mine.events[eventId]?.changes ?? {}); }, getCurrentPage: ctx => ctx.session.page, setPage(ctx, page) { diff --git a/source/menu/events/details.ts b/source/menu/events/details.ts index b059f21d..1ec6a417 100644 --- a/source/menu/events/details.ts +++ b/source/menu/events/details.ts @@ -7,26 +7,27 @@ import { replyMenuToContext, } from 'grammy-inline-menu'; import {html as format} from 'telegram-format'; +import {getEventName} from '../../lib/all-events.ts'; import {backMainButtons} from '../../lib/inline-menu.ts'; -import type {MyContext} from '../../lib/types.ts'; +import type {EventId, MyContext} from '../../lib/types.ts'; import * as changesMenu from './changes/index.ts'; -function getNameFromPath(path: string): string { +function getIdFromPath(path: string): EventId { const match = /\/d:([^/]+)\//.exec(path)!; - return match[1]!.replaceAll(';', '/'); + return match[1]! as EventId; } export const bot = new Composer(); bot.use(changesMenu.bot); export const menu = new MenuTemplate((ctx, path) => { - const name = getNameFromPath(path); - const event = ctx.userconfig.mine.events[name]!; + const eventId = getIdFromPath(path); + const event = ctx.userconfig.mine.events[eventId]!; const changes = Object.keys(event.changes ?? {}).length; let text = format.bold('Veranstaltung'); text += '\n'; - text += name; + text += getEventName(eventId); text += '\n'; if (changes > 0) { @@ -69,15 +70,15 @@ menu.submenu('c', changesMenu.menu, { }); const alertMenu = new MenuTemplate((_, path) => { - const name = getNameFromPath(path); + const name = getEventName(getIdFromPath(path)); return `Wie lange im vorraus möchtest du an einen Termin der Veranstaltung ${name} erinnert werden?`; }); alertMenu.interact('nope', { text: '🔕 Garnicht', do(ctx, path) { - const name = getNameFromPath(path); - delete ctx.userconfig.mine.events[name]!.alertMinutesBefore; + const eventId = getIdFromPath(path); + delete ctx.userconfig.mine.events[eventId]!.alertMinutesBefore; return '..'; }, }); @@ -100,9 +101,9 @@ alertMenu.choose('t', { throw new Error('how?'); } - const name = getNameFromPath(ctx.callbackQuery.data); + const eventId = getIdFromPath(ctx.callbackQuery.data); const minutes = Number(key); - ctx.userconfig.mine.events[name]!.alertMinutesBefore = minutes; + ctx.userconfig.mine.events[eventId]!.alertMinutesBefore = minutes; return '..'; }, }); @@ -114,11 +115,11 @@ menu.submenu('alert', alertMenu, {text: '⏰ Erinnerung'}); const noteQuestion = new StatelessQuestion( 'event-notes', async (ctx, path) => { - const name = getNameFromPath(path); + const eventId = getIdFromPath(path); if (ctx.message.text) { const notes = ctx.message.text; - ctx.userconfig.mine.events[name]!.notes = notes; + ctx.userconfig.mine.events[eventId]!.notes = notes; } await replyMenuToContext(menu, ctx, path); @@ -130,9 +131,9 @@ bot.use(noteQuestion.middleware()); menu.interact('set-notes', { text: '🗒 Schreibe Notiz', async do(ctx, path) { - const name = getNameFromPath(path); + const eventId = getIdFromPath(path); const text = `Welche Notizen möchtest du an den Kalendereinträgen von ${ - format.escape(name) + format.escape(getEventName(eventId)) } stehen haben?`; await noteQuestion.replyWithHTML(ctx, text, getMenuOfPath(path)); await deleteMenuFromContext(ctx); @@ -141,34 +142,35 @@ menu.interact('set-notes', { }); menu.interact('remove-notes', { - text: 'Notiz löschen', joinLastRow: true, + text: 'Notiz löschen', hide(ctx, path) { - const name = getNameFromPath(path); - return !ctx.userconfig.mine.events[name]!.notes; + const eventId = getIdFromPath(path); + return !ctx.userconfig.mine.events[eventId]!.notes; }, do(ctx, path) { - const name = getNameFromPath(path); - delete ctx.userconfig.mine.events[name]!.notes; + const eventId = getIdFromPath(path); + delete ctx.userconfig.mine.events[eventId]!.notes; return true; }, }); const removeMenu = new MenuTemplate(ctx => { - const event = ctx.match![1]!.replaceAll(';', '/'); + const eventId = ctx.match![1]! as EventId; return ( - event + getEventName(eventId) + '\n\nBist du dir sicher, dass du diese Veranstaltung entfernen möchtest?' ); }); removeMenu.interact('y', { text: 'Ja ich will!', async do(ctx) { - const event = ctx.match![1]!.replaceAll(';', '/'); + const eventId = ctx.match![1]! as EventId; + const eventName = getEventName(eventId); // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete ctx.userconfig.mine.events[event]; + delete ctx.userconfig.mine.events[eventId]; - await ctx.answerCallbackQuery(`${event} wurde aus deinem Kalender entfernt.`); + await ctx.answerCallbackQuery(`${eventName} wurde aus deinem Kalender entfernt.`); return true; }, }); diff --git a/source/menu/events/index.ts b/source/menu/events/index.ts index d5b22790..1f7c490e 100644 --- a/source/menu/events/index.ts +++ b/source/menu/events/index.ts @@ -2,32 +2,36 @@ import {Composer} from 'grammy'; import {MenuTemplate} from 'grammy-inline-menu'; import {html as format} from 'telegram-format'; import * as allEvents from '../../lib/all-events.ts'; +import {getEventName} from '../../lib/all-events.ts'; import {backMainButtons} from '../../lib/inline-menu.ts'; +import {typedEntries, typedKeys} from '../../lib/javascript-helper.ts'; import type {MyContext} from '../../lib/types.ts'; import * as addMenu from './add.ts'; import * as detailsMenu from './details.ts'; export const bot = new Composer(); export const menu = new MenuTemplate(async ctx => { + delete ctx.session.eventAdd; + let text = format.bold('Veranstaltungen'); text += '\n\n'; - const events = Object.keys(ctx.userconfig.mine.events); - events.sort(); - if (events.length > 0) { - const nonExisting = new Set(await allEvents.nonExisting(events)); + const eventIds = typedKeys(ctx.userconfig.mine.events); + if (eventIds.length > 0) { + const nonExisting = new Set(eventIds.filter(eventId => !allEvents.exists(eventId))); text += 'Du hast folgende Veranstaltungen im Kalender:'; text += '\n'; - text += events - .map(o => { + text += eventIds + .map(eventId => { let line = '- '; - if (nonExisting.has(o)) { + if (!allEvents.exists(eventId)) { line += '⚠️ '; } - line += format.escape(o); + line += format.escape(getEventName(eventId)); return line; }) + .sort((a, b) => a.localeCompare(b)) .join('\n'); if (nonExisting.size > 0) { @@ -58,15 +62,15 @@ bot.use(detailsMenu.bot); menu.interact('remove-old', { text: '🗑 Entferne nicht mehr Existierende', - async hide(ctx) { - const nonExisting = await allEvents.nonExisting(Object.keys(ctx.userconfig.mine.events)); - return nonExisting.length === 0; - }, - async do(ctx) { - const nonExisting = new Set(await allEvents.nonExisting(Object.keys(ctx.userconfig.mine.events))); - for (const name of nonExisting) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete ctx.userconfig.mine.events[name]; + hide: ctx => + typedKeys(ctx.userconfig.mine.events).every(eventId => + allEvents.exists(eventId)), + do(ctx) { + for (const eventId of typedKeys(ctx.userconfig.mine.events)) { + if (!allEvents.exists(eventId)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete ctx.userconfig.mine.events[eventId]; + } } return true; @@ -78,27 +82,26 @@ menu.submenu('a', addMenu.menu, {text: '➕ Veranstaltung hinzufügen'}); menu.chooseIntoSubmenu('d', detailsMenu.menu, { columns: 1, choices(ctx) { - const result: Record = {}; - - for (const [name, details] of Object.entries(ctx.userconfig.mine.events)) { - let title = name + ' '; + const entries = typedEntries(ctx.userconfig.mine.events) + .map(([eventId, details]) => { + let title = getEventName(eventId) + ' '; - if (Object.keys(details.changes ?? {}).length > 0) { - title += '✏️'; - } - - if (details.alertMinutesBefore !== undefined) { - title += '⏰'; - } + if (Object.keys(details.changes ?? {}).length > 0) { + title += '✏️'; + } - if (details.notes) { - title += '🗒'; - } + if (details.alertMinutesBefore !== undefined) { + title += '⏰'; + } - result[name.replaceAll('/', ';')] = title.trim(); - } + if (details.notes) { + title += '🗒'; + } - return result; + return [eventId, title.trim()] as const; + }) + .sort((a, b) => a[1]?.localeCompare(b[1])); + return Object.fromEntries(entries); }, getCurrentPage: ctx => ctx.session.page, setPage(ctx, page) { diff --git a/source/menu/mensa/index.ts b/source/menu/mensa/index.ts index 77018b74..73069431 100644 --- a/source/menu/mensa/index.ts +++ b/source/menu/mensa/index.ts @@ -1,5 +1,4 @@ import {MenuTemplate} from 'grammy-inline-menu'; -import * as mensaGit from '../../lib/mensa-git.ts'; import {generateMealText} from '../../lib/mensa-helper.ts'; import {getMealsOfDay} from '../../lib/mensa-meals.ts'; import type {MyContext} from '../../lib/types.ts'; @@ -16,10 +15,6 @@ const WEEKDAYS = [ ] as const; const DAY_IN_MS = 1000 * 60 * 60 * 24; -setInterval(async () => mensaGit.pull(), 1000 * 60 * 30); // Every 30 minutes -// eslint-disable-next-line @typescript-eslint/no-floating-promises -mensaGit.pull(); - function getYearMonthDay(date: Readonly): Readonly<{year: number; month: number; day: number}> { const year = date.getFullYear(); const month = date.getMonth() + 1; diff --git a/source/parts/changes-inline.ts b/source/parts/changes-inline.ts index 7708490c..bc4deb38 100644 --- a/source/parts/changes-inline.ts +++ b/source/parts/changes-inline.ts @@ -7,22 +7,28 @@ import { generateChangeTextHeader, generateShortChangeText, } from '../lib/change-helper.ts'; -import type {Change, MyContext, NaiveDateTime} from '../lib/types.ts'; +import {typedEntries} from '../lib/javascript-helper.ts'; +import type { + Change, + EventId, + MyContext, + NaiveDateTime, +} from '../lib/types.ts'; export const bot = new Composer(); function generateInlineQueryResultFromChange( - name: string, + eventId: EventId, date: NaiveDateTime, change: Change, from: User, ): InlineQueryResultArticle { - const id = `${name}#${date}#${from.id}`; + const id = `${eventId}#${date}#${from.id}`; return { description: generateChangeDescription(change), id, input_message_content: { - message_text: generateChangeText(name, date, change), + message_text: generateChangeText(eventId, date, change), parse_mode: format.parse_mode, }, reply_markup: { @@ -30,7 +36,7 @@ function generateInlineQueryResultFromChange( [{text: 'zu meinem Kalender hinzufügen', callback_data: 'c:a:' + id}], ], }, - title: generateShortChangeText(name, date), + title: generateShortChangeText(eventId, date), type: 'article', }; } @@ -51,15 +57,14 @@ bot.on('inline_query', async ctx => { const results: InlineQueryResultArticle[] = []; - for (const [event, details] of Object.entries(ctx.userconfig.mine.events)) { - for (const [dateKey, change] of Object.entries(details.changes ?? {})) { - const date = dateKey as NaiveDateTime; - const isMatched = regex.test(generateShortChangeText(event, date)); + for (const [eventId, details] of typedEntries(ctx.userconfig.mine.events)) { + for (const [date, change] of typedEntries(details.changes ?? {})) { + const isMatched = regex.test(generateShortChangeText(eventId, date)); if (!isMatched) { continue; } - results.push(generateInlineQueryResultFromChange(event, date, change, ctx.from)); + results.push(generateInlineQueryResultFromChange(eventId, date, change, ctx.from)); } } @@ -74,31 +79,31 @@ bot.on('inline_query', async ctx => { }); type ChangeRelatedInfos = { - name: string; + eventId: EventId; date: NaiveDateTime; fromId: number; change: Change; }; async function getChangeFromContextMatch(ctx: MyContext): Promise { - const name = ctx.match![1]!; + const eventId = ctx.match![1]! as EventId; const date = ctx.match![2]! as NaiveDateTime; const fromId = Number(ctx.match![3]!); - if (!Object.keys(ctx.userconfig.mine.events).includes(name)) { + if (!(eventId in ctx.userconfig.mine.events)) { await ctx.answerCallbackQuery('Du besuchst diese Veranstaltung garnicht. 🤔'); return undefined; } try { const fromconfig = await ctx.userconfig.loadConfig(fromId); - const searchedChange = fromconfig.events[name]?.changes?.[date]; + const searchedChange = fromconfig.events[eventId]?.changes?.[date]; if (!searchedChange) { throw new Error('User does not have this change'); } return { - name, + eventId, date, fromId, change: searchedChange, @@ -115,7 +120,7 @@ bot.callbackQuery(/^c:a:(.+)#(.+)#(.+)$/, async ctx => { return; } - const {name, date, fromId, change} = meta; + const {eventId, date, fromId, change} = meta; if (ctx.from?.id === Number(fromId)) { await ctx.answerCallbackQuery('Das ist deine eigene Änderung 😉'); @@ -123,7 +128,7 @@ bot.callbackQuery(/^c:a:(.+)#(.+)#(.+)$/, async ctx => { } // Prüfen ob man bereits eine Änderung mit dem Namen und dem Datum hat. - const currentChange = ctx.userconfig.mine.events[name]?.changes?.[date]; + const currentChange = ctx.userconfig.mine.events[eventId]?.changes?.[date]; if (currentChange) { const warning @@ -131,7 +136,7 @@ bot.callbackQuery(/^c:a:(.+)#(.+)#(.+)$/, async ctx => { await ctx.answerCallbackQuery(warning); let text = warning + '\n'; - text += generateChangeTextHeader(name, date); + text += generateChangeTextHeader(eventId, date); text += '\nDiese Veränderung ist bereits in deinem Kalender:'; text += '\n' + format.escape(generateChangeDescription(currentChange)); @@ -143,7 +148,7 @@ bot.callbackQuery(/^c:a:(.+)#(.+)#(.+)$/, async ctx => { [ { text: 'Überschreiben', - callback_data: `c:af:${name}#${date}#${fromId}`, + callback_data: `c:af:${eventId}#${date}#${fromId}`, }, {text: 'Abbrechen', callback_data: 'c:cancel'}, ], @@ -156,8 +161,8 @@ bot.callbackQuery(/^c:a:(.+)#(.+)#(.+)$/, async ctx => { return; } - ctx.userconfig.mine.events[name]!.changes ??= {}; - ctx.userconfig.mine.events[name]!.changes[date] = change; + ctx.userconfig.mine.events[eventId]!.changes ??= {}; + ctx.userconfig.mine.events[eventId]!.changes[date] = change; await ctx.answerCallbackQuery('Die Änderung wurde hinzugefügt'); }); @@ -173,8 +178,8 @@ bot.callbackQuery(/^c:af:(.+)#(.+)#(.+)$/, async ctx => { return; } - const {name, date, change} = meta; - ctx.userconfig.mine.events[name]!.changes ??= {}; - ctx.userconfig.mine.events[name]!.changes[date] = change; + const {eventId, date, change} = meta; + ctx.userconfig.mine.events[eventId]!.changes ??= {}; + ctx.userconfig.mine.events[eventId]!.changes[date] = change; return ctx.editMessageText('Die Änderung wurde hinzugefügt.'); });