Skip to content

Commit c66f8cc

Browse files
committed
Merge remote-tracking branch 'pr/1882'
# Conflicts: # src/bases/calendar-core.ts
2 parents b30151c + 8320080 commit c66f8cc

5 files changed

Lines changed: 240 additions & 19 deletions

File tree

docs/releases/unreleased.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,4 @@ Example:
199199
- Made the markdown editor areas in task modals easier to click and focus.
200200
- Strengthened local CSS linting to catch unscoped selectors, unknown CSS, and fixed-position overlays before review.
201201
- Reduced false-positive plugin review warnings by making background auto-export and auto-archive schedulers non-overlapping and tightening type/string conversion paths.
202+
- ([#1882](https://github.com/callumalpass/tasknotes/pull/1882)) Made Google Calendar task descriptions use mobile-friendly plain text for Obsidian links and display labels for wiki-style project/context links. Thanks to @martin-forge for the contribution.

src/bases/calendar-core.ts

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import { TimeblockCreationModal } from "../modals/TimeblockCreationModal";
3535
import { openTaskSelector } from "../modals/TaskSelectorWithCreateModal";
3636
import { TimeblockInfoModal } from "../modals/TimeblockInfoModal";
3737

38+
const MIN_EXTERNAL_TIMED_EVENT_DURATION_MS = 1;
39+
3840
export interface CalendarEvent {
3941
id: string;
4042
title: string;
@@ -788,11 +790,17 @@ export function createICSEvent(
788790
subscriptionName = subscription.name;
789791
}
790792

793+
const { start, end } = normalizeExternalTimedEventRange(
794+
icsEvent.start,
795+
icsEvent.end,
796+
icsEvent.allDay
797+
);
798+
791799
return {
792800
id: icsEvent.id,
793801
title: icsEvent.title,
794-
start: icsEvent.start,
795-
end: getRenderableICSEventEnd(icsEvent),
802+
start,
803+
end,
796804
allDay: icsEvent.allDay,
797805
backgroundColor: backgroundColor,
798806
borderColor: borderColor,
@@ -816,19 +824,58 @@ export function createICSEvent(
816824
}
817825
}
818826

819-
function getRenderableICSEventEnd(icsEvent: ICSEvent): string | undefined {
820-
if (icsEvent.allDay || !icsEvent.end) {
821-
return icsEvent.end;
827+
/**
828+
* FullCalendar list views can render a timed external event under multiple day
829+
* headers when the provider supplies a true zero-duration range (end === start).
830+
* Clamp those point-in-time external events to a minimal positive duration
831+
* before handing them to FullCalendar, while preserving the raw provider event
832+
* unchanged in extendedProps for display and debugging.
833+
*/
834+
function normalizeExternalTimedEventRange(
835+
start: string,
836+
end: string | undefined,
837+
allDay: boolean
838+
): { start: string; end?: string } {
839+
if (allDay || !end) {
840+
return { start, end };
841+
}
842+
843+
const startDate = new Date(start);
844+
const endDate = new Date(end);
845+
846+
if (
847+
Number.isNaN(startDate.getTime()) ||
848+
Number.isNaN(endDate.getTime()) ||
849+
endDate.getTime() !== startDate.getTime()
850+
) {
851+
return { start, end };
852+
}
853+
854+
const normalizedEnd = new Date(endDate.getTime() + MIN_EXTERNAL_TIMED_EVENT_DURATION_MS);
855+
return {
856+
start,
857+
end: formatExternalTimedEventEnd(normalizedEnd, end),
858+
};
859+
}
860+
861+
function formatExternalTimedEventEnd(date: Date, originalEnd: string): string {
862+
if (/Z$/i.test(originalEnd)) {
863+
return date.toISOString();
822864
}
823865

824-
const startTime = Date.parse(icsEvent.start);
825-
const endTime = Date.parse(icsEvent.end);
826-
const sameInstant =
827-
Number.isFinite(startTime) && Number.isFinite(endTime)
828-
? startTime === endTime
829-
: icsEvent.start === icsEvent.end;
866+
const offsetMatch = originalEnd.match(/([+-])(\d{2}):?(\d{2})$/);
867+
if (offsetMatch) {
868+
const [, sign, hours, minutes] = offsetMatch;
869+
const offsetMinutes = Number(hours) * 60 + Number(minutes);
870+
const offsetMs = offsetMinutes * 60 * 1000 * (sign === "+" ? 1 : -1);
871+
const shifted = new Date(date.getTime() + offsetMs);
872+
const pad = (value: number, length = 2) => String(value).padStart(length, "0");
873+
const datePart = `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`;
874+
const timePart = `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}.${pad(shifted.getUTCMilliseconds(), 3)}`;
875+
return `${datePart}T${timePart}${sign}${hours}:${minutes}`;
876+
}
830877

831-
return sameInstant ? undefined : icsEvent.end;
878+
return format(date, "yyyy-MM-dd'T'HH:mm:ss.SSS");
832879
}
833880

834881
/**

src/services/TaskCalendarSyncService.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -290,12 +290,12 @@ export class TaskCalendarSyncService {
290290

291291
// Add contexts
292292
if (task.contexts && task.contexts.length > 0) {
293-
parts.push(t("contexts", { value: task.contexts.map((c) => `@${c}`).join(", ") }));
293+
parts.push(t("contexts", { value: task.contexts.map((c) => `@${this.toCalendarDescriptionLabel(c)}`).join(", ") }));
294294
}
295295

296296
// Add projects
297297
if (task.projects && task.projects.length > 0) {
298-
parts.push(t("projects", { value: task.projects.join(", ") }));
298+
parts.push(t("projects", { value: task.projects.map((p) => this.toCalendarDescriptionLabel(p)).join(", ") }));
299299
}
300300

301301
// Add separator before link
@@ -309,14 +309,28 @@ export class TaskCalendarSyncService {
309309
const vaultName = this.plugin.app.vault.getName();
310310
const encodedPath = encodeURIComponent(task.path);
311311
const obsidianUri = `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodedPath}`;
312-
// Google Calendar renders HTML in descriptions, so use an anchor tag
313312
const linkText = t("openInObsidian");
314-
parts.push(`<a href="${obsidianUri}">${linkText}</a>`);
313+
parts.push(`${linkText}: ${obsidianUri}`);
315314
}
316315

317316
return parts.join("\n");
318317
}
319318

319+
private toCalendarDescriptionLabel(value: string): string {
320+
return value
321+
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, "$2")
322+
.replace(/\[\[([^\]]+)\]\]/g, (_match, target: string) => this.basenameForDisplay(target))
323+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1")
324+
.trim();
325+
}
326+
327+
private basenameForDisplay(target: string): string {
328+
const withoutHeading = target.split("#")[0];
329+
const withoutExtension = withoutHeading.replace(/\.md$/i, "");
330+
const basename = withoutExtension.split("/").pop();
331+
return basename || withoutExtension || target;
332+
}
333+
320334
/**
321335
* Get the date to use for the calendar event based on settings
322336
*/

tests/services/TaskCalendarSyncService.test.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,38 @@ describe("TaskCalendarSyncService", () => {
1414
googleCalendarExport: {
1515
syncOnTaskUpdate: true,
1616
targetCalendarId: "test-calendar",
17+
includeObsidianLink: true,
1718
}
1819
},
20+
app: {
21+
vault: {
22+
getName: jest.fn().mockReturnValue("Example Vault"),
23+
},
24+
},
1925
cacheManager: {
2026
getTaskInfo: jest.fn()
2127
},
2228
statusManager: {
23-
getStatusConfig: jest.fn().mockReturnValue({ label: "Todo" })
29+
getStatusConfig: jest.fn((status: string) => ({ label: status === "ready" ? "Ready" : "Todo" }))
2430
},
2531
priorityManager: {
26-
getPriorityConfig: jest.fn().mockReturnValue({ label: "High" })
32+
getPriorityConfig: jest.fn((priority: string) => ({ label: priority === "2-high" ? "High" : "Medium" }))
2733
},
2834
i18n: {
29-
translate: jest.fn().mockReturnValue("Untitled Task")
35+
translate: jest.fn((key: string, params?: Record<string, string | number>) => {
36+
const translations: Record<string, string> = {
37+
"settings.integrations.googleCalendarExport.eventDescription.untitledTask": "Untitled Task",
38+
"settings.integrations.googleCalendarExport.eventDescription.priority": "Priority: {value}",
39+
"settings.integrations.googleCalendarExport.eventDescription.status": "Status: {value}",
40+
"settings.integrations.googleCalendarExport.eventDescription.scheduled": "Scheduled: {value}",
41+
"settings.integrations.googleCalendarExport.eventDescription.timeEstimate": "Time Estimate: {value}",
42+
"settings.integrations.googleCalendarExport.eventDescription.contexts": "Contexts: {value}",
43+
"settings.integrations.googleCalendarExport.eventDescription.projects": "Projects: {value}",
44+
"settings.integrations.googleCalendarExport.eventDescription.openInObsidian": "Open in Obsidian",
45+
};
46+
const translation = translations[key] || key;
47+
return translation.replace(/\{(\w+)\}/g, (_match, name) => String(params?.[name] ?? ""));
48+
})
3049
}
3150
};
3251

@@ -78,4 +97,38 @@ describe("TaskCalendarSyncService", () => {
7897
expect(syncService.executeTaskUpdate).toHaveBeenCalledTimes(1);
7998
expect(syncService.executeTaskUpdate).toHaveBeenCalledWith(secondPayload);
8099
});
100+
101+
it("should build plain-text calendar descriptions for external calendar clients", () => {
102+
const description = syncService.buildEventDescription({
103+
path: "Tasks/Prepare quarterly planning notes.md",
104+
title: "Prepare quarterly planning notes",
105+
status: "ready",
106+
priority: "2-high",
107+
scheduled: "2026-04-29",
108+
timeEstimate: 180,
109+
projects: [
110+
"[[Projects/Quarterly Planning|Quarterly Planning]]",
111+
"[[Projects/Nested Project.md]]",
112+
"[Markdown Project](Projects/Markdown%20Project.md)",
113+
],
114+
contexts: ["[[People/Alex Example|Alex Example]]", "admin"],
115+
} as TaskInfo);
116+
117+
expect(description).toContain("Priority: High");
118+
expect(description).toContain("Status: Ready");
119+
expect(description).toContain("Scheduled: 2026-04-29");
120+
expect(description).toContain("Time Estimate: 3h 0m");
121+
expect(description).toContain("Contexts: @Alex Example, @admin");
122+
expect(description).toContain(
123+
"Projects: Quarterly Planning, Nested Project, Markdown Project"
124+
);
125+
expect(description).toContain(
126+
"Open in Obsidian: obsidian://open?vault=Example%20Vault&file=Tasks%2FPrepare%20quarterly%20planning%20notes.md"
127+
);
128+
expect(description).not.toContain("[[");
129+
expect(description).not.toContain("]]");
130+
expect(description).not.toContain("<a ");
131+
expect(description).not.toContain("</a>");
132+
expect(description).not.toContain("](");
133+
});
81134
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { beforeEach, describe, expect, it } from "@jest/globals";
2+
3+
import { createICSEvent } from "../../../src/bases/calendar-core";
4+
import type TaskNotesPlugin from "../../../src/main";
5+
import type { ICSEvent } from "../../../src/types";
6+
7+
function createCalendarPlugin(): TaskNotesPlugin {
8+
return {} as TaskNotesPlugin;
9+
}
10+
11+
beforeEach(() => {
12+
(globalThis as typeof globalThis & {
13+
activeDocument?: {
14+
body: {
15+
classList: {
16+
contains: (className: string) => boolean;
17+
};
18+
};
19+
};
20+
}).activeDocument = {
21+
body: {
22+
classList: {
23+
contains: (_className: string) => false,
24+
},
25+
},
26+
};
27+
});
28+
29+
function createGoogleCalendarEvent(overrides: Partial<ICSEvent> = {}): ICSEvent {
30+
return {
31+
id: "google-primary-zero-duration-event",
32+
subscriptionId: "google-primary",
33+
title: "Reserved pickup cutoff",
34+
start: "2026-04-22T23:12:00",
35+
end: "2026-04-22T23:12:00",
36+
allDay: false,
37+
color: "#16a765",
38+
...overrides,
39+
};
40+
}
41+
42+
describe("Issue #1823: zero-duration Google Calendar list duplication", () => {
43+
it("adds a minimal duration to zero-duration timed Google Calendar events", () => {
44+
const icsEvent = createGoogleCalendarEvent();
45+
46+
const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin());
47+
48+
expect(calendarEvent).not.toBeNull();
49+
expect(calendarEvent?.start).toBe("2026-04-22T23:12:00");
50+
expect(calendarEvent?.end).not.toBe("2026-04-22T23:12:00");
51+
expect(calendarEvent?.allDay).toBe(false);
52+
expect(calendarEvent?.extendedProps.icsEvent?.end).toBe("2026-04-22T23:12:00");
53+
expect(new Date(calendarEvent!.end!).getTime() - new Date(calendarEvent!.start).getTime()).toBe(1);
54+
});
55+
56+
it("preserves an explicit offset when normalizing zero-duration timed events", () => {
57+
const icsEvent = createGoogleCalendarEvent({
58+
start: "2026-04-22T23:12:00+01:00",
59+
end: "2026-04-22T23:12:00+01:00",
60+
});
61+
62+
const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin());
63+
64+
expect(calendarEvent?.start).toBe("2026-04-22T23:12:00+01:00");
65+
expect(calendarEvent?.end).toBe("2026-04-22T23:12:00.001+01:00");
66+
expect(new Date(calendarEvent!.end!).getTime() - new Date(calendarEvent!.start).getTime()).toBe(1);
67+
});
68+
69+
it("preserves UTC formatting when normalizing zero-duration timed events", () => {
70+
const icsEvent = createGoogleCalendarEvent({
71+
start: "2026-04-22T22:12:00.000Z",
72+
end: "2026-04-22T22:12:00.000Z",
73+
});
74+
75+
const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin());
76+
77+
expect(calendarEvent?.start).toBe("2026-04-22T22:12:00.000Z");
78+
expect(calendarEvent?.end).toBe("2026-04-22T22:12:00.001Z");
79+
expect(new Date(calendarEvent!.end!).getTime() - new Date(calendarEvent!.start).getTime()).toBe(1);
80+
});
81+
82+
it("leaves non-zero timed Google Calendar events unchanged", () => {
83+
const icsEvent = createGoogleCalendarEvent({
84+
end: "2026-04-22T23:42:00",
85+
});
86+
87+
const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin());
88+
89+
expect(calendarEvent?.start).toBe("2026-04-22T23:12:00");
90+
expect(calendarEvent?.end).toBe("2026-04-22T23:42:00");
91+
});
92+
93+
it("leaves all-day Google Calendar events unchanged", () => {
94+
const icsEvent = createGoogleCalendarEvent({
95+
start: "2026-04-22",
96+
end: "2026-04-23",
97+
allDay: true,
98+
});
99+
100+
const calendarEvent = createICSEvent(icsEvent, createCalendarPlugin());
101+
102+
expect(calendarEvent?.start).toBe("2026-04-22");
103+
expect(calendarEvent?.end).toBe("2026-04-23");
104+
expect(calendarEvent?.allDay).toBe(true);
105+
});
106+
});

0 commit comments

Comments
 (0)