diff --git a/apps/client/package.json b/apps/client/package.json
index bcc591818d..4a9b78bb67 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -22,6 +22,7 @@
"@fullcalendar/interaction": "6.1.20",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/multimonth": "6.1.20",
+ "@fullcalendar/rrule": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@maplibre/maplibre-gl-leaflet": "0.1.3",
"@mermaid-js/layout-elk": "0.2.0",
@@ -63,6 +64,7 @@
"react-i18next": "16.5.4",
"react-window": "2.2.7",
"reveal.js": "5.2.1",
+ "rrule": "2.8.1",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1",
"vanilla-js-wheel-zoom": "9.0.4"
diff --git a/apps/client/src/menus/electron_context_menu.ts b/apps/client/src/menus/electron_context_menu.ts
index 219a009c7c..6baba6a951 100644
--- a/apps/client/src/menus/electron_context_menu.ts
+++ b/apps/client/src/menus/electron_context_menu.ts
@@ -3,6 +3,8 @@ import options from "../services/options.js";
import zoomService from "../components/zoom.js";
import contextMenu, { type MenuItem } from "./context_menu.js";
import { t } from "../services/i18n.js";
+import server from "../services/server.js";
+import * as clipboardExt from "../services/clipboard_ext.js";
import type { BrowserWindow } from "electron";
import type { CommandNames, AppContext } from "../components/app_context.js";
@@ -60,6 +62,33 @@ function setupContextMenu() {
uiIcon: "bx bx-copy",
handler: () => webContents.copy()
});
+
+ items.push({
+ enabled: hasText,
+ title: t("electron_context_menu.copy-as-markdown"),
+ uiIcon: "bx bx-copy-alt",
+ handler: async () => {
+ const selection = window.getSelection();
+ if (!selection || !selection.rangeCount) return '';
+
+ const range = selection.getRangeAt(0);
+ const div = document.createElement('div');
+ div.appendChild(range.cloneContents());
+
+ const htmlContent = div.innerHTML;
+ if (htmlContent) {
+ try {
+ const { markdownContent } = await server.post<{ markdownContent: string }>(
+ "other/to-markdown",
+ { htmlContent }
+ );
+ await clipboardExt.copyTextWithToast(markdownContent);
+ } catch (error) {
+ console.error("Failed to copy as markdown:", error);
+ }
+ }
+ }
+ });
}
if (!["", "javascript:", "about:blank#blocked"].includes(params.linkURL) && params.mediaType === "none") {
diff --git a/apps/client/src/translations/ar/translation.json b/apps/client/src/translations/ar/translation.json
index 3eaf9a8432..18831c4a8a 100644
--- a/apps/client/src/translations/ar/translation.json
+++ b/apps/client/src/translations/ar/translation.json
@@ -882,6 +882,7 @@
"electron_context_menu": {
"cut": "قص",
"copy": "نسخ",
+ "copy-as-markdown": "نسخ كـ Markdown",
"paste": "لصق",
"copy-link": "نسخ الرابط",
"add-term-to-dictionary": "اضافة \"{{term}}\" الى القاموس",
diff --git a/apps/client/src/translations/cn/translation.json b/apps/client/src/translations/cn/translation.json
index 253d277679..3648e48842 100644
--- a/apps/client/src/translations/cn/translation.json
+++ b/apps/client/src/translations/cn/translation.json
@@ -1760,6 +1760,7 @@
"add-term-to-dictionary": "将 \"{{term}}\" 添加到字典",
"cut": "剪切",
"copy": "复制",
+ "copy-as-markdown": "复制为 Markdown",
"copy-link": "复制链接",
"paste": "粘贴",
"paste-as-plain-text": "以纯文本粘贴",
diff --git a/apps/client/src/translations/de/translation.json b/apps/client/src/translations/de/translation.json
index b90f5f8ebf..9f8bf82b0b 100644
--- a/apps/client/src/translations/de/translation.json
+++ b/apps/client/src/translations/de/translation.json
@@ -1729,6 +1729,7 @@
"add-term-to-dictionary": "Begriff \"{{term}}\" zum Wörterbuch hinzufügen",
"cut": "Ausschneiden",
"copy": "Kopieren",
+ "copy-as-markdown": "Als Markdown kopieren",
"copy-link": "Link kopieren",
"paste": "Einfügen",
"paste-as-plain-text": "Als unformatierten Text einfügen",
diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index 04a372ddae..4bb4c11dd6 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -1810,6 +1810,7 @@
"add-term-to-dictionary": "Add \"{{term}}\" to dictionary",
"cut": "Cut",
"copy": "Copy",
+ "copy-as-markdown": "Copy as Markdown",
"copy-link": "Copy link",
"paste": "Paste",
"paste-as-plain-text": "Paste as plain text",
diff --git a/apps/client/src/translations/es/translation.json b/apps/client/src/translations/es/translation.json
index 7f4538c6d5..2c31f84f2f 100644
--- a/apps/client/src/translations/es/translation.json
+++ b/apps/client/src/translations/es/translation.json
@@ -1778,6 +1778,7 @@
"add-term-to-dictionary": "Agregar \"{{term}}\" al diccionario",
"cut": "Cortar",
"copy": "Copiar",
+ "copy-as-markdown": "Copiar como markdown",
"copy-link": "Copiar enlace",
"paste": "Pegar",
"paste-as-plain-text": "Pegar como texto plano",
diff --git a/apps/client/src/translations/fr/translation.json b/apps/client/src/translations/fr/translation.json
index c1d844ae99..be1265054b 100644
--- a/apps/client/src/translations/fr/translation.json
+++ b/apps/client/src/translations/fr/translation.json
@@ -1689,6 +1689,7 @@
"add-term-to-dictionary": "Ajouter «{{term}}» au dictionnaire",
"cut": "Couper",
"copy": "Copier",
+ "copy-as-markdown": "Copier en markdown",
"copy-link": "Copier le lien",
"paste": "Coller",
"paste-as-plain-text": "Coller comme texte brut",
diff --git a/apps/client/src/translations/ga/translation.json b/apps/client/src/translations/ga/translation.json
index 25b5acaffd..5f8b7f8f0c 100644
--- a/apps/client/src/translations/ga/translation.json
+++ b/apps/client/src/translations/ga/translation.json
@@ -1808,6 +1808,7 @@
"add-term-to-dictionary": "Cuir \"{{term}}\" leis an bhfoclóir",
"cut": "Gearr",
"copy": "Cóipeáil",
+ "copy-as-markdown": "Cóipeáil mar markdown",
"copy-link": "Cóipeáil nasc",
"paste": "Greamaigh",
"paste-as-plain-text": "Greamaigh mar théacs simplí",
diff --git a/apps/client/src/translations/hi/translation.json b/apps/client/src/translations/hi/translation.json
index ac5ed8073d..a48c11c6de 100644
--- a/apps/client/src/translations/hi/translation.json
+++ b/apps/client/src/translations/hi/translation.json
@@ -1810,6 +1810,7 @@
"add-term-to-dictionary": "\"{{term}}\" को डिक्शनरी में जोड़ें",
"cut": "कट (Cut)",
"copy": "कॉपी (Copy)",
+ "copy-as-markdown": "Markdown के रूप में कॉपी करें",
"copy-link": "लिंक कॉपी करें",
"paste": "पेस्ट (Paste)",
"paste-as-plain-text": "प्लेन टेक्स्ट की तरह पेस्ट करें",
diff --git a/apps/client/src/translations/it/translation.json b/apps/client/src/translations/it/translation.json
index 6692b9e984..c1ed534487 100644
--- a/apps/client/src/translations/it/translation.json
+++ b/apps/client/src/translations/it/translation.json
@@ -334,6 +334,7 @@
"electron_context_menu": {
"cut": "Taglia",
"copy": "Copia",
+ "copy-as-markdown": "Copia come markdown",
"paste": "Incolla",
"copy-link": "Copia collegamento",
"paste-as-plain-text": "Incolla come testo semplice",
diff --git a/apps/client/src/translations/ja/translation.json b/apps/client/src/translations/ja/translation.json
index 40cca8bdc9..386712af3b 100644
--- a/apps/client/src/translations/ja/translation.json
+++ b/apps/client/src/translations/ja/translation.json
@@ -1353,6 +1353,7 @@
"add-term-to-dictionary": "辞書に \"{{term}}\" を追加",
"cut": "切り取り",
"copy": "コピー",
+ "copy-as-markdown": "Markdownとしてコピー",
"copy-link": "リンクをコピー",
"paste": "貼り付け",
"paste-as-plain-text": "プレーンテキストで貼り付け",
diff --git a/apps/client/src/translations/pl/translation.json b/apps/client/src/translations/pl/translation.json
index 1202d45124..41ffdb60ae 100644
--- a/apps/client/src/translations/pl/translation.json
+++ b/apps/client/src/translations/pl/translation.json
@@ -614,6 +614,7 @@
"electron_context_menu": {
"cut": "Wytnij",
"copy": "Kopiuj",
+ "copy-as-markdown": "Kopiuj jako markdown",
"copy-link": "Kopiuj link",
"paste": "Wklej",
"paste-as-plain-text": "Wklej jako zwykły tekst",
diff --git a/apps/client/src/translations/pt/translation.json b/apps/client/src/translations/pt/translation.json
index 167d85e8c0..45570c6db4 100644
--- a/apps/client/src/translations/pt/translation.json
+++ b/apps/client/src/translations/pt/translation.json
@@ -1774,6 +1774,7 @@
"add-term-to-dictionary": "Adicionar \"{{term}}\" ao dicionário",
"cut": "Cortar",
"copy": "Copiar",
+ "copy-as-markdown": "Copiar como markdown",
"copy-link": "Copiar ligação",
"paste": "Colar",
"paste-as-plain-text": "Colar como texto sem formatação",
diff --git a/apps/client/src/translations/pt_br/translation.json b/apps/client/src/translations/pt_br/translation.json
index 3db8d8c8e1..22fd6856d1 100644
--- a/apps/client/src/translations/pt_br/translation.json
+++ b/apps/client/src/translations/pt_br/translation.json
@@ -1637,6 +1637,7 @@
"add-term-to-dictionary": "Adicionar \"{{term}}\" ao dicionário",
"cut": "Cortar",
"copy": "Copiar",
+ "copy-as-markdown": "Copiar como markdown",
"copy-link": "Copiar link",
"paste": "Colar",
"paste-as-plain-text": "Colar como texto sem formatação",
diff --git a/apps/client/src/translations/ro/translation.json b/apps/client/src/translations/ro/translation.json
index a7bfd1f00b..e1f5810cf0 100644
--- a/apps/client/src/translations/ro/translation.json
+++ b/apps/client/src/translations/ro/translation.json
@@ -1724,6 +1724,7 @@
"electron_context_menu": {
"add-term-to-dictionary": "Adaugă „{{term}}” în dicționar",
"copy": "Copiază",
+ "copy-as-markdown": "Copiază ca markdown",
"copy-link": "Copiază legătura",
"cut": "Decupează",
"paste": "Lipește",
diff --git a/apps/client/src/translations/ru/translation.json b/apps/client/src/translations/ru/translation.json
index f023814bdc..c3e1344e2d 100644
--- a/apps/client/src/translations/ru/translation.json
+++ b/apps/client/src/translations/ru/translation.json
@@ -708,6 +708,7 @@
"paste": "Вставить",
"copy-link": "Скопировать ссылку",
"copy": "Скопировать",
+ "copy-as-markdown": "Копировать как Markdown",
"cut": "Вырезать",
"search_online": "Поиск \"{{term}}\" в {{searchEngine}}",
"add-term-to-dictionary": "Добавить \"{{term}}\" в словарь",
diff --git a/apps/client/src/translations/tw/translation.json b/apps/client/src/translations/tw/translation.json
index 71eaf097a5..9eecb868e1 100644
--- a/apps/client/src/translations/tw/translation.json
+++ b/apps/client/src/translations/tw/translation.json
@@ -1722,6 +1722,7 @@
"add-term-to-dictionary": "將 \"{{term}}\" 新增至字典",
"cut": "剪下",
"copy": "複製",
+ "copy-as-markdown": "複製為 Markdown",
"copy-link": "複製連結",
"paste": "貼上",
"paste-as-plain-text": "以純文字貼上",
diff --git a/apps/client/src/translations/uk/translation.json b/apps/client/src/translations/uk/translation.json
index 8861fca0bc..6c1951a035 100644
--- a/apps/client/src/translations/uk/translation.json
+++ b/apps/client/src/translations/uk/translation.json
@@ -1533,6 +1533,7 @@
"add-term-to-dictionary": "Додати \"{{term}}\" до словника",
"cut": "Вирізати",
"copy": "Копіювати",
+ "copy-as-markdown": "Копіювати як Markdown",
"copy-link": "Копіювати посилання",
"paste": "Вставити",
"paste-as-plain-text": "Вставити як звичайний текст",
diff --git a/apps/client/src/widgets/collections/calendar/event_builder.spec.ts b/apps/client/src/widgets/collections/calendar/event_builder.spec.ts
index 2c872a14e5..88299e8b65 100644
--- a/apps/client/src/widgets/collections/calendar/event_builder.spec.ts
+++ b/apps/client/src/widgets/collections/calendar/event_builder.spec.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import { buildNote, buildNotes } from "../../../test/easy-froca.js";
import { buildEvent, buildEvents } from "./event_builder.js";
import { LOCALE_MAPPINGS } from "./index.js";
@@ -148,7 +148,7 @@ describe("Promoted attributes", () => {
expect(event).toHaveLength(1);
expect(event[0]?.promotedAttributes).toMatchObject([
[ "assignee", "Target note" ]
- ])
+ ]);
});
it("supports start time and end time", async () => {
@@ -177,6 +177,86 @@ describe("Promoted attributes", () => {
});
+
+describe("Recurrence", () => {
+ it("supports valid recurrence without end date", async () => {
+ const noteIds = buildNotes([
+ {
+ title: "Recurring Event",
+ "#startDate": "2025-05-05",
+ "#recurrence": "FREQ=DAILY;COUNT=5"
+ }
+ ]);
+ const events = await buildEvents(noteIds);
+
+ expect(events).toHaveLength(1);
+ expect(events[0]).toMatchObject({
+ title: "Recurring Event",
+ start: "2025-05-05",
+ });
+ expect(events[0].rrule).toContain("DTSTART:20250505");
+ expect(events[0].rrule).toContain("FREQ=DAILY;COUNT=5");
+ expect(events[0].end).toBeUndefined();
+ });
+
+ it("supports recurrence with start and end time (duration calculated)", async () => {
+ const noteIds = buildNotes([
+ {
+ title: "Timed Recurring Event",
+ "#startDate": "2025-05-05",
+ "#startTime": "13:00",
+ "#endTime": "15:30",
+ "#recurrence": "FREQ=WEEKLY;COUNT=3"
+ }
+ ]);
+ const events = await buildEvents(noteIds);
+
+ expect(events).toHaveLength(1);
+ expect(events[0]).toMatchObject({
+ title: "Timed Recurring Event",
+ start: "2025-05-05T13:00:00",
+ duration: "02:30"
+ });
+ expect(events[0].rrule).toContain("DTSTART:20250505T130000");
+ expect(events[0].end).toBeUndefined();
+ });
+
+ it("removes end date when recurrence is valid", async () => {
+ const noteIds = buildNotes([
+ {
+ title: "Recurring With End",
+ "#startDate": "2025-05-05",
+ "#endDate": "2025-05-07",
+ "#recurrence": "FREQ=DAILY;COUNT=2"
+ }
+ ]);
+ const events = await buildEvents(noteIds);
+
+ expect(events).toHaveLength(1);
+ expect(events[0].rrule).toBeDefined();
+ expect(events[0].end).toBeUndefined();
+ });
+
+ it("writes to console on invalid recurrence rule", async () => {
+ const noteIds = buildNotes([
+ {
+ title: "Invalid Recurrence",
+ "#startDate": "2025-05-05",
+ "#recurrence": "RRULE:FREQ=INVALID"
+ }
+ ]);
+
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ await buildEvents(noteIds);
+ const calledWithInvalid = consoleSpy.mock.calls.some(call =>
+ call[0].includes("has an invalid #recurrence string")
+ );
+ expect(calledWithInvalid).toBe(true);
+ consoleSpy.mockRestore();
+ });
+});
+
+
describe("Building locales", () => {
it("every language has a locale defined", async () => {
for (const { id, contentOnly } of LOCALES) {
diff --git a/apps/client/src/widgets/collections/calendar/event_builder.ts b/apps/client/src/widgets/collections/calendar/event_builder.ts
index f4611ccd7e..dec64feee8 100644
--- a/apps/client/src/widgets/collections/calendar/event_builder.ts
+++ b/apps/client/src/widgets/collections/calendar/event_builder.ts
@@ -1,17 +1,22 @@
import { EventInput, EventSourceFuncArg, EventSourceInput } from "@fullcalendar/core/index.js";
+import { dayjs } from "@triliumnext/commons";
import clsx from "clsx";
+import { start } from "repl";
+import * as rruleLib from 'rrule';
import FNote from "../../../entities/fnote";
import froca from "../../../services/froca";
import server from "../../../services/server";
-import { formatDateToLocalISO, getCustomisableLabel, getMonthsInDateRange, offsetDate } from "./utils";
+import toastService from "../../../services/toast";
+import { getCustomisableLabel, getMonthsInDateRange } from "./utils";
interface Event {
startDate: string,
endDate?: string | null,
startTime?: string | null,
endTime?: string | null,
- isArchived?: boolean;
+ isArchived?: boolean,
+ recurrence?: string | null;
}
export async function buildEvents(noteIds: string[]) {
@@ -28,8 +33,17 @@ export async function buildEvents(noteIds: string[]) {
const endDate = getCustomisableLabel(note, "endDate", "calendar:endDate");
const startTime = getCustomisableLabel(note, "startTime", "calendar:startTime");
const endTime = getCustomisableLabel(note, "endTime", "calendar:endTime");
+ const recurrence = getCustomisableLabel(note, "recurrence", "calendar:recurrence");
const isArchived = note.hasLabel("archived");
- events.push(await buildEvent(note, { startDate, endDate, startTime, endTime, isArchived }));
+ try {
+ events.push(await buildEvent(note, { startDate, endDate, startTime, endTime, recurrence, isArchived }));
+ } catch (error) {
+ if (error instanceof Error) {
+ const errorMessage = error.message;
+ toastService.showError(errorMessage);
+ console.error(errorMessage);
+ }
+ }
}
return events.flat();
@@ -59,6 +73,7 @@ export async function buildEventsForCalendar(note: FNote, e: EventSourceFuncArg)
events.push(await buildEvent(dateNote, { startDate }));
+
if (dateNote.hasChildren()) {
const childNoteIds = await dateNote.getSubtreeNoteIds();
for (const childNoteId of childNoteIds) {
@@ -79,7 +94,7 @@ export async function buildEventsForCalendar(note: FNote, e: EventSourceFuncArg)
return events.flat();
}
-export async function buildEvent(note: FNote, { startDate, endDate, startTime, endTime, isArchived }: Event) {
+export async function buildEvent(note: FNote, { startDate, endDate, startTime, endTime, recurrence, isArchived }: Event) {
const customTitleAttributeName = note.getLabelValue("calendar:title");
const titles = await parseCustomTitle(customTitleAttributeName, note);
const colorClass = note.getColorClass();
@@ -98,9 +113,10 @@ export async function buildEvent(note: FNote, { startDate, endDate, startTime, e
startDate = (startTime ? `${startDate}T${startTime}:00` : startDate);
if (!startTime) {
- const endDateOffset = offsetDate(endDate ?? startDate, 1);
- if (endDateOffset) {
- endDate = formatDateToLocalISO(endDateOffset);
+ if (endDate) {
+ endDate = dayjs(endDate).add(1, "day").format("YYYY-MM-DD");
+ } else if (startDate) {
+ endDate = dayjs(startDate).add(1, "day").format("YYYY-MM-DD");
}
}
@@ -118,6 +134,30 @@ export async function buildEvent(note: FNote, { startDate, endDate, startTime, e
if (endDate) {
eventData.end = endDate;
}
+
+ if (recurrence) {
+ // Generate rrule string
+ const rruleString = `DTSTART:${dayjs(startDate).format("YYYYMMDD[T]HHmmss")}\n${recurrence}`;
+
+ // Validate rrule string
+ let rruleValid = true;
+ try {
+ rruleLib.rrulestr(rruleString, { forceset: true }) as rruleLib.RRuleSet;
+ } catch {
+ rruleValid = false;
+ }
+
+ if (rruleValid) {
+ delete eventData.end;
+ eventData.rrule = rruleString;
+ if (endDate){
+ const duration = dayjs.duration(dayjs(endDate).diff(dayjs(startDate)));
+ eventData.duration = duration.format("HH:mm");
+ }
+ } else {
+ throw new Error(`Note "${note.noteId} ${note.title}" has an invalid #recurrence string ${recurrence}. Excluding...`);
+ }
+ }
events.push(eventData);
}
return events;
diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx
index 23f8371125..349d666e6c 100644
--- a/apps/client/src/widgets/collections/calendar/index.tsx
+++ b/apps/client/src/widgets/collections/calendar/index.tsx
@@ -252,6 +252,7 @@ function usePlugins(isEditable: boolean, isCalendarRoot: boolean) {
plugins.push((await import("@fullcalendar/timegrid")).default);
plugins.push((await import("@fullcalendar/list")).default);
plugins.push((await import("@fullcalendar/multimonth")).default);
+ plugins.push((await import("@fullcalendar/rrule")).default);
if (isEditable || isCalendarRoot) {
plugins.push((await import("@fullcalendar/interaction")).default);
}
diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Collections/Calendar.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Collections/Calendar.html
index 472192219c..4149ac77f0 100644
--- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Collections/Calendar.html
+++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Collections/Calendar.html
@@ -185,6 +185,18 @@
at which the event ends (in relation with endDate if
present, or startDate).
+
#recurrence
+ RRULE string
+ that if present, determines whether a task should repeat or not. Note that
+ it does not include the DTSTART attribute,
+ which is derived from the #startDate and
+ #startTimedirectly. For examples of valid RRULE strings
+ see https://icalendar.org/rrule-tool.html
+ #color
When not used in a Journal, the calendar is recursive. That is, it will look for events not just in its child notes but also in the children of these child notes.
++
The built in calendar view also supports repeating tasks. If a child note + of the calendar has a #recurrence label with a valid recurrence, that event + will repeat on the calendar according to the recurrence string.
+For example, to make a note repeat on the calendar:
+Every Day - #recurrence="FREQ=DAILY;INTERVAL=1"
+
Every 3 days - #recurrence="FREQ=DAILY;INTERVAL=3"
+
Every week - #recurrence="FREQ=WEEKLY;INTERVAL=1"
+
Every 2 weeks on Monday, Wednesday and Friday - #recurrence="FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR"
+
Every 3 months - #recurrence="FREQ=MONTHLY;INTERVAL=3"
+
Every 2 months on the First Sunday - #recurrence="FREQ=MONTHLY;INTERVAL=2;BYDAY=1SU"
+
Every month on the Last Friday - #recurrence="FREQ=MONTHLY;INTERVAL=1;BYDAY=-1FR"
+
And so on.
+For other examples of valid RRULE strings
+ see https://icalendar.org/rrule-tool.html
+
Note that the recurrence string does not include the DTSTART attribute
+ as defined in the iCAL specifications. This is derived directly from the
+ startDateand startTime attributes
If you want to override the label the calendar uses to fetch the recurrence
+ string, you can use the #calendar:recurrence attribute.
+ For example, you can set #calendar:recurrence=taskRepeats.
+ Then you can set your recurrence string like #taskRepeats="FREQ=DAILY;INTERVAL=1"
+
Also note that the recurrence label can be made promoted as with the start + and end dates.
+ +
It is possible to integrate the calendar view into the Journal with day
diff --git a/docs/User Guide/User Guide/Collections/Calendar.md b/docs/User Guide/User Guide/Collections/Calendar.md
index f7309feacd..82bb59b5d8 100644
--- a/docs/User Guide/User Guide/Collections/Calendar.md
+++ b/docs/User Guide/User Guide/Collections/Calendar.md
@@ -72,11 +72,12 @@ For each note of the calendar, the following attributes can be used:
| `#endDate` | Similar to `startDate`, mentions the end date if the event spans across multiple days. The date is inclusive, so the end day is also considered. The attribute can be missing for single-day events. |
| `#startTime` | The time the event starts at. If this value is missing, then the event is considered a full-day event. The format is `HH:MM` (hours in 24-hour format and minutes). |
| `#endTime` | Similar to `startTime`, it mentions the time at which the event ends (in relation with `endDate` if present, or `startDate`). |
+| `#recurrence` | This is an optional CalDAV `RRULE` string that if present, determines whether a task should repeat or not. Note that it does not include the `DTSTART` attribute, which is derived from the `#startDate` and `#startTime` directly. For examples of valid `RRULE` strings see [https://icalendar.org/rrule-tool.html](https://icalendar.org/rrule-tool.html) |
| `#color` | Displays the event with a specified color (named such as `red`, `gray` or hex such as `#FF0000`). This will also change the color of the note in other places such as the note tree. |
-| `#calendar:color` | **❌️ Removed since v0.100.0. Use** `**#color**` **instead.**
Similar to `#color`, but applies the color only for the event in the calendar and not for other places such as the note tree. |
+| `#calendar:color` | **❌️ Removed since v0.100.0. Use** `**#color**` **instead.**
Similar to `#color`, but applies the color only for the event in the calendar and not for other places such as the note tree. |
| `#iconClass` | If present, the icon of the note will be displayed to the left of the event title. |
| `#calendar:title` | Changes the title of an event to point to an attribute of the note other than the title, can either a label or a relation (without the `#` or `~` symbol). See _Use-cases_ for more information. |
-| `#calendar:displayedAttributes` | Allows displaying the value of one or more attributes in the calendar like this:

`#weight="70" #Mood="Good" #calendar:displayedAttributes="weight,Mood"`
It can also be used with relations, case in which it will display the title of the target note:
`~assignee=@My assignee #calendar:displayedAttributes="assignee"` |
+| `#calendar:displayedAttributes` | Allows displaying the value of one or more attributes in the calendar like this:

`#weight="70" #Mood="Good" #calendar:displayedAttributes="weight,Mood"`
It can also be used with relations, case in which it will display the title of the target note:
`~assignee=@My assignee #calendar:displayedAttributes="assignee"` |
| `#calendar:startDate` | Allows using a different label to represent the start date, other than `startDate` (e.g. `expiryDate`). The label name **must not be** prefixed with `#`. If the label is not defined for a note, the default will be used instead. |
| `#calendar:endDate` | Similar to `#calendar:startDate`, allows changing the attribute which is being used to read the end date. |
| `#calendar:startTime` | Similar to `#calendar:startDate`, allows changing the attribute which is being used to read the start time. |
@@ -102,6 +103,32 @@ This will result in:
When not used in a Journal, the calendar is recursive. That is, it will look for events not just in its child notes but also in the children of these child notes.
+## Recurrence
+
+The built in calendar view also supports repeating tasks. If a child note of the calendar has a #recurrence label with a valid recurrence, that event will repeat on the calendar according to the recurrence string.
+
+For example, to make a note repeat on the calendar:
+
+* Every Day - `#recurrence="FREQ=DAILY;INTERVAL=1"`
+* Every 3 days - `#recurrence="FREQ=DAILY;INTERVAL=3"`
+* Every week - `#recurrence="FREQ=WEEKLY;INTERVAL=1"`
+* Every 2 weeks on Monday, Wednesday and Friday - `#recurrence="FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR"`
+* Every 3 months - `#recurrence="FREQ=MONTHLY;INTERVAL=3"`
+* Every 2 months on the First Sunday - `#recurrence="FREQ=MONTHLY;INTERVAL=2;BYDAY=1SU"`
+* Every month on the Last Friday - `#recurrence="FREQ=MONTHLY;INTERVAL=1;BYDAY=-1FR"`
+* And so on.
+
+For other examples of valid `RRULE` strings see [https://icalendar.org/rrule-tool.html](https://icalendar.org/rrule-tool.html)
+
+Note that the recurrence string does not include the `DTSTART` attribute as defined in the iCAL specifications. This is derived directly from the `startDate` and `startTime` attributes
+
+If you want to override the label the calendar uses to fetch the recurrence string, you can use the `#calendar:recurrence` attribute. For example, you can set `#calendar:recurrence=taskRepeats`. Then you can set your recurrence string like `#taskRepeats="FREQ=DAILY;INTERVAL=1"`
+
+Also note that the recurrence label can be made promoted as with the start and end dates.
+
+> [!WARNING]
+> If the recurrence string is not valid, a toast will be shown with the note ID and title of the note with the erroneous recurrence message. This note will not be added to the calendar
+
## Use-cases
### Using with the Journal / calendar
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4170ce63e5..7ccffa7937 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -200,6 +200,9 @@ importers:
'@fullcalendar/multimonth':
specifier: 6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)
+ '@fullcalendar/rrule':
+ specifier: 6.1.20
+ version: 6.1.20(@fullcalendar/core@6.1.20)(rrule@2.8.1)
'@fullcalendar/timegrid':
specifier: 6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)
@@ -323,6 +326,9 @@ importers:
reveal.js:
specifier: 5.2.1
version: 5.2.1
+ rrule:
+ specifier: 2.8.1
+ version: 2.8.1
svg-pan-zoom:
specifier: 3.6.2
version: 3.6.2
@@ -3392,6 +3398,12 @@ packages:
peerDependencies:
'@fullcalendar/core': ~6.1.20
+ '@fullcalendar/rrule@6.1.20':
+ resolution: {integrity: sha512-5Awk7bmaA97hSZRpIBehenXkYreVIvx8nnaMFZ/LDGRuK1mgbR4vSUrDTvVU+oEqqKnj/rqMBByWqN5NeehQxw==}
+ peerDependencies:
+ '@fullcalendar/core': ~6.1.20
+ rrule: ^2.6.0
+
'@fullcalendar/timegrid@6.1.20':
resolution: {integrity: sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==}
peerDependencies:
@@ -13131,6 +13143,9 @@ packages:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
+ rrule@2.8.1:
+ resolution: {integrity: sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==}
+
rrweb-cssom@0.8.0:
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
@@ -16043,6 +16058,8 @@ snapshots:
'@ckeditor/ckeditor5-core': 47.4.0
'@ckeditor/ckeditor5-upload': 47.4.0
ckeditor5: 47.4.0
+ transitivePeerDependencies:
+ - supports-color
'@ckeditor/ckeditor5-ai@47.4.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)':
dependencies:
@@ -16183,6 +16200,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
'@ckeditor/ckeditor5-widget': 47.4.0
es-toolkit: 1.39.5
+ transitivePeerDependencies:
+ - supports-color
'@ckeditor/ckeditor5-cloud-services@47.4.0':
dependencies:
@@ -16397,6 +16416,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
+ transitivePeerDependencies:
+ - supports-color
'@ckeditor/ckeditor5-editor-inline@47.4.0':
dependencies:
@@ -16512,6 +16533,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
+ transitivePeerDependencies:
+ - supports-color
'@ckeditor/ckeditor5-font@47.4.0':
dependencies:
@@ -16586,6 +16609,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
'@ckeditor/ckeditor5-widget': 47.4.0
ckeditor5: 47.4.0
+ transitivePeerDependencies:
+ - supports-color
'@ckeditor/ckeditor5-html-embed@47.4.0':
dependencies:
@@ -16770,8 +16795,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
- transitivePeerDependencies:
- - supports-color
'@ckeditor/ckeditor5-merge-fields@47.4.0':
dependencies:
@@ -16794,8 +16817,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
- transitivePeerDependencies:
- - supports-color
'@ckeditor/ckeditor5-operations-compressor@47.4.0':
dependencies:
@@ -16850,8 +16871,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
'@ckeditor/ckeditor5-widget': 47.4.0
ckeditor5: 47.4.0
- transitivePeerDependencies:
- - supports-color
'@ckeditor/ckeditor5-pagination@47.4.0':
dependencies:
@@ -16959,8 +16978,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
- transitivePeerDependencies:
- - supports-color
'@ckeditor/ckeditor5-slash-command@47.4.0':
dependencies:
@@ -16973,8 +16990,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
- transitivePeerDependencies:
- - supports-color
'@ckeditor/ckeditor5-source-editing-enhanced@47.4.0':
dependencies:
@@ -17022,8 +17037,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
- transitivePeerDependencies:
- - supports-color
'@ckeditor/ckeditor5-table@47.4.0':
dependencies:
@@ -17036,8 +17049,6 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
- transitivePeerDependencies:
- - supports-color
'@ckeditor/ckeditor5-template@47.4.0':
dependencies:
@@ -17150,8 +17161,6 @@ snapshots:
'@ckeditor/ckeditor5-engine': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
es-toolkit: 1.39.5
- transitivePeerDependencies:
- - supports-color
'@ckeditor/ckeditor5-widget@47.4.0':
dependencies:
@@ -17171,8 +17180,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
- transitivePeerDependencies:
- - supports-color
'@codemirror/autocomplete@6.18.6':
dependencies:
@@ -18514,6 +18521,11 @@ snapshots:
'@fullcalendar/core': 6.1.20
'@fullcalendar/daygrid': 6.1.20(@fullcalendar/core@6.1.20)
+ '@fullcalendar/rrule@6.1.20(@fullcalendar/core@6.1.20)(rrule@2.8.1)':
+ dependencies:
+ '@fullcalendar/core': 6.1.20
+ rrule: 2.8.1
+
'@fullcalendar/timegrid@6.1.20(@fullcalendar/core@6.1.20)':
dependencies:
'@fullcalendar/core': 6.1.20
@@ -30286,6 +30298,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ rrule@2.8.1:
+ dependencies:
+ tslib: 2.8.1
+
rrweb-cssom@0.8.0:
optional: true