2025-03-16 21:20:28 +02:00
|
|
|
import type { Calendar, DateSelectArg, DatesSetArg, EventChangeArg, EventDropArg, EventInput, EventSourceFunc, EventSourceFuncArg, 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";
|
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-15 23:48:06 +02:00
|
|
|
import dialogService from "../../services/dialog.js";
|
|
|
|
import attributes from "../../services/attributes.js";
|
2025-04-13 21:18:43 +03:00
|
|
|
import type { CommandListenerData, EventData } from "../../components/app_context.js";
|
2025-04-13 23:09:14 +03:00
|
|
|
import utils, { hasTouchBar } from "../../services/utils.js";
|
2025-02-22 10:12:36 +02:00
|
|
|
import date_notes from "../../services/date_notes.js";
|
|
|
|
import appContext from "../../components/app_context.js";
|
2025-03-16 20:34:05 +02:00
|
|
|
import type { EventImpl } from "@fullcalendar/core/internal";
|
2025-03-16 21:20:28 +02:00
|
|
|
import debounce, { type DebouncedFunction } from "debounce";
|
2025-04-13 23:20:22 +03:00
|
|
|
import type { TouchBarItem } from "../../components/touch_bar.js";
|
2025-04-13 21:18:43 +03:00
|
|
|
import type { SegmentedControlSegment } from "electron";
|
2025-02-13 23:46:20 +02:00
|
|
|
|
2025-04-01 23:24:21 +03:00
|
|
|
const TPL = /*html*/`
|
2025-02-13 23:46:20 +02:00
|
|
|
<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
|
|
|
|
2025-05-29 17:44:00 +03:00
|
|
|
.search-result-widget-content .calendar-view {
|
|
|
|
position: absolute;
|
|
|
|
top: 0;
|
|
|
|
left: 0;
|
|
|
|
right: 0;
|
|
|
|
bottom: 0;
|
|
|
|
}
|
|
|
|
|
2025-02-15 14:07:39 +02:00
|
|
|
.calendar-container {
|
|
|
|
height: 100%;
|
2025-03-16 20:57:57 +02:00
|
|
|
--fc-page-bg-color: var(--main-background-color);
|
2025-03-16 20:53:54 +02:00
|
|
|
--fc-border-color: var(--main-border-color);
|
|
|
|
--fc-neutral-bg-color: var(--launcher-pane-background-color);
|
|
|
|
--fc-list-event-hover-bg-color: var(--left-pane-item-hover-background);
|
2025-02-15 14:07:39 +02:00
|
|
|
}
|
2025-02-15 14:15:43 +02:00
|
|
|
|
|
|
|
.calendar-container .fc-toolbar.fc-header-toolbar {
|
|
|
|
margin-bottom: 0.5em;
|
|
|
|
}
|
|
|
|
|
2025-03-16 20:53:54 +02:00
|
|
|
.calendar-container .fc-list-sticky .fc-list-day > * {
|
|
|
|
z-index: 50;
|
|
|
|
}
|
|
|
|
|
2025-02-16 19:20:59 +02:00
|
|
|
body.desktop:not(.zen) .calendar-container .fc-toolbar.fc-header-toolbar {
|
2025-02-16 18:09:01 +02:00
|
|
|
padding-right: 5em;
|
|
|
|
}
|
|
|
|
|
2025-02-15 14:15:43 +02:00
|
|
|
.calendar-container .fc-toolbar-title {
|
|
|
|
font-size: 1.3rem;
|
|
|
|
font-weight: normal;
|
|
|
|
}
|
|
|
|
|
2025-02-23 19:23:00 +02:00
|
|
|
.calendar-container a.fc-event {
|
|
|
|
text-decoration: none;
|
|
|
|
}
|
|
|
|
|
2025-02-15 14:15:43 +02:00
|
|
|
.calendar-container .fc-button {
|
|
|
|
padding: 0.2em 0.5em;
|
|
|
|
}
|
2025-02-23 19:14:09 +02:00
|
|
|
|
|
|
|
.calendar-container .promoted-attribute {
|
|
|
|
font-size: 0.85em;
|
|
|
|
opacity: 0.85;
|
2025-04-08 23:38:04 +03:00
|
|
|
overflow: hidden;
|
2025-02-23 19:14:09 +02:00
|
|
|
}
|
2025-02-13 23:46:20 +02:00
|
|
|
</style>
|
|
|
|
|
2025-04-13 21:18:43 +03:00
|
|
|
<div class="calendar-container" tabindex="100">
|
2025-02-15 10:23:33 +02:00
|
|
|
</div>
|
2025-02-13 23:46:20 +02:00
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
|
2025-02-15 23:48:06 +02:00
|
|
|
// TODO: Deduplicate
|
|
|
|
interface CreateChildResponse {
|
|
|
|
note: {
|
|
|
|
noteId: string;
|
2025-03-02 20:47:57 +01:00
|
|
|
};
|
2025-02-15 23:48:06 +02:00
|
|
|
}
|
|
|
|
|
2025-05-28 20:42:21 +03:00
|
|
|
interface Event {
|
|
|
|
startDate: string,
|
|
|
|
endDate?: string | null,
|
|
|
|
startTime?: string | null,
|
|
|
|
endTime?: string | null
|
|
|
|
}
|
|
|
|
|
2025-03-16 21:20:28 +02:00
|
|
|
const CALENDAR_VIEWS = [
|
|
|
|
"timeGridWeek",
|
|
|
|
"dayGridMonth",
|
|
|
|
"multiMonthYear",
|
|
|
|
"listMonth"
|
|
|
|
]
|
|
|
|
|
2025-02-13 23:46:20 +02:00
|
|
|
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-15 23:48:06 +02:00
|
|
|
private parentNote: FNote;
|
2025-02-16 13:22:44 +02:00
|
|
|
private calendar?: Calendar;
|
2025-02-22 10:00:18 +02:00
|
|
|
private isCalendarRoot: boolean;
|
2025-03-16 21:20:28 +02:00
|
|
|
private lastView?: string;
|
|
|
|
private debouncedSaveView?: DebouncedFunction<() => void>;
|
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 23:48:06 +02:00
|
|
|
this.parentNote = args.parentNote;
|
2025-02-22 10:00:18 +02:00
|
|
|
this.isCalendarRoot = false;
|
2025-02-15 10:13:47 +02:00
|
|
|
args.$parent.append(this.$root);
|
2025-02-13 23:46:20 +02:00
|
|
|
}
|
|
|
|
|
2025-02-21 17:17:53 +02:00
|
|
|
get isFullHeight(): boolean {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2025-02-13 23:46:20 +02:00
|
|
|
async renderList(): Promise<JQuery<HTMLElement> | undefined> {
|
2025-02-22 10:57:40 +02:00
|
|
|
this.isCalendarRoot = this.parentNote.hasLabel("calendarRoot") || this.parentNote.hasLabel("workspaceCalendarRoot");
|
2025-02-22 10:03:38 +02:00
|
|
|
const isEditable = !this.isCalendarRoot;
|
2025-02-15 12:05:35 +02:00
|
|
|
|
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);
|
2025-03-16 19:46:39 +02:00
|
|
|
plugins.push((await import("@fullcalendar/timegrid")).default);
|
2025-03-16 20:53:54 +02:00
|
|
|
plugins.push((await import("@fullcalendar/list")).default);
|
2025-03-16 20:57:57 +02:00
|
|
|
plugins.push((await import("@fullcalendar/multimonth")).default);
|
2025-02-22 10:12:36 +02:00
|
|
|
if (isEditable || this.isCalendarRoot) {
|
2025-02-15 12:05:35 +02:00
|
|
|
plugins.push((await import("@fullcalendar/interaction")).default);
|
|
|
|
}
|
2025-02-15 10:23:33 +02:00
|
|
|
|
2025-02-22 10:56:10 +02:00
|
|
|
let eventBuilder: EventSourceFunc;
|
|
|
|
if (!this.isCalendarRoot) {
|
2025-02-28 19:03:08 +02:00
|
|
|
eventBuilder = async () => await CalendarView.buildEvents(this.noteIds)
|
2025-02-22 10:56:10 +02:00
|
|
|
} else {
|
|
|
|
eventBuilder = async (e: EventSourceFuncArg) => await this.#buildEventsForCalendar(e);
|
|
|
|
}
|
|
|
|
|
2025-03-16 21:20:28 +02:00
|
|
|
// Parse user's initial view, if valid.
|
|
|
|
let initialView = "dayGridMonth";
|
|
|
|
const userInitialView = this.parentNote.getLabelValue("calendar:view");
|
|
|
|
if (userInitialView && CALENDAR_VIEWS.includes(userInitialView)) {
|
|
|
|
initialView = userInitialView;
|
|
|
|
}
|
|
|
|
|
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-03-16 21:20:28 +02:00
|
|
|
initialView,
|
2025-02-22 10:56:10 +02:00
|
|
|
events: eventBuilder,
|
2025-02-15 23:48:06 +02:00
|
|
|
editable: isEditable,
|
|
|
|
selectable: isEditable,
|
|
|
|
select: (e) => this.#onCalendarSelection(e),
|
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,
|
2025-02-21 17:52:11 +02:00
|
|
|
weekends: !this.parentNote.hasAttribute("label", "calendar:hideWeekends"),
|
2025-02-21 17:54:13 +02:00
|
|
|
weekNumbers: this.parentNote.hasAttribute("label", "calendar:weekNumbers"),
|
2025-02-21 17:17:53 +02:00
|
|
|
locale: await CalendarView.#getLocale(),
|
2025-02-21 18:40:54 +02:00
|
|
|
height: "100%",
|
2025-03-16 20:40:14 +02:00
|
|
|
nowIndicator: true,
|
2025-05-29 15:29:05 +03:00
|
|
|
handleWindowResize: false,
|
2025-04-08 23:28:27 +03:00
|
|
|
eventDidMount: (e) => {
|
|
|
|
const { iconClass, promotedAttributes } = e.event.extendedProps;
|
|
|
|
|
2025-04-08 23:33:57 +03:00
|
|
|
// Prepend the icon to the title, if any.
|
|
|
|
if (iconClass) {
|
|
|
|
let titleContainer;
|
|
|
|
switch (e.view.type) {
|
|
|
|
case "timeGridWeek":
|
|
|
|
case "dayGridMonth":
|
|
|
|
titleContainer = e.el.querySelector(".fc-event-title");
|
|
|
|
break;
|
|
|
|
case "multiMonthYear":
|
|
|
|
break;
|
|
|
|
case "listMonth":
|
|
|
|
titleContainer = e.el.querySelector(".fc-list-event-title a");
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (titleContainer) {
|
|
|
|
const icon = /*html*/`<span class="${iconClass}"></span> `;
|
|
|
|
titleContainer.insertAdjacentHTML("afterbegin", icon);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-04-08 23:28:27 +03:00
|
|
|
// Append promoted attributes to the end of the event container.
|
|
|
|
if (promotedAttributes) {
|
|
|
|
let promotedAttributesHtml = "";
|
|
|
|
for (const [name, value] of promotedAttributes) {
|
2025-04-18 21:13:03 +02:00
|
|
|
promotedAttributesHtml = promotedAttributesHtml + /*html*/`\
|
2025-04-08 23:28:27 +03:00
|
|
|
<div class="promoted-attribute">
|
|
|
|
<span class="promoted-attribute-name">${name}</span>: <span class="promoted-attribute-value">${value}</span>
|
|
|
|
</div>`;
|
|
|
|
}
|
|
|
|
|
|
|
|
let mainContainer;
|
|
|
|
switch (e.view.type) {
|
|
|
|
case "timeGridWeek":
|
2025-04-08 23:38:04 +03:00
|
|
|
case "dayGridMonth":
|
2025-04-08 23:28:27 +03:00
|
|
|
mainContainer = e.el.querySelector(".fc-event-main");
|
|
|
|
break;
|
2025-04-08 23:38:04 +03:00
|
|
|
case "multiMonthYear":
|
|
|
|
break;
|
2025-04-08 23:28:27 +03:00
|
|
|
case "listMonth":
|
|
|
|
mainContainer = e.el.querySelector(".fc-list-event-title");
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
$(mainContainer ?? e.el).append($(promotedAttributesHtml));
|
|
|
|
}
|
|
|
|
},
|
2025-02-22 10:12:36 +02:00
|
|
|
dateClick: async (e) => {
|
|
|
|
if (!this.isCalendarRoot) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const note = await date_notes.getDayNote(e.dateStr);
|
|
|
|
if (note) {
|
2025-03-03 21:02:18 +01:00
|
|
|
appContext.tabManager.getActiveContext()?.setNote(note.noteId);
|
2025-02-22 10:12:36 +02:00
|
|
|
}
|
2025-03-16 20:46:59 +02:00
|
|
|
},
|
2025-03-16 21:20:28 +02:00
|
|
|
datesSet: (e) => this.#onDatesSet(e),
|
2025-03-16 20:46:59 +02:00
|
|
|
headerToolbar: {
|
|
|
|
start: "title",
|
2025-03-16 21:20:28 +02:00
|
|
|
end: `${CALENDAR_VIEWS.join(",")} today prev,next`
|
2025-02-22 10:12:36 +02:00
|
|
|
}
|
2025-02-15 12:26:58 +02:00
|
|
|
});
|
|
|
|
calendar.render();
|
2025-02-16 13:22:44 +02:00
|
|
|
this.calendar = calendar;
|
2025-02-15 12:05:35 +02:00
|
|
|
|
2025-05-29 15:29:05 +03:00
|
|
|
new ResizeObserver(() => calendar.updateSize())
|
|
|
|
.observe(this.$calendarContainer[0]);
|
|
|
|
|
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-03-16 21:20:28 +02:00
|
|
|
#onDatesSet(e: DatesSetArg) {
|
|
|
|
const currentView = e.view.type;
|
|
|
|
if (currentView === this.lastView) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!this.debouncedSaveView) {
|
|
|
|
this.debouncedSaveView = debounce(() => {
|
|
|
|
if (this.lastView) {
|
|
|
|
attributes.setLabel(this.parentNote.noteId, "calendar:view", this.lastView);
|
|
|
|
}
|
|
|
|
}, 1_000);
|
|
|
|
}
|
|
|
|
|
|
|
|
this.debouncedSaveView();
|
|
|
|
this.lastView = currentView;
|
2025-04-13 21:31:20 +03:00
|
|
|
|
2025-04-13 23:09:14 +03:00
|
|
|
if (hasTouchBar) {
|
|
|
|
appContext.triggerCommand("refreshTouchBar");
|
|
|
|
}
|
2025-03-16 21:20:28 +02:00
|
|
|
}
|
|
|
|
|
2025-02-15 23:48:06 +02:00
|
|
|
async #onCalendarSelection(e: DateSelectArg) {
|
2025-03-16 20:09:21 +02:00
|
|
|
// Handle start and end date
|
2025-03-16 20:34:05 +02:00
|
|
|
const { startDate, endDate } = this.#parseStartEndDateFromEvent(e);
|
2025-02-15 23:48:06 +02:00
|
|
|
if (!startDate) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2025-03-16 20:09:21 +02:00
|
|
|
// Handle start and end time.
|
2025-03-16 20:34:05 +02:00
|
|
|
const { startTime, endTime } = this.#parseStartEndTimeFromEvent(e);
|
2025-03-16 20:09:21 +02:00
|
|
|
|
|
|
|
// Ask for the title
|
2025-02-15 23:48:06 +02:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2025-03-16 20:09:21 +02:00
|
|
|
// Create the note.
|
2025-02-15 23:48:06 +02:00
|
|
|
const { note } = await server.post<CreateChildResponse>(`notes/${this.parentNote.noteId}/children?target=into`, {
|
|
|
|
title,
|
|
|
|
content: "",
|
|
|
|
type: "text"
|
|
|
|
});
|
2025-03-16 20:09:21 +02:00
|
|
|
|
|
|
|
// Set the attributes.
|
2025-02-15 23:48:06 +02:00
|
|
|
attributes.setLabel(note.noteId, "startDate", startDate);
|
|
|
|
if (endDate) {
|
|
|
|
attributes.setLabel(note.noteId, "endDate", endDate);
|
|
|
|
}
|
2025-03-16 20:09:21 +02:00
|
|
|
if (startTime) {
|
|
|
|
attributes.setLabel(note.noteId, "startTime", startTime);
|
|
|
|
}
|
|
|
|
if (endTime) {
|
|
|
|
attributes.setLabel(note.noteId, "endTime", endTime);
|
|
|
|
}
|
2025-02-15 23:48:06 +02:00
|
|
|
}
|
|
|
|
|
2025-03-16 20:34:05 +02:00
|
|
|
#parseStartEndDateFromEvent(e: DateSelectArg | EventImpl) {
|
|
|
|
const startDate = CalendarView.#formatDateToLocalISO(e.start);
|
|
|
|
if (!startDate) {
|
|
|
|
return { startDate: null, endDate: null };
|
|
|
|
}
|
|
|
|
let endDate;
|
|
|
|
if (e.allDay) {
|
|
|
|
endDate = CalendarView.#formatDateToLocalISO(CalendarView.#offsetDate(e.end, -1));
|
|
|
|
} else {
|
|
|
|
endDate = CalendarView.#formatDateToLocalISO(e.end);
|
|
|
|
}
|
|
|
|
return { startDate, endDate };
|
|
|
|
}
|
|
|
|
|
|
|
|
#parseStartEndTimeFromEvent(e: DateSelectArg | EventImpl) {
|
2025-05-28 20:42:21 +03:00
|
|
|
let startTime: string | undefined | null = null;
|
|
|
|
let endTime: string | undefined | null = null;
|
2025-03-16 20:34:05 +02:00
|
|
|
if (!e.allDay) {
|
|
|
|
startTime = CalendarView.#formatTimeToLocalISO(e.start);
|
|
|
|
endTime = CalendarView.#formatTimeToLocalISO(e.end);
|
|
|
|
}
|
|
|
|
|
|
|
|
return { startTime, endTime };
|
|
|
|
}
|
|
|
|
|
2025-02-15 12:26:58 +02:00
|
|
|
async #onEventMoved(e: EventChangeArg) {
|
2025-03-16 20:34:05 +02:00
|
|
|
// Handle start and end date
|
|
|
|
let { startDate, endDate } = this.#parseStartEndDateFromEvent(e.event);
|
|
|
|
if (!startDate) {
|
|
|
|
return;
|
|
|
|
}
|
2025-02-15 12:26:58 +02:00
|
|
|
const noteId = e.event.extendedProps.noteId;
|
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;
|
|
|
|
}
|
|
|
|
|
2025-03-09 13:03:15 +01:00
|
|
|
// Since they can be customized via calendar:startDate=$foo and calendar:endDate=$bar we need to determine the
|
|
|
|
// attributes to be effectively updated
|
2025-03-16 20:34:05 +02:00
|
|
|
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";
|
2025-03-09 13:03:15 +01:00
|
|
|
|
|
|
|
attributes.setAttribute(note, "label", startAttribute, startDate);
|
|
|
|
attributes.setAttribute(note, "label", endAttribute, endDate);
|
2025-03-16 20:34:05 +02:00
|
|
|
|
|
|
|
// Update start time and end time if needed.
|
|
|
|
if (!e.event.allDay) {
|
|
|
|
const startAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:startTime").shift()?.value||"startTime";
|
|
|
|
const endAttribute = note.getAttributes("label").filter(attr => attr.name == "calendar:endTime").shift()?.value||"endTime";
|
|
|
|
|
|
|
|
const { startTime, endTime } = this.#parseStartEndTimeFromEvent(e.event);
|
|
|
|
attributes.setAttribute(note, "label", startAttribute, startTime);
|
|
|
|
attributes.setAttribute(note, "label", endAttribute, endTime);
|
|
|
|
}
|
2025-02-13 23:46:20 +02:00
|
|
|
}
|
|
|
|
|
2025-02-21 17:52:11 +02:00
|
|
|
onEntitiesReloaded({ loadResults }: EventData<"entitiesReloaded">) {
|
2025-02-16 13:22:44 +02:00
|
|
|
// Refresh note IDs if they got changed.
|
2025-02-21 17:52:11 +02:00
|
|
|
if (loadResults.getBranchRows().some((branch) => branch.parentNoteId === this.parentNote.noteId)) {
|
2025-02-16 13:22:44 +02:00
|
|
|
this.noteIds = this.parentNote.getChildNoteIds();
|
|
|
|
}
|
|
|
|
|
2025-02-21 17:52:11 +02:00
|
|
|
// Refresh calendar on attribute change.
|
2025-03-16 21:20:28 +02:00
|
|
|
if (loadResults.getAttributeRows().some((attribute) => attribute.noteId === this.parentNote.noteId && attribute.name?.startsWith("calendar:") && attribute.name !== "calendar:view")) {
|
2025-02-21 17:52:11 +02:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Refresh dataset on subnote change.
|
2025-02-16 13:22:44 +02:00
|
|
|
if (this.calendar && loadResults.getAttributeRows().some((a) => this.noteIds.includes(a.noteId ?? ""))) {
|
|
|
|
this.calendar.refetchEvents();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-02-22 10:56:10 +02:00
|
|
|
async #buildEventsForCalendar(e: EventSourceFuncArg) {
|
2025-05-28 20:42:21 +03:00
|
|
|
const events: EventInput[] = [];
|
2025-02-22 10:56:10 +02:00
|
|
|
|
|
|
|
// 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.
|
2025-02-22 11:31:26 +02:00
|
|
|
const dateNotesForMonth = await server.get<Record<string, string>>(`special-notes/notes-for-month/${month}?calendarRoot=${this.parentNote.noteId}`);
|
2025-02-22 10:56:10 +02:00
|
|
|
const dateNoteIds = Object.values(dateNotesForMonth);
|
2025-03-02 20:47:57 +01:00
|
|
|
allDateNoteIds = [...allDateNoteIds, ...dateNoteIds];
|
2025-02-22 10:56:10 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
}
|
|
|
|
|
2025-03-16 19:53:20 +02:00
|
|
|
events.push(await CalendarView.buildEvent(dateNote, { startDate }));
|
2025-02-22 10:56:10 +02:00
|
|
|
|
|
|
|
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];
|
2025-03-16 19:53:20 +02:00
|
|
|
const event = await CalendarView.buildEvent(childNote, { startDate });
|
2025-02-22 10:56:10 +02:00
|
|
|
events.push(event);
|
|
|
|
}
|
|
|
|
|
|
|
|
return events.flat();
|
|
|
|
}
|
|
|
|
|
2025-02-28 19:03:08 +02:00
|
|
|
static async buildEvents(noteIds: string[]) {
|
2025-02-15 10:43:46 +02:00
|
|
|
const notes = await froca.getNotes(noteIds);
|
|
|
|
const events: EventSourceInput = [];
|
|
|
|
|
|
|
|
for (const note of notes) {
|
2025-02-27 00:02:58 +02:00
|
|
|
const startDate = CalendarView.#getCustomisableLabel(note, "startDate", "calendar:startDate");
|
2025-02-15 11:13:44 +02:00
|
|
|
|
2025-02-22 09:47:48 +02:00
|
|
|
if (note.hasChildren()) {
|
2025-02-28 19:03:08 +02:00
|
|
|
const childrenEventData = await this.buildEvents(note.getChildNoteIds());
|
2025-02-22 09:47:48 +02:00
|
|
|
if (childrenEventData.length > 0) {
|
|
|
|
events.push(childrenEventData);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-02-23 18:39:34 +02:00
|
|
|
if (!startDate) {
|
|
|
|
continue;
|
|
|
|
}
|
2025-02-15 10:43:46 +02:00
|
|
|
|
2025-02-27 00:02:58 +02:00
|
|
|
const endDate = CalendarView.#getCustomisableLabel(note, "endDate", "calendar:endDate");
|
2025-03-16 19:53:20 +02:00
|
|
|
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 }));
|
2025-02-15 10:43:46 +02:00
|
|
|
}
|
|
|
|
|
2025-02-22 09:47:48 +02:00
|
|
|
return events.flat();
|
2025-02-15 10:43:46 +02:00
|
|
|
}
|
|
|
|
|
2025-02-27 00:02:58 +02:00
|
|
|
/**
|
|
|
|
* Allows the user to customize the attribute from which to obtain a particular value. For example, if `customLabelNameAttribute` is `calendar:startDate`
|
2025-03-09 22:44:45 +01:00
|
|
|
* and `defaultLabelName` is `startDate` and the note at hand has `#calendar:startDate=myStartDate #myStartDate=2025-02-26` then the value returned will
|
2025-02-27 00:02:58 +02:00
|
|
|
* 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);
|
2025-03-09 22:44:45 +01:00
|
|
|
if (customAttributeName) {
|
|
|
|
const customValue = note.getLabelValue(customAttributeName);
|
2025-02-27 00:02:58 +02:00
|
|
|
if (customValue) {
|
|
|
|
return customValue;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return note.getLabelValue(defaultLabelName);
|
|
|
|
}
|
|
|
|
|
2025-05-28 20:42:21 +03:00
|
|
|
static async buildEvent(note: FNote, { startDate, endDate, startTime, endTime }: Event) {
|
2025-03-09 22:44:45 +01:00
|
|
|
const customTitleAttributeName = note.getLabelValue("calendar:title");
|
|
|
|
const titles = await CalendarView.#parseCustomTitle(customTitleAttributeName, note);
|
2025-02-22 10:56:10 +02:00
|
|
|
const color = note.getLabelValue("calendar:color") ?? note.getLabelValue("color");
|
|
|
|
const events: EventInput[] = [];
|
2025-02-23 19:14:09 +02:00
|
|
|
|
2025-03-08 22:17:58 +01:00
|
|
|
const calendarDisplayedAttributes = note.getLabelValue("calendar:displayedAttributes")?.split(",");
|
2025-03-09 21:26:41 +01:00
|
|
|
let displayedAttributesData: Array<[string, string]> | null = null;
|
2025-03-08 22:17:58 +01:00
|
|
|
if (calendarDisplayedAttributes) {
|
|
|
|
displayedAttributesData = await this.#buildDisplayedAttributes(note, calendarDisplayedAttributes);
|
2025-02-22 23:34:14 +01:00
|
|
|
}
|
|
|
|
|
2025-02-22 10:56:10 +02:00
|
|
|
for (const title of titles) {
|
2025-03-16 21:56:19 +02:00
|
|
|
if (startTime && endTime && !endDate) {
|
2025-03-16 20:16:52 +02:00
|
|
|
endDate = startDate;
|
|
|
|
}
|
|
|
|
|
2025-03-16 20:00:43 +02:00
|
|
|
startDate = (startTime ? `${startDate}T${startTime}:00` : startDate);
|
2025-03-16 21:56:19 +02:00
|
|
|
if (!startTime) {
|
2025-03-16 20:16:52 +02:00
|
|
|
const endDateOffset = CalendarView.#offsetDate(endDate ?? startDate, 1);
|
|
|
|
if (endDateOffset) {
|
|
|
|
endDate = CalendarView.#formatDateToLocalISO(endDateOffset);
|
2025-03-16 19:53:20 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
endDate = (endTime ? `${endDate}T${endTime}:00` : endDate);
|
2025-02-22 10:56:10 +02:00
|
|
|
const eventData: EventInput = {
|
2025-02-23 19:14:09 +02:00
|
|
|
title: title,
|
2025-02-22 10:56:10 +02:00
|
|
|
start: startDate,
|
|
|
|
url: `#${note.noteId}`,
|
|
|
|
noteId: note.noteId,
|
|
|
|
color: color ?? undefined,
|
2025-02-23 19:14:09 +02:00
|
|
|
iconClass: note.getLabelValue("iconClass"),
|
2025-03-08 22:17:58 +01:00
|
|
|
promotedAttributes: displayedAttributesData
|
2025-02-22 10:56:10 +02:00
|
|
|
};
|
2025-03-16 19:53:20 +02:00
|
|
|
if (endDate) {
|
|
|
|
eventData.end = endDate;
|
2025-02-22 10:56:10 +02:00
|
|
|
}
|
|
|
|
events.push(eventData);
|
|
|
|
}
|
|
|
|
return events;
|
|
|
|
}
|
|
|
|
|
2025-03-08 22:17:58 +01:00
|
|
|
static async #buildDisplayedAttributes(note: FNote, calendarDisplayedAttributes: string[]) {
|
|
|
|
const filteredDisplayedAttributes = note.getAttributes().filter((attr): boolean => calendarDisplayedAttributes.includes(attr.name))
|
2025-03-09 21:26:41 +01:00
|
|
|
const result: Array<[string, string]> = [];
|
2025-02-23 19:14:09 +02:00
|
|
|
|
2025-03-08 22:17:58 +01:00
|
|
|
for (const attribute of filteredDisplayedAttributes) {
|
2025-03-09 21:26:41 +01:00
|
|
|
if (attribute.type === "label") result.push([attribute.name, attribute.value]);
|
|
|
|
else result.push([attribute.name, (await attribute.getTargetNote())?.title || ""])
|
2025-02-23 19:14:09 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
2025-03-09 22:44:45 +01:00
|
|
|
static async #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);
|
2025-02-15 11:41:08 +02:00
|
|
|
if (relations.length > 0) {
|
|
|
|
const noteIds = relations.map((r) => r.targetNoteId);
|
|
|
|
const notesFromRelation = await froca.getNotes(noteIds);
|
2025-05-28 20:42:21 +03:00
|
|
|
const titles: string[][] = [];
|
2025-02-15 11:41:08 +02:00
|
|
|
|
|
|
|
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-03-02 20:47:57 +01:00
|
|
|
return [note.title];
|
2025-02-15 11:13:44 +02:00
|
|
|
}
|
|
|
|
|
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);
|
2025-03-02 20:47:57 +01:00
|
|
|
return localDate.toISOString().split("T")[0];
|
2025-02-15 12:26:58 +02:00
|
|
|
}
|
|
|
|
|
2025-03-16 20:09:21 +02:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2025-02-15 23:48:06 +02:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2025-04-13 21:18:43 +03:00
|
|
|
buildTouchBarCommand({ TouchBar, buildIcon }: CommandListenerData<"buildTouchBar">) {
|
|
|
|
if (!this.calendar) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const items: TouchBarItem[] = [];
|
|
|
|
const $toolbarItems = this.$calendarContainer.find(".fc-toolbar-chunk .fc-button-group, .fc-toolbar-chunk > button");
|
|
|
|
|
|
|
|
for (const item of $toolbarItems) {
|
|
|
|
// Button groups.
|
|
|
|
if (item.classList.contains("fc-button-group")) {
|
|
|
|
let mode: "single" | "buttons" = "single";
|
2025-04-13 21:31:20 +03:00
|
|
|
let selectedIndex = 0;
|
2025-04-13 21:18:43 +03:00
|
|
|
const segments: SegmentedControlSegment[] = [];
|
|
|
|
const subItems = item.childNodes as NodeListOf<HTMLElement>;
|
2025-04-13 21:31:20 +03:00
|
|
|
let index = 0;
|
2025-04-13 21:18:43 +03:00
|
|
|
for (const subItem of subItems) {
|
2025-04-13 21:31:20 +03:00
|
|
|
if (subItem.ariaPressed === "true") {
|
|
|
|
selectedIndex = index;
|
|
|
|
}
|
|
|
|
index++;
|
|
|
|
|
2025-04-13 21:18:43 +03:00
|
|
|
// Text button.
|
|
|
|
if (subItem.innerText) {
|
|
|
|
segments.push({ label: subItem.innerText });
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Icon button.
|
|
|
|
const iconEl = subItem.querySelector("span.fc-icon");
|
2025-05-28 20:42:21 +03:00
|
|
|
let icon: string | null = null;
|
2025-04-13 21:18:43 +03:00
|
|
|
if (iconEl?.classList.contains("fc-icon-chevron-left")) {
|
|
|
|
icon = "NSImageNameTouchBarGoBackTemplate";
|
|
|
|
mode = "buttons";
|
|
|
|
} else if (iconEl?.classList.contains("fc-icon-chevron-right")) {
|
|
|
|
icon = "NSImageNameTouchBarGoForwardTemplate";
|
|
|
|
mode = "buttons";
|
|
|
|
}
|
|
|
|
|
|
|
|
if (icon) {
|
|
|
|
segments.push({
|
|
|
|
icon: buildIcon(icon)
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
items.push(new TouchBar.TouchBarSegmentedControl({
|
|
|
|
mode,
|
|
|
|
segments,
|
2025-04-13 21:31:20 +03:00
|
|
|
selectedIndex,
|
|
|
|
change: (selectedIndex, isSelected) => subItems[selectedIndex].click()
|
2025-04-13 21:18:43 +03:00
|
|
|
}));
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Standalone item.
|
|
|
|
if (item.innerText) {
|
|
|
|
items.push(new TouchBar.TouchBarButton({
|
|
|
|
label: item.innerText,
|
|
|
|
click: () => item.click()
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return items;
|
|
|
|
}
|
|
|
|
|
2025-02-13 23:46:20 +02:00
|
|
|
}
|