Skip to content
Open
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
19 changes: 19 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,22 @@ 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
- (#1823) Fixed zero-duration timed external calendar events rendering on multiple days in list-style calendar views
- Adds a minimal display duration before passing point-in-time external events to FullCalendar
- Preserves the original provider event data for context menus and debugging
- Thanks to @martin-forge for reporting and debugging
- 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.
- Track exported Google Calendar task events in plugin data so startup can recover cleanup for task files deleted while Obsidian was closed.
- 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.
- 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.
- Prevent duplicate Google Calendar task events when concurrent syncs race before the newly created event ID reaches Obsidian metadata.
- Prevent pending intermediate status updates from overwriting completed Google Calendar task events when users quickly cycle a task to done.
- Mark Google Calendar events as completed when tasks were already done before they became calendar-eligible.
- Google Calendar task descriptions now use mobile-friendly plain text for Obsidian links and display labels for wiki-style project/context links.
142 changes: 138 additions & 4 deletions src/bases/calendar-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,11 +578,17 @@ export function createICSEvent(icsEvent: ICSEvent, plugin: TaskNotesPlugin): Cal
subscriptionName = subscription.name;
}

const { start, end } = normalizeExternalTimedEventRange(
icsEvent.start,
icsEvent.end,
icsEvent.allDay
);

return {
id: icsEvent.id,
title: icsEvent.title,
start: icsEvent.start,
end: icsEvent.end,
start: start,
end: end,
allDay: icsEvent.allDay,
backgroundColor: backgroundColor,
borderColor: borderColor,
Expand All @@ -602,6 +608,60 @@ export function createICSEvent(icsEvent: ICSEvent, plugin: TaskNotesPlugin): Cal
}
}

/**
* FullCalendar list views can render a timed external event under multiple day
* headers when the provider supplies a true zero-duration range (end === start).
* Clamp those point-in-time external events to a minimal positive duration
* before handing them to FullCalendar, while preserving the raw provider event
* unchanged in extendedProps for display and debugging.
*/
function normalizeExternalTimedEventRange(
start: string,
end: string | undefined,
allDay: boolean
): { start: string; end?: string } {
if (allDay || !end) {
return { start, end };
}

const startDate = new Date(start);
const endDate = new Date(end);

if (
Number.isNaN(startDate.getTime()) ||
Number.isNaN(endDate.getTime()) ||
endDate.getTime() !== startDate.getTime()
) {
return { start, end };
}

const normalizedEnd = new Date(endDate.getTime() + 1);
return {
start,
end: formatExternalTimedEventEnd(normalizedEnd, end),
};
}

function formatExternalTimedEventEnd(date: Date, originalEnd: string): string {
if (/Z$/i.test(originalEnd)) {
return date.toISOString();
}

const offsetMatch = originalEnd.match(/([+-])(\d{2}):?(\d{2})$/);
if (offsetMatch) {
const [, sign, hours, minutes] = offsetMatch;
const offsetMinutes = Number(hours) * 60 + Number(minutes);
const offsetMs = offsetMinutes * 60 * 1000 * (sign === "+" ? 1 : -1);
const shifted = new Date(date.getTime() + offsetMs);
const pad = (value: number, length = 2) => String(value).padStart(length, "0");
const datePart = `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`;
const timePart = `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}.${pad(shifted.getUTCMilliseconds(), 3)}`;
return `${datePart}T${timePart}${sign}${hours}:${minutes}`;
}

return format(date, "yyyy-MM-dd'T'HH:mm:ss.SSS");
}

/**
* Get recurring time from task recurrence rule
*/
Expand Down Expand Up @@ -744,6 +804,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 +890,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 +935,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
3 changes: 2 additions & 1 deletion src/bootstrap/pluginBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,11 @@ export function initializeServicesLazily(plugin: TaskNotesPlugin): void {

plugin.taskCalendarSyncService = new (await import("../services/TaskCalendarSyncService"))
.TaskCalendarSyncService(plugin, plugin.googleCalendarService);
plugin.taskCalendarSyncService.startDeletionQueueProcessor();

plugin.registerEvent(
plugin.emitter.on("file-deleted", (data: FileDeletedEventData) => {
if (!plugin.taskCalendarSyncService?.isEnabled()) {
if (!plugin.taskCalendarSyncService) {
return;
}

Expand Down
10 changes: 7 additions & 3 deletions src/components/BatchContextMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,12 +334,16 @@ export class BatchContextMenu {
const file = plugin.app.vault.getAbstractFileByPath(path);
if (file) {
// Delete from Google Calendar before trashing file
if (plugin.taskCalendarSyncService?.isEnabled()) {
if (plugin.taskCalendarSyncService) {
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
22 changes: 15 additions & 7 deletions src/components/TaskContextMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,14 +466,22 @@ export class TaskContextMenu {
});
if (confirmed) {
// Delete from Google Calendar before trashing file
if (plugin.taskCalendarSyncService?.isEnabled() && task.googleCalendarEventId) {
plugin.taskCalendarSyncService
.deleteTaskFromCalendarByPath(task.path, task.googleCalendarEventId)
.catch((error) => {
console.warn("Failed to delete task from Google Calendar:", error);
});
if (
plugin.taskCalendarSyncService &&
(task.googleCalendarEventId || task.googleCalendarExceptionEventId)
) {
try {
await plugin.taskCalendarSyncService
.deleteTaskFromCalendarByPath(
task.path,
task.googleCalendarEventId,
task.googleCalendarExceptionEventId
);
} catch (error) {
console.warn("Failed to delete task from Google Calendar:", error);
}
}
plugin.app.vault.trash(file, true);
await plugin.app.vault.trash(file, true);
}
});
});
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
3 changes: 3 additions & 0 deletions src/services/GoogleCalendarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,9 @@ export class GoogleCalendarService extends CalendarProvider {

// Build update payload
const payload: any = { ...currentEvent };
if (payload.status === "cancelled") {
payload.status = "confirmed";
}

// Support both 'title' and 'summary'
if (updates.title !== undefined || updates.summary !== undefined) {
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