Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
22 changes: 18 additions & 4 deletions src/services/TaskCalendarSyncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(`<a href="${obsidianUri}">${linkText}</a>`);
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
*/
Expand Down
59 changes: 56 additions & 3 deletions tests/services/TaskCalendarSyncService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | number>) => {
const translations: Record<string, string> = {
"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] ?? ""));
})
}
};

Expand Down Expand Up @@ -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("<a ");
expect(description).not.toContain("</a>");
expect(description).not.toContain("](");
});
});
Loading