import type { EventAttributes } from 'ics'; import nepali from 'nepali-calendar-js'; import { v5 as uuid5 } from 'uuid'; import { newDate, sha256 } from '#shared/helpers.ts'; import type { Translator } from '#shared/translator.ts'; export class Day { year: number; month: number; day: number; dayOfWeek: number; constructor(year: number, month: number, day: number, dayOfWeek?: number | null) { this.year = year; this.month = month; this.day = day; this.dayOfWeek = dayOfWeek ? dayOfWeek : new Date(this.year, this.month - 1, this.day).getDay() || 7; } static fromDate(date: Date): Day { return new Day(date.getFullYear(), date.getMonth() + 1, date.getDate()); } static today(): Day { return Day.fromDate(newDate()); } toDate(): Date { return new Date(this.year, this.month - 1, this.day); } equals(other: Day | null): boolean { return !!other && this.year === other.year && this.month === other.month && this.day === other.day; } toString(): string { return `${this.year}-${this.month.toString().padStart(2, '0')}-${this.day.toString().padStart(2, '0')}`; } // for comparisons toInt(): number { return parseInt(`${this.year}${this.month.toString().padStart(2, '0')}${this.day.toString().padStart(2, '0')}`); } next(): Day { const d = this.toDate(); d.setDate(d.getDate() + 1); return Day.fromDate(d); } prev(): Day { const d = this.toDate(); d.setDate(d.getDate() - 1); return Day.fromDate(d); } } export function* iterateMonth(year: number, month: number): Generator { for (let day = 1; day <= 31; day++) { const d = new Date(year, month - 1, day); if (d.getDate() !== day) { return; } yield new Day(year, month, day, d.getDay() || 7); } } export const EventLevel = { Month: 0, Week: 1, Nameday: 2, Day: 3, CustomDay: 4, } as const; type EventLevelValue = typeof EventLevel[keyof typeof EventLevel]; type EventImage = { type: 'flag'; name: string; class?: string; } | { type: 'icon'; name: string; }; export class Event { name: string; display: EventImage; month: number; generator: (monthDays: Generator) => Generator; level: EventLevelValue; terms: string[]; timeDescription: string | null; localCalendar: string | null; yearCondition: ((year: number) => boolean) | null; daysMemoise?: Record; comment: string | null; constructor( name: string, display: EventImage | string | null, month: number, generator: (monthDays: Generator) => Generator, level: EventLevelValue, terms: string[] = [], timeDescription: string | null = null, localCalendar: string | null = null, yearCondition: ((year: number) => boolean) | null = null, comment: string | null = null, ) { this.name = name; if (typeof display === 'string') { this.display = { type: 'flag', name: display }; } else if (display === null) { this.display = { type: 'icon', name: 'arrow-circle-right' }; } else { this.display = display; } this.month = month; this.generator = generator; this.level = level; this.terms = terms; this.timeDescription = timeDescription; this.localCalendar = localCalendar; this.yearCondition = yearCondition; this.daysMemoise = {}; this.comment = comment; } getDays(year: number): Day[] { if (this.yearCondition && !this.yearCondition(year)) { return []; } if (this.daysMemoise === undefined) { // shouldn't happen, but somehow does, but only on prod? this.daysMemoise = {}; } if (this.daysMemoise[year] === undefined) { this.daysMemoise[year] = [...this.generator(iterateMonth(year, this.month))]; } return this.daysMemoise[year]; } length(): number { return [...this.getDays(2021)].length; } getRange(year?: number): string { if (year === undefined) { year = Day.today().year; } const days = this.getDays(year); if (days.length === 1) { return days[0].day.toString(); } return `${days[0].day} – ${days[days.length - 1].day}`; } isFirstDay(day: Day): boolean { return this.getDays(day.year)[0].equals(day); } getUuid(baseUrl: string): string { return uuid5(`${baseUrl}/calendar/event/${this.name}`, uuid5.URL); } toIcs( year: number, translator: Translator, clearLinkedText: (text: string, quotes?: boolean) => string, sequence: number = 1, onlyFirstDays: boolean = false, calNameExtra: string = '', ): EventAttributes | null { const days = this.getDays(year); if (!days.length) { return null; } let [name, param] = this.name.split('$'); name = translator.get(`calendar.events.${name}`) ?? name; if (param) { name = name.replace(/%param%/g, param); } name = clearLinkedText(name); const first = days[0]; let last = days[days.length - 1]; if (onlyFirstDays && !first.equals(last)) { last = first; name += ` (${translator.translate('calendar.start')}`; } last = last.next(); return { title: name, start: [first.year, first.month, first.day], end: [last.year, last.month, last.day], calName: translator.translate('calendar.headerLong') + calNameExtra, sequence, }; } static fromCustom(customSettings: CustomEvent): Event { return new Event( customSettings.name, { type: 'icon', name: customSettings.icon }, customSettings.month, day(customSettings.day), EventLevel.CustomDay, [], null, null, null, customSettings.comment, ); } } export class NepaliDay extends Day { nYear: number; nMonth: number; nDay: number; constructor( gYear: number, gMonth: number, gDay: number, gDayOfWeek: number | null, nYear: number, nMonth: number, nDay: number, ) { super(gYear, gMonth, gDay, gDayOfWeek); this.nYear = nYear; this.nMonth = nMonth; this.nDay = nDay; } } export class NepaliEvent extends Event { override getDays(year: number): Day[] { if (this.daysMemoise === undefined) { // shouldn't happen, but somehow does, but only on prod? this.daysMemoise = {}; } if (this.daysMemoise[year] === undefined) { this.daysMemoise[year] = [...this.generator(this._iterateNepaliMonth(year, this.month))]; } return this.daysMemoise[year]; } *_iterateNepaliMonth(gYear: number, nMonth: number): Generator { for (const possibleYearOffset of [56, 57]) { const daysInMonth = nepali.nepaliMonthLength(gYear + possibleYearOffset, nMonth); for (let nDay = 1; nDay <= daysInMonth; nDay++) { const { gy, gm, gd } = nepali.toGregorian(gYear + possibleYearOffset, nMonth, nDay); if (gy === gYear) { yield new NepaliDay(gy, gm, gd, null, gYear + possibleYearOffset, nMonth, nDay); } } } } } export function day(dayOfMonth: number): (monthDays: Generator) => Generator { function* internal(monthDays: Generator): Generator { for (const d of monthDays) { if (d.day === dayOfMonth) { yield d; } } } return internal; } export function* month(monthDays: Generator): Generator { for (const d of monthDays) { yield d; } } export function week( generator: (monthDays: Generator) => Generator, ): (monthDays: Generator) => Generator { function* internal(monthDays: Generator): Generator { let count = 0; for (const d of generator(monthDays)) { yield d; count++; if (count === 7) { return; } } } return internal; } export function weekStarting(start: number): (monthDays: Generator) => Generator { function* internal(monthDays: Generator): Generator { let count = 0; for (const d of monthDays) { if (d.day >= start && count < 7) { yield d; count++; } } } return internal; } export function dayYear(day: number, year: number): (monthDays: Generator) => Generator { function* internal(monthDays: Generator): Generator { for (const d of monthDays) { if (d.day === day && d.year === year) { yield d; } } } return internal; } export interface CustomEvent { month: number; day: number; icon: string; name: string; comment: string; } export interface MiastamaszerujaceEvent { name: string; date: Day; link: string | null; } export class Year { year: number; events: Event[]; eventsByDate: Record; eventsByTerm: Record; eventsByUuid: Record; eventsByName: Record; constructor(year: number, events: Event[], baseUrl: string) { this.year = year; this.events = events; this.eventsByDate = {}; for (const event of events) { for (const d of event.getDays(year)) { const k = d.toString(); if (this.eventsByDate[k] === undefined) { this.eventsByDate[k] = []; } this.eventsByDate[k].push(event); } } for (const date in this.eventsByDate) { if (!Object.hasOwn(this.eventsByDate, date)) { continue; } this.eventsByDate[date].sort((a, b) => b.level - a.level); } this.eventsByTerm = {}; for (const event of events) { for (const term of event.terms) { if (this.eventsByTerm[term] === undefined) { this.eventsByTerm[term] = []; } if (event.getDays(this.year).length) { this.eventsByTerm[term].push(event); } } } for (const term in this.eventsByTerm) { if (!Object.hasOwn(this.eventsByTerm, term)) { continue; } this.eventsByTerm[term].sort((a, b) => a.getDays(this.year)[0].toInt() - b.getDays(this.year)[0].toInt()); } this.eventsByUuid = {}; for (const event of events) { this.eventsByUuid[event.getUuid(baseUrl)] = event; } this.eventsByName = {}; for (const event of events) { this.eventsByName[event.name] = event; } } isCurrent(): boolean { return this.year === Day.today().year; } } export class Calendar { _events: Event[]; _baseUrl: string; _minYear: number; _maxYear: number; _years: Record; constructor(events: Event[], baseUrl: string, minYear: number = 0, maxYear: number = 9999) { this._events = events; this._baseUrl = baseUrl; this._minYear = minYear; this._maxYear = maxYear; this._years = {}; } getYear(year: number): Year | null { if (year < this._minYear || year > this._maxYear || Number.isNaN(year)) { return null; } if (this._years[year] === undefined) { this._years[year] = new Year(year, this._events, this._baseUrl); } return this._years[year]; } getCurrentYear(): Year | null { return this.getYear(Day.today().year); } *getAllYears(): Generator { for (let y = this._minYear; y <= this._maxYear; y++) { yield this.getYear(y)!; } } async buildSummary(): Promise> { const summary: Record = {}; for (const year of this.getAllYears()) { for (let month = 1; month <= 12; month++) { for (const day of iterateMonth(year.year, month)) { const events = (year.eventsByDate[day.toString()] || []).map((event) => event.name); summary[day.toString()] = await sha256(JSON.stringify(events)); } } } return summary; } static generatePersonalCalendarEvents(events: (string | CustomEvent)[], year: Year): Event[] { return [...new Set(events)] .map((event) => typeof event === 'string' ? year.eventsByName[event] : Event.fromCustom(event)) .filter((e) => !!e) .filter((e) => e.getDays(year.year).length > 0) .sort((a, b) => { const aFirstDay = a.getDays(year.year)[0]; const bFirstDay = b.getDays(year.year)[0]; const monthDiff = aFirstDay.month - bFirstDay.month; if (monthDiff !== 0) { return monthDiff; } return aFirstDay.day - bFirstDay.day; }); } }