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
7 changes: 7 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,10 @@ Example:
```

-->

## Fixed

- (#1696) Fixed Google Calendar recurring tasks creating duplicate moved occurrences instead of converging on one series instance plus one detached exception event
- 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
- 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
- Thanks to @martin-forge for reporting, reproducing, and patching the recurring exception sync failure
78 changes: 76 additions & 2 deletions src/bases/calendar-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,75 @@ export function createRecurringEvent(
};
}

function buildRecurringInstanceExclusionSet(
task: TaskInfo,
nextScheduledDate: string
): Set<string> {
const exclusions = new Set<string>();
const normalizeDateValue = (value: unknown): string | undefined => {
if (typeof value === "string") {
const normalized = getDatePart(value);
return typeof normalized === "string" && normalized ? normalized : undefined;
}
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) return undefined;
return formatDateForStorage(value);
}
if (typeof value === "number") {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return undefined;
return formatDateForStorage(date);
}
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
if (record.date instanceof Date) {
if (Number.isNaN(record.date.getTime())) return undefined;
return formatDateForStorage(record.date);
}
if (typeof record.data === "string") {
return normalizeDateValue(record.data);
}
if (typeof (value as { toISOString?: () => string }).toISOString === "function") {
try {
return normalizeDateValue(
(value as { toISOString: () => string }).toISOString()
);
} catch {
return undefined;
}
}
}
return undefined;
};
const addDate = (value: unknown): void => {
const normalized = normalizeDateValue(value);
if (normalized) exclusions.add(normalized);
};

addDate(nextScheduledDate);
addDate(task.googleCalendarExceptionOriginalScheduled);

if (Array.isArray(task.googleCalendarMovedOriginalDates)) {
for (const date of task.googleCalendarMovedOriginalDates) {
addDate(date);
}
}

// Calendar pipeline sometimes flattens these values into customProperties.
const customProperties = task.customProperties as Record<string, unknown> | undefined;
if (customProperties) {
addDate(customProperties.googleCalendarExceptionOriginalScheduled);
const movedDates = customProperties.googleCalendarMovedOriginalDates;
if (Array.isArray(movedDates)) {
for (const date of movedDates) {
addDate(date);
}
}
}

return exclusions;
}

/**
* Generate recurring task instances for calendar display
*/
Expand All @@ -761,6 +830,10 @@ export function generateRecurringTaskInstances(
const hasOriginalTime = hasTimeComponent(task.scheduled);
const templateTime = getRecurringTime(task);
const nextScheduledDate = getDatePart(task.scheduled);
const recurringInstanceExclusions = buildRecurringInstanceExclusionSet(
task,
nextScheduledDate
);

// 1. Create next scheduled occurrence event
const scheduledTime = hasOriginalTime ? getTimePart(task.scheduled) : null;
Expand Down Expand Up @@ -802,8 +875,9 @@ export function generateRecurringTaskInstances(
continue;
}

// Skip if conflicts with next scheduled occurrence
if (instanceDate === nextScheduledDate) {
// Skip if this date is already represented by the concrete current occurrence
// or by known moved-occurrence exclusions.
if (recurringInstanceExclusions.has(instanceDate)) {
continue;
}

Expand Down
10 changes: 10 additions & 0 deletions src/bases/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,16 @@ function createTaskInfoFromProperties(
"timeEstimate",
"completedDate",
"recurrence",
"recurrence_anchor",
"dateCreated",
"dateModified",
"timeEntries",
"reminders",
"icsEventId",
"googleCalendarEventId",
"googleCalendarExceptionEventId",
"googleCalendarExceptionOriginalScheduled",
"googleCalendarMovedOriginalDates",
"complete_instances",
"skipped_instances",
"blockedBy",
Expand Down Expand Up @@ -162,6 +167,11 @@ function createTaskInfoFromProperties(
totalTrackedTime: totalTrackedTime,
reminders: props.reminders,
icsEventId: props.icsEventId,
googleCalendarEventId: props.googleCalendarEventId,
googleCalendarExceptionEventId: props.googleCalendarExceptionEventId,
googleCalendarExceptionOriginalScheduled: props.googleCalendarExceptionOriginalScheduled,
googleCalendarMovedOriginalDates: props.googleCalendarMovedOriginalDates,
recurrence_anchor: props.recurrence_anchor,
complete_instances: props.complete_instances,
skipped_instances: props.skipped_instances,
blockedBy: props.blockedBy,
Expand Down
8 changes: 6 additions & 2 deletions src/components/BatchContextMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,10 +336,14 @@ export class BatchContextMenu {
// Delete from Google Calendar before trashing file
if (plugin.taskCalendarSyncService?.isEnabled()) {
const task = await plugin.cacheManager.getTaskInfo(path);
if (task?.googleCalendarEventId) {
if (task?.googleCalendarEventId || task?.googleCalendarExceptionEventId) {
try {
await plugin.taskCalendarSyncService
.deleteTaskFromCalendarByPath(path, task.googleCalendarEventId);
.deleteTaskFromCalendarByPath(
path,
task.googleCalendarEventId,
task.googleCalendarExceptionEventId
);
} catch (error) {
console.warn("Failed to delete task from Google Calendar:", error);
}
Expand Down
11 changes: 9 additions & 2 deletions src/components/TaskContextMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,9 +466,16 @@ export class TaskContextMenu {
});
if (confirmed) {
// Delete from Google Calendar before trashing file
if (plugin.taskCalendarSyncService?.isEnabled() && task.googleCalendarEventId) {
if (
plugin.taskCalendarSyncService?.isEnabled() &&
(task.googleCalendarEventId || task.googleCalendarExceptionEventId)
) {
plugin.taskCalendarSyncService
.deleteTaskFromCalendarByPath(task.path, task.googleCalendarEventId)
.deleteTaskFromCalendarByPath(
task.path,
task.googleCalendarEventId,
task.googleCalendarExceptionEventId
)
.catch((error) => {
console.warn("Failed to delete task from Google Calendar:", error);
});
Expand Down
45 changes: 45 additions & 0 deletions src/core/fieldMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,32 @@ export function mapTaskFromFrontmatter(
mapped.googleCalendarEventId = frontmatter[mapping.googleCalendarEventId];
}

if (
mapping.googleCalendarExceptionEventId &&
frontmatter[mapping.googleCalendarExceptionEventId] !== undefined
) {
mapped.googleCalendarExceptionEventId =
frontmatter[mapping.googleCalendarExceptionEventId];
}

if (
mapping.googleCalendarExceptionOriginalScheduled &&
frontmatter[mapping.googleCalendarExceptionOriginalScheduled] !== undefined
) {
mapped.googleCalendarExceptionOriginalScheduled =
frontmatter[mapping.googleCalendarExceptionOriginalScheduled];
}

if (
mapping.googleCalendarMovedOriginalDates &&
frontmatter[mapping.googleCalendarMovedOriginalDates] !== undefined
) {
const movedDates = frontmatter[mapping.googleCalendarMovedOriginalDates];
mapped.googleCalendarMovedOriginalDates = Array.isArray(movedDates)
? movedDates
: [movedDates];
}

if (frontmatter[mapping.reminders] !== undefined) {
const reminders = frontmatter[mapping.reminders];
if (Array.isArray(reminders)) {
Expand Down Expand Up @@ -268,6 +294,25 @@ export function mapTaskToFrontmatter(
frontmatter[mapping.icsEventId] = taskData.icsEventId;
}

if (taskData.googleCalendarEventId !== undefined) {
frontmatter[mapping.googleCalendarEventId] = taskData.googleCalendarEventId;
}

if (taskData.googleCalendarExceptionEventId !== undefined) {
frontmatter[mapping.googleCalendarExceptionEventId] =
taskData.googleCalendarExceptionEventId;
}

if (taskData.googleCalendarExceptionOriginalScheduled !== undefined) {
frontmatter[mapping.googleCalendarExceptionOriginalScheduled] =
taskData.googleCalendarExceptionOriginalScheduled;
}

if (taskData.googleCalendarMovedOriginalDates !== undefined) {
frontmatter[mapping.googleCalendarMovedOriginalDates] =
taskData.googleCalendarMovedOriginalDates;
}

if (taskData.reminders !== undefined && taskData.reminders.length > 0) {
frontmatter[mapping.reminders] = taskData.reminders;
}
Expand Down
2 changes: 1 addition & 1 deletion src/services/AutoArchiveService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class AutoArchiveService {
}

private hasGoogleCalendarLink(task: TaskInfo): boolean {
return !!task.googleCalendarEventId;
return !!(task.googleCalendarEventId || task.googleCalendarExceptionEventId);
}

private getCalendarCleanupState(): "ready" | "retry" | "skip" {
Expand Down
8 changes: 8 additions & 0 deletions src/services/MdbaseSpecService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,14 @@ export class MdbaseSpecService {
items: { type: "string" },
});
this.addRoleField(lines, "googleCalendarEventId", { type: "string" });
this.addRoleField(lines, "googleCalendarExceptionEventId", { type: "string" });
this.addRoleField(lines, "googleCalendarExceptionOriginalScheduled", {
type: "string",
});
this.addRoleField(lines, "googleCalendarMovedOriginalDates", {
type: "list",
items: { type: "date" },
});

// User-defined fields
if (settings.userFields && settings.userFields.length > 0) {
Expand Down
Loading
Loading