diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 121110a03..7180304bf 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -31,3 +31,11 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ``` --> + +## Fixed + +- (#1451) Calendar views no longer render the Google Calendar copy of an event that + was already exported from a TaskNotes task. The task-side event is shown instead, so + exported tasks no longer appear twice on the calendar and mini-calendar. Unrelated + Google Calendar events remain visible. Detached recurring exceptions and expanded + recurring instances of an exported series are also suppressed. diff --git a/src/bases/CalendarView.ts b/src/bases/CalendarView.ts index ed1868e73..5b74aaec2 100644 --- a/src/bases/CalendarView.ts +++ b/src/bases/CalendarView.ts @@ -78,7 +78,10 @@ import { getCalendarConfigValue as getCalendarConfigValueFromSnapshot, } from "./calendarConfigSnapshot"; import { buildCalendarPropertyEvent } from "./calendarPropertyEvents"; -import { buildExternalCalendarEvents } from "./calendarExternalEvents"; +import { + buildExternalCalendarEvents, + buildLinkedExternalCalendarEventPredicate, +} from "./calendarExternalEvents"; import { decorateCalendarIcsEventElement, getCalendarRelatedNoteTooltip, @@ -1872,6 +1875,11 @@ export class CalendarView extends BasesViewBase { plugin: this.plugin, toggles: this.googleCalendarToggles, relatedNoteCountsByEventId, + isLinkedToTask: buildLinkedExternalCalendarEventPredicate( + this.currentTasks, + "google", + this.plugin + ), }) ); } diff --git a/src/bases/MiniCalendarView.ts b/src/bases/MiniCalendarView.ts index 18bb7e7e3..7175606f3 100644 --- a/src/bases/MiniCalendarView.ts +++ b/src/bases/MiniCalendarView.ts @@ -11,6 +11,11 @@ import { import type { BasesEntry, BasesPropertyId, BasesView, BasesViewFactory } from "obsidian"; import TaskNotesPlugin from "../main"; import { BasesViewBase } from "./BasesViewBase"; +import { + buildLinkedExternalCalendarEventPredicate, + type LinkedExternalCalendarEventPredicate, +} from "./calendarExternalEvents"; +import { identifyTaskNotesFromBasesData } from "./helpers"; import { ICSEvent, TaskInfo } from "../types"; import { format } from "date-fns"; import { @@ -248,8 +253,19 @@ export class MiniCalendarView extends BasesViewBase { // Use raw Bases data (has getValue() method) const basesEntries = this.data.data; + // Build a linked-event filter from the TaskNotes tasks in this view so + // provider-side mirrors of exported tasks are suppressed (issue #1451). + const linkedGoogleEventFilter = buildLinkedExternalCalendarEventPredicate( + await identifyTaskNotesFromBasesData( + this.dataAdapter.extractDataItems(), + this.plugin + ), + "google", + this.plugin + ); + // Index notes by date - this.indexNotesByDate(basesEntries); + this.indexNotesByDate(basesEntries, linkedGoogleEventFilter); // Render calendar grid this.renderCalendarControls(); @@ -287,11 +303,14 @@ export class MiniCalendarView extends BasesViewBase { } } - private indexNotesByDate(dataItems: BasesEntry[]): void { + private indexNotesByDate( + dataItems: BasesEntry[], + linkedGoogleEventFilter?: LinkedExternalCalendarEventPredicate + ): void { this.notesByDate.clear(); if (!this.dateProperty) { - this.indexExternalCalendarEvents(); + this.indexExternalCalendarEvents(linkedGoogleEventFilter); return; } @@ -400,7 +419,7 @@ export class MiniCalendarView extends BasesViewBase { } } - this.indexExternalCalendarEvents(); + this.indexExternalCalendarEvents(linkedGoogleEventFilter); } private addEntryToDate(dateKey: string, entry: NoteEntry): void { @@ -411,9 +430,11 @@ export class MiniCalendarView extends BasesViewBase { this.notesByDate.get(dateKey)?.push(entry); } - private indexExternalCalendarEvents(): void { + private indexExternalCalendarEvents( + linkedGoogleEventFilter?: LinkedExternalCalendarEventPredicate + ): void { this.indexICSEvents(); - this.indexGoogleCalendarEvents(); + this.indexGoogleCalendarEvents(linkedGoogleEventFilter); this.indexMicrosoftCalendarEvents(); } @@ -442,7 +463,9 @@ export class MiniCalendarView extends BasesViewBase { } } - private indexGoogleCalendarEvents(): void { + private indexGoogleCalendarEvents( + linkedGoogleEventFilter?: LinkedExternalCalendarEventPredicate + ): void { if (!this.plugin.googleCalendarService) { return; } @@ -457,6 +480,9 @@ export class MiniCalendarView extends BasesViewBase { const calendarId = icsEvent.subscriptionId.replace("google-", ""); if (this.googleCalendarToggles.get(calendarId) === false) continue; + // Suppress provider-side mirrors of tasks exported from TaskNotes (issue #1451). + if (linkedGoogleEventFilter?.(icsEvent)) continue; + const calendar = calendars.get(calendarId); this.indexExternalEvent( icsEvent, diff --git a/src/bases/calendarExternalEvents.ts b/src/bases/calendarExternalEvents.ts index 45b56c9c0..59def18c4 100644 --- a/src/bases/calendarExternalEvents.ts +++ b/src/bases/calendarExternalEvents.ts @@ -1,6 +1,6 @@ import type { EventInput } from "@fullcalendar/core"; import type TaskNotesPlugin from "../main"; -import type { ICSEvent } from "../types"; +import type { ICSEvent, TaskInfo } from "../types"; import { createICSEvent } from "./calendar-core"; export type ExternalCalendarProvider = "ics" | "google" | "microsoft"; @@ -13,6 +13,14 @@ export type ExternalCalendarEventFactory = ( type Nullable = T | null; +/** + * Predicate that, given an external provider event, reports whether it corresponds + * to a TaskNotes task that was exported to that provider's calendar. Calendar views + * use this to suppress the duplicate provider-side rendering of an exported task + * (upstream issue #1451). Unrelated events must remain visible. + */ +export type LinkedExternalCalendarEventPredicate = (event: ICSEvent) => boolean; + export interface BuildExternalCalendarEventsInput { events: readonly ICSEvent[]; provider: ExternalCalendarProvider; @@ -20,6 +28,12 @@ export interface BuildExternalCalendarEventsInput { toggles?: ReadonlyMap; relatedNoteCountsByEventId?: ReadonlyMap; createEvent?: ExternalCalendarEventFactory; + /** + * Optional predicate that flags provider events linked to an exported + * TaskNotes task. Linked events are filtered out so the calendar only + * renders the task-side representation. + */ + isLinkedToTask?: LinkedExternalCalendarEventPredicate; } export function getExternalCalendarToggleId( @@ -43,6 +57,113 @@ export function shouldIncludeExternalCalendarEvent( return toggles?.get(getExternalCalendarToggleId(event, provider)) !== false; } +/** + * Strip the provider-prefixed event id back down to the raw provider event id. + * + * Google/Microsoft ICSEvent ids are normalized as `{provider}-{calendarId}-{rawEventId}` + * (see GoogleCalendarService/MicrosoftCalendarService). Calendar ids may themselves + * contain hyphens, so only the known `{provider}-` segment is stripped from the + * subscription id before removing that calendar-id prefix from the event id. + */ +function getRawProviderEventId(event: ICSEvent, provider: ExternalCalendarProvider): string { + if (provider !== "google" && provider !== "microsoft") { + return event.id; + } + const calendarId = + provider === "google" + ? event.subscriptionId.replace("google-", "") + : event.subscriptionId.replace("microsoft-", ""); + const prefix = `${provider}-${calendarId}-`; + return event.id.startsWith(prefix) ? event.id.slice(prefix.length) : event.id; +} + +function getRawRecurringEventId( + event: ICSEvent, + provider: ExternalCalendarProvider +): string | undefined { + if (!event.recurringEventId) return undefined; + if (provider !== "google" && provider !== "microsoft") { + return event.recurringEventId; + } + const calendarId = + provider === "google" + ? event.subscriptionId.replace("google-", "") + : event.subscriptionId.replace("microsoft-", ""); + const prefix = `${provider}-${calendarId}-`; + return event.recurringEventId.startsWith(prefix) + ? event.recurringEventId.slice(prefix.length) + : event.recurringEventId; +} + +/** + * Build a predicate that identifies Google Calendar provider events which are the + * provider-side mirror of a TaskNotes task exported to Google Calendar. + * + * A task is linked to a Google event when, for the export target calendar: + * - the task's stored `googleCalendarEventId` equals the event's raw id (single + * event or recurring series master), or + * - the task's stored `googleCalendarExceptionEventId` equals the event's raw + * id (a detached/moved recurring occurrence), or + * - the event is an expanded instance of a recurring series whose master id + * equals the task's `googleCalendarEventId` (matched via the event's + * `recurringEventId`). + * + * Only events from the configured export target calendar are considered, so events + * from other Google calendars are always retained. Microsoft Calendar has no + * task-export mapping field today, so this helper returns a never-match predicate + * for the "microsoft" provider; the structure is in place to add Microsoft + * filtering if a mapping field is introduced. + */ +export function buildLinkedExternalCalendarEventPredicate( + tasks: readonly TaskInfo[], + provider: ExternalCalendarProvider, + plugin: TaskNotesPlugin +): LinkedExternalCalendarEventPredicate { + if (provider !== "google") { + return () => false; + } + + const targetCalendarId = plugin.settings?.googleCalendarExport?.targetCalendarId ?? ""; + + const masterEventIds = new Set(); + const exceptionEventIds = new Set(); + for (const task of tasks) { + if (task.googleCalendarEventId) { + masterEventIds.add(task.googleCalendarEventId); + } + if (task.googleCalendarExceptionEventId) { + exceptionEventIds.add(task.googleCalendarExceptionEventId); + } + } + + if (masterEventIds.size === 0 && exceptionEventIds.size === 0) { + return () => false; + } + + return (event: ICSEvent) => { + if (targetCalendarId === "") { + return false; + } + const calendarId = event.subscriptionId.replace("google-", ""); + if (calendarId !== targetCalendarId) { + return false; + } + + const rawEventId = getRawProviderEventId(event, "google"); + if (exceptionEventIds.has(rawEventId)) { + return true; + } + if (masterEventIds.has(rawEventId)) { + return true; + } + const rawRecurringEventId = getRawRecurringEventId(event, "google"); + if (rawRecurringEventId && masterEventIds.has(rawRecurringEventId)) { + return true; + } + return false; + }; +} + export function buildExternalCalendarEvents({ events, provider, @@ -50,6 +171,7 @@ export function buildExternalCalendarEvents({ toggles, relatedNoteCountsByEventId, createEvent = createICSEvent, + isLinkedToTask, }: BuildExternalCalendarEventsInput): EventInput[] { const calendarEvents: EventInput[] = []; @@ -58,6 +180,10 @@ export function buildExternalCalendarEvents({ continue; } + if (isLinkedToTask?.(event)) { + continue; + } + const calendarEvent = createEvent(event, plugin, { relatedNoteCount: relatedNoteCountsByEventId?.get(event.id), }); diff --git a/tests/unit/bases/calendarExternalEvents.test.ts b/tests/unit/bases/calendarExternalEvents.test.ts index 187204a63..34bb5e561 100644 --- a/tests/unit/bases/calendarExternalEvents.test.ts +++ b/tests/unit/bases/calendarExternalEvents.test.ts @@ -1,10 +1,11 @@ import { buildExternalCalendarEvents, + buildLinkedExternalCalendarEventPredicate, getExternalCalendarToggleId, shouldIncludeExternalCalendarEvent, } from "../../../src/bases/calendarExternalEvents"; import type TaskNotesPlugin from "../../../src/main"; -import type { ICSEvent } from "../../../src/types"; +import type { ICSEvent, TaskInfo } from "../../../src/types"; function createEvent(overrides: Partial = {}): ICSEvent { return { @@ -18,6 +19,24 @@ function createEvent(overrides: Partial = {}): ICSEvent { }; } +function createTask(overrides: Partial = {}): TaskInfo { + return { + path: "Tasks/task-1.md", + title: "Task one", + ...overrides, + } as TaskInfo; +} + +function createPlugin( + targetCalendarId = "primary" +): TaskNotesPlugin { + return { + settings: { + googleCalendarExport: { targetCalendarId }, + }, + } as unknown as TaskNotesPlugin; +} + describe("calendar external event assembly", () => { it("uses provider-specific toggle ids", () => { expect( @@ -108,3 +127,186 @@ describe("calendar external event assembly", () => { expect(events).toEqual([]); }); }); + +describe("buildLinkedExternalCalendarEventPredicate", () => { + it("returns a never-match predicate for non-google providers", () => { + const predicate = buildLinkedExternalCalendarEventPredicate( + [createTask({ googleCalendarEventId: "abc" })], + "microsoft", + createPlugin() + ); + + expect(predicate(createEvent({ id: "microsoft-work-abc", subscriptionId: "microsoft-work" }))).toBe(false); + }); + + it("returns a never-match predicate when no tasks carry google event ids", () => { + const predicate = buildLinkedExternalCalendarEventPredicate( + [createTask()], + "google", + createPlugin() + ); + + expect(predicate(createEvent({ id: "google-primary-abc", subscriptionId: "google-primary" }))).toBe(false); + }); + + it("filters a google event whose raw id matches a task googleCalendarEventId", () => { + const predicate = buildLinkedExternalCalendarEventPredicate( + [createTask({ googleCalendarEventId: "abc123" })], + "google", + createPlugin("primary") + ); + + const linked = createEvent({ + id: "google-primary-abc123", + subscriptionId: "google-primary", + }); + const unrelated = createEvent({ + id: "google-primary-other", + subscriptionId: "google-primary", + }); + + expect(predicate(linked)).toBe(true); + expect(predicate(unrelated)).toBe(false); + }); + + it("retains events from a different google calendar than the export target", () => { + const predicate = buildLinkedExternalCalendarEventPredicate( + [createTask({ googleCalendarEventId: "abc123" })], + "google", + createPlugin("primary") + ); + + const otherCalendarEvent = createEvent({ + id: "google-secondary-abc123", + subscriptionId: "google-secondary", + }); + + expect(predicate(otherCalendarEvent)).toBe(false); + }); + + it("filters a detached recurring exception by googleCalendarExceptionEventId", () => { + const predicate = buildLinkedExternalCalendarEventPredicate( + [ + createTask({ + googleCalendarEventId: "master123", + googleCalendarExceptionEventId: "exception456", + }), + ], + "google", + createPlugin("primary") + ); + + const exception = createEvent({ + id: "google-primary-exception456", + subscriptionId: "google-primary", + recurringEventId: "google-primary-master123", + }); + + expect(predicate(exception)).toBe(true); + }); + + it("filters expanded recurring instances whose recurringEventId matches a task master id", () => { + const predicate = buildLinkedExternalCalendarEventPredicate( + [createTask({ googleCalendarEventId: "master123" })], + "google", + createPlugin("primary") + ); + + const instance = createEvent({ + id: "google-primary-20260518T090000Z", + subscriptionId: "google-primary", + recurringEventId: "google-primary-master123", + }); + const unrelatedInstance = createEvent({ + id: "google-primary-20260519T090000Z", + subscriptionId: "google-primary", + recurringEventId: "google-primary-otherMaster", + }); + + expect(predicate(instance)).toBe(true); + expect(predicate(unrelatedInstance)).toBe(false); + }); + + it("handles calendar ids containing hyphens when stripping the prefix", () => { + const predicate = buildLinkedExternalCalendarEventPredicate( + [createTask({ googleCalendarEventId: "abc123" })], + "google", + createPlugin("family@group.calendar.google.com") + ); + + const linked = createEvent({ + id: "google-family@group.calendar.google.com-abc123", + subscriptionId: "google-family@group.calendar.google.com", + }); + + expect(predicate(linked)).toBe(true); + }); +}); + +describe("buildExternalCalendarEvents linked-event filtering", () => { + it("drops linked google events while retaining unrelated ones", () => { + const sourceEvents: ICSEvent[] = [ + createEvent({ id: "google-primary-linked", subscriptionId: "google-primary" }), + createEvent({ id: "google-primary-unrelated", subscriptionId: "google-primary" }), + ]; + const createCalendarEvent = jest.fn((event: ICSEvent) => ({ + id: event.id, + title: event.title, + start: event.start, + allDay: event.allDay, + extendedProps: { eventType: "ics" as const }, + })); + + const isLinkedToTask = buildLinkedExternalCalendarEventPredicate( + [createTask({ googleCalendarEventId: "linked" })], + "google", + createPlugin("primary") + ); + + const events = buildExternalCalendarEvents({ + events: sourceEvents, + provider: "google", + plugin: createPlugin("primary"), + createEvent: createCalendarEvent, + isLinkedToTask, + }); + + expect(events).toEqual([ + { + id: "google-primary-unrelated", + title: "Planning", + start: "2026-05-18T09:00", + allDay: false, + extendedProps: { eventType: "ics" }, + }, + ]); + expect(createCalendarEvent).toHaveBeenCalledTimes(1); + expect(createCalendarEvent).toHaveBeenCalledWith( + sourceEvents[1], + expect.anything(), + expect.anything() + ); + }); + + it("retains all events when no isLinkedToTask predicate is supplied", () => { + const sourceEvents: ICSEvent[] = [ + createEvent({ id: "google-primary-a", subscriptionId: "google-primary" }), + createEvent({ id: "google-primary-b", subscriptionId: "google-primary" }), + ]; + const createCalendarEvent = jest.fn((event: ICSEvent) => ({ + id: event.id, + title: event.title, + start: event.start, + allDay: event.allDay, + })); + + const events = buildExternalCalendarEvents({ + events: sourceEvents, + provider: "google", + plugin: createPlugin("primary"), + createEvent: createCalendarEvent, + }); + + expect(events).toHaveLength(2); + }); +});