Valentyne Stigloher 10180aa6a3 (refactor) use #shared alias instead of ~~/shared
the #shared alias used by Nuxt cannot be easily disabled and to prevent breackage with jiti, we make use of it
2025-08-17 18:56:02 +02:00

482 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Day, void> {
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<Day, void>) => Generator<Day, void>;
level: EventLevelValue;
terms: string[];
timeDescription: string | null;
localCalendar: string | null;
yearCondition: ((year: number) => boolean) | null;
daysMemoise?: Record<number, Day[]>;
comment: string | null;
constructor(
name: string,
display: EventImage | string | null,
month: number,
generator: (monthDays: Generator<Day, void>) => Generator<Day, void>,
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<string | undefined>(`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<NepaliDay, void> {
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<Day, void>) => Generator<Day, void> {
function* internal(monthDays: Generator<Day, void>): Generator<Day, void> {
for (const d of monthDays) {
if (d.day === dayOfMonth) {
yield d;
}
}
}
return internal;
}
export function* month(monthDays: Generator<Day, void>): Generator<Day, void> {
for (const d of monthDays) {
yield d;
}
}
export function week(
generator: (monthDays: Generator<Day, void>) => Generator<Day, void>,
): (monthDays: Generator<Day, void>) => Generator<Day, void> {
function* internal(monthDays: Generator<Day, void>): Generator<Day, void> {
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<Day, void>) => Generator<Day, void> {
function* internal(monthDays: Generator<Day, void>): Generator<Day, void> {
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<Day, void>) => Generator<Day, void> {
function* internal(monthDays: Generator<Day, void>): Generator<Day, void> {
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<string, Event[]>;
eventsByTerm: Record<string, Event[]>;
eventsByUuid: Record<string, Event>;
eventsByName: Record<string, Event>;
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<number, Year>;
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<Year, void> {
for (let y = this._minYear; y <= this._maxYear; y++) {
yield this.getYear(y)!;
}
}
async buildSummary(): Promise<Record<string, string>> {
const summary: Record<string, string> = {};
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;
});
}
}