Skip to content
Merged
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
5 changes: 5 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 6 additions & 2 deletions src/bases/CalendarView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
);
}
}

Expand Down
15 changes: 7 additions & 8 deletions src/bases/MiniCalendarView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });

Expand Down Expand Up @@ -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}`)
);
}
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions src/bases/calendarExternalEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string, boolean>,
calendar: Pick<ProviderCalendar, "id" | "primary">,
visible: boolean
): void {
toggles.set(calendar.id, visible);
if (calendar.primary) {
toggles.set(PRIMARY_CALENDAR_ALIAS, visible);
}
}

export function shouldIncludeExternalCalendarEvent(
event: Pick<ICSEvent, "subscriptionId">,
provider: ExternalCalendarProvider,
Expand Down
23 changes: 23 additions & 0 deletions src/services/CalendarProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends Pick<ProviderCalendar, "id" | "primary">>(
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)
Expand Down
18 changes: 16 additions & 2 deletions src/services/GoogleCalendarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/services/ICSNoteService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });

Expand Down Expand Up @@ -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 };
}

Expand Down
9 changes: 2 additions & 7 deletions src/ui/ICSCard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
30 changes: 30 additions & 0 deletions tests/unit/bases/calendarExternalEvents.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
buildExternalCalendarEvents,
getExternalCalendarToggleId,
setProviderCalendarToggle,
shouldIncludeExternalCalendarEvent,
} from "../../../src/bases/calendarExternalEvents";
import type TaskNotesPlugin from "../../../src/main";
Expand Down Expand Up @@ -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<string, boolean>();

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" }),
Expand Down
112 changes: 112 additions & 0 deletions tests/unit/issues/issue-google-primary-calendar-alias.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof requestUrl>;
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();
});
});
Loading