2025-02-15 12:26:58 +02:00
|
|
|
import type { EventChangeArg, EventDropArg, EventSourceInput, PluginDef } from "@fullcalendar/core";
|
2025-02-15 10:43:46 +02:00
|
|
|
import froca from "../../services/froca.js";
|
2025-02-15 10:13:47 +02:00
|
|
|
import ViewMode, { type ViewModeArgs } from "./view_mode.js";
|
2025-02-15 11:13:44 +02:00
|
|
|
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";
|
2025-02-15 20:18:27 +02:00
|
|
|
import options from "../../services/options.js";
|
2025-02-13 23:46:20 +02:00
|
|
|
|
|
|
|
const TPL = `
|
|
|
|
<div class="calendar-view">
|
|
|
|
<style>
|
|
|
|
.calendar-view {
|
|
|
|
overflow: hidden;
|
|
|
|
position: relative;
|
|
|
|
height: 100%;
|
2025-02-15 10:23:57 +02:00
|
|
|
user-select: none;
|
2025-02-15 10:35:14 +02:00
|
|
|
padding: 10px;
|
2025-02-13 23:46:20 +02:00
|
|
|
}
|
2025-02-15 10:24:40 +02:00
|
|
|
|
|
|
|
.calendar-view a {
|
|
|
|
color: unset;
|
|
|
|
}
|
2025-02-15 14:07:39 +02:00
|
|
|
|
|
|
|
.calendar-container {
|
|
|
|
height: 100%;
|
|
|
|
}
|
2025-02-15 14:15:43 +02:00
|
|
|
|
|
|
|
.calendar-container .fc-toolbar.fc-header-toolbar {
|
|
|
|
margin-bottom: 0.5em;
|
|
|
|
}
|
|
|
|
|
|
|
|
.calendar-container .fc-toolbar-title {
|
|
|
|
font-size: 1.3rem;
|
|
|
|
font-weight: normal;
|
|
|
|
}
|
|
|
|
|
|
|
|
.calendar-container .fc-button {
|
|
|
|
padding: 0.2em 0.5em;
|
|
|
|
}
|
2025-02-13 23:46:20 +02:00
|
|
|
</style>
|
|
|
|
|
2025-02-15 10:23:33 +02:00
|
|
|
<div class="calendar-container">
|
|
|
|
</div>
|
2025-02-13 23:46:20 +02:00
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
|
|
|
|
export default class CalendarView extends ViewMode {
|
|
|
|
|
|
|
|
private $root: JQuery<HTMLElement>;
|
2025-02-15 10:23:33 +02:00
|
|
|
private $calendarContainer: JQuery<HTMLElement>;
|
2025-02-15 10:43:46 +02:00
|
|
|
private noteIds: string[];
|
2025-02-13 23:46:20 +02:00
|
|
|
|
2025-02-15 10:13:47 +02:00
|
|
|
constructor(args: ViewModeArgs) {
|
|
|
|
super(args);
|
2025-02-13 23:46:20 +02:00
|
|
|
|
|
|
|
this.$root = $(TPL);
|
2025-02-15 10:23:33 +02:00
|
|
|
this.$calendarContainer = this.$root.find(".calendar-container");
|
2025-02-15 10:43:46 +02:00
|
|
|
this.noteIds = args.noteIds;
|
2025-02-15 10:13:47 +02:00
|
|
|
args.$parent.append(this.$root);
|
2025-02-13 23:46:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
async renderList(): Promise<JQuery<HTMLElement> | undefined> {
|
2025-02-15 12:05:35 +02:00
|
|
|
const editable = true;
|
|
|
|
|
2025-02-15 10:23:33 +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 (editable) {
|
|
|
|
plugins.push((await import("@fullcalendar/interaction")).default);
|
|
|
|
}
|
2025-02-15 10:23:33 +02:00
|
|
|
|
|
|
|
const calendar = new Calendar(this.$calendarContainer[0], {
|
2025-02-15 12:05:35 +02:00
|
|
|
plugins,
|
2025-02-15 10:43:46 +02:00
|
|
|
initialView: "dayGridMonth",
|
2025-02-15 12:05:35 +02:00
|
|
|
events: await CalendarView.#buildEvents(this.noteIds),
|
|
|
|
editable,
|
2025-02-15 12:26:58 +02:00
|
|
|
eventChange: (e) => this.#onEventMoved(e),
|
2025-02-15 21:45:53 +02:00
|
|
|
firstDay: options.getInt("firstDayOfWeek") ?? 0,
|
|
|
|
locale: await CalendarView.#getLocale()
|
2025-02-15 12:26:58 +02:00
|
|
|
});
|
|
|
|
calendar.render();
|
2025-02-15 12:05:35 +02:00
|
|
|
|
2025-02-15 12:26:58 +02:00
|
|
|
return this.$root;
|
|
|
|
}
|
2025-02-15 12:05:35 +02:00
|
|
|
|
2025-02-15 21:45:53 +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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-02-15 12:26:58 +02:00
|
|
|
async #onEventMoved(e: EventChangeArg) {
|
|
|
|
const startDate = CalendarView.#formatDateToLocalISO(e.event.start);
|
|
|
|
let endDate = CalendarView.#formatDateToLocalISO(e.event.end);
|
|
|
|
const noteId = e.event.extendedProps.noteId;
|
2025-02-15 12:05:35 +02:00
|
|
|
|
2025-02-15 12:26:58 +02:00
|
|
|
// 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);
|
|
|
|
}
|
2025-02-15 12:05:35 +02:00
|
|
|
|
2025-02-15 12:26:58 +02:00
|
|
|
// Don't store the end date if it's empty.
|
|
|
|
if (endDate === startDate) {
|
|
|
|
endDate = undefined;
|
|
|
|
}
|
2025-02-15 10:23:33 +02:00
|
|
|
|
2025-02-15 12:26:58 +02:00
|
|
|
// Update start date
|
|
|
|
const note = await froca.getNote(noteId);
|
|
|
|
if (!note) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
CalendarView.#setAttribute(note, "label", "startDate", startDate);
|
|
|
|
CalendarView.#setAttribute(note, "label", "endDate", endDate);
|
2025-02-13 23:46:20 +02:00
|
|
|
}
|
|
|
|
|
2025-02-15 10:43:46 +02:00
|
|
|
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");
|
2025-02-15 11:13:44 +02:00
|
|
|
const customTitle = note.getAttributeValue("label", "calendar:title");
|
|
|
|
|
2025-02-15 10:43:46 +02:00
|
|
|
if (!startDate) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2025-02-15 11:42:06 +02:00
|
|
|
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}`,
|
|
|
|
noteId: note.noteId
|
2025-02-15 11:42:06 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
const endDate = new Date(note.getAttributeValue("label", "endDate") ?? startDate);
|
|
|
|
if (endDate) {
|
|
|
|
// Fullcalendar end date is exclusive, not inclusive.
|
|
|
|
endDate.setDate(endDate.getDate() + 1);
|
2025-02-15 12:26:58 +02:00
|
|
|
eventData.end = CalendarView.#formatDateToLocalISO(endDate);
|
2025-02-15 11:42:06 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
events.push(eventData);
|
2025-02-15 10:58:12 +02:00
|
|
|
}
|
2025-02-15 10:43:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return events;
|
|
|
|
}
|
|
|
|
|
2025-02-15 11:41:08 +02:00
|
|
|
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();
|
|
|
|
}
|
2025-02-15 11:13:44 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-02-15 11:41:08 +02:00
|
|
|
return [ note.title ];
|
2025-02-15 11:13:44 +02:00
|
|
|
}
|
|
|
|
|
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.
|
2025-02-15 12:26:58 +02:00
|
|
|
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();
|
|
|
|
}
|
|
|
|
|
2025-02-15 12:26:58 +02:00
|
|
|
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];
|
|
|
|
}
|
|
|
|
|
2025-02-13 23:46:20 +02:00
|
|
|
}
|