From 8d7a3594f7a7339267f2bb63b622260ea9dc1329 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 16 Aug 2026 16:58:23 +1000 Subject: [PATCH 01/25] fix google oauth external browser launch --- docs/releases/unreleased.md | 7 ++ src/services/OAuthService.ts | 28 ++++- ...2229-google-oauth-external-browser.test.ts | 117 ++++++++++++++++++ 3 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 tests/unit/issues/issue-2229-google-oauth-external-browser.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 121110a03..0e5ac9e8d 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -31,3 +31,10 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ``` --> + +## Fixed + +- (#2229) Google Calendar OAuth authorization now opens in the system browser + instead of Obsidian's Web Viewer, avoiding Google's 401 malformed-request + page in the in-app browser. Thanks to @prethrive for reporting this and + confirming the workaround. diff --git a/src/services/OAuthService.ts b/src/services/OAuthService.ts index 5be0a3fc5..fd103e19e 100644 --- a/src/services/OAuthService.ts +++ b/src/services/OAuthService.ts @@ -14,6 +14,12 @@ type HttpModuleLike = { createServer(handler?: (req: HTTPRequestLike, res: HTTPResponseLike) => void): HTTPServerLike; }; +type ElectronModuleLike = { + shell?: { + openExternal?: (url: string) => Promise | void; + }; +}; + let cachedHttpModule: HttpModuleLike | null = null; function ensureHttpModule(): HttpModuleLike { @@ -180,7 +186,7 @@ export class OAuthService { ); // Open browser to authorization URL - window.open(authUrl, "_blank"); + await this.openAuthorizationUrl(authUrl); // Wait for callback with timeout const code = await this.waitForCallback(state, 300000); // 5 minute timeout @@ -215,6 +221,26 @@ export class OAuthService { } } + private async openAuthorizationUrl(authUrl: string): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports, import/no-extraneous-dependencies -- OAuth must bypass Obsidian's in-app Web Viewer and use the system browser on desktop. + const electron = require("electron") as ElectronModuleLike; + const shell = electron.shell; + if (shell?.openExternal) { + await shell.openExternal(authUrl); + return; + } + } catch (error) { + tasknotesLogger.warn("Failed to open OAuth URL in system browser; falling back to window.open.", { + category: "provider", + operation: "oauth-open-external", + error, + }); + } + + window.open(authUrl, "_blank"); + } + /** * Finds an available port in the given range */ diff --git a/tests/unit/issues/issue-2229-google-oauth-external-browser.test.ts b/tests/unit/issues/issue-2229-google-oauth-external-browser.test.ts new file mode 100644 index 000000000..e95d83e0b --- /dev/null +++ b/tests/unit/issues/issue-2229-google-oauth-external-browser.test.ts @@ -0,0 +1,117 @@ +const mockOpenExternal = jest.fn(); + +jest.mock("obsidian", () => ({ + Platform: { isDesktopApp: true }, + requestUrl: jest.fn(), +})); + +jest.mock( + "electron", + () => ({ + shell: { + openExternal: mockOpenExternal, + }, + }), + { virtual: true } +); + +import { OAuthService } from "../../../src/services/OAuthService"; +import { OAuthSecretStore } from "../../../src/services/OAuthSecretStore"; +import type TaskNotesPlugin from "../../../src/main"; +import type { OAuthConfig, OAuthProvider, OAuthTokens } from "../../../src/types"; + +class InMemorySecretStorage { + private readonly values = new Map(); + + getSecret(id: string): string | null { + return this.values.get(id) ?? null; + } + + setSecret(id: string, value: string): void { + this.values.set(id, value); + } +} + +type OAuthServiceInternals = OAuthService & { + findAvailablePort: jest.Mock, [number, number]>; + startCallbackServer: jest.Mock, [number]>; + stopCallbackServer: jest.Mock, []>; + generateCodeVerifier: jest.Mock; + generateCodeChallenge: jest.Mock, [string]>; + generateState: jest.Mock; + buildAuthorizationUrl: jest.Mock; + waitForCallback: jest.Mock, [string, number]>; + exchangeCodeForTokens: jest.Mock, [OAuthConfig, string, string]>; + storeConnection: jest.Mock, [OAuthProvider, OAuthTokens]>; +}; + +function createOAuthService(): { service: OAuthService; authUrl: string } { + const secretStore = new OAuthSecretStore(new InMemorySecretStorage()); + secretStore.setCredentials("google", { + clientId: "google-client-id", + clientSecret: "google-client-secret", + }); + const service = new OAuthService( + { + emitter: { + trigger: jest.fn(), + }, + } as unknown as TaskNotesPlugin, + secretStore + ) as unknown as OAuthServiceInternals; + const tokens: OAuthTokens = { + accessToken: "access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() + 3600, + scope: "calendar", + tokenType: "Bearer", + }; + const authUrl = + "https://accounts.google.com/o/oauth2/v2/auth?client_id=google-client-id"; + + service.findAvailablePort = jest.fn().mockResolvedValue(18080); + service.startCallbackServer = jest.fn().mockResolvedValue(undefined); + service.stopCallbackServer = jest.fn().mockResolvedValue(undefined); + service.generateCodeVerifier = jest.fn().mockReturnValue("code-verifier"); + service.generateCodeChallenge = jest.fn().mockResolvedValue("code-challenge"); + service.generateState = jest.fn().mockReturnValue("oauth-state"); + service.buildAuthorizationUrl = jest.fn().mockReturnValue(authUrl); + service.waitForCallback = jest.fn().mockResolvedValue("authorization-code"); + service.exchangeCodeForTokens = jest.fn().mockResolvedValue(tokens); + service.storeConnection = jest.fn().mockResolvedValue(undefined); + + return { service, authUrl }; +} + +describe("Issue #2229: Google OAuth opens outside Obsidian Web Viewer", () => { + let windowOpenSpy: jest.SpyInstance>; + + beforeEach(() => { + jest.clearAllMocks(); + windowOpenSpy = jest.spyOn(window, "open").mockImplementation(() => null); + }); + + afterEach(() => { + windowOpenSpy.mockRestore(); + }); + + it("uses the system browser for the OAuth authorization URL", async () => { + mockOpenExternal.mockResolvedValue(undefined); + const { service, authUrl } = createOAuthService(); + + await service.authenticate("google"); + + expect(mockOpenExternal).toHaveBeenCalledWith(authUrl); + expect(windowOpenSpy).not.toHaveBeenCalled(); + }); + + it("falls back to the existing window.open path when external launch fails", async () => { + mockOpenExternal.mockRejectedValue(new Error("external launch unavailable")); + const { service, authUrl } = createOAuthService(); + + await service.authenticate("google"); + + expect(mockOpenExternal).toHaveBeenCalledWith(authUrl); + expect(windowOpenSpy).toHaveBeenCalledWith(authUrl, "_blank"); + }); +}); From 8e1476cca3af9cf49126e16dddddd4135ac334e6 Mon Sep 17 00:00:00 2001 From: Tony Grosinger Date: Fri, 21 Aug 2026 07:20:11 -0700 Subject: [PATCH 02/25] Clear contexts and blockedBy on empty-array updates (#2221) * Clear contexts and blockedBy on empty-array updates The frontmatter deletion pass in removeUnsetMappedFields only removed the contexts and blockedBy keys when the update held a literal undefined, which JSON cannot express. HTTP clients sending PUT {"contexts": []} or {"blockedBy": []} therefore got a 200 with the previous value silently left in place. Use the same hasOwnProperty plus empty-array deletion pattern that projects and reminders already use. * docs: credit PR contribution --------- Co-authored-by: callumalpass --- docs/HTTP_API.md | 2 + docs/releases/unreleased.md | 4 + .../task-service/taskUpdatePlanning.ts | 4 +- .../unit/services/taskUpdatePlanning.test.ts | 79 +++++++++++++++++++ 4 files changed, 87 insertions(+), 2 deletions(-) diff --git a/docs/HTTP_API.md b/docs/HTTP_API.md index 6920db99e..343577d4e 100644 --- a/docs/HTTP_API.md +++ b/docs/HTTP_API.md @@ -211,6 +211,8 @@ Update task with partial payload. Configured TaskNotes user fields can be updated either by their frontmatter property key or via `customProperties`. +Sending an empty array for `contexts` or `blockedBy` clears the corresponding frontmatter field. + ```bash curl -X PUT "http://localhost:8080/api/tasks/TaskNotes%2FTasks%2FReview%20docs.md" \ -H "Content-Type: application/json" \ diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 121110a03..cc1bd1185 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -31,3 +31,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ``` --> + +## Fixed + +- Fixed `PUT /api/tasks/:id` ignoring empty arrays for `contexts` and `blockedBy`: sending `{"contexts": []}` or `{"blockedBy": []}` now clears the corresponding frontmatter field instead of silently leaving the previous value in place. The deletion pass previously fired only on a literal `undefined`, which JSON cannot express, so HTTP clients had no way to clear these fields. Thanks to @tgrosinger for the contribution. diff --git a/src/services/task-service/taskUpdatePlanning.ts b/src/services/task-service/taskUpdatePlanning.ts index 324608744..460569e00 100644 --- a/src/services/task-service/taskUpdatePlanning.ts +++ b/src/services/task-service/taskUpdatePlanning.ts @@ -307,7 +307,7 @@ function removeUnsetMappedFields( } if ( Object.prototype.hasOwnProperty.call(updates, "contexts") && - updates.contexts === undefined + (!Array.isArray(updates.contexts) || updates.contexts.length === 0) ) { delete frontmatter[fieldMapper.toUserField("contexts")]; } @@ -356,7 +356,7 @@ function removeUnsetMappedFields( } if ( Object.prototype.hasOwnProperty.call(updates, "blockedBy") && - updates.blockedBy === undefined + (!Array.isArray(updates.blockedBy) || updates.blockedBy.length === 0) ) { delete frontmatter[fieldMapper.toUserField("blockedBy")]; } diff --git a/tests/unit/services/taskUpdatePlanning.test.ts b/tests/unit/services/taskUpdatePlanning.test.ts index 31215bfc9..5c83ca4fe 100644 --- a/tests/unit/services/taskUpdatePlanning.test.ts +++ b/tests/unit/services/taskUpdatePlanning.test.ts @@ -232,6 +232,85 @@ describe("taskUpdatePlanning", () => { expect(result.finalTags).toEqual([]); }); + it("clears contexts and blockedBy when an update explicitly sets them to empty arrays", () => { + const frontmatter: Record = { + title: "Old", + status: "open", + contexts: ["old"], + blockedBy: [{ uid: "[[Other]]", reltype: "FINISHTOSTART" }], + tags: ["task"], + }; + + applyTaskUpdateFrontmatterChange({ + frontmatter, + originalTask: createTask(), + updates: { contexts: [], blockedBy: [] }, + recurrenceUpdates: {}, + dateModified: "2026-05-19T09:00:00.000Z", + fieldMapper: createFieldMapper(), + taskIdentification: { + method: "tag", + tag: "task", + propertyName: "", + propertyValue: "", + }, + storeTitleInFilename: false, + updateCompletedDateInFrontmatter: jest.fn(), + }); + + expect(frontmatter).not.toHaveProperty("contexts"); + expect(frontmatter).not.toHaveProperty("blockedBy"); + }); + + it("leaves contexts and blockedBy alone when the update omits them or keeps values", () => { + const frontmatter: Record = { + title: "Old", + status: "open", + contexts: ["old"], + blockedBy: [{ uid: "[[Other]]", reltype: "FINISHTOSTART" }], + tags: ["task"], + }; + + applyTaskUpdateFrontmatterChange({ + frontmatter, + originalTask: createTask(), + updates: { title: "Renamed" }, + recurrenceUpdates: {}, + dateModified: "2026-05-19T09:00:00.000Z", + fieldMapper: createFieldMapper(), + taskIdentification: { + method: "tag", + tag: "task", + propertyName: "", + propertyValue: "", + }, + storeTitleInFilename: false, + updateCompletedDateInFrontmatter: jest.fn(), + }); + + expect(frontmatter.contexts).toEqual(["old"]); + expect(frontmatter.blockedBy).toEqual([{ uid: "[[Other]]", reltype: "FINISHTOSTART" }]); + + applyTaskUpdateFrontmatterChange({ + frontmatter, + originalTask: createTask(), + updates: { contexts: ["new"] }, + recurrenceUpdates: {}, + dateModified: "2026-05-19T09:00:00.000Z", + fieldMapper: createFieldMapper(), + taskIdentification: { + method: "tag", + tag: "task", + propertyName: "", + propertyValue: "", + }, + storeTitleInFilename: false, + updateCompletedDateInFrontmatter: jest.fn(), + }); + + expect(frontmatter.contexts).toEqual(["new"]); + }); + it("builds the returned task state from the same planned mutation", () => { const updated = buildUpdatedTaskFromPlan({ originalTask: createTask({ completedDate: undefined }), From dbfcd314e1ad3b3688f078f4fc11e8ba35dbc8d1 Mon Sep 17 00:00:00 2001 From: Tony Grosinger Date: Sat, 1 Aug 2026 18:40:25 -0700 Subject: [PATCH 03/25] perf: don't block onload on Bases registration registerBasesTaskList retried registration five times with 200ms sleeps when the Bases plugin had not finished loading, and onload awaited the whole thing. On mobile, where plugin load ordering is less predictable, that could stall plugin startup for a full second. registerBasesIntegration already owns retrying: it schedules a timer that re-attempts until the views are actually registered, checks the Bases plugin is enabled before each try, and is torn down on unload. So drop the inner loop entirely and make registerBasesTaskList a single attempt that reports its result, leaving one retry mechanism instead of two. onload no longer awaits it either. --- src/bases/registration.ts | 19 +------------------ src/main.ts | 4 +++- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/src/bases/registration.ts b/src/bases/registration.ts index 987d0062c..de7a56e13 100644 --- a/src/bases/registration.ts +++ b/src/bases/registration.ts @@ -278,24 +278,7 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise window.setTimeout(r, 200)); - if (await attemptRegistration()) { - return true; - } - } - - logger.warn("Failed to register views after multiple attempts", { - category: "configuration", - operation: "register-views", - }); - return false; + return attemptRegistration(); } /** diff --git a/src/main.ts b/src/main.ts index 643880fd4..9bf715adf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -314,7 +314,9 @@ export default class TaskNotesPlugin extends Plugin { this.migrationPromise = this.performEarlyMigrationCheck(); initializeCalendarProviders(this); - await registerBasesIntegration(this); + // Not awaited: if Bases has not loaded yet this schedules a retry timer, + // and initializeAfterLayoutReady attempts registration again. + void registerBasesIntegration(this); // Defer expensive initialization until layout is ready this.app.workspace.onLayoutReady(() => { From b4bed613dc763ad1630c2c0eda87fdc5b34283bb Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 23 Aug 2026 12:39:52 +1000 Subject: [PATCH 04/25] docs: credit Bases startup improvement --- docs/releases/unreleased.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 0e5ac9e8d..36553db91 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -32,6 +32,10 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l --> +## Changed + +- Improved TaskNotes startup performance by allowing Bases registration to finish asynchronously. Thanks to @tgrosinger for the contribution. + ## Fixed - (#2229) Google Calendar OAuth authorization now opens in the system browser From b329204c9c7ffb831c7bfb116accda0524694248 Mon Sep 17 00:00:00 2001 From: Tony Grosinger Date: Sat, 22 Aug 2026 19:49:23 -0700 Subject: [PATCH 05/25] perf: don't block onload on Bases registration (#2187) Avoid blocking plugin startup while Bases registration retries asynchronously.\n\nThanks to @tgrosinger for the contribution. --- docs/releases/unreleased.md | 4 ++++ src/bases/registration.ts | 19 +------------------ src/main.ts | 4 +++- 3 files changed, 8 insertions(+), 19 deletions(-) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index cc1bd1185..52c067566 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -32,6 +32,10 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l --> +## Changed + +- Improved TaskNotes startup performance by allowing Bases registration to finish asynchronously. Thanks to @tgrosinger for the contribution. + ## Fixed - Fixed `PUT /api/tasks/:id` ignoring empty arrays for `contexts` and `blockedBy`: sending `{"contexts": []}` or `{"blockedBy": []}` now clears the corresponding frontmatter field instead of silently leaving the previous value in place. The deletion pass previously fired only on a literal `undefined`, which JSON cannot express, so HTTP clients had no way to clear these fields. Thanks to @tgrosinger for the contribution. diff --git a/src/bases/registration.ts b/src/bases/registration.ts index 987d0062c..de7a56e13 100644 --- a/src/bases/registration.ts +++ b/src/bases/registration.ts @@ -278,24 +278,7 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise window.setTimeout(r, 200)); - if (await attemptRegistration()) { - return true; - } - } - - logger.warn("Failed to register views after multiple attempts", { - category: "configuration", - operation: "register-views", - }); - return false; + return attemptRegistration(); } /** diff --git a/src/main.ts b/src/main.ts index 643880fd4..9bf715adf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -314,7 +314,9 @@ export default class TaskNotesPlugin extends Plugin { this.migrationPromise = this.performEarlyMigrationCheck(); initializeCalendarProviders(this); - await registerBasesIntegration(this); + // Not awaited: if Bases has not loaded yet this schedules a retry timer, + // and initializeAfterLayoutReady attempts registration again. + void registerBasesIntegration(this); // Defer expensive initialization until layout is ready this.app.workspace.onLayoutReady(() => { From 1da981a3aef69b1e55ac44c9eac7d7caa2270253 Mon Sep 17 00:00:00 2001 From: Martin Ball <228563004+martin-forge@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:55:36 +0100 Subject: [PATCH 06/25] Fix provider calendar source names on event cards (#2192) Show the configured Google or Microsoft calendar name on event cards.\n\nThanks to @martin-forge for the contribution. --- docs/releases/unreleased.md | 3 + src/ui/ICSCard.ts | 33 ++++++- ...ssue-provider-calendar-source-name.test.ts | 90 +++++++++++++++++++ 3 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 tests/unit/issues/issue-provider-calendar-source-name.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 52c067566..db23eb8c1 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -39,3 +39,6 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Fixed - Fixed `PUT /api/tasks/:id` ignoring empty arrays for `contexts` and `blockedBy`: sending `{"contexts": []}` or `{"blockedBy": []}` now clears the corresponding frontmatter field instead of silently leaving the previous value in place. The deletion pass previously fired only on a literal `undefined`, which JSON cannot express, so HTTP clients had no way to clear these fields. Thanks to @tgrosinger for the contribution. +- (#2192) Agenda and list cards for Google and Microsoft calendar events now + show the name of the calendar the event belongs to, instead of the generic + "Calendar" label. Thanks to @martin-forge for the contribution. diff --git a/src/ui/ICSCard.ts b/src/ui/ICSCard.ts index 4ec81ef5d..16a5c63dd 100644 --- a/src/ui/ICSCard.ts +++ b/src/ui/ICSCard.ts @@ -1,6 +1,6 @@ import { setIcon, setTooltip } from "obsidian"; import TaskNotesPlugin from "../main"; -import { ICSEvent } from "../types"; +import { ICSEvent, ICSSubscription } from "../types"; import { ICSEventContextMenu } from "../components/ICSEventContextMenu"; import { formatTime } from "../utils/dateUtils"; import { ICSEventInfoModal } from "../modals/ICSEventInfoModal"; @@ -46,6 +46,31 @@ function renderRelatedNoteIndicator( }); } +function getEventSourceName( + icsEvent: ICSEvent, + plugin: TaskNotesPlugin, + subscription: ICSSubscription | undefined +): string { + if (subscription?.name) { + return subscription.name; + } + + 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) + ); + return calendar?.summary || provider.providerName; + } + + return plugin.i18n.translate("ui.icsCard.calendarFallback"); +} + function formatTimeRange(icsEvent: ICSEvent, plugin: TaskNotesPlugin): string { try { if (!icsEvent.start) return ""; @@ -85,12 +110,12 @@ export function createICSEventCard( card.dataset.relatedNoteCount = String(opts.relatedNoteCount); } - // Determine subscription color and name + // Determine subscription color and source name const subscription = plugin.icsSubscriptionService ?.getSubscriptions() .find((s) => s.id === icsEvent.subscriptionId); const color = icsEvent.color || subscription?.color || "var(--color-accent)"; - const sourceName = subscription?.name || plugin.i18n.translate("ui.icsCard.calendarFallback"); + const sourceName = getEventSourceName(icsEvent, plugin, subscription); // Main row const mainRow = card.createDiv({ cls: "task-card__main-row" }); @@ -228,7 +253,7 @@ export function updateICSEventCard( ?.getSubscriptions() .find((s) => s.id === icsEvent.subscriptionId); const color = icsEvent.color || subscription?.color || "var(--color-accent)"; - const sourceName = subscription?.name || plugin.i18n.translate("ui.icsCard.calendarFallback"); + const sourceName = getEventSourceName(icsEvent, plugin, subscription); // Update icon color on wrapper to propagate to svg (icons use currentColor) element.style.setProperty("--current-status-color", color); diff --git a/tests/unit/issues/issue-provider-calendar-source-name.test.ts b/tests/unit/issues/issue-provider-calendar-source-name.test.ts new file mode 100644 index 000000000..383c14a09 --- /dev/null +++ b/tests/unit/issues/issue-provider-calendar-source-name.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { createICSEventCard, updateICSEventCard } from "../../../src/ui/ICSCard"; +import type { ICSEvent } from "../../../src/types"; + +function createEvent(overrides: Partial = {}): ICSEvent { + return { + id: "google-primary-event-1", + subscriptionId: "google-primary", + title: "Team sync", + start: "2026-08-03T10:00:00", + end: "2026-08-03T11:00:00", + allDay: false, + ...overrides, + }; +} + +function createPlugin() { + const provider = { + providerName: "Google Calendar", + extractEventIds: (event: ICSEvent) => ({ + calendarId: event.subscriptionId.replace("google-", ""), + eventId: event.id, + }), + getAvailableCalendars: jest.fn(() => [ + { + id: "person@example.com", + summary: "Personal", + primary: true, + }, + ]), + }; + + return { + app: {}, + i18n: { + translate: (key: string) => (key === "ui.icsCard.calendarFallback" ? "Calendar" : key), + }, + settings: { + calendarViewSettings: { + timeFormat: "24", + }, + }, + icsSubscriptionService: { + getSubscriptions: () => [], + }, + calendarProviderRegistry: { + findProviderForEvent: jest.fn(() => provider), + }, + }; +} + +describe("provider calendar source names on ICS cards", () => { + it("shows a provider calendar name for primary-alias Google events", () => { + const card = createICSEventCard(createEvent(), createPlugin() as any); + + expect(card.querySelector(".task-card__metadata")?.textContent).toContain("Personal"); + expect(card.querySelector(".task-card__metadata")?.textContent).not.toContain("Calendar"); + }); + + it("refreshes the provider calendar name when an existing card updates", () => { + const plugin = createPlugin(); + const card = createICSEventCard(createEvent(), plugin as any); + plugin.calendarProviderRegistry.findProviderForEvent.mockReturnValue({ + providerName: "Microsoft Calendar", + extractEventIds: () => ({ calendarId: "work", eventId: "event-1" }), + getAvailableCalendars: () => [{ id: "work", summary: "Work" }], + }); + + updateICSEventCard( + card, + createEvent({ id: "microsoft-work-event-1", subscriptionId: "microsoft-work" }), + plugin as any + ); + + expect(card.querySelector(".task-card__metadata")?.textContent).toContain("Work"); + expect(card.querySelector(".task-card__metadata")?.textContent).not.toContain("Personal"); + }); + + it("uses the translated fallback when no subscription or provider owns the event", () => { + const plugin = createPlugin(); + plugin.calendarProviderRegistry.findProviderForEvent.mockReturnValue(undefined); + + const card = createICSEventCard( + createEvent({ id: "unknown-event", subscriptionId: "unknown" }), + plugin as any + ); + + expect(card.querySelector(".task-card__metadata")?.textContent).toContain("Calendar"); + }); +}); From e611b0ea222c2b26497aa8a27a4467f47562a1b2 Mon Sep 17 00:00:00 2001 From: Martin Ball <228563004+martin-forge@users.noreply.github.com> Date: Sun, 23 Aug 2026 05:01:39 +0100 Subject: [PATCH 07/25] Fix raw HTML in Google Calendar event descriptions (#2193) Normalize Google Calendar HTML descriptions to safe readable plain text.\n\nThanks to @martin-forge for the contribution. --- docs/releases/unreleased.md | 4 + src/services/GoogleCalendarService.ts | 3 +- src/utils/calendarDescription.ts | 259 +++++++++++++++++++ tests/__mocks__/obsidian.ts | 8 + tests/services/GoogleCalendarService.test.ts | 40 ++- tests/unit/utils/calendarDescription.test.ts | 125 +++++++++ 6 files changed, 437 insertions(+), 2 deletions(-) create mode 100644 src/utils/calendarDescription.ts create mode 100644 tests/unit/utils/calendarDescription.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index db23eb8c1..1a0f22f07 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -39,6 +39,10 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Fixed - Fixed `PUT /api/tasks/:id` ignoring empty arrays for `contexts` and `blockedBy`: sending `{"contexts": []}` or `{"blockedBy": []}` now clears the corresponding frontmatter field instead of silently leaving the previous value in place. The deletion pass previously fired only on a literal `undefined`, which JSON cannot express, so HTTP clients had no way to clear these fields. Thanks to @tgrosinger for the contribution. +- (#2193) Google Calendar event descriptions written with formatting now read as + plain text in event details, copied text, and generated notes, instead of + showing raw HTML tags. Paragraph breaks, list structure, and link addresses are + kept. Thanks to @martin-forge for the contribution. - (#2192) Agenda and list cards for Google and Microsoft calendar events now show the name of the calendar the event belongs to, instead of the generic "Calendar" label. Thanks to @martin-forge for the contribution. diff --git a/src/services/GoogleCalendarService.ts b/src/services/GoogleCalendarService.ts index 68ab065a5..c8566f6fa 100644 --- a/src/services/GoogleCalendarService.ts +++ b/src/services/GoogleCalendarService.ts @@ -16,6 +16,7 @@ import { validateCalendarId, validateEventId, validateRequired } from "./validat import { CalendarProvider, ProviderCalendar } from "./CalendarProvider"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; import { publishUserNotice } from "../core/userNotices"; +import { normalizeCalendarDescription } from "../utils/calendarDescription"; const tasknotesLogger = createTaskNotesLogger({ tag: "Services/GoogleCalendarService" }); @@ -499,7 +500,7 @@ export class GoogleCalendarService extends CalendarProvider { id: `google-${calendarId}-${googleEvent.id}`, subscriptionId: `google-${calendarId}`, title: googleEvent.summary || "Untitled Event", - description: googleEvent.description, + description: normalizeCalendarDescription(googleEvent.description), start: start, end: end, allDay: allDay, diff --git a/src/utils/calendarDescription.ts b/src/utils/calendarDescription.ts new file mode 100644 index 000000000..7fc07320e --- /dev/null +++ b/src/utils/calendarDescription.ts @@ -0,0 +1,259 @@ +import { sanitizeHTMLToDom } from "obsidian"; + +/** + * Plain-text normalization for calendar event descriptions. + * + * The Google Calendar API documents the event `description` field as one that + * "can contain HTML". Google Calendar and third-party integrations therefore + * store markup such as `

`, `
`, `