From 60b74f5959b88b4c04082a9f5d53ceed720e920a Mon Sep 17 00:00:00 2001 From: BeatLink Date: Sun, 22 Feb 2026 18:13:08 -0500 Subject: [PATCH 01/10] Add Recurrence Support for Calendar --- apps/client/package.json | 1 + .../collections/calendar/event_builder.ts | 19 +++++- .../widgets/collections/calendar/index.tsx | 1 + pnpm-lock.yaml | 65 +++++++++++++------ 4 files changed, 63 insertions(+), 23 deletions(-) diff --git a/apps/client/package.json b/apps/client/package.json index 7acbff720f..9db5093b45 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -23,6 +23,7 @@ "@fullcalendar/list": "6.1.20", "@fullcalendar/multimonth": "6.1.20", "@fullcalendar/timegrid": "6.1.20", + "@fullcalendar/rrule": "6.1.20", "@maplibre/maplibre-gl-leaflet": "0.1.3", "@mermaid-js/layout-elk": "0.2.0", "@mind-elixir/node-menu": "5.0.1", diff --git a/apps/client/src/widgets/collections/calendar/event_builder.ts b/apps/client/src/widgets/collections/calendar/event_builder.ts index f4611ccd7e..19ff80bdc7 100644 --- a/apps/client/src/widgets/collections/calendar/event_builder.ts +++ b/apps/client/src/widgets/collections/calendar/event_builder.ts @@ -11,7 +11,8 @@ interface Event { endDate?: string | null, startTime?: string | null, endTime?: string | null, - isArchived?: boolean; + isArchived?: boolean, + recurrence?: string | null; } export async function buildEvents(noteIds: string[]) { @@ -28,8 +29,9 @@ 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 })); + events.push(await buildEvent(note, { startDate, endDate, startTime, endTime, recurrence, isArchived })); } return events.flat(); @@ -79,7 +81,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(); @@ -118,6 +120,17 @@ export async function buildEvent(note: FNote, { startDate, endDate, startTime, e if (endDate) { eventData.end = endDate; } + + if (recurrence) { + eventData.rrule = `DTSTART:${startDate.replace(/[-:]/g, "")}\n${recurrence}`; + if (endDate){ + const duration = (d => + String(d / 36e5 | 0).padStart(2, "0") + ":" + + String(d / 6e4 % 60 | 0).padStart(2, "0") + )((new Date(endDate)) - (new Date(startDate))); + eventData.duration = duration + } + } 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/pnpm-lock.yaml b/pnpm-lock.yaml index 55a6484280..6b1e2d6e85 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) @@ -3392,6 +3395,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: @@ -13031,6 +13040,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==} @@ -15950,6 +15962,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: @@ -16090,12 +16104,16 @@ 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: '@ckeditor/ckeditor5-core': 47.4.0 '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-code-block@47.4.0(patch_hash=2361d8caad7d6b5bddacc3a3b4aa37dbfba260b1c1b22a450413a79c1bb1ce95)': dependencies: @@ -16107,6 +16125,8 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.4.0 '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-collaboration-core@47.4.0': dependencies: @@ -16286,6 +16306,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-editor-classic@47.4.0': dependencies: @@ -16306,6 +16328,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: @@ -16375,6 +16399,8 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.4.0 '@ckeditor/ckeditor5-undo': 47.4.0 ckeditor5: 47.4.0 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-export-inline-styles@47.4.0': dependencies: @@ -16417,6 +16443,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: @@ -16480,6 +16508,8 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.4.0 '@ckeditor/ckeditor5-utils': 47.4.0 ckeditor5: 47.4.0 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-horizontal-line@47.4.0': dependencies: @@ -16489,6 +16519,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: @@ -16515,6 +16547,8 @@ snapshots: '@ckeditor/ckeditor5-widget': 47.4.0 ckeditor5: 47.4.0 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-icons@47.4.0': {} @@ -16658,6 +16692,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.4.0 '@ckeditor/ckeditor5-widget': 47.4.0 ckeditor5: 47.4.0 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-mention@47.4.0(patch_hash=5981fb59ba35829e4dff1d39cf771000f8a8fdfa7a34b51d8af9549541f2d62d)': dependencies: @@ -16667,8 +16703,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: @@ -16689,8 +16723,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: @@ -16745,8 +16777,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: @@ -16854,8 +16884,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: @@ -16868,8 +16896,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: @@ -16917,8 +16943,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: @@ -16931,8 +16955,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: @@ -17007,8 +17029,6 @@ snapshots: '@ckeditor/ckeditor5-icons': 47.4.0 '@ckeditor/ckeditor5-ui': 47.4.0 '@ckeditor/ckeditor5-utils': 47.4.0 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-upload@47.4.0': dependencies: @@ -17045,8 +17065,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: @@ -17066,8 +17084,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: @@ -18409,6 +18425,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 @@ -30031,6 +30052,10 @@ snapshots: transitivePeerDependencies: - supports-color + rrule@2.8.1: + dependencies: + tslib: 2.8.1 + rrweb-cssom@0.8.0: optional: true From e53cd7443a37958dd9b78d1deeab1e92d5d484a1 Mon Sep 17 00:00:00 2001 From: BeatLink Date: Sun, 22 Feb 2026 18:22:21 -0500 Subject: [PATCH 02/10] Add Docs for Recurrence --- .../User Guide/User Guide/Collections/Calendar.html | 12 ++++++++++++ docs/User Guide/User Guide/Collections/Calendar.md | 5 +++-- 2 files changed, 15 insertions(+), 2 deletions(-) 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..6675a93c86 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 + + 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 + #startTimedirectly. For examples of valid RRULE strings + see https://icalendar.org/rrule-tool.html + + #color diff --git a/docs/User Guide/User Guide/Collections/Calendar.md b/docs/User Guide/User Guide/Collections/Calendar.md index f7309feacd..e0be591847 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:      

