import type { Calendar, DateSelectArg, EventChangeArg, EventDropArg, EventInput, EventSourceFunc, EventSourceFuncArg, EventSourceInput, PluginDef } from "@fullcalendar/core"; import froca from "../../services/froca.js"; import ViewMode, { type ViewModeArgs } from "./view_mode.js"; import type FNote from "../../entities/fnote.js"; import server from "../../services/server.js"; import { t } from "../../services/i18n.js"; import options from "../../services/options.js"; import dialogService from "../../services/dialog.js"; import attributes from "../../services/attributes.js"; import type { EventData } from "../../components/app_context.js"; import utils from "../../services/utils.js"; import date_notes from "../../services/date_notes.js"; import appContext from "../../components/app_context.js"; const TPL = `
`; // TODO: Deduplicate interface CreateChildResponse { note: { noteId: string; }; } export default class CalendarView extends ViewMode { private $root: JQuery; private $calendarContainer: JQuery; private noteIds: string[]; private parentNote: FNote; private calendar?: Calendar; private isCalendarRoot: boolean; constructor(args: ViewModeArgs) { super(args); this.$root = $(TPL); this.$calendarContainer = this.$root.find(".calendar-container"); this.noteIds = args.noteIds; this.parentNote = args.parentNote; this.isCalendarRoot = false; args.$parent.append(this.$root); } get isFullHeight(): boolean { return true; } async renderList(): Promise | undefined> { this.isCalendarRoot = this.parentNote.hasLabel("calendarRoot") || this.parentNote.hasLabel("workspaceCalendarRoot"); const isEditable = !this.isCalendarRoot; const { Calendar } = await import("@fullcalendar/core"); const plugins: PluginDef[] = []; plugins.push((await import("@fullcalendar/daygrid")).default); plugins.push((await import("@fullcalendar/timegrid")).default); if (isEditable || this.isCalendarRoot) { plugins.push((await import("@fullcalendar/interaction")).default); } let eventBuilder: EventSourceFunc; if (!this.isCalendarRoot) { eventBuilder = async () => await CalendarView.buildEvents(this.noteIds) } else { eventBuilder = async (e: EventSourceFuncArg) => await this.#buildEventsForCalendar(e); } const calendar = new Calendar(this.$calendarContainer[0], { plugins, initialView: "timeGridWeek", events: eventBuilder, editable: isEditable, selectable: isEditable, select: (e) => this.#onCalendarSelection(e), eventChange: (e) => this.#onEventMoved(e), firstDay: options.getInt("firstDayOfWeek") ?? 0, weekends: !this.parentNote.hasAttribute("label", "calendar:hideWeekends"), weekNumbers: this.parentNote.hasAttribute("label", "calendar:weekNumbers"), locale: await CalendarView.#getLocale(), height: "100%", eventContent: (e) => { let html = ""; const { iconClass, promotedAttributes } = e.event.extendedProps; // Title and icon if (iconClass) { html += ` `; } html += utils.escapeHtml(e.event.title); // Promoted attributes if (promotedAttributes) { for (const [name, value] of promotedAttributes) { html += `\ `; } } return { html }; }, dateClick: async (e) => { if (!this.isCalendarRoot) { return; } const note = await date_notes.getDayNote(e.dateStr); if (note) { appContext.tabManager.getActiveContext()?.setNote(note.noteId); } } }); calendar.render(); this.calendar = calendar; return this.$root; } static async #getLocale() { const locale = options.get("locale"); // Here we hard-code the imports in order to ensure that they are embedded by webpack without having to load all the languages. switch (locale) { case "de": return (await import("@fullcalendar/core/locales/de")).default; case "es": return (await import("@fullcalendar/core/locales/es")).default; case "fr": return (await import("@fullcalendar/core/locales/fr")).default; case "cn": return (await import("@fullcalendar/core/locales/zh-cn")).default; case "tw": return (await import("@fullcalendar/core/locales/zh-tw")).default; case "ro": return (await import("@fullcalendar/core/locales/ro")).default; case "en": default: return undefined; } } async #onCalendarSelection(e: DateSelectArg) { // Handle start and end date const startDate = CalendarView.#formatDateToLocalISO(e.start); if (!startDate) { return; } let endDate; if (e.allDay) { endDate = CalendarView.#formatDateToLocalISO(CalendarView.#offsetDate(e.end, -1)); } else { endDate = CalendarView.#formatDateToLocalISO(e.end); } // Handle start and end time. let startTime = null; let endTime = null; if (!e.allDay) { startTime = CalendarView.#formatTimeToLocalISO(e.start); endTime = CalendarView.#formatTimeToLocalISO(e.end); } // Ask for the title const title = await dialogService.prompt({ message: t("relation_map.enter_title_of_new_note"), defaultValue: t("relation_map.default_new_note_title") }); if (!title?.trim()) { return; } // Create the note. const { note } = await server.post(`notes/${this.parentNote.noteId}/children?target=into`, { title, content: "", type: "text" }); // Set the attributes. attributes.setLabel(note.noteId, "startDate", startDate); if (endDate) { attributes.setLabel(note.noteId, "endDate", endDate); } if (startTime) { attributes.setLabel(note.noteId, "startTime", startTime); } if (endTime) { attributes.setLabel(note.noteId, "endTime", endTime); } } async #onEventMoved(e: EventChangeArg) { const startDate = CalendarView.#formatDateToLocalISO(e.event.start); // Fullcalendar end date is exclusive, not inclusive but we store it the other way around. let endDate = CalendarView.#formatDateToLocalISO(CalendarView.#offsetDate(e.event.end, -1)); const noteId = e.event.extendedProps.noteId; // Don't store the end date if it's empty. if (endDate === startDate) { endDate = undefined; } // Update start date const note = await froca.getNote(noteId); if (!note) { return; } // Since they can be customized via calendar:startDate=$foo and calendar:endDate=$bar we need to determine the // attributes to be effectively updated const startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startDate").shift()?.value||"startDate" const endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endDate").shift()?.value||"endDate" attributes.setAttribute(note, "label", startAttribute, startDate); attributes.setAttribute(note, "label", endAttribute, endDate); } onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) { // Refresh note IDs if they got changed. if (loadResults.getBranchRows().some((branch) => branch.parentNoteId === this.parentNote.noteId)) { this.noteIds = this.parentNote.getChildNoteIds(); } // Refresh calendar on attribute change. if (loadResults.getAttributeRows().some((attribute) => attribute.noteId === this.parentNote.noteId && attribute.name?.startsWith("calendar:"))) { return true; } // Refresh dataset on subnote change. if (this.calendar && loadResults.getAttributeRows().some((a) => this.noteIds.includes(a.noteId ?? ""))) { this.calendar.refetchEvents(); } } async #buildEventsForCalendar(e: EventSourceFuncArg) { const events = []; // Gather all the required date note IDs. const dateRange = utils.getMonthsInDateRange(e.startStr, e.endStr); let allDateNoteIds: string[] = []; for (const month of dateRange) { // TODO: Deduplicate get type. const dateNotesForMonth = await server.get>(`special-notes/notes-for-month/${month}?calendarRoot=${this.parentNote.noteId}`); const dateNoteIds = Object.values(dateNotesForMonth); allDateNoteIds = [...allDateNoteIds, ...dateNoteIds]; } // Request all the date notes. const dateNotes = await froca.getNotes(allDateNoteIds); const childNoteToDateMapping: Record = {}; for (const dateNote of dateNotes) { const startDate = dateNote.getLabelValue("dateNote"); if (!startDate) { continue; } events.push(await CalendarView.buildEvent(dateNote, { startDate })); if (dateNote.hasChildren()) { const childNoteIds = dateNote.getChildNoteIds(); 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 CalendarView.buildEvent(childNote, { startDate }); events.push(event); } return events.flat(); } static async buildEvents(noteIds: string[]) { const notes = await froca.getNotes(noteIds); const events: EventSourceInput = []; for (const note of notes) { const startDate = CalendarView.#getCustomisableLabel(note, "startDate", "calendar:startDate"); if (note.hasChildren()) { const childrenEventData = await this.buildEvents(note.getChildNoteIds()); if (childrenEventData.length > 0) { events.push(childrenEventData); } } if (!startDate) { continue; } const endDate = CalendarView.#getCustomisableLabel(note, "endDate", "calendar:endDate"); const startTime = CalendarView.#getCustomisableLabel(note, "startTime", "calendar:startTime"); const endTime = CalendarView.#getCustomisableLabel(note, "endTime", "calendar:endTime"); events.push(await CalendarView.buildEvent(note, { startDate, endDate, startTime, endTime })); } return events.flat(); } /** * Allows the user to customize the attribute from which to obtain a particular value. For example, if `customLabelNameAttribute` is `calendar:startDate` * and `defaultLabelName` is `startDate` and the note at hand has `#calendar:startDate=myStartDate #myStartDate=2025-02-26` then the value returned will * be `2025-02-26`. If there is no custom attribute value, then the value of the default attribute is returned instead (e.g. `#startDate`). * * @param note the note from which to read the values. * @param defaultLabelName the name of the label in case a custom value is not found. * @param customLabelNameAttribute the name of the label to look for a custom value. * @returns the value of either the custom label or the default label. */ static #getCustomisableLabel(note: FNote, defaultLabelName: string, customLabelNameAttribute: string) { const customAttributeName = note.getLabelValue(customLabelNameAttribute); if (customAttributeName) { const customValue = note.getLabelValue(customAttributeName); if (customValue) { return customValue; } } return note.getLabelValue(defaultLabelName); } static async buildEvent(note: FNote, { startDate, endDate, startTime, endTime }: { startDate: string, endDate?: string | null, startTime?: string | null, endTime?: string | null }) { const customTitleAttributeName = note.getLabelValue("calendar:title"); const titles = await CalendarView.#parseCustomTitle(customTitleAttributeName, note); const color = note.getLabelValue("calendar:color") ?? note.getLabelValue("color"); const events: EventInput[] = []; const calendarDisplayedAttributes = note.getLabelValue("calendar:displayedAttributes")?.split(","); let displayedAttributesData: Array<[string, string]> | null = null; if (calendarDisplayedAttributes) { displayedAttributesData = await this.#buildDisplayedAttributes(note, calendarDisplayedAttributes); } for (const title of titles) { if (endTime && !endDate) { endDate = startDate; } startDate = (startTime ? `${startDate}T${startTime}:00` : startDate); if (!endDate && !startTime) { const endDateOffset = CalendarView.#offsetDate(endDate ?? startDate, 1); if (endDateOffset) { endDate = CalendarView.#formatDateToLocalISO(endDateOffset); } } endDate = (endTime ? `${endDate}T${endTime}:00` : endDate); const eventData: EventInput = { title: title, start: startDate, url: `#${note.noteId}`, noteId: note.noteId, color: color ?? undefined, iconClass: note.getLabelValue("iconClass"), promotedAttributes: displayedAttributesData }; if (endDate) { eventData.end = endDate; } events.push(eventData); } return events; } static async #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; } static async #parseCustomTitle(customTitlettributeName: string | null, note: FNote, allowRelations = true): Promise { 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 = []; for (const targetNote of notesFromRelation) { const targetCustomTitleValue = targetNote.getAttributeValue("label", "calendar:title"); console.log("Parse custom title for ", targetNote.noteId, targetNote.getAttributes(), targetNote.getOwnedAttributes()); const targetTitles = await CalendarView.#parseCustomTitle(targetCustomTitleValue, targetNote, false); titles.push(targetTitles.flat()); } return titles.flat(); } } } return [note.title]; } static #formatDateToLocalISO(date: Date | null | undefined) { if (!date) { return undefined; } const offset = date.getTimezoneOffset(); const localDate = new Date(date.getTime() - offset * 60 * 1000); return localDate.toISOString().split("T")[0]; } static #formatTimeToLocalISO(date: Date | null | undefined) { if (!date) { return undefined; } const offset = date.getTimezoneOffset(); const localDate = new Date(date.getTime() - offset * 60 * 1000); return localDate.toISOString() .split("T")[1] .substring(0, 5); } static #offsetDate(date: Date | string | null | undefined, offset: number) { if (!date) { return undefined; } const newDate = new Date(date); newDate.setDate(newDate.getDate() + offset); return newDate; } }