2026-02-27 18:01:23 -05:00

203 lines
7.6 KiB
TypeScript

import { EventInput, EventSourceFuncArg, EventSourceInput } from "@fullcalendar/core/index.js";
import { dayjs } from "@triliumnext/commons";
import clsx from "clsx";
import { start } from "repl";
import * as rruleLib from 'rrule';
import FNote from "../../../entities/fnote";
import froca from "../../../services/froca";
import server from "../../../services/server";
import toastService from "../../../services/toast";
import { getCustomisableLabel, getMonthsInDateRange } from "./utils";
interface Event {
startDate: string,
endDate?: string | null,
startTime?: string | null,
endTime?: string | null,
isArchived?: boolean,
recurrence?: string | null;
}
export async function buildEvents(noteIds: string[]) {
const notes = await froca.getNotes(noteIds);
const events: EventSourceInput = [];
for (const note of notes) {
const startDate = getCustomisableLabel(note, "startDate", "calendar:startDate");
if (!startDate) {
continue;
}
const endDate = getCustomisableLabel(note, "endDate", "calendar:endDate");
const startTime = getCustomisableLabel(note, "startTime", "calendar:startTime");
const endTime = getCustomisableLabel(note, "endTime", "calendar:endTime");
const recurrence = getCustomisableLabel(note, "recurrence", "calendar:recurrence");
const isArchived = note.hasLabel("archived");
try {
events.push(await buildEvent(note, { startDate, endDate, startTime, endTime, recurrence, isArchived }));
} catch (error) {
if (error instanceof Error) {
const errorMessage = error.message;
toastService.showError(errorMessage);
console.error(errorMessage);
}
}
}
return events.flat();
}
export async function buildEventsForCalendar(note: FNote, e: EventSourceFuncArg) {
const events: EventInput[] = [];
// Gather all the required date note IDs.
const dateRange = getMonthsInDateRange(e.startStr, e.endStr);
let allDateNoteIds: string[] = [];
for (const month of dateRange) {
// TODO: Deduplicate get type.
const dateNotesForMonth = await server.get<Record<string, string>>(`special-notes/notes-for-month/${month}?calendarRoot=${note.noteId}`);
const dateNoteIds = Object.values(dateNotesForMonth);
allDateNoteIds = [...allDateNoteIds, ...dateNoteIds];
}
// Request all the date notes.
const dateNotes = await froca.getNotes(allDateNoteIds);
const childNoteToDateMapping: Record<string, string> = {};
for (const dateNote of dateNotes) {
const startDate = dateNote.getLabelValue("dateNote");
if (!startDate) {
continue;
}
events.push(await buildEvent(dateNote, { startDate }));
if (dateNote.hasChildren()) {
const childNoteIds = await dateNote.getSubtreeNoteIds();
for (const childNoteId of childNoteIds) {
childNoteToDateMapping[childNoteId] = startDate;
}
}
}
// Request all child notes of date notes in a single run.
const childNoteIds = Object.keys(childNoteToDateMapping);
const childNotes = await froca.getNotes(childNoteIds);
for (const childNote of childNotes) {
const startDate = childNoteToDateMapping[childNote.noteId];
const event = await buildEvent(childNote, { startDate });
events.push(event);
}
return events.flat();
}
export async function buildEvent(note: FNote, { startDate, endDate, startTime, endTime, recurrence, isArchived }: Event) {
const customTitleAttributeName = note.getLabelValue("calendar:title");
const titles = await parseCustomTitle(customTitleAttributeName, note);
const colorClass = note.getColorClass();
const events: EventInput[] = [];
const calendarDisplayedAttributes = note.getLabelValue("calendar:displayedAttributes")?.split(",");
let displayedAttributesData: Array<[string, string]> | null = null;
if (calendarDisplayedAttributes) {
displayedAttributesData = await buildDisplayedAttributes(note, calendarDisplayedAttributes);
}
for (const title of titles) {
if (startTime && endTime && !endDate) {
endDate = startDate;
}
startDate = (startTime ? `${startDate}T${startTime}:00` : startDate);
if (!startTime) {
if (endDate) {
endDate = dayjs(endDate).add(1, "day").format("YYYY-MM-DD");
} else if (startDate) {
endDate = dayjs(startDate).add(1, "day").format("YYYY-MM-DD");
}
}
endDate = (endTime ? `${endDate}T${endTime}:00` : endDate);
const eventData: EventInput = {
id: note.noteId,
title,
start: startDate,
url: `#${note.noteId}?popup`,
noteId: note.noteId,
iconClass: note.getLabelValue("iconClass"),
promotedAttributes: displayedAttributesData,
className: clsx({archived: isArchived}, colorClass)
};
if (endDate) {
eventData.end = endDate;
}
if (recurrence) {
// Generate rrule string
const rruleString = `DTSTART:${dayjs(startDate).format("YYYYMMDD[T]HHmmss")}\n${recurrence}`;
// Validate rrule string
let rruleValid = true;
try {
rruleLib.rrulestr(rruleString, { forceset: true }) as rruleLib.RRuleSet;
} catch {
rruleValid = false;
}
if (rruleValid) {
delete eventData.end;
eventData.rrule = rruleString;
if (endDate){
const duration = dayjs.duration(dayjs(endDate).diff(dayjs(startDate)));
eventData.duration = duration.format("HH:mm");
}
} else {
throw new Error(`Note "${note.noteId} ${note.title}" has an invalid #recurrence string ${recurrence}. Excluding...`);
}
}
events.push(eventData);
}
return events;
}
async function parseCustomTitle(customTitlettributeName: string | null, note: FNote, allowRelations = true): Promise<string[]> {
if (customTitlettributeName) {
const labelValue = note.getAttributeValue("label", customTitlettributeName);
if (labelValue) return [labelValue];
if (allowRelations) {
const relations = note.getRelations(customTitlettributeName);
if (relations.length > 0) {
const noteIds = relations.map((r) => r.targetNoteId);
const notesFromRelation = await froca.getNotes(noteIds);
const titles: string[][] = [];
for (const targetNote of notesFromRelation) {
const targetCustomTitleValue = targetNote.getAttributeValue("label", "calendar:title");
const targetTitles = await parseCustomTitle(targetCustomTitleValue, targetNote, false);
titles.push(targetTitles.flat());
}
return titles.flat();
}
}
}
return [note.title];
}
async function buildDisplayedAttributes(note: FNote, calendarDisplayedAttributes: string[]) {
const filteredDisplayedAttributes = note.getAttributes().filter((attr): boolean => calendarDisplayedAttributes.includes(attr.name));
const result: Array<[string, string]> = [];
for (const attribute of filteredDisplayedAttributes) {
if (attribute.type === "label") result.push([attribute.name, attribute.value]);
else result.push([attribute.name, (await attribute.getTargetNote())?.title || ""]);
}
return result;
}