mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-23 04:34:15 -04:00

the #shared alias used by Nuxt cannot be easily disabled and to prevent breackage with jiti, we make use of it
482 lines
14 KiB
TypeScript
482 lines
14 KiB
TypeScript
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;
|
||
});
|
||
}
|
||
}
|