Notes/src/public/app/widgets/view_widgets/calendar_view.ts

337 lines
12 KiB
TypeScript
Raw Normal View History

import type { Calendar, DateSelectArg, 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";
2025-02-15 12:05:35 +02:00
import server from "../../services/server.js";
import ws from "../../services/ws.js";
2025-02-15 20:15:54 +02:00
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";
const TPL = `
<div class="calendar-view">
<style>
.calendar-view {
overflow: hidden;
position: relative;
height: 100%;
user-select: none;
2025-02-15 10:35:14 +02:00
padding: 10px;
}
.calendar-view a {
color: unset;
}
2025-02-15 14:07:39 +02:00
.calendar-container {
height: 100%;
}
.calendar-container .fc-toolbar.fc-header-toolbar {
margin-bottom: 0.5em;
}
body.desktop:not(.zen) .calendar-container .fc-toolbar.fc-header-toolbar {
padding-right: 5em;
}
.calendar-container .fc-toolbar-title {
font-size: 1.3rem;
font-weight: normal;
}
.calendar-container .fc-button {
padding: 0.2em 0.5em;
}
</style>
<div class="calendar-container">
</div>
</div>
`;
// TODO: Deduplicate
interface CreateChildResponse {
note: {
noteId: string;
}
}
export default class CalendarView extends ViewMode {
private $root: JQuery<HTMLElement>;
private $calendarContainer: JQuery<HTMLElement>;
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<JQuery<HTMLElement> | undefined> {
const isEditable = true;
2025-02-15 12:05:35 +02:00
const { Calendar } = await import("@fullcalendar/core");
2025-02-15 12:05:35 +02:00
const plugins: PluginDef[] = [];
plugins.push((await import("@fullcalendar/daygrid")).default);
if (isEditable) {
2025-02-15 12:05:35 +02:00
plugins.push((await import("@fullcalendar/interaction")).default);
}
const calendar = new Calendar(this.$calendarContainer[0], {
2025-02-15 12:05:35 +02:00
plugins,
initialView: "dayGridMonth",
events: async () => await this.#buildEvents(this.noteIds),
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 = e.event.extendedProps.iconClass;
if (iconClass) {
html += `<span class="${iconClass}"></span> `;
}
html += utils.escapeHtml(e.event.title);
return { html };
})
});
calendar.render();
this.calendar = calendar;
this.isCalendarRoot = this.parentNote.hasLabel("calendarRoot");
2025-02-15 12:05:35 +02:00
return this.$root;
}
2025-02-15 12:05:35 +02:00
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) {
const startDate = CalendarView.#formatDateToLocalISO(e.start);
if (!startDate) {
return;
}
const endDate = CalendarView.#formatDateToLocalISO(CalendarView.#offsetDate(e.end, -1));
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;
}
const { note } = await server.post<CreateChildResponse>(`notes/${this.parentNote.noteId}/children?target=into`, {
title,
content: "",
type: "text"
});
attributes.setLabel(note.noteId, "startDate", startDate);
if (endDate) {
attributes.setLabel(note.noteId, "endDate", endDate);
}
}
async #onEventMoved(e: EventChangeArg) {
const startDate = CalendarView.#formatDateToLocalISO(e.event.start);
2025-02-16 19:57:44 +02:00
// 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;
2025-02-15 12:05:35 +02:00
// 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);
}
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 #buildEvents(noteIds: string[], enforcedStartDate?: string) {
const notes = await froca.getNotes(noteIds);
const events: EventSourceInput = [];
for (const note of notes) {
let startDate;
if (enforcedStartDate) {
startDate = enforcedStartDate;
} else if (!this.isCalendarRoot) {
startDate = note.getLabelValue("startDate");
} else {
startDate = note.getLabelValue("dateNote");
}
const customTitle = note.getAttributeValue("label", "calendar:title");
2025-02-17 22:20:20 +01:00
const color = note.getAttributeValue("label", "calendar:color") ?? note.getAttributeValue("label", "color") ?? undefined;
if (note.hasChildren()) {
const dateNote = note.getLabelValue("dateNote");
let enforcedStartDate = undefined;
if (dateNote) {
// This is a day note which can have children. Make sure the children are added to the calendar even if they themselves don't have it.
enforcedStartDate = dateNote;
}
const childrenEventData = await this.#buildEvents(note.getChildNoteIds(), enforcedStartDate);
if (childrenEventData.length > 0) {
events.push(childrenEventData);
}
}
if (!startDate) {
continue;
}
const titles = await CalendarView.#parseCustomTitle(customTitle, note);
for (const title of titles) {
const eventData: typeof events[0] = {
title: title,
2025-02-15 11:46:17 +02:00
start: startDate,
2025-02-15 12:05:35 +02:00
url: `#${note.noteId}`,
2025-02-17 22:20:20 +01:00
noteId: note.noteId,
color: color,
iconClass: note.getLabelValue("iconClass")
};
const endDate = CalendarView.#offsetDate(note.getAttributeValue("label", "endDate") ?? startDate, 1);
if (endDate) {
eventData.end = CalendarView.#formatDateToLocalISO(endDate);
}
events.push(eventData);
}
}
return events.flat();
}
static async #parseCustomTitle(customTitleValue: string | null, note: FNote, allowRelations = true): Promise<string[]> {
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 ];
}
2025-02-15 12:05:35 +02:00
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;
2025-02-15 12:05:35 +02:00
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];
}
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;
}
}