Skip to content

Commit 568314a

Browse files
committed
Fix Google Calendar task sync reliability and rendering
1 parent 1f824c3 commit 568314a

27 files changed

Lines changed: 3111 additions & 187 deletions

docs/releases/unreleased.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,22 @@ Example:
2323
```
2424
2525
-->
26+
27+
## Fixed
28+
29+
- (#1696) Fixed Google Calendar recurring tasks creating duplicate moved occurrences instead of converging on one series instance plus one detached exception event
30+
- Scheduled-anchor recurring moves now preserve the original series date, add the correct Google `EXDATE`, and create or remove the detached Google event as the moved occurrence is resolved
31+
- Archive, delete, and retry flows now clean up both the recurring master link and any detached exception link so stale Google events do not linger
32+
- Thanks to @martin-forge for reporting, reproducing, and patching the recurring exception sync failure
33+
- (#1823) Fixed zero-duration timed external calendar events rendering on multiple days in list-style calendar views
34+
- Adds a minimal display duration before passing point-in-time external events to FullCalendar
35+
- Preserves the original provider event data for context menus and debugging
36+
- Thanks to @martin-forge for reporting and debugging
37+
- Persist failed Google Calendar task-event deletions in plugin data and retry them after restart or reconnect, preventing orphaned task events when a task file is deleted while Google cleanup fails or sync is not ready.
38+
- Track exported Google Calendar task events in plugin data so startup can recover cleanup for task files deleted while Obsidian was closed.
39+
- Persist Google Calendar task sync requests while Google Calendar is not ready and replay the current task state after reconnect for scheduled, due, or both-date calendar modes.
40+
- Restore cancelled Google Calendar event tombstones when a task is synced to an existing event ID, so deleted-but-still-addressable events become visible again.
41+
- Prevent duplicate Google Calendar task events when concurrent syncs race before the newly created event ID reaches Obsidian metadata.
42+
- Prevent pending intermediate status updates from overwriting completed Google Calendar task events when users quickly cycle a task to done.
43+
- Mark Google Calendar events as completed when tasks were already done before they became calendar-eligible.
44+
- Google Calendar task descriptions now use mobile-friendly plain text for Obsidian links and display labels for wiki-style project/context links.

src/bases/calendar-core.ts

Lines changed: 138 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -578,11 +578,17 @@ export function createICSEvent(icsEvent: ICSEvent, plugin: TaskNotesPlugin): Cal
578578
subscriptionName = subscription.name;
579579
}
580580

581+
const { start, end } = normalizeExternalTimedEventRange(
582+
icsEvent.start,
583+
icsEvent.end,
584+
icsEvent.allDay
585+
);
586+
581587
return {
582588
id: icsEvent.id,
583589
title: icsEvent.title,
584-
start: icsEvent.start,
585-
end: icsEvent.end,
590+
start: start,
591+
end: end,
586592
allDay: icsEvent.allDay,
587593
backgroundColor: backgroundColor,
588594
borderColor: borderColor,
@@ -602,6 +608,60 @@ export function createICSEvent(icsEvent: ICSEvent, plugin: TaskNotesPlugin): Cal
602608
}
603609
}
604610

611+
/**
612+
* FullCalendar list views can render a timed external event under multiple day
613+
* headers when the provider supplies a true zero-duration range (end === start).
614+
* Clamp those point-in-time external events to a minimal positive duration
615+
* before handing them to FullCalendar, while preserving the raw provider event
616+
* unchanged in extendedProps for display and debugging.
617+
*/
618+
function normalizeExternalTimedEventRange(
619+
start: string,
620+
end: string | undefined,
621+
allDay: boolean
622+
): { start: string; end?: string } {
623+
if (allDay || !end) {
624+
return { start, end };
625+
}
626+
627+
const startDate = new Date(start);
628+
const endDate = new Date(end);
629+
630+
if (
631+
Number.isNaN(startDate.getTime()) ||
632+
Number.isNaN(endDate.getTime()) ||
633+
endDate.getTime() !== startDate.getTime()
634+
) {
635+
return { start, end };
636+
}
637+
638+
const normalizedEnd = new Date(endDate.getTime() + 1);
639+
return {
640+
start,
641+
end: formatExternalTimedEventEnd(normalizedEnd, end),
642+
};
643+
}
644+
645+
function formatExternalTimedEventEnd(date: Date, originalEnd: string): string {
646+
if (/Z$/i.test(originalEnd)) {
647+
return date.toISOString();
648+
}
649+
650+
const offsetMatch = originalEnd.match(/([+-])(\d{2}):?(\d{2})$/);
651+
if (offsetMatch) {
652+
const [, sign, hours, minutes] = offsetMatch;
653+
const offsetMinutes = Number(hours) * 60 + Number(minutes);
654+
const offsetMs = offsetMinutes * 60 * 1000 * (sign === "+" ? 1 : -1);
655+
const shifted = new Date(date.getTime() + offsetMs);
656+
const pad = (value: number, length = 2) => String(value).padStart(length, "0");
657+
const datePart = `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`;
658+
const timePart = `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}.${pad(shifted.getUTCMilliseconds(), 3)}`;
659+
return `${datePart}T${timePart}${sign}${hours}:${minutes}`;
660+
}
661+
662+
return format(date, "yyyy-MM-dd'T'HH:mm:ss.SSS");
663+
}
664+
605665
/**
606666
* Get recurring time from task recurrence rule
607667
*/
@@ -744,6 +804,75 @@ export function createRecurringEvent(
744804
};
745805
}
746806

807+
function buildRecurringInstanceExclusionSet(
808+
task: TaskInfo,
809+
nextScheduledDate: string
810+
): Set<string> {
811+
const exclusions = new Set<string>();
812+
const normalizeDateValue = (value: unknown): string | undefined => {
813+
if (typeof value === "string") {
814+
const normalized = getDatePart(value);
815+
return typeof normalized === "string" && normalized ? normalized : undefined;
816+
}
817+
if (value instanceof Date) {
818+
if (Number.isNaN(value.getTime())) return undefined;
819+
return formatDateForStorage(value);
820+
}
821+
if (typeof value === "number") {
822+
const date = new Date(value);
823+
if (Number.isNaN(date.getTime())) return undefined;
824+
return formatDateForStorage(date);
825+
}
826+
if (value && typeof value === "object") {
827+
const record = value as Record<string, unknown>;
828+
if (record.date instanceof Date) {
829+
if (Number.isNaN(record.date.getTime())) return undefined;
830+
return formatDateForStorage(record.date);
831+
}
832+
if (typeof record.data === "string") {
833+
return normalizeDateValue(record.data);
834+
}
835+
if (typeof (value as { toISOString?: () => string }).toISOString === "function") {
836+
try {
837+
return normalizeDateValue(
838+
(value as { toISOString: () => string }).toISOString()
839+
);
840+
} catch {
841+
return undefined;
842+
}
843+
}
844+
}
845+
return undefined;
846+
};
847+
const addDate = (value: unknown): void => {
848+
const normalized = normalizeDateValue(value);
849+
if (normalized) exclusions.add(normalized);
850+
};
851+
852+
addDate(nextScheduledDate);
853+
addDate(task.googleCalendarExceptionOriginalScheduled);
854+
855+
if (Array.isArray(task.googleCalendarMovedOriginalDates)) {
856+
for (const date of task.googleCalendarMovedOriginalDates) {
857+
addDate(date);
858+
}
859+
}
860+
861+
// Calendar pipeline sometimes flattens these values into customProperties.
862+
const customProperties = task.customProperties as Record<string, unknown> | undefined;
863+
if (customProperties) {
864+
addDate(customProperties.googleCalendarExceptionOriginalScheduled);
865+
const movedDates = customProperties.googleCalendarMovedOriginalDates;
866+
if (Array.isArray(movedDates)) {
867+
for (const date of movedDates) {
868+
addDate(date);
869+
}
870+
}
871+
}
872+
873+
return exclusions;
874+
}
875+
747876
/**
748877
* Generate recurring task instances for calendar display
749878
*/
@@ -761,6 +890,10 @@ export function generateRecurringTaskInstances(
761890
const hasOriginalTime = hasTimeComponent(task.scheduled);
762891
const templateTime = getRecurringTime(task);
763892
const nextScheduledDate = getDatePart(task.scheduled);
893+
const recurringInstanceExclusions = buildRecurringInstanceExclusionSet(
894+
task,
895+
nextScheduledDate
896+
);
764897

765898
// 1. Create next scheduled occurrence event
766899
const scheduledTime = hasOriginalTime ? getTimePart(task.scheduled) : null;
@@ -802,8 +935,9 @@ export function generateRecurringTaskInstances(
802935
continue;
803936
}
804937

805-
// Skip if conflicts with next scheduled occurrence
806-
if (instanceDate === nextScheduledDate) {
938+
// Skip if this date is already represented by the concrete current occurrence
939+
// or by known moved-occurrence exclusions.
940+
if (recurringInstanceExclusions.has(instanceDate)) {
807941
continue;
808942
}
809943

src/bases/helpers.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,16 @@ function createTaskInfoFromProperties(
8989
"timeEstimate",
9090
"completedDate",
9191
"recurrence",
92+
"recurrence_anchor",
9293
"dateCreated",
9394
"dateModified",
9495
"timeEntries",
9596
"reminders",
9697
"icsEventId",
98+
"googleCalendarEventId",
99+
"googleCalendarExceptionEventId",
100+
"googleCalendarExceptionOriginalScheduled",
101+
"googleCalendarMovedOriginalDates",
97102
"complete_instances",
98103
"skipped_instances",
99104
"blockedBy",
@@ -162,6 +167,11 @@ function createTaskInfoFromProperties(
162167
totalTrackedTime: totalTrackedTime,
163168
reminders: props.reminders,
164169
icsEventId: props.icsEventId,
170+
googleCalendarEventId: props.googleCalendarEventId,
171+
googleCalendarExceptionEventId: props.googleCalendarExceptionEventId,
172+
googleCalendarExceptionOriginalScheduled: props.googleCalendarExceptionOriginalScheduled,
173+
googleCalendarMovedOriginalDates: props.googleCalendarMovedOriginalDates,
174+
recurrence_anchor: props.recurrence_anchor,
165175
complete_instances: props.complete_instances,
166176
skipped_instances: props.skipped_instances,
167177
blockedBy: props.blockedBy,

src/bootstrap/pluginBootstrap.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,10 +270,11 @@ export function initializeServicesLazily(plugin: TaskNotesPlugin): void {
270270

271271
plugin.taskCalendarSyncService = new (await import("../services/TaskCalendarSyncService"))
272272
.TaskCalendarSyncService(plugin, plugin.googleCalendarService);
273+
plugin.taskCalendarSyncService.startDeletionQueueProcessor();
273274

274275
plugin.registerEvent(
275276
plugin.emitter.on("file-deleted", (data: FileDeletedEventData) => {
276-
if (!plugin.taskCalendarSyncService?.isEnabled()) {
277+
if (!plugin.taskCalendarSyncService) {
277278
return;
278279
}
279280

src/components/BatchContextMenu.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -334,12 +334,16 @@ export class BatchContextMenu {
334334
const file = plugin.app.vault.getAbstractFileByPath(path);
335335
if (file) {
336336
// Delete from Google Calendar before trashing file
337-
if (plugin.taskCalendarSyncService?.isEnabled()) {
337+
if (plugin.taskCalendarSyncService) {
338338
const task = await plugin.cacheManager.getTaskInfo(path);
339-
if (task?.googleCalendarEventId) {
339+
if (task?.googleCalendarEventId || task?.googleCalendarExceptionEventId) {
340340
try {
341341
await plugin.taskCalendarSyncService
342-
.deleteTaskFromCalendarByPath(path, task.googleCalendarEventId);
342+
.deleteTaskFromCalendarByPath(
343+
path,
344+
task.googleCalendarEventId,
345+
task.googleCalendarExceptionEventId
346+
);
343347
} catch (error) {
344348
console.warn("Failed to delete task from Google Calendar:", error);
345349
}

src/components/TaskContextMenu.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -466,14 +466,22 @@ export class TaskContextMenu {
466466
});
467467
if (confirmed) {
468468
// Delete from Google Calendar before trashing file
469-
if (plugin.taskCalendarSyncService?.isEnabled() && task.googleCalendarEventId) {
470-
plugin.taskCalendarSyncService
471-
.deleteTaskFromCalendarByPath(task.path, task.googleCalendarEventId)
472-
.catch((error) => {
473-
console.warn("Failed to delete task from Google Calendar:", error);
474-
});
469+
if (
470+
plugin.taskCalendarSyncService &&
471+
(task.googleCalendarEventId || task.googleCalendarExceptionEventId)
472+
) {
473+
try {
474+
await plugin.taskCalendarSyncService
475+
.deleteTaskFromCalendarByPath(
476+
task.path,
477+
task.googleCalendarEventId,
478+
task.googleCalendarExceptionEventId
479+
);
480+
} catch (error) {
481+
console.warn("Failed to delete task from Google Calendar:", error);
482+
}
475483
}
476-
plugin.app.vault.trash(file, true);
484+
await plugin.app.vault.trash(file, true);
477485
}
478486
});
479487
});

src/core/fieldMapping.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,32 @@ export function mapTaskFromFrontmatter(
131131
mapped.googleCalendarEventId = frontmatter[mapping.googleCalendarEventId];
132132
}
133133

134+
if (
135+
mapping.googleCalendarExceptionEventId &&
136+
frontmatter[mapping.googleCalendarExceptionEventId] !== undefined
137+
) {
138+
mapped.googleCalendarExceptionEventId =
139+
frontmatter[mapping.googleCalendarExceptionEventId];
140+
}
141+
142+
if (
143+
mapping.googleCalendarExceptionOriginalScheduled &&
144+
frontmatter[mapping.googleCalendarExceptionOriginalScheduled] !== undefined
145+
) {
146+
mapped.googleCalendarExceptionOriginalScheduled =
147+
frontmatter[mapping.googleCalendarExceptionOriginalScheduled];
148+
}
149+
150+
if (
151+
mapping.googleCalendarMovedOriginalDates &&
152+
frontmatter[mapping.googleCalendarMovedOriginalDates] !== undefined
153+
) {
154+
const movedDates = frontmatter[mapping.googleCalendarMovedOriginalDates];
155+
mapped.googleCalendarMovedOriginalDates = Array.isArray(movedDates)
156+
? movedDates
157+
: [movedDates];
158+
}
159+
134160
if (frontmatter[mapping.reminders] !== undefined) {
135161
const reminders = frontmatter[mapping.reminders];
136162
if (Array.isArray(reminders)) {
@@ -268,6 +294,25 @@ export function mapTaskToFrontmatter(
268294
frontmatter[mapping.icsEventId] = taskData.icsEventId;
269295
}
270296

297+
if (taskData.googleCalendarEventId !== undefined) {
298+
frontmatter[mapping.googleCalendarEventId] = taskData.googleCalendarEventId;
299+
}
300+
301+
if (taskData.googleCalendarExceptionEventId !== undefined) {
302+
frontmatter[mapping.googleCalendarExceptionEventId] =
303+
taskData.googleCalendarExceptionEventId;
304+
}
305+
306+
if (taskData.googleCalendarExceptionOriginalScheduled !== undefined) {
307+
frontmatter[mapping.googleCalendarExceptionOriginalScheduled] =
308+
taskData.googleCalendarExceptionOriginalScheduled;
309+
}
310+
311+
if (taskData.googleCalendarMovedOriginalDates !== undefined) {
312+
frontmatter[mapping.googleCalendarMovedOriginalDates] =
313+
taskData.googleCalendarMovedOriginalDates;
314+
}
315+
271316
if (taskData.reminders !== undefined && taskData.reminders.length > 0) {
272317
frontmatter[mapping.reminders] = taskData.reminders;
273318
}

src/services/AutoArchiveService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export class AutoArchiveService {
1616
}
1717

1818
private hasGoogleCalendarLink(task: TaskInfo): boolean {
19-
return !!task.googleCalendarEventId;
19+
return !!(task.googleCalendarEventId || task.googleCalendarExceptionEventId);
2020
}
2121

2222
private getCalendarCleanupState(): "ready" | "retry" | "skip" {

src/services/GoogleCalendarService.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,9 @@ export class GoogleCalendarService extends CalendarProvider {
602602

603603
// Build update payload
604604
const payload: any = { ...currentEvent };
605+
if (payload.status === "cancelled") {
606+
payload.status = "confirmed";
607+
}
605608

606609
// Support both 'title' and 'summary'
607610
if (updates.title !== undefined || updates.summary !== undefined) {

src/services/MdbaseSpecService.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,14 @@ export class MdbaseSpecService {
230230
items: { type: "string" },
231231
});
232232
this.addRoleField(lines, "googleCalendarEventId", { type: "string" });
233+
this.addRoleField(lines, "googleCalendarExceptionEventId", { type: "string" });
234+
this.addRoleField(lines, "googleCalendarExceptionOriginalScheduled", {
235+
type: "string",
236+
});
237+
this.addRoleField(lines, "googleCalendarMovedOriginalDates", {
238+
type: "list",
239+
items: { type: "date" },
240+
});
233241

234242
// User-defined fields
235243
if (settings.userFields && settings.userFields.length > 0) {

0 commit comments

Comments
 (0)