import type { EventChangeArg, EventDropArg, 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 ws from "../../services/ws.js"; import { t } from "../../services/i18n.js"; import options from "../../services/options.js"; const TPL = `
`; export default class CalendarView extends ViewMode { private $root: JQuery; private $calendarContainer: JQuery; private noteIds: string[]; constructor(args: ViewModeArgs) { super(args); this.$root = $(TPL); this.$calendarContainer = this.$root.find(".calendar-container"); this.noteIds = args.noteIds; args.$parent.append(this.$root); } async renderList(): Promise | undefined> { const editable = true; const { Calendar } = await import("@fullcalendar/core"); const plugins: PluginDef[] = []; plugins.push((await import("@fullcalendar/daygrid")).default); if (editable) { plugins.push((await import("@fullcalendar/interaction")).default); } const calendar = new Calendar(this.$calendarContainer[0], { plugins, initialView: "dayGridMonth", events: await CalendarView.#buildEvents(this.noteIds), editable, eventChange: (e) => this.#onEventMoved(e), buttonText: { today: t("calendar_view.today"), month: t("calendar_view.month"), week: t("calendar_view.week"), day: t("calendar_view.day"), list: t("calendar_view.list") }, firstDay: options.getInt("firstDayOfWeek") ?? 0 }); calendar.render(); return this.$root; } async #onEventMoved(e: EventChangeArg) { const startDate = CalendarView.#formatDateToLocalISO(e.event.start); let endDate = CalendarView.#formatDateToLocalISO(e.event.end); const noteId = e.event.extendedProps.noteId; // Fullcalendar end date is exclusive, not inclusive but we store it the other way around. if (endDate) { const endDateParsed = new Date(endDate); endDateParsed.setDate(endDateParsed.getDate() - 1); endDate = CalendarView.#formatDateToLocalISO(endDateParsed); } // 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; } CalendarView.#setAttribute(note, "label", "startDate", startDate); CalendarView.#setAttribute(note, "label", "endDate", endDate); } static async #buildEvents(noteIds: string[]) { const notes = await froca.getNotes(noteIds); const events: EventSourceInput = []; for (const note of notes) { const startDate = note.getAttributeValue("label", "startDate"); const customTitle = note.getAttributeValue("label", "calendar:title"); if (!startDate) { continue; } const titles = await CalendarView.#parseCustomTitle(customTitle, note); for (const title of titles) { const eventData: typeof events[0] = { title: title, start: startDate, url: `#${note.noteId}`, noteId: note.noteId }; const endDate = new Date(note.getAttributeValue("label", "endDate") ?? startDate); if (endDate) { // Fullcalendar end date is exclusive, not inclusive. endDate.setDate(endDate.getDate() + 1); eventData.end = CalendarView.#formatDateToLocalISO(endDate); } events.push(eventData); } } return events; } static async #parseCustomTitle(customTitleValue: string | null, note: FNote, allowRelations = true): Promise { if (customTitleValue) { const attributeName = customTitleValue.substring(1); if (customTitleValue.startsWith("#")) { const labelValue = note.getAttributeValue("label", attributeName); if (labelValue) { return [ labelValue ]; } } else if (allowRelations && customTitleValue.startsWith("~")) { const relations = note.getRelations(attributeName); 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"); const targetTitles = await CalendarView.#parseCustomTitle(targetCustomTitleValue, targetNote, false); titles.push(targetTitles.flat()); } return titles.flat(); } } } return [ note.title ]; } static async #setAttribute(note: FNote, type: "label" | "relation", name: string, value: string | null | undefined) { if (value) { // Create or update the attribute. await server.put(`notes/${note.noteId}/set-attribute`, { type, name, value }); } else { // Remove the attribute if it exists on the server but we don't define a value for it. const attributeId = note.getAttribute(type, name)?.attributeId; if (attributeId) { await server.remove(`notes/${note.noteId}/attributes/${attributeId}`); } } await ws.waitForMaxKnownEntityChangeId(); } 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]; } }