From 6e210fee6b0715a13efbf9557527b152faeb89cf Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Wed, 29 Apr 2026 13:57:46 +0100 Subject: [PATCH] Use plain text Google Calendar descriptions --- docs/releases/unreleased.md | 4 ++ src/services/TaskCalendarSyncService.ts | 22 +++++-- .../services/TaskCalendarSyncService.test.ts | 59 ++++++++++++++++++- 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index aa1214690..876b5aeed 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -23,3 +23,7 @@ Example: ``` --> + +## Fixed + +- Google Calendar task descriptions now use mobile-friendly plain text for Obsidian links and display labels for wiki-style project/context links. diff --git a/src/services/TaskCalendarSyncService.ts b/src/services/TaskCalendarSyncService.ts index 01af8cc3a..3a32813ee 100644 --- a/src/services/TaskCalendarSyncService.ts +++ b/src/services/TaskCalendarSyncService.ts @@ -266,12 +266,12 @@ export class TaskCalendarSyncService { // Add contexts if (task.contexts && task.contexts.length > 0) { - parts.push(t("contexts", { value: task.contexts.map((c) => `@${c}`).join(", ") })); + parts.push(t("contexts", { value: task.contexts.map((c) => `@${this.toCalendarDescriptionLabel(c)}`).join(", ") })); } // Add projects if (task.projects && task.projects.length > 0) { - parts.push(t("projects", { value: task.projects.join(", ") })); + parts.push(t("projects", { value: task.projects.map((p) => this.toCalendarDescriptionLabel(p)).join(", ") })); } // Add separator before link @@ -285,14 +285,28 @@ export class TaskCalendarSyncService { const vaultName = this.plugin.app.vault.getName(); const encodedPath = encodeURIComponent(task.path); const obsidianUri = `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodedPath}`; - // Google Calendar renders HTML in descriptions, so use an anchor tag const linkText = t("openInObsidian"); - parts.push(`${linkText}`); + parts.push(`${linkText}: ${obsidianUri}`); } return parts.join("\n"); } + private toCalendarDescriptionLabel(value: string): string { + return value + .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, "$2") + .replace(/\[\[([^\]]+)\]\]/g, (_match, target: string) => this.basenameForDisplay(target)) + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1") + .trim(); + } + + private basenameForDisplay(target: string): string { + const withoutHeading = target.split("#")[0]; + const withoutExtension = withoutHeading.replace(/\.md$/i, ""); + const basename = withoutExtension.split("/").pop(); + return basename || withoutExtension || target; + } + /** * Get the date to use for the calendar event based on settings */ diff --git a/tests/services/TaskCalendarSyncService.test.ts b/tests/services/TaskCalendarSyncService.test.ts index 21c0dd4be..dd20660d3 100644 --- a/tests/services/TaskCalendarSyncService.test.ts +++ b/tests/services/TaskCalendarSyncService.test.ts @@ -14,19 +14,38 @@ describe("TaskCalendarSyncService", () => { googleCalendarExport: { syncOnTaskUpdate: true, targetCalendarId: "test-calendar", + includeObsidianLink: true, } }, + app: { + vault: { + getName: jest.fn().mockReturnValue("Martin OS"), + }, + }, cacheManager: { getTaskInfo: jest.fn() }, statusManager: { - getStatusConfig: jest.fn().mockReturnValue({ label: "Todo" }) + getStatusConfig: jest.fn((status: string) => ({ label: status === "ready" ? "Ready" : "Todo" })) }, priorityManager: { - getPriorityConfig: jest.fn().mockReturnValue({ label: "High" }) + getPriorityConfig: jest.fn((priority: string) => ({ label: priority === "2-high" ? "High" : "Medium" })) }, i18n: { - translate: jest.fn().mockReturnValue("Untitled Task") + translate: jest.fn((key: string, params?: Record) => { + const translations: Record = { + "settings.integrations.googleCalendarExport.eventDescription.untitledTask": "Untitled Task", + "settings.integrations.googleCalendarExport.eventDescription.priority": "Priority: {value}", + "settings.integrations.googleCalendarExport.eventDescription.status": "Status: {value}", + "settings.integrations.googleCalendarExport.eventDescription.scheduled": "Scheduled: {value}", + "settings.integrations.googleCalendarExport.eventDescription.timeEstimate": "Time Estimate: {value}", + "settings.integrations.googleCalendarExport.eventDescription.contexts": "Contexts: {value}", + "settings.integrations.googleCalendarExport.eventDescription.projects": "Projects: {value}", + "settings.integrations.googleCalendarExport.eventDescription.openInObsidian": "Open in Obsidian", + }; + const translation = translations[key] || key; + return translation.replace(/\{(\w+)\}/g, (_match, name) => String(params?.[name] ?? "")); + }) } }; @@ -78,4 +97,38 @@ describe("TaskCalendarSyncService", () => { expect(syncService.executeTaskUpdate).toHaveBeenCalledTimes(1); expect(syncService.executeTaskUpdate).toHaveBeenCalledWith(secondPayload); }); + + it("should build plain-text calendar descriptions for external calendar clients", () => { + const description = syncService.buildEventDescription({ + path: "1 Tasks/Tasks/Download first personal data export batch.md", + title: "Download first personal data export batch", + status: "ready", + priority: "2-high", + scheduled: "2026-04-29", + timeEstimate: 180, + projects: [ + "[[0 Collect personal data exports for vault intelligence|Collect personal data exports for vault intelligence]]", + "[[Projects/Nested Project.md]]", + "[Markdown Project](Projects/Markdown%20Project.md)", + ], + contexts: ["[[People/Martin Ball|Martin Ball]]", "admin"], + } as TaskInfo); + + expect(description).toContain("Priority: High"); + expect(description).toContain("Status: Ready"); + expect(description).toContain("Scheduled: 2026-04-29"); + expect(description).toContain("Time Estimate: 3h 0m"); + expect(description).toContain("Contexts: @Martin Ball, @admin"); + expect(description).toContain( + "Projects: Collect personal data exports for vault intelligence, Nested Project, Markdown Project" + ); + expect(description).toContain( + "Open in Obsidian: obsidian://open?vault=Martin%20OS&file=1%20Tasks%2FTasks%2FDownload%20first%20personal%20data%20export%20batch.md" + ); + expect(description).not.toContain("[["); + expect(description).not.toContain("]]"); + expect(description).not.toContain(""); + expect(description).not.toContain("]("); + }); });