Skip to content
21 changes: 21 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,24 @@ 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
- Stale recurring exception metadata is now ignored when moving an on-pattern scheduled occurrence, preventing the old exception date from replacing the true original series date
- 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.
- Reconcile Google Calendar task exports when valid task file changes are made outside TaskNotes while Obsidian is running, and repair already-linked task events changed while Obsidian was closed without bulk-creating events for existing unlinked tasks on startup.
- 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.
17 changes: 16 additions & 1 deletion src/bases/CalendarView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1878,7 +1878,16 @@ export class CalendarView extends BasesViewBase {
private handleEventDidMount(arg: any): void {
if (!arg?.event?.extendedProps) return;

const { taskInfo, timeblock, icsEvent, eventType, basesEntry } = arg.event.extendedProps;
const {
taskInfo,
timeblock,
icsEvent,
eventType,
basesEntry,
isRecurringInstance,
isNextScheduledOccurrence,
isPatternInstance,
} = arg.event.extendedProps;

// Add calendar icon to provider-managed calendar events in grid views
if (icsEvent && arg.view.type !== 'listWeek') {
Expand Down Expand Up @@ -1962,9 +1971,15 @@ export class CalendarView extends BasesViewBase {

// Use shared UTC-anchored target date logic
const targetDate = getTargetDateForEvent(arg);
const scheduledDateContext =
taskInfo.recurrence &&
(isRecurringInstance || isNextScheduledOccurrence || isPatternInstance)
? targetDate
: undefined;

cardElement = createTaskCard(enrichedTask, this.plugin, visibleProperties, this.buildTaskCardOptions({
targetDate: targetDate,
scheduledDateContext,
}));
}
// Render ICS events with ICSCard
Expand Down
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
43 changes: 40 additions & 3 deletions src/bootstrap/pluginBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { PomodoroService } from "../services/PomodoroService";
import { AutoExportService } from "../services/AutoExportService";

type FileDeletedEventData = { path: string; prevCache?: unknown };
type FileUpdatedEventData = { path: string; file?: unknown };

export function registerTaskNotesIcon(): void {
addIcon(
Expand Down Expand Up @@ -270,20 +271,56 @@ export function initializeServicesLazily(plugin: TaskNotesPlugin): void {

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

plugin.registerEvent(
plugin.emitter.on("file-updated", (data: FileUpdatedEventData) => {
if (data?.file) {
plugin.autoArchiveService
.reconcileTaskByPath(data.path)
.catch((error) => {
console.warn(
"Failed to reconcile externally updated task with auto-archive:",
error
);
});
}

if (!plugin.taskCalendarSyncService || !data?.file) {
return;
}

plugin.taskCalendarSyncService
.handleExternalTaskFileUpdated(data.path)
.catch((error) => {
console.warn(
"Failed to reconcile externally updated task with Google Calendar:",
error
);
});
})
);

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

const eventIdKey = plugin.fieldMapper.toUserField("googleCalendarEventId");
const exceptionEventIdKey = plugin.fieldMapper.toUserField("googleCalendarExceptionEventId");
const prevCache = data.prevCache as { frontmatter?: Record<string, unknown> } | undefined;
const eventId = prevCache?.frontmatter?.[eventIdKey];
const exceptionEventId = prevCache?.frontmatter?.[exceptionEventIdKey];

const eventIds = [eventId, exceptionEventId].filter(
(id): id is string => typeof id === "string" && id.length > 0
);

if (typeof eventId === "string" && eventId.length > 0) {
if (eventIds.length > 0) {
plugin.taskCalendarSyncService
.deleteTaskFromCalendarByPath(data.path, eventId)
.deleteTaskFromCalendarByPath(data.path, ...eventIds)
.catch((error) => {
console.warn(
"Failed to delete task from Google Calendar on file deletion:",
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
Loading
Loading