From f4e3f174997ae2b2685fa811152c0febfa071c04 Mon Sep 17 00:00:00 2001 From: martin-forge <228563004+martin-forge@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:47:00 +0100 Subject: [PATCH] fix(google-calendar): resolve the primary calendar alias in every lookup A Google calendar enabled as `primary` is fetched under that alias, so its events carry `primary` as their calendar id while the calendar list reports the account's real id. Event cards already reconcile the two; three other lookups compared ids directly and missed. The calendar's own color was replaced by the default Google blue, its per-calendar visibility toggle matched nothing and so never hid anything, and mini calendar entries and event-linked notes fell back to the generic provider name. Resolve the alias through one shared helper, and register the account's own calendar under both keys when building visibility toggles. Event and subscription ids are unchanged, so notes already linked to alias-fetched events still match. --- docs/releases/unreleased.md | 5 + src/bases/CalendarView.ts | 8 +- src/bases/MiniCalendarView.ts | 15 ++- src/bases/calendarExternalEvents.ts | 17 +++ src/services/CalendarProvider.ts | 23 ++++ src/services/GoogleCalendarService.ts | 18 ++- src/services/ICSNoteService.ts | 6 +- src/ui/ICSCard.ts | 9 +- .../unit/bases/calendarExternalEvents.test.ts | 30 +++++ ...ssue-google-primary-calendar-alias.test.ts | 112 ++++++++++++++++++ 10 files changed, 221 insertions(+), 22 deletions(-) create mode 100644 tests/unit/issues/issue-google-primary-calendar-alias.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 21617927f..c84ab3e30 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -34,6 +34,11 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Fixed +- (#2309) Fixed Google calendars enabled through the `primary` alias falling back to the + default blue instead of their own calendar color. The alias now also resolves for the + calendar's visibility toggle, which previously matched nothing, and for the calendar name + shown on mini calendar entries and event-linked notes. + - (#1849) Fixed context menus stacking on top of each other. Only one menu stays open at a time, and clicking the same indicator again closes its menu. This previously applied to date fields only, and now covers priority, status, recurrence, reminders, task, ICS event, and batch menus. - Thanks to @3zra47 for reporting and @YBKF for the contribution. diff --git a/src/bases/CalendarView.ts b/src/bases/CalendarView.ts index 9ca0bf0b6..fdc9d7ff4 100644 --- a/src/bases/CalendarView.ts +++ b/src/bases/CalendarView.ts @@ -79,7 +79,7 @@ import { getCalendarConfigValue as getCalendarConfigValueFromSnapshot, } from "./calendarConfigSnapshot"; import { buildCalendarPropertyEvent } from "./calendarPropertyEvents"; -import { buildExternalCalendarEvents } from "./calendarExternalEvents"; +import { buildExternalCalendarEvents, setProviderCalendarToggle } from "./calendarExternalEvents"; import { decorateCalendarIcsEventElement, getCalendarRelatedNoteTooltip, @@ -918,7 +918,11 @@ export class CalendarView extends BasesViewBase { const calendars = this.plugin.googleCalendarService.getAvailableCalendars(); for (const cal of calendars) { const key = `showGoogleCalendar_${cal.id}`; - this.googleCalendarToggles.set(cal.id, this.getConfigOption(key, true)); + setProviderCalendarToggle( + this.googleCalendarToggles, + cal, + this.getConfigOption(key, true) + ); } } diff --git a/src/bases/MiniCalendarView.ts b/src/bases/MiniCalendarView.ts index 18bb7e7e3..f27a348e4 100644 --- a/src/bases/MiniCalendarView.ts +++ b/src/bases/MiniCalendarView.ts @@ -36,6 +36,8 @@ import { import { ICSEventInfoModal } from "../modals/ICSEventInfoModal"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { createElementInDocument } from "../utils/documentDom"; +import { findProviderCalendar } from "../services/CalendarProvider"; +import { setProviderCalendarToggle } from "./calendarExternalEvents"; const tasknotesLogger = createTaskNotesLogger({ tag: "Bases/MiniCalendarView" }); @@ -209,8 +211,9 @@ export class MiniCalendarView extends BasesViewBase { if (this.plugin.googleCalendarService) { for (const calendar of this.plugin.googleCalendarService.getAvailableCalendars()) { - this.googleCalendarToggles.set( - calendar.id, + setProviderCalendarToggle( + this.googleCalendarToggles, + calendar, getToggleValue(`showGoogleCalendar_${calendar.id}`) ); } @@ -447,17 +450,13 @@ export class MiniCalendarView extends BasesViewBase { return; } - const calendars = new Map( - this.plugin.googleCalendarService - .getAvailableCalendars() - .map((calendar) => [calendar.id, calendar]) - ); + const calendars = this.plugin.googleCalendarService.getAvailableCalendars(); for (const icsEvent of this.plugin.googleCalendarService.getAllEvents()) { const calendarId = icsEvent.subscriptionId.replace("google-", ""); if (this.googleCalendarToggles.get(calendarId) === false) continue; - const calendar = calendars.get(calendarId); + const calendar = findProviderCalendar(calendars, calendarId); this.indexExternalEvent( icsEvent, calendar?.summary || "Google Calendar", diff --git a/src/bases/calendarExternalEvents.ts b/src/bases/calendarExternalEvents.ts index 45b56c9c0..091206c99 100644 --- a/src/bases/calendarExternalEvents.ts +++ b/src/bases/calendarExternalEvents.ts @@ -2,6 +2,7 @@ import type { EventInput } from "@fullcalendar/core"; import type TaskNotesPlugin from "../main"; import type { ICSEvent } from "../types"; import { createICSEvent } from "./calendar-core"; +import { PRIMARY_CALENDAR_ALIAS, ProviderCalendar } from "../services/CalendarProvider"; export type ExternalCalendarProvider = "ics" | "google" | "microsoft"; @@ -35,6 +36,22 @@ export function getExternalCalendarToggleId( return event.subscriptionId; } +/** + * Registers a provider calendar's visibility toggle. Calendars fetched under the + * primary alias carry that alias as their calendar id, so the account's own + * calendar has to answer to both keys for its toggle to take effect. + */ +export function setProviderCalendarToggle( + toggles: Map, + calendar: Pick, + visible: boolean +): void { + toggles.set(calendar.id, visible); + if (calendar.primary) { + toggles.set(PRIMARY_CALENDAR_ALIAS, visible); + } +} + export function shouldIncludeExternalCalendarEvent( event: Pick, provider: ExternalCalendarProvider, diff --git a/src/services/CalendarProvider.ts b/src/services/CalendarProvider.ts index 67847f2a1..b32922a7a 100644 --- a/src/services/CalendarProvider.ts +++ b/src/services/CalendarProvider.ts @@ -22,6 +22,29 @@ export interface ProviderCalendar { primary?: boolean; } +/** + * Google accepts `primary` as an alias for the signed-in account's own calendar. + * A calendar configured that way is fetched under the alias, so its events carry + * `primary` as their calendar id while the provider's calendar list reports the + * account's real calendar id. + */ +export const PRIMARY_CALENDAR_ALIAS = "primary"; + +/** + * Finds a provider calendar by id, resolving the primary alias to the account's + * own calendar so alias-configured events still match their calendar metadata. + */ +export function findProviderCalendar>( + calendars: readonly T[], + calendarId: string +): T | undefined { + return calendars.find( + (candidate) => + candidate.id === calendarId || + (calendarId === PRIMARY_CALENDAR_ALIAS && candidate.primary === true) + ); +} + /** * Event date/time configuration * Supports both all-day events (date) and timed events (dateTime + timeZone) diff --git a/src/services/GoogleCalendarService.ts b/src/services/GoogleCalendarService.ts index 8d85747d6..f8a34b2d7 100644 --- a/src/services/GoogleCalendarService.ts +++ b/src/services/GoogleCalendarService.ts @@ -13,7 +13,7 @@ import { TokenExpiredError, } from "./errors"; import { validateCalendarId, validateEventId, validateRequired } from "./validation"; -import { CalendarProvider, ProviderCalendar } from "./CalendarProvider"; +import { CalendarProvider, findProviderCalendar, ProviderCalendar } from "./CalendarProvider"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { publishUserNotice } from "../core/userNotices"; import { normalizeCalendarDescription } from "../utils/calendarDescription"; @@ -454,6 +454,20 @@ export class GoogleCalendarService extends CalendarProvider { } } + /** + * Resolves a calendar's color, including for calendars fetched under the + * primary alias, whose colors are cached under the account's real calendar id. + */ + private getCalendarColor(calendarId: string): string | undefined { + const directColor = this.calendarColors.get(calendarId); + if (directColor) { + return directColor; + } + + const calendar = findProviderCalendar(this.availableCalendars, calendarId); + return calendar ? this.calendarColors.get(calendar.id) : undefined; + } + /** * Converts a Google Calendar event to TaskNotes ICSEvent format */ @@ -492,7 +506,7 @@ export class GoogleCalendarService extends CalendarProvider { // Priority 2: Calendar-level color (from calendar metadata) if (!color) { - color = this.calendarColors.get(calendarId); + color = this.getCalendarColor(calendarId); } // Priority 3: Default Google Calendar blue diff --git a/src/services/ICSNoteService.ts b/src/services/ICSNoteService.ts index 8a6776d09..cd76f9330 100644 --- a/src/services/ICSNoteService.ts +++ b/src/services/ICSNoteService.ts @@ -15,6 +15,7 @@ import type { InterpolationValues, TranslationKey } from "../i18n"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { publishUserNotice } from "../core/userNotices"; import { processVaultFrontMatter } from "./VaultMutationService"; +import { findProviderCalendar } from "./CalendarProvider"; const tasknotesLogger = createTaskNotesLogger({ tag: "Services/ICSNoteService" }); @@ -60,10 +61,9 @@ export class ICSNoteService { .find((event) => event.id === trimmedEventId); if (googleEvent) { const calendarId = googleEvent.subscriptionId.replace("google-", ""); + const calendars = this.plugin.googleCalendarService?.getAvailableCalendars() ?? []; const subscriptionName = - this.plugin.googleCalendarService - ?.getAvailableCalendars() - .find((calendar) => calendar.id === calendarId)?.summary || "Google Calendar"; + findProviderCalendar(calendars, calendarId)?.summary || "Google Calendar"; return { event: googleEvent, subscriptionName }; } diff --git a/src/ui/ICSCard.ts b/src/ui/ICSCard.ts index 16a5c63dd..daa63b3fb 100644 --- a/src/ui/ICSCard.ts +++ b/src/ui/ICSCard.ts @@ -4,6 +4,7 @@ import { ICSEvent, ICSSubscription } from "../types"; import { ICSEventContextMenu } from "../components/ICSEventContextMenu"; import { formatTime } from "../utils/dateUtils"; import { ICSEventInfoModal } from "../modals/ICSEventInfoModal"; +import { findProviderCalendar } from "../services/CalendarProvider"; export interface ICSCardOptions { showDate: boolean; @@ -58,13 +59,7 @@ function getEventSourceName( const provider = plugin.calendarProviderRegistry?.findProviderForEvent(icsEvent); if (provider) { const { calendarId } = provider.extractEventIds(icsEvent); - const calendar = provider - .getAvailableCalendars() - .find( - (candidate) => - candidate.id === calendarId || - (calendarId === "primary" && candidate.primary === true) - ); + const calendar = findProviderCalendar(provider.getAvailableCalendars(), calendarId); return calendar?.summary || provider.providerName; } diff --git a/tests/unit/bases/calendarExternalEvents.test.ts b/tests/unit/bases/calendarExternalEvents.test.ts index 187204a63..52d0fb15f 100644 --- a/tests/unit/bases/calendarExternalEvents.test.ts +++ b/tests/unit/bases/calendarExternalEvents.test.ts @@ -1,6 +1,7 @@ import { buildExternalCalendarEvents, getExternalCalendarToggleId, + setProviderCalendarToggle, shouldIncludeExternalCalendarEvent, } from "../../../src/bases/calendarExternalEvents"; import type TaskNotesPlugin from "../../../src/main"; @@ -56,6 +57,35 @@ describe("calendar external event assembly", () => { ).toBe(true); }); + it("applies a primary calendar's toggle to events fetched under the alias", () => { + const toggles = new Map(); + + setProviderCalendarToggle(toggles, { id: "person@example.com", primary: true }, false); + setProviderCalendarToggle(toggles, { id: "team@example.com" }, true); + + expect( + shouldIncludeExternalCalendarEvent( + createEvent({ subscriptionId: "google-primary" }), + "google", + toggles + ) + ).toBe(false); + expect( + shouldIncludeExternalCalendarEvent( + createEvent({ subscriptionId: "google-person@example.com" }), + "google", + toggles + ) + ).toBe(false); + expect( + shouldIncludeExternalCalendarEvent( + createEvent({ subscriptionId: "google-team@example.com" }), + "google", + toggles + ) + ).toBe(true); + }); + it("builds provider events with related note counts", () => { const sourceEvents = [ createEvent({ id: "included", subscriptionId: "microsoft-work" }), diff --git a/tests/unit/issues/issue-google-primary-calendar-alias.test.ts b/tests/unit/issues/issue-google-primary-calendar-alias.test.ts new file mode 100644 index 000000000..d54990364 --- /dev/null +++ b/tests/unit/issues/issue-google-primary-calendar-alias.test.ts @@ -0,0 +1,112 @@ +import { requestUrl } from "obsidian"; +import type TaskNotesPlugin from "../../../src/main"; +import { GoogleCalendarService } from "../../../src/services/GoogleCalendarService"; +import { findProviderCalendar } from "../../../src/services/CalendarProvider"; +import type { OAuthService } from "../../../src/services/OAuthService"; + +jest.mock("obsidian", () => ({ + Platform: { isDesktopApp: true }, + requestUrl: jest.fn(), +})); + +const PRIMARY_CALENDAR = { + id: "person@example.com", + summary: "Personal", + primary: true, + backgroundColor: "#16a765", +}; + +function createPlugin(enabledGoogleCalendars: string[]): TaskNotesPlugin { + return { + settings: { + enabledGoogleCalendars, + googleCalendarSyncTokens: {}, + }, + saveSettingsDataOnly: jest.fn().mockResolvedValue(undefined), + } as unknown as TaskNotesPlugin; +} + +function createOAuthService(): OAuthService { + return { + isConnected: jest.fn().mockResolvedValue(true), + getValidToken: jest.fn().mockResolvedValue("access-token"), + } as unknown as OAuthService; +} + +function mockCalendarResponses(): void { + const requestMock = requestUrl as jest.MockedFunction; + requestMock.mockReset(); + requestMock + .mockResolvedValueOnce({ + status: 200, + json: { items: [PRIMARY_CALENDAR] }, + text: "", + arrayBuffer: new ArrayBuffer(0), + headers: {}, + }) + .mockResolvedValueOnce({ + status: 200, + json: { + items: [ + { + id: "event-1", + summary: "Standup", + start: { date: "2026-09-12" }, + end: { date: "2026-09-13" }, + }, + ], + nextSyncToken: "next-sync-token", + }, + text: "", + arrayBuffer: new ArrayBuffer(0), + headers: {}, + }); +} + +describe("Google primary calendar alias", () => { + it("colors events from a calendar enabled through the primary alias", async () => { + mockCalendarResponses(); + const service = new GoogleCalendarService(createPlugin(["primary"]), createOAuthService()); + + await service.refreshAllCalendars(); + + const [event] = service.getAllEvents(); + expect(event.color).toBe("#16a765"); + }); + + it("keeps alias-configured event ids stable", async () => { + mockCalendarResponses(); + const service = new GoogleCalendarService(createPlugin(["primary"]), createOAuthService()); + + await service.refreshAllCalendars(); + + const [event] = service.getAllEvents(); + expect(event.subscriptionId).toBe("google-primary"); + expect(event.id).toBe("google-primary-event-1"); + }); + + it("still colors events fetched under a calendar's own id", async () => { + mockCalendarResponses(); + const service = new GoogleCalendarService( + createPlugin(["person@example.com"]), + createOAuthService() + ); + + await service.refreshAllCalendars(); + + const [event] = service.getAllEvents(); + expect(event.color).toBe("#16a765"); + }); + + it("resolves the primary alias to the account's own calendar", () => { + const calendars = [{ id: "team@example.com", summary: "Work" }, PRIMARY_CALENDAR]; + + expect(findProviderCalendar(calendars, "primary")?.summary).toBe("Personal"); + expect(findProviderCalendar(calendars, "team@example.com")?.summary).toBe("Work"); + expect(findProviderCalendar(calendars, "missing@example.com")).toBeUndefined(); + }); + + it("has no primary calendar to resolve when none is marked", () => { + expect(findProviderCalendar([{ id: "team@example.com" }], "primary")).toBeUndefined(); + }); +});