![](7_Calendar_image.png)     

`#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:       

![](7_Calendar_image.png)      

`#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. | From 9b456391484a56e017123ea9f1171cfe1e39145e Mon Sep 17 00:00:00 2001 From: BeatLink Date: Sun, 22 Feb 2026 18:43:18 -0500 Subject: [PATCH 03/10] Update apps/client/src/widgets/collections/calendar/event_builder.ts Replace end with duration if recurrence set. Make duration calculation clearer Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../src/widgets/collections/calendar/event_builder.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/client/src/widgets/collections/calendar/event_builder.ts b/apps/client/src/widgets/collections/calendar/event_builder.ts index 19ff80bdc7..4967a5ca1b 100644 --- a/apps/client/src/widgets/collections/calendar/event_builder.ts +++ b/apps/client/src/widgets/collections/calendar/event_builder.ts @@ -122,13 +122,13 @@ export async function buildEvent(note: FNote, { startDate, endDate, startTime, e } if (recurrence) { + delete eventData.end; eventData.rrule = `DTSTART:${startDate.replace(/[-:]/g, "")}\n${recurrence}`; if (endDate){ - const duration = (d => - String(d / 36e5 | 0).padStart(2, "0") + ":" + - String(d / 6e4 % 60 | 0).padStart(2, "0") - )((new Date(endDate)) - (new Date(startDate))); - eventData.duration = duration + const diffMs = new Date(endDate).getTime() - new Date(startDate).getTime(); + const hours = Math.floor(diffMs / 3600000); + const minutes = Math.floor((diffMs / 60000) % 60); + eventData.duration = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`; } } events.push(eventData); From 59d8a98eeaca3cd4a43ec235ef3df4b5b52fe8a7 Mon Sep 17 00:00:00 2001 From: Zexin Yuan Date: Mon, 23 Feb 2026 23:16:31 +0800 Subject: [PATCH 04/10] feat(client/menus): add Copy as Markdown to electron context menu --- .../client/src/menus/electron_context_menu.ts | 29 +++++++++++++++++++ .../src/translations/ar/translation.json | 1 + .../src/translations/cn/translation.json | 1 + .../src/translations/de/translation.json | 1 + .../src/translations/en/translation.json | 1 + .../src/translations/es/translation.json | 1 + .../src/translations/fr/translation.json | 1 + .../src/translations/ga/translation.json | 1 + .../src/translations/hi/translation.json | 1 + .../src/translations/it/translation.json | 1 + .../src/translations/ja/translation.json | 1 + .../src/translations/pl/translation.json | 1 + .../src/translations/pt/translation.json | 1 + .../src/translations/pt_br/translation.json | 1 + .../src/translations/ro/translation.json | 1 + .../src/translations/ru/translation.json | 1 + .../src/translations/tw/translation.json | 1 + .../src/translations/uk/translation.json | 1 + 18 files changed, 46 insertions(+) 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 9b27037faf..a70fab24a3 100644 --- a/apps/client/src/translations/cn/translation.json +++ b/apps/client/src/translations/cn/translation.json @@ -1758,6 +1758,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..1f4d42db9c 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 c5020a4d0c..d8d94fb7af 100644 --- a/apps/client/src/translations/ga/translation.json +++ b/apps/client/src/translations/ga/translation.json @@ -1807,6 +1807,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 0ce70cbf62..8e26e7d191 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 e040e7e6e1..cd0eeb9a35 100644 --- a/apps/client/src/translations/ja/translation.json +++ b/apps/client/src/translations/ja/translation.json @@ -1352,6 +1352,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 982cb71c43..48a62eec65 100644 --- a/apps/client/src/translations/pl/translation.json +++ b/apps/client/src/translations/pl/translation.json @@ -612,6 +612,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": "Вставити як звичайний текст", From bfcbfac5bbb2cf7f9183aa7a90ae3fcfe4ecfef9 Mon Sep 17 00:00:00 2001 From: BeatLink Date: Fri, 27 Feb 2026 10:47:56 -0500 Subject: [PATCH 05/10] Add recurrence error handling --- apps/client/package.json | 3 +- .../collections/calendar/event_builder.ts | 33 +++++++++++++++---- pnpm-lock.yaml | 3 ++ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/apps/client/package.json b/apps/client/package.json index 9db5093b45..1fd8bea2c6 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -22,8 +22,8 @@ "@fullcalendar/interaction": "6.1.20", "@fullcalendar/list": "6.1.20", "@fullcalendar/multimonth": "6.1.20", - "@fullcalendar/timegrid": "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", "@mind-elixir/node-menu": "5.0.1", @@ -64,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/widgets/collections/calendar/event_builder.ts b/apps/client/src/widgets/collections/calendar/event_builder.ts index 4967a5ca1b..b60a203d89 100644 --- a/apps/client/src/widgets/collections/calendar/event_builder.ts +++ b/apps/client/src/widgets/collections/calendar/event_builder.ts @@ -1,9 +1,11 @@ import { EventInput, EventSourceFuncArg, EventSourceInput } from "@fullcalendar/core/index.js"; import clsx from "clsx"; +import * as rruleLib from 'rrule'; import FNote from "../../../entities/fnote"; import froca from "../../../services/froca"; import server from "../../../services/server"; +import toastService from "../../../services/toast"; import { formatDateToLocalISO, getCustomisableLabel, getMonthsInDateRange, offsetDate } from "./utils"; interface Event { @@ -122,13 +124,30 @@ export async function buildEvent(note: FNote, { startDate, endDate, startTime, e } if (recurrence) { - delete eventData.end; - eventData.rrule = `DTSTART:${startDate.replace(/[-:]/g, "")}\n${recurrence}`; - if (endDate){ - const diffMs = new Date(endDate).getTime() - new Date(startDate).getTime(); - const hours = Math.floor(diffMs / 3600000); - const minutes = Math.floor((diffMs / 60000) % 60); - eventData.duration = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`; + // Generate rrule string + const rruleString = `DTSTART:${startDate.replace(/[-:]/g, "")}\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 diffMs = new Date(endDate).getTime() - new Date(startDate).getTime(); + const hours = Math.floor(diffMs / 3600000); + const minutes = Math.floor((diffMs / 60000) % 60); + eventData.duration = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`; + } + } else { + const errorMessage = `Note "${note.noteId} ${note.title}" has an invalid #recurrence string. This note will not recur.`; + toastService.showError(errorMessage); + console.error(errorMessage); } } events.push(eventData); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b1e2d6e85..0097f4aaf2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -326,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 From 8554dc249cac454cd585e6d5177afa1d2f252aa1 Mon Sep 17 00:00:00 2001 From: BeatLink Date: Fri, 27 Feb 2026 11:43:23 -0500 Subject: [PATCH 06/10] Fix recurrence error management --- .../collections/calendar/event_builder.ts | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/apps/client/src/widgets/collections/calendar/event_builder.ts b/apps/client/src/widgets/collections/calendar/event_builder.ts index b60a203d89..cd27a89a5b 100644 --- a/apps/client/src/widgets/collections/calendar/event_builder.ts +++ b/apps/client/src/widgets/collections/calendar/event_builder.ts @@ -33,7 +33,15 @@ export async function buildEvents(noteIds: string[]) { 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, recurrence, 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(); @@ -61,7 +69,16 @@ export async function buildEventsForCalendar(note: FNote, e: EventSourceFuncArg) continue; } - events.push(await buildEvent(dateNote, { startDate })); + try { + events.push(await buildEvent(dateNote, { startDate })); + } catch (error) { + if (error instanceof Error) { + const errorMessage = error.message; + toastService.showError(errorMessage); + console.error(errorMessage); + } + } + if (dateNote.hasChildren()) { const childNoteIds = await dateNote.getSubtreeNoteIds(); @@ -76,8 +93,16 @@ export async function buildEventsForCalendar(note: FNote, e: EventSourceFuncArg) const childNotes = await froca.getNotes(childNoteIds); for (const childNote of childNotes) { const startDate = childNoteToDateMapping[childNote.noteId]; - const event = await buildEvent(childNote, { startDate }); - events.push(event); + try { + const event = await buildEvent(childNote, { startDate }); + events.push(event); + } catch (error) { + if (error instanceof Error) { + const errorMessage = error.message; + toastService.showError(errorMessage); + console.error(errorMessage); + } + } } return events.flat(); @@ -145,9 +170,7 @@ export async function buildEvent(note: FNote, { startDate, endDate, startTime, e eventData.duration = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`; } } else { - const errorMessage = `Note "${note.noteId} ${note.title}" has an invalid #recurrence string. This note will not recur.`; - toastService.showError(errorMessage); - console.error(errorMessage); + throw new Error(`Note "${note.noteId} ${note.title}" has an invalid #recurrence string. Excluding...`); } } events.push(eventData); From e029379194c7f8220a3e782fd3275a9029ee9e34 Mon Sep 17 00:00:00 2001 From: BeatLink Date: Fri, 27 Feb 2026 17:39:12 -0500 Subject: [PATCH 07/10] Add recurrence testing, use dayjs for calendar --- .../calendar/event_builder.spec.ts | 84 ++++++++++++++++++- .../collections/calendar/event_builder.ts | 21 ++--- 2 files changed, 93 insertions(+), 12 deletions(-) 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 cd27a89a5b..92952ca68b 100644 --- a/apps/client/src/widgets/collections/calendar/event_builder.ts +++ b/apps/client/src/widgets/collections/calendar/event_builder.ts @@ -1,12 +1,14 @@ 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 toastService from "../../../services/toast"; -import { formatDateToLocalISO, getCustomisableLabel, getMonthsInDateRange, offsetDate } from "./utils"; +import { getCustomisableLabel, getMonthsInDateRange } from "./utils"; interface Event { startDate: string, @@ -127,9 +129,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"); } } @@ -150,7 +153,7 @@ export async function buildEvent(note: FNote, { startDate, endDate, startTime, e if (recurrence) { // Generate rrule string - const rruleString = `DTSTART:${startDate.replace(/[-:]/g, "")}\n${recurrence}`; + const rruleString = `DTSTART:${dayjs(startDate).format("YYYYMMDD[T]HHmmss")}\n${recurrence}`; // Validate rrule string let rruleValid = true; @@ -164,13 +167,11 @@ export async function buildEvent(note: FNote, { startDate, endDate, startTime, e delete eventData.end; eventData.rrule = rruleString; if (endDate){ - const diffMs = new Date(endDate).getTime() - new Date(startDate).getTime(); - const hours = Math.floor(diffMs / 3600000); - const minutes = Math.floor((diffMs / 60000) % 60); - eventData.duration = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`; + 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. Excluding...`); + throw new Error(`Note "${note.noteId} ${note.title}" has an invalid #recurrence string ${recurrence}. Excluding...`); } } events.push(eventData); From c938bcc657e4106effb909478d1fd6b1fe96b10c Mon Sep 17 00:00:00 2001 From: BeatLink Date: Fri, 27 Feb 2026 18:01:23 -0500 Subject: [PATCH 08/10] Remove unnecessary error checks --- .../collections/calendar/event_builder.ts | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/apps/client/src/widgets/collections/calendar/event_builder.ts b/apps/client/src/widgets/collections/calendar/event_builder.ts index 92952ca68b..dec64feee8 100644 --- a/apps/client/src/widgets/collections/calendar/event_builder.ts +++ b/apps/client/src/widgets/collections/calendar/event_builder.ts @@ -71,15 +71,7 @@ export async function buildEventsForCalendar(note: FNote, e: EventSourceFuncArg) continue; } - try { - events.push(await buildEvent(dateNote, { startDate })); - } catch (error) { - if (error instanceof Error) { - const errorMessage = error.message; - toastService.showError(errorMessage); - console.error(errorMessage); - } - } + events.push(await buildEvent(dateNote, { startDate })); if (dateNote.hasChildren()) { @@ -95,16 +87,8 @@ export async function buildEventsForCalendar(note: FNote, e: EventSourceFuncArg) const childNotes = await froca.getNotes(childNoteIds); for (const childNote of childNotes) { const startDate = childNoteToDateMapping[childNote.noteId]; - try { - const event = await buildEvent(childNote, { startDate }); - events.push(event); - } catch (error) { - if (error instanceof Error) { - const errorMessage = error.message; - toastService.showError(errorMessage); - console.error(errorMessage); - } - } + const event = await buildEvent(childNote, { startDate }); + events.push(event); } return events.flat(); From 57198199474297f7b1af2925b1d657e032473110 Mon Sep 17 00:00:00 2001 From: BeatLink Date: Fri, 27 Feb 2026 18:08:13 -0500 Subject: [PATCH 09/10] Update Recurrence Docs --- .../User Guide/Collections/Calendar.html | 59 +++++++++++++++++++ .../User Guide/Collections/Calendar.md | 26 ++++++++ 2 files changed, 85 insertions(+) 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 6675a93c86..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 @@ -294,6 +294,65 @@

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 +

+

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. 

+ +

 

Use-cases

Using with the Journal / calendar

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 e0be591847..82bb59b5d8 100644 --- a/docs/User Guide/User Guide/Collections/Calendar.md +++ b/docs/User Guide/User Guide/Collections/Calendar.md @@ -103,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 From b9abdcb189df00d43a67162a8da4438efe7e8adc Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 28 Feb 2026 18:58:25 +0200 Subject: [PATCH 10/10] chore(electron_context_menu): use upper case for Markdown --- apps/client/src/translations/en/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 1f4d42db9c..4bb4c11dd6 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1810,7 +1810,7 @@ "add-term-to-dictionary": "Add \"{{term}}\" to dictionary", "cut": "Cut", "copy": "Copy", - "copy-as-markdown": "Copy as markdown", + "copy-as-markdown": "Copy as Markdown", "copy-link": "Copy link", "paste": "Paste", "paste-as-plain-text": "Paste as plain text",