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(); + }); +});